Add keyboard support for inbox and simplify code (#4380)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-01-18 19:45:14 +04:00 committed by GitHub
parent cf8ff1be31
commit ed634ccabf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 283 additions and 127 deletions

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { ActionIcon, CheckBox, Component, IconMoreH, Label, showPopup } from '@hcengineering/ui'
import { ActionIcon, Component, IconMoreH, Label, showPopup } from '@hcengineering/ui'
import notification, {
ActivityNotificationViewlet,
DisplayInboxNotification,
@ -71,13 +71,13 @@
}}
>
<div class="header">
<CheckBox
circle
kind="primary"
on:value={(event) => {
dispatch('check', event.detail)
}}
/>
<!-- <CheckBox-->
<!-- circle-->
<!-- kind="primary"-->
<!-- on:value={(event) => {-->
<!-- dispatch('check', event.detail)-->
<!-- }}-->
<!-- />-->
<NotifyContextIcon {value} />
{#if presenterMixin?.labelPresenter}
@ -135,10 +135,6 @@
font-weight: 500;
max-width: 20.5rem;
}
&:hover {
background-color: var(--highlight-hover);
}
}
.labels {

View File

@ -32,7 +32,8 @@
Scroller,
Separator,
TabItem,
TabList
TabList,
Location
} from '@hcengineering/ui'
import chunter from '@hcengineering/chunter'
import { Ref, WithLookup } from '@hcengineering/core'
@ -41,7 +42,7 @@
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import Filter from '../Filter.svelte'
import { getDisplayInboxNotifications } from '../../utils'
import { getDisplayInboxNotifications, resolveLocation } from '../../utils'
import { InboxNotificationsFilter } from '../../types'
export let visibleNav: boolean = true
@ -89,17 +90,21 @@
viewlets = res
})
$: getDisplayInboxNotifications($notificationsByContextStore, filter).then((res) => {
displayNotifications = res
})
$: displayNotifications = getDisplayInboxNotifications($notificationsByContextStore, filter)
locationStore.subscribe((newLocation) => {
selectedContextId = newLocation.fragment as Ref<DocNotifyContext> | undefined
syncLocation(newLocation)
})
async function syncLocation (newLocation: Location) {
const loc = await resolveLocation(newLocation)
selectedContextId = loc?.loc.fragment as Ref<DocNotifyContext> | undefined
if (selectedContextId !== selectedContext?._id) {
selectedContext = undefined
}
})
}
$: selectedContext = selectedContextId
? selectedContext ?? $notifyContextsStore.find(({ _id }) => _id === selectedContextId)
@ -242,7 +247,8 @@
props={{
notifications: filteredNotifications,
checkedContexts,
viewlets
viewlets,
selectedContext
}}
on:click={selectContext}
/>

View File

