platform/plugins/notification-resources/src/utils.ts
Andrey Sobolev 4cce003364
UBERF-7959: Fix async issues (#6409)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
2024-08-28 11:51:20 +05:00

825 lines
24 KiB
TypeScript

//
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import activity, {
type ActivityMessage,
type DisplayDocUpdateMessage,
type DocUpdateMessage
} from '@hcengineering/activity'
import {
activityMessagesComparator,
combineActivityMessages,
isActivityMessageClass,
isReactionMessage,
messageInFocus
} from '@hcengineering/activity-resources'
import { Analytics } from '@hcengineering/analytics'
import chunter, { type ThreadMessage } from '@hcengineering/chunter'
import core, {
SortingOrder,
getCurrentAccount,
type Class,
type Doc,
type DocumentUpdate,
type Ref,
type TxOperations,
type WithLookup
} from '@hcengineering/core'
import notification, {
NotificationStatus,
notificationId,
type ActivityInboxNotification,
type BaseNotificationType,
type Collaborators,
type DisplayInboxNotification,
type DocNotifyContext,
type InboxNotification,
type MentionInboxNotification,
type NotificationProvider,
type NotificationProviderSetting,
type NotificationTypeSetting
} from '@hcengineering/notification'
import { getMetadata } from '@hcengineering/platform'
import { MessageBox, createQuery, getClient } from '@hcengineering/presentation'
import {
getCurrentLocation,
getLocation,
locationStorageKeyId,
navigate,
parseLocation,
showPopup,
type Location,
type ResolvedLocation
} from '@hcengineering/ui'
import { decodeObjectURI, encodeObjectURI, type LinkIdProvider } from '@hcengineering/view'
import { getObjectLinkId } from '@hcengineering/view-resources'
import { get, writable } from 'svelte/store'
import { InboxNotificationsClientImpl } from './inboxNotificationsClient'
import { type InboxData, type InboxNotificationsFilter } from './types'
export const providersSettings = writable<NotificationProviderSetting[]>([])
export const typesSettings = writable<NotificationTypeSetting[]>([])
const providerSettingsQuery = createQuery(true)
const typeSettingsQuery = createQuery(true)
export function loadNotificationSettings (): void {
providerSettingsQuery.query(
notification.class.NotificationProviderSetting,
{ space: core.space.Workspace },
(res) => {
providersSettings.set(res)
}
)
typeSettingsQuery.query(notification.class.NotificationTypeSetting, { space: core.space.Workspace }, (res) => {
typesSettings.set(res)
})
}
loadNotificationSettings()
export async function hasDocNotifyContextPinAction (docNotifyContext: DocNotifyContext): Promise<boolean> {
return !docNotifyContext.isPinned
}
export async function hasDocNotifyContextUnpinAction (docNotifyContext: DocNotifyContext): Promise<boolean> {
return docNotifyContext.isPinned
}
/**
* @public
*/
export async function canReadNotifyContext (doc: DocNotifyContext): Promise<boolean> {
const inboxNotificationsClient = InboxNotificationsClientImpl.getClient()
return (
get(inboxNotificationsClient.inboxNotificationsByContext)
.get(doc._id)
?.some(({ isViewed }) => !isViewed) ?? false
)
}
/**
* @public
*/
export async function canUnReadNotifyContext (doc: DocNotifyContext): Promise<boolean> {
const canReadContext = await canReadNotifyContext(doc)
return !canReadContext
}
/**
* @public
*/
export async function readNotifyContext (doc: DocNotifyContext): Promise<void> {
const inboxClient = InboxNotificationsClientImpl.getClient()
const inboxNotifications = get(inboxClient.inboxNotificationsByContext).get(doc._id) ?? []
const ops = getClient().apply(doc._id, 'readNotifyContext')
try {
await inboxClient.readNotifications(
ops,
inboxNotifications.map(({ _id }) => _id)
)
await ops.update(doc, { lastViewedTimestamp: Date.now() })
} finally {
await ops.commit()
}
}
/**
* @public
*/
export async function unReadNotifyContext (doc: DocNotifyContext): Promise<void> {
const inboxClient = InboxNotificationsClientImpl.getClient()
const inboxNotifications = get(inboxClient.inboxNotificationsByContext).get(doc._id) ?? []
const notificationsToUnread = inboxNotifications.filter(({ isViewed }) => isViewed)
if (notificationsToUnread.length === 0) {
return
}
const ops = getClient().apply(doc._id, 'unReadNotifyContext')
try {
await inboxClient.unreadNotifications(
ops,
notificationsToUnread.map(({ _id }) => _id)
)
const toUnread = inboxNotifications.find(isActivityNotification)
if (toUnread !== undefined) {
const createdOn = (toUnread as WithLookup<ActivityInboxNotification>)?.$lookup?.attachedTo?.createdOn
if (createdOn === undefined || createdOn === 0) {
return
}
await ops.diffUpdate(doc, { lastViewedTimestamp: createdOn - 1 })
}
} finally {
await ops.commit()
}
}
/**
* @public
*/
export async function archiveContextNotifications (doc?: DocNotifyContext): Promise<void> {
if (doc === undefined) {
return
}
const ops = getClient().apply(doc._id, 'archiveContextNotifications')
try {
const notifications = await ops.findAll(
notification.class.InboxNotification,
{ docNotifyContext: doc._id, archived: false },
{ projection: { _id: 1, _class: 1, space: 1 } }
)
for (const notification of notifications) {
await ops.updateDoc(notification._class, notification.space, notification._id, { archived: true, isViewed: true })
}
await ops.update(doc, { lastViewedTimestamp: Date.now() })
} finally {
await ops.commit()
}
}
/**
* @public
*/
export async function unarchiveContextNotifications (doc?: DocNotifyContext): Promise<void> {
if (doc === undefined) {
return
}
const ops = getClient().apply(doc._id, 'unarchiveContextNotifications')
try {
const notifications = await ops.findAll(
notification.class.InboxNotification,
{ docNotifyContext: doc._id, archived: true },
{ projection: { _id: 1, _class: 1, space: 1 } }
)
for (const notification of notifications) {
await ops.updateDoc(notification._class, notification.space, notification._id, { archived: false })
}
} finally {
await ops.commit()
}
}
enum OpWithMe {
Add = 'add',
Remove = 'remove'
}
async function updateMeInCollaborators (
client: TxOperations,
docClass: Ref<Class<Doc>>,
docId: Ref<Doc>,
op: OpWithMe
): Promise<void> {
const me = getCurrentAccount()._id
const hierarchy = client.getHierarchy()
const target = await client.findOne(docClass, { _id: docId })
if (target !== undefined) {
if (hierarchy.hasMixin(target, notification.mixin.Collaborators)) {
const collab = hierarchy.as(target, notification.mixin.Collaborators)
let collabUpdate: DocumentUpdate<Collaborators> | undefined
if (collab.collaborators.includes(me) && op === OpWithMe.Remove) {
collabUpdate = {
$pull: {
collaborators: me
}
}
} else if (!collab.collaborators.includes(me) && op === OpWithMe.Add) {
collabUpdate = {
$push: {
collaborators: me
}
}
}
if (collabUpdate !== undefined) {
await client.updateMixin(
collab._id,
collab._class,
collab.space,
notification.mixin.Collaborators,
collabUpdate
)
}
}
}
}
/**
* @public
*/
export async function unsubscribe (context: DocNotifyContext): Promise<void> {
const client = getClient()
await updateMeInCollaborators(client, context.objectClass, context.objectId, OpWithMe.Remove)
}
/**
* @public
*/
export async function subscribe (docClass: Ref<Class<Doc>>, docId: Ref<Doc>): Promise<void> {
const client = getClient()
await updateMeInCollaborators(client, docClass, docId, OpWithMe.Add)
}
export async function pinDocNotifyContext (object: DocNotifyContext): Promise<void> {
const client = getClient()
await client.updateDoc(object._class, object.space, object._id, {
isPinned: true
})
}
export async function unpinDocNotifyContext (object: DocNotifyContext): Promise<void> {
const client = getClient()
await client.updateDoc(object._class, object.space, object._id, {
isPinned: false
})
}
export async function archiveAll (): Promise<void> {
const client = InboxNotificationsClientImpl.getClient()
showPopup(
MessageBox,
{
label: notification.string.ArchiveAllConfirmationTitle,
message: notification.string.ArchiveAllConfirmationMessage,
action: async () => {
await client.archiveAllNotifications()
}
},
'top'
)
}
export async function readAll (): Promise<void> {
const client = InboxNotificationsClientImpl.getClient()
await client.readAllNotifications()
}
export async function unreadAll (): Promise<void> {
const client = InboxNotificationsClientImpl.getClient()
await client.unreadAllNotifications()
}
export function isActivityNotification (doc?: InboxNotification): doc is ActivityInboxNotification {
if (doc === undefined) return false
return doc._class === notification.class.ActivityInboxNotification
}
export function isMentionNotification (doc?: InboxNotification): doc is MentionInboxNotification {
if (doc === undefined) return false
return doc._class === notification.class.MentionInboxNotification
}
export async function getDisplayInboxNotifications (
notifications: Array<WithLookup<InboxNotification>>,
filter: InboxNotificationsFilter = 'all',
objectClass?: Ref<Class<Doc>>
): Promise<DisplayInboxNotification[]> {
const result: DisplayInboxNotification[] = []
const activityNotifications: Array<WithLookup<ActivityInboxNotification>> = []
for (const notification of notifications) {
if (filter === 'unread' && notification.isViewed) {
continue
}
if (isActivityNotification(notification)) {
activityNotifications.push(notification)
} else {
result.push(notification)
}
}
const messages: ActivityMessage[] = activityNotifications
.map((activityNotification) => activityNotification.$lookup?.attachedTo)
.filter((message): message is ActivityMessage => {
if (message === undefined) {
return false
}
if (objectClass === undefined) {
return true
}
if (message._class !== activity.class.DocUpdateMessage) {
return false
}
return (message as DocUpdateMessage).objectClass === objectClass
})
const combinedMessages = await combineActivityMessages(
messages.sort(activityMessagesComparator),
SortingOrder.Descending
)
for (const message of combinedMessages) {
if (message._class === activity.class.DocUpdateMessage) {
const displayMessage = message as DisplayDocUpdateMessage
const ids: Array<Ref<ActivityMessage>> = displayMessage.combinedMessagesIds ?? [displayMessage._id]
const activityNotification = activityNotifications.find(({ attachedTo }) => attachedTo === message._id)
if (activityNotification === undefined) {
continue
}
const combined = activityNotifications.filter(({ attachedTo }) => ids.includes(attachedTo))
const displayNotification = {
...activityNotification,
combinedIds: combined.map(({ _id }) => _id),
combinedMessages: combined
.map((a) => a.$lookup?.attachedTo)
.filter((m): m is ActivityMessage => m !== undefined)
}
result.push(displayNotification)
} else {
const activityNotification = activityNotifications.find(({ attachedTo }) => attachedTo === message._id)
if (activityNotification !== undefined) {
result.push({
...activityNotification,
combinedIds: [activityNotification._id],
combinedMessages: [message]
})
}
}
}
return result.sort(
(notification1, notification2) =>
(notification2.createdOn ?? notification2.modifiedOn) - (notification1.createdOn ?? notification1.modifiedOn)
)
}
export async function getDisplayInboxData (
notificationsByContext: Map<Ref<DocNotifyContext>, InboxNotification[]>,
filter: InboxNotificationsFilter = 'all',
objectClass?: Ref<Class<Doc>>
): Promise<InboxData> {
const result: InboxData = new Map()
for (const key of notificationsByContext.keys()) {
const notifications = notificationsByContext.get(key) ?? []
const displayNotifications = await getDisplayInboxNotifications(notifications, filter, objectClass)
if (displayNotifications.length > 0) {
result.set(key, displayNotifications)
}
}
return result
}
export async function hasInboxNotifications (
notificationsByContext: Map<Ref<DocNotifyContext>, InboxNotification[]>
): Promise<boolean> {
const unreadInboxData = await getDisplayInboxData(notificationsByContext, 'unread')
return unreadInboxData.size > 0
}
export async function getNotificationsCount (
context: DocNotifyContext | undefined,
notifications: InboxNotification[] = []
): Promise<number> {
if (context === undefined || notifications.length === 0) {
return 0
}
const unreadNotifications = await getDisplayInboxNotifications(notifications, 'unread')
return unreadNotifications.length
}
export async function resolveLocation (loc: Location): Promise<ResolvedLocation | undefined> {
if (loc.path[2] !== notificationId) {
return undefined
}
const [_id, _class] = decodeObjectURI(loc.path[3])
if (_id === undefined || _class === undefined) {
return {
loc: {
path: [loc.path[0], loc.path[1], notificationId],
fragment: undefined
},
defaultLocation: {
path: [loc.path[0], loc.path[1], notificationId],
fragment: undefined
}
}
}
return await generateLocation(loc, _id, _class)
}
async function generateLocation (
loc: Location,
_id: string,
_class: Ref<Class<Doc>>
): Promise<ResolvedLocation | undefined> {
const client = getClient()
const appComponent = loc.path[0] ?? ''
const workspace = loc.path[1] ?? ''
const threadId = loc.path[4] as Ref<ActivityMessage> | undefined
const thread =
threadId !== undefined ? await client.findOne(activity.class.ActivityMessage, { _id: threadId }) : undefined
if (thread === undefined) {
return {
loc: {
path: [appComponent, workspace, notificationId, encodeObjectURI(_id, _class)],
fragment: undefined,
query: { ...loc.query }
},
defaultLocation: {
path: [appComponent, workspace, notificationId, encodeObjectURI(_id, _class)],
fragment: undefined,
query: { ...loc.query }
}
}
}
return {
loc: {
path: [appComponent, workspace, notificationId, encodeObjectURI(_id, _class), threadId as string],
fragment: undefined,
query: { ...loc.query }
},
defaultLocation: {
path: [appComponent, workspace, notificationId, encodeObjectURI(_id, _class), threadId as string],
fragment: undefined,
query: { ...loc.query }
}
}
}
async function navigateToInboxDoc (
providers: LinkIdProvider[],
_id?: Ref<Doc>,
_class?: Ref<Class<Doc>>,
thread?: Ref<ActivityMessage>,
message?: Ref<ActivityMessage>
): Promise<void> {
const loc = getLocation()
if (loc.path[2] !== notificationId) {
return
}
if (_id === undefined || _class === undefined) {
resetInboxContext()
return
}
const id = await getObjectLinkId(providers, _id, _class)
loc.path[3] = encodeObjectURI(id, _class)
if (thread !== undefined) {
loc.path[4] = thread
loc.path.length = 5
} else {
loc.path[4] = ''
loc.path.length = 4
}
loc.query = { ...loc.query, message: message ?? null }
messageInFocus.set(message)
Analytics.handleEvent('inbox.ReadDoc', { objectId: id, objectClass: _class, thread, message })
navigate(loc)
}
export function resetInboxContext (): void {
const loc = getLocation()
if (loc.path[2] !== notificationId) {
return
}
loc.query = { message: null }
loc.path.length = 3
localStorage.setItem(`${locationStorageKeyId}_${notificationId}`, JSON.stringify(loc))
navigate(loc)
}
export async function selectInboxContext (
linkProviders: LinkIdProvider[],
context: DocNotifyContext,
notification?: WithLookup<InboxNotification>
): Promise<void> {
const client = getClient()
const hierarchy = client.getHierarchy()
const { objectId, objectClass } = context
if (isMentionNotification(notification) && isActivityMessageClass(notification.mentionedInClass)) {
const selectedMsg = notification.mentionedIn as Ref<ActivityMessage>
void navigateToInboxDoc(
linkProviders,
objectId,
objectClass,
isActivityMessageClass(objectClass) ? (objectId as Ref<ActivityMessage>) : undefined,
selectedMsg
)
return
}
if (hierarchy.isDerived(objectClass, activity.class.ActivityMessage)) {
const message = (notification as WithLookup<ActivityInboxNotification>)?.$lookup?.attachedTo
if (objectClass === chunter.class.ThreadMessage) {
const thread = await client.findOne(
chunter.class.ThreadMessage,
{
_id: objectId as Ref<ThreadMessage>
},
{ projection: { _id: 1, attachedTo: 1 } }
)
void navigateToInboxDoc(linkProviders, objectId, objectClass, thread?.attachedTo, thread?._id)
return
}
if (isReactionMessage(message)) {
void navigateToInboxDoc(linkProviders, objectId, objectClass, undefined, objectId as Ref<ActivityMessage>)
return
}
const selectedMsg = (notification as ActivityInboxNotification)?.attachedTo
void navigateToInboxDoc(
linkProviders,
objectId,
objectClass,
selectedMsg !== undefined ? (objectId as Ref<ActivityMessage>) : undefined,
selectedMsg ?? (objectId as Ref<ActivityMessage>)
)
return
}
void navigateToInboxDoc(
linkProviders,
objectId,
objectClass,
undefined,
(notification as ActivityInboxNotification)?.attachedTo
)
}
export const pushAllowed = writable<boolean>(false)
export async function checkPermission (value: boolean): Promise<boolean> {
if (!value) return true
if ('serviceWorker' in navigator && 'PushManager' in window) {
try {
const loc = getCurrentLocation()
const registration = await navigator.serviceWorker.getRegistration(`/${loc.path[0]}/${loc.path[1]}`)
if (registration !== undefined) {
const current = await registration.pushManager.getSubscription()
const res = current !== null
pushAllowed.set(current !== null)
void registration.update()
addWorkerListener()
return res
}
} catch {
pushAllowed.set(false)
return false
}
}
pushAllowed.set(false)
return false
}
function addWorkerListener (): void {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data !== undefined && event.data.type === 'notification-click') {
const { url, _id } = event.data
if (url !== undefined) {
navigate(parseLocation(new URL(url)))
}
if (_id !== undefined) {
void cleanTag(_id)
}
}
})
}
export function pushAvailable (): boolean {
const publicKey = getMetadata(notification.metadata.PushPublicKey)
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
publicKey !== undefined &&
'Notification' in window &&
Notification.permission !== 'denied'
)
}
export async function subscribePush (): Promise<boolean> {
const client = getClient()
const publicKey = getMetadata(notification.metadata.PushPublicKey)
if ('serviceWorker' in navigator && 'PushManager' in window && publicKey !== undefined) {
try {
const loc = getCurrentLocation()
let registration = await navigator.serviceWorker.getRegistration(`/${loc.path[0]}/${loc.path[1]}`)
if (registration !== undefined) {
await registration.update()
} else {
registration = await navigator.serviceWorker.register('/serviceWorker.js', {
scope: `/${loc.path[0]}/${loc.path[1]}`
})
}
const current = await registration.pushManager.getSubscription()
if (current == null) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey
})
await client.createDoc(notification.class.PushSubscription, core.space.Workspace, {
user: getCurrentAccount()._id,
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth'))
}
})
} else {
const exists = await client.findOne(notification.class.PushSubscription, {
user: getCurrentAccount()._id,
endpoint: current.endpoint
})
if (exists === undefined) {
await client.createDoc(notification.class.PushSubscription, core.space.Workspace, {
user: getCurrentAccount()._id,
endpoint: current.endpoint,
keys: {
p256dh: arrayBufferToBase64(current.getKey('p256dh')),
auth: arrayBufferToBase64(current.getKey('auth'))
}
})
}
}
addWorkerListener()
pushAllowed.set(true)
return true
} catch (err) {
console.error('Service Worker registration failed:', err)
pushAllowed.set(false)
return false
}
}
pushAllowed.set(false)
return false
}
async function cleanTag (_id: Ref<Doc>): Promise<void> {
const client = getClient()
const notifications = await client.findAll(notification.class.BrowserNotification, {
tag: _id,
status: NotificationStatus.New
})
for (const notification of notifications) {
await client.update(notification, { status: NotificationStatus.Notified })
}
}
function arrayBufferToBase64 (buffer: ArrayBuffer | null): string {
if (buffer != null) {
const bytes = new Uint8Array(buffer)
const array = Array.from(bytes)
const binary = String.fromCharCode.apply(null, array)
return btoa(binary)
} else {
return ''
}
}
export function notificationsComparator (notifications1: InboxNotification, notifications2: InboxNotification): number {
const createdOn1 = notifications1.createdOn ?? 0
const createdOn2 = notifications2.createdOn ?? 0
if (createdOn1 > createdOn2) {
return -1
}
if (createdOn1 < createdOn2) {
return 1
}
return 0
}
export function isNotificationAllowed (type: BaseNotificationType, providerId: Ref<NotificationProvider>): boolean {
const client = getClient()
const provider = client.getModel().findAllSync(notification.class.NotificationProvider, { _id: providerId })[0]
if (provider === undefined) return false
const providerSetting = get(providersSettings).find((it) => it.attachedTo === providerId)
if (providerSetting !== undefined && !providerSetting.enabled) return false
if (providerSetting === undefined && !provider.defaultEnabled) return false
const providerDefaults = client.getModel().findAllSync(notification.class.NotificationProviderDefaults, {})
if (providerDefaults.some((it) => it.provider === provider._id && it.ignoredTypes.includes(type._id))) {
return false
}
if (provider.ignoreAll === true) {
const excludedIgnore = providerDefaults.some(
(it) => provider._id === it.provider && it.excludeIgnore !== undefined && it.excludeIgnore.includes(type._id)
)
if (!excludedIgnore) return false
}
const setting = get(typesSettings).find((it) => it.attachedTo === provider._id && it.type === type._id)
if (setting !== undefined) {
return setting.enabled
}
if (providerDefaults.some((it) => it.provider === provider._id && it.enabledTypes.includes(type._id))) {
return true
}
return type.defaultEnabled
}