// // Copyright © 2023 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 ActivityMessagesFilter, type DisplayActivityMessage, type DisplayDocUpdateMessage, type DocUpdateMessage } from '@hcengineering/activity' import aiBot from '@hcengineering/ai-bot' import { summarizeMessages as aiSummarizeMessages, translate as aiTranslate } from '@hcengineering/ai-bot-resources' import { type Channel, type ChatMessage, type DirectMessage, type ThreadMessage } from '@hcengineering/chunter' import contact, { type Employee, getCurrentEmployee, getName, type Person } from '@hcengineering/contact' import { employeeByAccountStore, employeeByIdStore, PersonIcon } from '@hcengineering/contact-resources' import core, { AccountRole, type AccountUuid, type Class, type Client, type Doc, getCurrentAccount, hasAccountRole, notEmpty, type Ref, type Space, type Timestamp } from '@hcengineering/core' import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification' import { InboxNotificationsClientImpl, isActivityNotification, isMentionNotification, isReactionNotification } from '@hcengineering/notification-resources' import { type Asset, getMetadata, translate } from '@hcengineering/platform' import { getClient } from '@hcengineering/presentation' import { type AnySvelteComponent, languageStore } from '@hcengineering/ui' import { classIcon, getDocLinkTitle, getDocTitle } from '@hcengineering/view-resources' import { get, type Unsubscriber, writable } from 'svelte/store' import ChannelIcon from './components/ChannelIcon.svelte' import DirectIcon from './components/DirectIcon.svelte' import { openChannelInSidebar, resetChunterLocIfEqual } from './navigation' import chunter from './plugin' import { shownTranslatedMessagesStore, translatedMessagesStore, translatingMessagesStore } from './stores' import love, { type MeetingMinutes } from '@hcengineering/love' export async function getDmName (client: Client, space?: Space): Promise { if (space === undefined) { return '' } return await buildDmName(client, space.members) } export async function buildDmName (client: Client, accounts: AccountUuid[]): Promise { if (accounts.length === 0) { return '' } let unsub: Unsubscriber | undefined const employeeByAccountPromise = new Promise>((resolve) => { unsub = employeeByAccountStore.subscribe((p) => { resolve(p) }) }) const me = getCurrentEmployee() const employeeByAccount = await employeeByAccountPromise unsub?.() const names: string[] = [] const processedPersons: Array> = [] let myName = '' for (const acc of accounts) { const employee = employeeByAccount.get(acc) ?? (await client.findOne(contact.class.Person, { personUuid: acc })) if (employee === undefined) { continue } if (processedPersons.includes(employee._id)) { continue } if (me === employee._id) { myName = getName(client.getHierarchy(), employee) processedPersons.push(employee._id) continue } names.push(getName(client.getHierarchy(), employee)) processedPersons.push(employee._id) } return names.length > 0 ? names.join(', ') : myName } export async function dmIdentifierProvider (): Promise { return await translate(chunter.string.Direct, {}) } export async function canDeleteMessage (doc?: ChatMessage): Promise { if (doc === undefined) { return false } const me = getCurrentAccount() if (hasAccountRole(me, AccountRole.Maintainer)) { return true } return doc.createdBy !== undefined && me.socialIds.includes(doc.createdBy) } export function canReplyToThread (doc?: ActivityMessage): boolean { if (doc === undefined) { return false } if (doc._class === chunter.class.ThreadMessage) { return false } if (doc._class === activity.class.DocUpdateMessage) { return (doc as DocUpdateMessage).objectClass !== activity.class.Reaction } return true } export async function canCopyMessageLink (doc?: ActivityMessage | ActivityMessage[]): Promise { const message = Array.isArray(doc) ? doc[0] : doc if (message === undefined) { return false } if (message._class === activity.class.DocUpdateMessage) { return (message as DocUpdateMessage).objectClass !== activity.class.Reaction } return true } export async function getDmPersons (client: Client, space: Space): Promise { if (space === undefined) { return [] } const myAcc = getCurrentAccount().uuid const accounts = space.members.length > 1 ? space.members.filter((m) => m !== myAcc) : [myAcc] return await client.findAll(contact.class.Person, { personUuid: { $in: accounts } }) } export async function DirectTitleProvider ( client: Client, id: Ref, doc?: DirectMessage ): Promise { const direct = doc ?? (await client.findOne(chunter.class.DirectMessage, { _id: id })) if (direct === undefined) { return '' } return await getDmName(client, direct) } export async function ChannelTitleProvider (client: Client, id: Ref, doc?: Channel): Promise { const channel = doc ?? (await client.findOne(chunter.class.Channel, { _id: id })) if (channel === undefined) { return '' } return channel.name } export enum SearchType { Messages, Files, Contacts } export async function getTitle (doc: Doc): Promise { const client = getClient() const hierarchy = client.getHierarchy() let clazz = hierarchy.getClass(doc._class) let label = clazz.shortLabel while (label === undefined && clazz.extends !== undefined) { clazz = hierarchy.getClass(clazz.extends) label = clazz.shortLabel } label = label ?? doc._class return `${label}-${doc._id}` } export function getObjectIcon (_class: Ref>): Asset | AnySvelteComponent | undefined { const client = getClient() const hierarchy = client.getHierarchy() if (_class === chunter.class.Channel) { return ChannelIcon } if (_class === chunter.class.DirectMessage) { return DirectIcon } if (hierarchy.isDerived(_class, contact.class.Person)) { return PersonIcon } return classIcon(client, _class) } export async function getChannelName ( _id: Ref, _class: Ref>, object?: Doc ): Promise { const client = getClient() if (client.getHierarchy().isDerived(_class, chunter.class.ChunterSpace)) { return await getDocTitle(client, _id, _class, object) } return await getDocLinkTitle(client, _id, _class, object) } export function getUnreadThreadsCount (): number { const notificationClient = InboxNotificationsClientImpl.getClient() const threadIds = get(notificationClient.activityInboxNotifications) .filter(({ attachedToClass, isViewed }) => attachedToClass === chunter.class.ThreadMessage && !isViewed) .map(({ $lookup }) => $lookup?.attachedTo?.attachedTo) .filter((_id) => _id !== undefined) return new Set(threadIds).size } export function getClosestDate (selectedDate: Timestamp, dates: Timestamp[]): Timestamp | undefined { if (dates.length === 0) { return } let closestDate: Timestamp | undefined = dates[dates.length - 1] const reversedDates = [...dates].reverse() for (const date of reversedDates) { if (date < selectedDate) { break } else if (date - selectedDate < closestDate - selectedDate) { closestDate = date } } return closestDate } export function filterChatMessages ( messages: DisplayActivityMessage[], filters: ActivityMessagesFilter[], filterResources: Map, (message: ActivityMessage, _class?: Ref) => boolean>, objectClass: Ref>, selectedIds: Array> ): DisplayActivityMessage[] { if (selectedIds.length === 0 || selectedIds.includes(activity.ids.AllFilter)) { return messages } const selectedFilters = filters.filter(({ _id }) => selectedIds.includes(_id)) if (selectedFilters.length === 0) { return messages } const filtersFns: Array<(message: ActivityMessage, _class?: Ref) => boolean> = [] for (const filter of selectedFilters) { const filterFn = filterResources.get(filter._id) if (filterFn !== undefined) { filtersFns.push(filterFn) } } return messages.filter((message) => filtersFns.some((filterFn) => filterFn(message, objectClass))) } export async function joinChannel (channel: Channel, value: AccountUuid | AccountUuid[]): Promise { const client = getClient() if (Array.isArray(value)) { if (value.length > 0) { await client.update(channel, { $push: { members: { $each: value, $position: 0 } } }) } } else { await client.update(channel, { $push: { members: value } }) } } export async function leaveChannel (channel: Channel | undefined, value: AccountUuid | AccountUuid[]): Promise { if (channel === undefined) return const client = getClient() if (Array.isArray(value)) { if (value.length > 0) { await client.update(channel, { $pull: { members: { $in: value } } }) } } else { await client.update(channel, { $pull: { members: value } }) await resetChunterLocIfEqual(channel._id, channel._class, channel) } } // NOTE: Store timestamp updates to avoid unnecessary updates when if the server takes a long time to respond const contextsTimestampStore = writable, number>>(new Map()) // NOTE: Sometimes user can read message before notification is created and we should mark it as viewed when notification is received export const chatReadMessagesStore = writable>>(new Set()) function getAllIds (messages: DisplayActivityMessage[]): Array> { return messages .map((message) => { const combined = message._class === activity.class.DocUpdateMessage ? (message as DisplayDocUpdateMessage)?.combinedMessagesIds : undefined return [message._id, ...(combined ?? [])] }) .flat() } let toReadTimer: any const toRead = new Set>() export function recheckNotifications (context: DocNotifyContext): void { const client = getClient() const inboxClient = InboxNotificationsClientImpl.getClient() const messages = get(chatReadMessagesStore) if (messages.size === 0) { return } const notifications = get(inboxClient.inboxNotificationsByContext).get(context._id) ?? [] notifications .filter((it) => { if (it.isViewed) { return false } if (isMentionNotification(it)) { return messages.has(it.mentionedIn as Ref) } if (isActivityNotification(it)) { return messages.has(it.attachedTo) } return false }) .forEach((n) => toRead.add(n._id)) clearTimeout(toReadTimer) toReadTimer = setTimeout(() => { const toReadData = Array.from(toRead) toRead.clear() void (async () => { const _client = client.apply(undefined, 'recheckNotifications', true) await inboxClient.readNotifications(_client, toReadData) await _client.commit() })() }, 500) } export async function readChannelMessages ( messages: DisplayActivityMessage[], contextId: Ref ): Promise { if (messages.length === 0) { return } const inboxClient = InboxNotificationsClientImpl.getClient() const context = get(inboxClient.contextById).get(contextId) if (context === undefined) return const op = getClient().apply(undefined, 'readViewportMessages', true) try { const allIds = getAllIds(messages) const notifications = get(inboxClient.activityInboxNotifications) .filter(({ attachedTo, $lookup, isViewed }) => { if (isViewed) return false return allIds.includes(attachedTo) }) .map((n) => n._id) const relatedMentions = get(inboxClient.otherInboxNotifications) .filter((n) => !n.isViewed && isMentionNotification(n) && allIds.includes(n.mentionedIn as Ref)) .map((n) => n._id) const reactionNotifications = get(inboxClient.otherInboxNotifications) .filter((n) => !n.isViewed && isReactionNotification(n) && allIds.includes(n.attachedTo)) .map((n) => n._id) chatReadMessagesStore.update((store) => new Set([...store, ...allIds])) const storedTimestampUpdates = get(contextsTimestampStore).get(context._id) const newTimestamp = messages[messages.length - 1].createdOn ?? 0 const prevTimestamp = Math.max(storedTimestampUpdates ?? 0, context.lastViewedTimestamp ?? 0) if (prevTimestamp < newTimestamp) { contextsTimestampStore.update((store) => { store.set(context._id, newTimestamp) return store }) await op.update(context, { lastViewedTimestamp: newTimestamp }) } await inboxClient.readNotifications(op, [...notifications, ...relatedMentions, ...reactionNotifications]) } finally { await op.commit() } } export async function leaveChannelAction ( context?: DocNotifyContext, _?: Event, props?: { object?: Channel } ): Promise { if (context === undefined) { return } const client = getClient() const channel = props?.object ?? (await client.findOne(chunter.class.Channel, { _id: context.objectId as Ref })) if (channel === undefined) { return } await leaveChannel(channel, getCurrentAccount().uuid) await client.remove(context) await resetChunterLocIfEqual(channel._id, channel._class, channel) } export async function removeChannelAction (context?: DocNotifyContext, _?: Event): Promise { if (context === undefined) { return } const client = getClient() const hierarchy = client.getHierarchy() const { objectId, objectClass, objectSpace } = context if (hierarchy.isDerived(objectClass, chunter.class.Channel)) { const channel = await client.findOne(chunter.class.Channel, { _id: objectId as Ref, space: objectSpace }) await leaveChannel(channel, getCurrentAccount().uuid) await client.remove(context) } else { const object = await client.findOne(objectClass, { _id: objectId, space: objectSpace }) await client.update(context, { hidden: true }) await resetChunterLocIfEqual(objectId, objectClass, object) } } export function isThreadMessage (message: ActivityMessage): message is ThreadMessage { return message._class === chunter.class.ThreadMessage } export function getChannelSpace (_class: Ref>, _id: Ref, space: Ref): Ref { return getClient().getHierarchy().isDerived(_class, core.class.Space) ? (_id as Ref) : space } export async function translateMessage (message: ChatMessage): Promise { if (get(translatingMessagesStore).has(message._id)) { return } if (get(translatedMessagesStore).has(message._id)) { shownTranslatedMessagesStore.update((store) => store.add(message._id)) return } translatingMessagesStore.update((store) => store.add(message._id)) const response = await aiTranslate(message.message, get(languageStore)) if (response !== undefined) { translatedMessagesStore.update((store) => store.set(message._id, response.text)) shownTranslatedMessagesStore.update((store) => store.add(message._id)) } translatingMessagesStore.update((store) => { store.delete(message._id) return store }) } export async function showOriginalMessage (message: ChatMessage): Promise { shownTranslatedMessagesStore.update((store) => { store.delete(message._id) return store }) } export async function canTranslateMessage (): Promise { const url = getMetadata(aiBot.metadata.EndpointURL) ?? '' return url !== '' } export async function summarizeMessages (doc: Doc): Promise { await aiSummarizeMessages(get(languageStore), doc._id, doc._class) } export async function canSummarizeMessages (doc: Doc): Promise { if (doc?._id === undefined) return false const url = getMetadata(aiBot.metadata.EndpointURL) ?? '' if (url === '') return false const client = getClient() const hierarchy = client.getHierarchy() if (!hierarchy.isDerived(doc._class, love.class.MeetingMinutes)) return false return ((doc as MeetingMinutes).transcription ?? 0) > 0 } export async function startConversationAction (docs?: Employee | Employee[]): Promise { if (docs === undefined) return const employees = Array.isArray(docs) ? docs : [docs] const employeeIds = employees.map(({ _id }) => _id) const dm = await createDirect(employeeIds) if (dm !== undefined) { await openChannelInSidebar(dm, chunter.class.DirectMessage, undefined, undefined, true) } } export async function createDirect (employeeIds: Array>): Promise> { const client = getClient() const me = getCurrentEmployee() const myAcc = getCurrentAccount() const existingDms = await client.findAll(chunter.class.DirectMessage, {}) const newDirectEmployeeIds = Array.from(new Set([...employeeIds, me])) let direct: DirectMessage | undefined const employeeById = get(employeeByIdStore) const newDirectAccounts = new Set(newDirectEmployeeIds.map((it) => employeeById.get(it)?.personUuid).filter(notEmpty)) for (const dm of existingDms) { const existAccounts = new Set(dm.members) if (existAccounts.size !== newDirectAccounts.size) { continue } let match = true for (const acc of existAccounts) { if (!newDirectAccounts.has(acc)) { match = false break } } if (match) { direct = dm break } } const dmId = direct?._id ?? (await client.createDoc(chunter.class.DirectMessage, core.space.Space, { name: '', description: '', private: true, archived: false, members: Array.from(newDirectAccounts) })) const context = await client.findOne(notification.class.DocNotifyContext, { user: myAcc.uuid, objectId: dmId, objectClass: chunter.class.DirectMessage }) if (context !== undefined) { if (context.hidden) { await client.updateDoc(context._class, context.space, context._id, { hidden: false }) } } else { const space = await client.findOne(contact.class.PersonSpace, { person: me }, { projection: { _id: 1 } }) if (space == null) return dmId await client.createDoc(notification.class.DocNotifyContext, space._id, { user: myAcc.uuid, objectId: dmId, objectClass: chunter.class.DirectMessage, objectSpace: core.space.Space, hidden: false, isPinned: false }) } return dmId }