@ -13,10 +13,9 @@
// limitations under the License.
-->
<script lang="ts">
import { Scroller } from '@hcengineering/ui'
import { ActivityNotificationViewlet, DisplayInboxNotification } from '@hcengineering/notification'
import { ListView } from '@hcengineering/ui'
import { ActivityNotificationViewlet, DisplayInboxNotification, DocNotifyContext } from '@hcengineering/notification'
import { createEventDispatcher } from 'svelte'
import { flip } from 'svelte/animate'
import InboxNotificationPresenter from './InboxNotificationPresenter.svelte'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
@ -29,44 +28,94 @@
const inboxClient = InboxNotificationsClientImpl.getClient()
const notifyContextsStore = inboxClient.docNotifyContexts
async function handleCheck (notification: DisplayInboxNotification, isChecked: boolean) {
if (!isChecked) {
return
}
let list: ListView
let listSelection = 0
let element: HTMLDivElement | undefined
await deleteInboxNotification(notification)
function onKeydown (key: KeyboardEvent): void {
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection - 1)
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection + 1)
}
if (key.code === 'Backspace') {
key.preventDefault()
key.stopPropagation()
const notification = notifications[listSelection]
deleteInboxNotification(notification)
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
const notification = notifications[listSelection]
const context = $notifyContextsStore.find(({ _id }) => _id === notification.docNotifyContext)
dispatch('click', {
context,
notification
})
}
}
$: if (element) {
element.focus()
}
// async function handleCheck(notification: DisplayInboxNotification, isChecked: boolean) {
// if (!isChecked) {
// return
// }
//
// await deleteInboxNotification(notification)
// }
</script>
<Scroller>
{#each notifications as notification (notification._id)}
<div animate:flip={{ duration: 500 }}>
<div class="notification gap-2 ml-2">
<!-- <div class="mt-6">-->
<!-- <CheckBox-->
<!-- circle-->
<!-- kind="primary"-->
<!-- on:value={(event) => {-->
<!-- handleCheck(notification, event.detail)-->
<!-- }}-->
<!-- />-->
<!-- </div>-->
<InboxNotificationPresenter
value={notification}
{viewlets}
onClick={() => {
dispatch('click', {
context: $notifyContextsStore.find(({ _id }) => _id === notification.docNotifyContext),
notification
})
}}
/>
</div>
</div>
{/each}
</Scroller>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="root" bind:this={element} tabindex="0" on:keydown={onKeydown}>
<ListView bind:this={list} bind:selection={listSelection} count={notifications.length}>
<svelte:fragment slot="item" let:item={itemIndex}>
{@const notification = notifications[itemIndex]}
{#key notification._id}
<div class="notification gap-2 ml-2">
<!-- <div class="mt-6">-->
<!-- <CheckBox-->
<!-- circle-->
<!-- kind="primary"-->
<!-- on:value={(event) => {-->
<!-- handleCheck(notification, event.detail)-->
<!-- }}-->
<!-- />-->
<!-- </div>-->
<InboxNotificationPresenter
value={notification}
{viewlets}
onClick={() => {
dispatch('click', {
context: $notifyContextsStore.find(({ _id }) => _id === notification.docNotifyContext),
notification
})
}}
/>
</div>
{/key}
</svelte:fragment>
</ListView>
</div>
<style lang="scss">
.root {
&:focus {
outline: 0;
}
}
.notification {
display: flex;
}

View File

@ -14,8 +14,9 @@
-->
<script lang="ts">
import { ActivityNotificationViewlet, DisplayInboxNotification, DocNotifyContext } from '@hcengineering/notification'
import { groupByArray, Ref } from '@hcengineering/core'
import { flip } from 'svelte/animate'
import { Ref } from '@hcengineering/core'
import { createEventDispatcher } from 'svelte'
import { ListView } from '@hcengineering/ui'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import DocNotifyContextCard from '../DocNotifyContextCard.svelte'
@ -24,12 +25,33 @@
export let notifications: DisplayInboxNotification[] = []
export let viewlets: ActivityNotificationViewlet[] = []
const dispatch = createEventDispatcher()
const inboxClient = InboxNotificationsClientImpl.getClient()
const notifyContextsStore = inboxClient.docNotifyContexts
let displayNotificationsByContext = new Map<Ref<DocNotifyContext>, DisplayInboxNotification[]>()
let list: ListView
let listSelection = 0
let element: HTMLDivElement | undefined
$: displayNotificationsByContext = groupByArray(notifications, ({ docNotifyContext }) => docNotifyContext)
let displayData: [Ref<DocNotifyContext>, DisplayInboxNotification[]][] = []
$: updateDisplayData(notifications)
function updateDisplayData (notifications: DisplayInboxNotification[]) {
const result: [Ref<DocNotifyContext>, DisplayInboxNotification[]][] = []
notifications.forEach((item) => {
const data = result.find(([_id]) => _id === item.docNotifyContext)
if (!data) {
result.push([item.docNotifyContext, [item]])
} else {
data[1].push(item)
}
})
displayData = result
}
async function handleCheck (context: DocNotifyContext, isChecked: boolean) {
if (!isChecked) {
@ -38,28 +60,73 @@
await deleteContextNotifications(context)
}
function onKeydown (key: KeyboardEvent): void {
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection - 1)
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(listSelection + 1)
}
if (key.code === 'Backspace') {
key.preventDefault()
key.stopPropagation()
const context = $notifyContextsStore.find(({ _id }) => _id === displayData[listSelection][0])
deleteContextNotifications(context)
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
const context = $notifyContextsStore.find(({ _id }) => _id === displayData[listSelection][0])
dispatch('click', { context })
}
}
$: if (element) {
element.focus()
}
</script>
{#each displayNotificationsByContext as [contextId, contextNotifications] (contextId)}
<div animate:flip={{ duration: 500 }}>
{#if contextNotifications.length}
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="root" bind:this={element} tabindex="0" on:keydown={onKeydown}>
<ListView bind:this={list} bind:selection={listSelection} count={displayData.length}>
<svelte:fragment slot="item" let:item={itemIndex}>
{@const contextId = displayData[itemIndex][0]}
{@const contextNotifications = displayData[itemIndex][1]}
{@const context = $notifyContextsStore.find(({ _id }) => _id === contextId)}
{#if context}
<DocNotifyContextCard
value={context}
notifications={contextNotifications}
{viewlets}
on:click
on:check={(event) => handleCheck(context, event.detail)}
/>
<div class="separator" />
{/if}
{/if}
</div>
{/each}
{#key contextId}
{#if context}
<DocNotifyContextCard
value={context}
notifications={contextNotifications}
{viewlets}
on:click={(event) => {
dispatch('click', event.detail)
listSelection = itemIndex
}}
on:check={(event) => handleCheck(context, event.detail)}
/>
<div class="separator" />
{/if}
{/key}
</svelte:fragment>
</ListView>
</div>
<style lang="scss">
.root {
&:focus {
outline: 0;
}
}
.separator {
width: 100%;
height: 1px;

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type ActivityMessage } from '@hcengineering/activity'
import activity, { type ActivityMessage } from '@hcengineering/activity'
import { SortingOrder, getCurrentAccount, type Class, type Doc, type Ref, type WithLookup } from '@hcengineering/core'
import notification, {
type ActivityInboxNotification,
@ -22,7 +22,7 @@ import notification, {
type InboxNotificationsClient
} from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import { derived, writable } from 'svelte/store'
import { derived, get, writable } from 'svelte/store'
/**
* @public
@ -33,7 +33,19 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
readonly docNotifyContexts = writable<DocNotifyContext[]>([])
readonly docNotifyContextByDoc = writable<Map<Ref<Doc>, DocNotifyContext>>(new Map())
readonly inboxNotifications = writable<InboxNotification[]>([])
readonly activityInboxNotifications = writable<Array<WithLookup<ActivityInboxNotification>>>([])
readonly otherInboxNotifications = writable<InboxNotification[]>([])
readonly inboxNotifications = derived(
[this.activityInboxNotifications, this.otherInboxNotifications],
([activityNotifications, otherNotifications]) => {
return otherNotifications
.concat(activityNotifications)
.sort((n1, n2) => (n2.createdOn ?? n2.modifiedOn) - (n1.createdOn ?? n1.modifiedOn))
},
[] as InboxNotification[]
)
readonly inboxNotificationsByContext = derived(
[this.docNotifyContexts, this.inboxNotifications],
([notifyContexts, inboxNotifications]) => {
@ -53,20 +65,11 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
}
)
readonly activityInboxNotifications = derived(
[this.inboxNotifications],
([notifications]) =>
notifications.filter(
(n): n is ActivityInboxNotification => n._class === notification.class.ActivityInboxNotification
),
[] as ActivityInboxNotification[]
)
private readonly docNotifyContextsQuery = createQuery(true)
private readonly inboxNotificationsQuery = createQuery(true)
private readonly otherInboxNotificationsQuery = createQuery(true)
private readonly activityInboxNotificationsQuery = createQuery(true)
private _docNotifyContextByDoc = new Map<Ref<Doc>, DocNotifyContext>()
private _inboxNotifications: Array<WithLookup<InboxNotification>> = []
private constructor () {
this.docNotifyContextsQuery.query(
@ -80,14 +83,14 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
this.docNotifyContextByDoc.set(this._docNotifyContextByDoc)
}
)
this.inboxNotificationsQuery.query(
this.otherInboxNotificationsQuery.query(
notification.class.InboxNotification,
{
_class: { $nin: [notification.class.ActivityInboxNotification] },
user: getCurrentAccount()._id
},
(result: InboxNotification[]) => {
this.inboxNotifications.set(result)
this._inboxNotifications = result
this.otherInboxNotifications.set(result)
},
{
sort: {
@ -95,6 +98,24 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
}
}
)
this.activityInboxNotificationsQuery.query(
notification.class.ActivityInboxNotification,
{
user: getCurrentAccount()._id
},
(result: ActivityInboxNotification[]) => {
this.activityInboxNotifications.set(result)
},
{
sort: {
createdOn: SortingOrder.Descending
},
lookup: {
attachedTo: activity.class.ActivityMessage
}
}
)
}
static createClient (): InboxNotificationsClientImpl {
@ -117,7 +138,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
return
}
const inboxNotifications = this._inboxNotifications.filter(
const inboxNotifications = get(this.inboxNotifications).filter(
(notification) => notification.docNotifyContext === docNotifyContext._id && !notification.isViewed
)
@ -180,7 +201,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
async readMessages (ids: Array<Ref<ActivityMessage>>): Promise<void> {
const client = getClient()
const notificationsToRead = this._inboxNotifications
const notificationsToRead = get(this.inboxNotifications)
.filter((n): n is ActivityInboxNotification => n._class === notification.class.ActivityInboxNotification)
.filter(({ attachedTo, isViewed }) => ids.includes(attachedTo) && !isViewed)
@ -191,7 +212,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
async readNotifications (ids: Array<Ref<InboxNotification>>): Promise<void> {
const client = getClient()
const notificationsToRead = this._inboxNotifications.filter(({ _id }) => ids.includes(_id))
const notificationsToRead = get(this.inboxNotifications).filter(({ _id }) => ids.includes(_id))
await Promise.all(
notificationsToRead.map(async (notification) => await client.update(notification, { isViewed: true }))
@ -200,7 +221,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
async unreadNotifications (ids: Array<Ref<InboxNotification>>): Promise<void> {
const client = getClient()
const notificationsToUnread = this._inboxNotifications.filter(({ _id }) => ids.includes(_id))
const notificationsToUnread = get(this.inboxNotifications).filter(({ _id }) => ids.includes(_id))
await Promise.all(
notificationsToUnread.map(async (notification) => await client.update(notification, { isViewed: false }))
@ -209,7 +230,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient {
async deleteNotifications (ids: Array<Ref<InboxNotification>>): Promise<void> {
const client = getClient()
const inboxNotifications = this._inboxNotifications.filter(({ _id }) => ids.includes(_id))
const inboxNotifications = get(this.inboxNotifications).filter(({ _id }) => ids.includes(_id))
await Promise.all(inboxNotifications.map(async (notification) => await client.remove(notification)))
}
}

View File

@ -48,7 +48,8 @@ import {
canUnReadNotifyContext,
readNotifyContext,
unReadNotifyContext,
deleteContextNotifications
deleteContextNotifications,
hasInboxNotifications
} from './utils'
import { InboxNotificationsClientImpl } from './inboxNotificationsClient'
@ -85,7 +86,8 @@ export default async (): Promise<Resources> => ({
IsDocNotifyContextTracked: isDocNotifyContextVisible,
HasHiddenDocNotifyContext: hasHiddenDocNotifyContext,
CanReadNotifyContext: canReadNotifyContext,
CanUnReadNotifyContext: canUnReadNotifyContext
CanUnReadNotifyContext: canUnReadNotifyContext,
HasInboxNotifications: hasInboxNotifications
},
actionImpl: {
Unsubscribe: unsubscribe,

View File

@ -21,7 +21,8 @@ import {
getCurrentAccount,
type Ref,
SortingOrder,
type TxOperations
type TxOperations,
type WithLookup
} from '@hcengineering/core'
import notification, {
type ActivityInboxNotification,
@ -203,7 +204,11 @@ export async function unReadNotifyContext (doc: DocNotifyContext): Promise<void>
/**
* @public
*/
export async function deleteContextNotifications (doc: DocNotifyContext): Promise<void> {
export async function deleteContextNotifications (doc?: DocNotifyContext): Promise<void> {
if (doc === undefined) {
return
}
const client = getClient()
const inboxClient = InboxNotificationsClientImpl.getClient()
const inboxNotifications = get(inboxClient.inboxNotificationsByContext).get(doc._id) ?? []
@ -324,9 +329,11 @@ async function generateLocation (
const workspace = loc.path[1] ?? ''
const messageId = loc.query?.message as Ref<ActivityMessage> | undefined
const context = await client.findOne(notification.class.DocNotifyContext, { _id: contextId })
const contextNotification = await client.findOne(notification.class.InboxNotification, {
docNotifyContext: contextId
})
if (context === undefined) {
if (contextNotification === undefined) {
return {
loc: {
path: [loc.path[0], loc.path[1], inboxId],
@ -355,12 +362,11 @@ async function generateLocation (
}
}
export async function getDisplayInboxNotifications (
export function getDisplayInboxNotifications (
notificationsByContext: Map<Ref<DocNotifyContext>, InboxNotification[]>,
filter: InboxNotificationsFilter,
filter: InboxNotificationsFilter = 'all',
objectClass?: Ref<Class<Doc>>
): Promise<DisplayInboxNotification[]> {
const client = getClient()
): DisplayInboxNotification[] {
const filteredNotifications = Array.from(notificationsByContext.values())
.flat()
.filter(({ isViewed }) => {
@ -377,20 +383,14 @@ export async function getDisplayInboxNotifications (
})
const activityNotifications = filteredNotifications.filter(
(n): n is ActivityInboxNotification => n._class === notification.class.ActivityInboxNotification
(n): n is WithLookup<ActivityInboxNotification> => n._class === notification.class.ActivityInboxNotification
)
const displayNotifications: DisplayInboxNotification[] = filteredNotifications.filter(
({ _class }) => _class !== notification.class.ActivityInboxNotification
)
const messages: Array<ActivityMessage | undefined> = await Promise.all(
activityNotifications.map(
async (activityNotification) =>
await client.findOne(activity.class.ActivityMessage, { _id: activityNotification.attachedTo })
)
)
const filteredMessages = messages
const messages: ActivityMessage[] = activityNotifications
.map((activityNotification) => activityNotification.$lookup?.attachedTo)
.filter((message): message is ActivityMessage => {
if (message === undefined) {
return false
@ -406,7 +406,7 @@ export async function getDisplayInboxNotifications (
})
.sort(activityMessagesComparator)
const combinedMessages = combineActivityMessages(filteredMessages, SortingOrder.Descending)
const combinedMessages = combineActivityMessages(messages, SortingOrder.Descending)
for (const message of combinedMessages) {
if (message._class === activity.class.DocUpdateMessage) {
@ -442,3 +442,11 @@ export async function getDisplayInboxNotifications (
return displayNotifications
}
export async function hasInboxNotifications (
notificationsByContext: Map<Ref<DocNotifyContext>, InboxNotification[]>
): Promise<boolean> {
const displayNotifications = getDisplayInboxNotifications(notificationsByContext)
return displayNotifications.some(({ isViewed }) => !isViewed)
}

View File

@ -272,8 +272,8 @@ export interface DocNotifyContext extends Doc {
export interface InboxNotificationsClient {
docNotifyContextByDoc: Writable<Map<Ref<Doc>, DocNotifyContext>>
docNotifyContexts: Writable<DocNotifyContext[]>
inboxNotifications: Writable<InboxNotification[]>
activityInboxNotifications: Readable<ActivityInboxNotification[]>
inboxNotifications: Readable<InboxNotification[]>
activityInboxNotifications: Writable<ActivityInboxNotification[]>
inboxNotificationsByContext: Readable<Map<Ref<DocNotifyContext>, InboxNotification[]>>
readDoc: (_id: Ref<Doc>) => Promise<void>
forceReadDoc: (_id: Ref<Doc>, _class: Ref<Class<Doc>>) => Promise<void>
@ -393,7 +393,10 @@ const notification = plugin(notificationId, {
GetInboxNotificationsClient: '' as Resource<InboxNotificationsClientFactory>,
HasHiddenDocNotifyContext: '' as Resource<(doc: Doc[]) => Promise<boolean>>,
IsDocNotifyContextHidden: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
IsDocNotifyContextTracked: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>
IsDocNotifyContextTracked: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
HasInboxNotifications: '' as Resource<
(notificationsByContext: Map<Ref<DocNotifyContext>, InboxNotification[]>) => Promise<boolean>
>
},
resolver: {
Location: '' as Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>

View File

@ -16,7 +16,7 @@
import contact, { Employee, PersonAccount } from '@hcengineering/contact'
import core, { Class, Doc, getCurrentAccount, Ref, setCurrentAccount, Space } from '@hcengineering/core'
import login from '@hcengineering/login'
import notification, { inboxId } from '@hcengineering/notification'
import notification, { DocNotifyContext, inboxId, InboxNotification } from '@hcengineering/notification'
import { BrowserNotificatator, InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { broadcastEvent, getMetadata, getResource, IntlString } from '@hcengineering/platform'
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
@ -162,17 +162,21 @@
)
const workspaceId = $location.path[1]
const inboxClient = InboxNotificationsClientImpl.getClient()
const inboxNotificationsByContextStore = inboxClient.inboxNotificationsByContext
let hasNotificationsFn: ((data: Map<Ref<DocNotifyContext>, InboxNotification[]>) => Promise<boolean>) | undefined =
undefined
let hasInboxNotifications = false
let syncPromise: Promise<void> | undefined = undefined
let locUpdate = 0
const notificationClient = InboxNotificationsClientImpl.getClient()
getResource(notification.function.HasInboxNotifications).then((f) => {
hasNotificationsFn = f
})
notificationClient.inboxNotificationsByContext.subscribe((inboxNotificationsByContext) => {
hasInboxNotifications = Array.from(inboxNotificationsByContext.entries()).some(([_, notifications]) =>
notifications.some(({ isViewed }) => !isViewed)
)
$: hasNotificationsFn?.($inboxNotificationsByContextStore).then((res) => {
hasInboxNotifications = res
})
const doSyncLoc = async (loc: Location, iteration: number): Promise<void> => {