mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-26 11:34:58 +02:00
Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
// Copyright © 2025 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 communication, {
|
|
type Applet,
|
|
type MessageAction,
|
|
type MessageActionFunction,
|
|
type MessageActionVisibilityTester
|
|
} from '@hcengineering/communication'
|
|
import { languageStore, showPopup } from '@hcengineering/ui'
|
|
import emojiPlugin from '@hcengineering/emoji'
|
|
import {
|
|
type AppletParams,
|
|
type AttachmentID,
|
|
type BlobAttachment,
|
|
type BlobParams,
|
|
type LinkPreviewParams,
|
|
linkPreviewType,
|
|
type Message,
|
|
type MessageID,
|
|
MessageType
|
|
} from '@hcengineering/communication-types'
|
|
import cardPlugin, { type Card, type MasterTag } from '@hcengineering/card'
|
|
import { addRefreshListener, deleteFile, getClient, getCommunicationClient } from '@hcengineering/presentation'
|
|
import {
|
|
AccountRole,
|
|
fillDefaults,
|
|
generateId,
|
|
getCurrentAccount,
|
|
hasAccountRole,
|
|
type MarkupBlobRef,
|
|
type Ref
|
|
} from '@hcengineering/core'
|
|
import { getMetadata, getResource } from '@hcengineering/platform'
|
|
import { employeeByPersonIdStore } from '@hcengineering/contact-resources'
|
|
import { getEmployeeBySocialId } from '@hcengineering/contact'
|
|
import chat from '@hcengineering/chat'
|
|
import { markupToText } from '@hcengineering/text'
|
|
import { get } from 'svelte/store'
|
|
import { translate as aiTranslate } from '@hcengineering/ai-bot-resources'
|
|
import aiBot from '@hcengineering/ai-bot'
|
|
import CreateCardFromMessagePopup from './components/CreateCardFromMessagePopup.svelte'
|
|
import { Analytics } from '@hcengineering/analytics'
|
|
import { isAppletAttachment, isBlobAttachment, isLinkPreviewAttachment } from '@hcengineering/communication-shared'
|
|
|
|
import { isCardAllowedForCommunications, loadLinkPreviewParams, showForbidden, toggleReaction, toMarkup } from './utils'
|
|
import {
|
|
isMessageManualTranslating,
|
|
messageEditingStore,
|
|
showOriginalMessagesStore,
|
|
threadCreateMessageStore,
|
|
translateMessagesStore,
|
|
translateToStore
|
|
} from './stores'
|
|
import { type AppletDraft, type BlobDraft, type LinkPreviewDraft } from './types'
|
|
|
|
export const addReaction: MessageActionFunction = async (message, card: Card, evt, onOpen, onClose) => {
|
|
if (!isCardAllowedForCommunications(card)) {
|
|
await showForbidden()
|
|
return
|
|
}
|
|
|
|
if (onOpen !== undefined) onOpen()
|
|
|
|
showPopup(
|
|
emojiPlugin.component.EmojiPopup,
|
|
{},
|
|
evt?.target as HTMLElement,
|
|
async (result) => {
|
|
if (onClose !== undefined) onClose()
|
|
const emoji = result?.text
|
|
if (emoji == null) return
|
|
|
|
await toggleReaction(message, emoji)
|
|
},
|
|
() => {}
|
|
)
|
|
}
|
|
|
|
export const replyInThread: MessageActionFunction = async (message: Message, parentCard: Card): Promise<void> => {
|
|
if (!isCardAllowedForCommunications(parentCard)) {
|
|
await showForbidden()
|
|
return
|
|
}
|
|
|
|
const thread = message.threads[0]
|
|
if (thread != null) {
|
|
const client = getClient()
|
|
const _id = thread.threadId
|
|
const card = await client.findOne(cardPlugin.class.Card, { _id: _id as Ref<Card> })
|
|
if (card === undefined) return
|
|
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
|
|
await r(_id, card)
|
|
return
|
|
}
|
|
|
|
await attachCardToMessage(
|
|
message,
|
|
parentCard,
|
|
createThreadTitle(message, parentCard),
|
|
chat.masterTag.Thread,
|
|
`${parentCard._id}_${message.id}` as Ref<Card>
|
|
)
|
|
}
|
|
|
|
export async function attachCardToMessage (
|
|
message: Message,
|
|
parentCard: Card,
|
|
title: string,
|
|
type: Ref<MasterTag>,
|
|
_id?: Ref<Card>
|
|
): Promise<void> {
|
|
const client = getClient()
|
|
const communicationClient = getCommunicationClient()
|
|
const hierarchy = client.getHierarchy()
|
|
|
|
const threadCardID = _id ?? generateId<Card>()
|
|
|
|
const author =
|
|
get(employeeByPersonIdStore).get(message.creator) ?? (await getEmployeeBySocialId(client, message.creator))
|
|
const data = fillDefaults<Card>(
|
|
hierarchy,
|
|
{
|
|
title,
|
|
rank: '',
|
|
content: '' as MarkupBlobRef,
|
|
blobs: {},
|
|
parentInfo: [
|
|
{
|
|
_id: parentCard._id,
|
|
_class: parentCard._class,
|
|
title: parentCard.title
|
|
}
|
|
]
|
|
},
|
|
type
|
|
)
|
|
await client.createDoc(type, parentCard.space, data, threadCardID)
|
|
await communicationClient.attachThread(parentCard._id, message.id, threadCardID, type)
|
|
if (author?.active === true && author?.personUuid !== undefined) {
|
|
await communicationClient.addCollaborators(threadCardID, type, [author.personUuid])
|
|
}
|
|
const threadCard = await client.findOne(cardPlugin.class.Card, { _id: threadCardID })
|
|
if (threadCard === undefined) return
|
|
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
|
|
await r(threadCard._id, threadCard)
|
|
}
|
|
|
|
function createThreadTitle (message: Message, parent: Card): string {
|
|
const markup = toMarkup(message.content)
|
|
const messageText = markupToText(markup).trim()
|
|
|
|
return messageText.length > 0 ? messageText : `Thread from ${parent.title}`
|
|
}
|
|
|
|
export const canReplyInThread: MessageActionVisibilityTester = (message: Message): boolean => {
|
|
return message.type === MessageType.Text && message.extra?.threadRoot !== true
|
|
}
|
|
|
|
export const translateMessage: MessageActionFunction = async (message: Message): Promise<void> => {
|
|
const language = get(translateToStore) ?? get(languageStore)
|
|
|
|
if (isMessageManualTranslating(message.cardId, message.id)) return
|
|
const result = get(translateMessagesStore).find((it) => it.cardId === message.cardId && it.messageId === message.id)
|
|
|
|
showOriginalMessagesStore.update((store) =>
|
|
store.filter(([cId, mId]) => cId !== message.cardId || mId !== message.id)
|
|
)
|
|
|
|
if (result != null) return
|
|
|
|
translateMessagesStore.update((store) => {
|
|
store.push({ inProgress: true, messageId: message.id, cardId: message.cardId })
|
|
return store
|
|
})
|
|
|
|
const markup = toMarkup(message.content)
|
|
const currentTranslate = message?.translates?.[language] ?? ''
|
|
const response = currentTranslate !== '' ? toMarkup(currentTranslate) : (await aiTranslate(markup, language))?.text
|
|
|
|
if (response !== undefined) {
|
|
translateMessagesStore.update((store) => {
|
|
return store.map((it) => {
|
|
if (it.cardId === message.cardId && it.messageId === message.id) {
|
|
return {
|
|
...it,
|
|
inProgress: false,
|
|
result: response
|
|
}
|
|
}
|
|
return it
|
|
})
|
|
})
|
|
} else {
|
|
translateMessagesStore.update((store) => {
|
|
return store.filter((it) => it.cardId !== message.cardId || it.messageId !== message.id)
|
|
})
|
|
}
|
|
}
|
|
|
|
export const canTranslateMessage: MessageActionVisibilityTester = (message: Message): boolean => {
|
|
const url = getMetadata(aiBot.metadata.EndpointURL) ?? ''
|
|
if (url === '') return false
|
|
return message.type === MessageType.Text
|
|
}
|
|
|
|
export const showOriginalMessage: MessageActionFunction = async (message: Message): Promise<void> => {
|
|
const messageId = message.id
|
|
|
|
showOriginalMessagesStore.update((store) => {
|
|
store.push([message.cardId, messageId])
|
|
return store
|
|
})
|
|
}
|
|
|
|
export const canShowOriginalMessage: MessageActionVisibilityTester = (message: Message): boolean => {
|
|
return canTranslateMessage(message)
|
|
}
|
|
|
|
export const editMessage: MessageActionFunction = async (message: Message): Promise<void> => {
|
|
messageEditingStore.set(message.id)
|
|
}
|
|
|
|
export const canEditMessage: MessageActionVisibilityTester = (message: Message): boolean => {
|
|
if (message.type !== MessageType.Text) return false
|
|
const me = getCurrentAccount()
|
|
return me.socialIds.includes(message.creator)
|
|
}
|
|
|
|
export const removeMessage: MessageActionFunction = async (message: Message): Promise<void> => {
|
|
const communicationClient = getCommunicationClient()
|
|
await communicationClient.removeMessage(message.cardId, message.id)
|
|
}
|
|
|
|
export const canRemoveMessage: MessageActionVisibilityTester = (message: Message): boolean => {
|
|
if (message.type !== MessageType.Text) return false
|
|
const me = getCurrentAccount()
|
|
return me.socialIds.includes(message.creator)
|
|
}
|
|
|
|
export const createCard: MessageActionFunction = async (message: Message, card: Card): Promise<void> => {
|
|
if (!hasAccountRole(getCurrentAccount(), AccountRole.User)) {
|
|
await showForbidden()
|
|
return
|
|
}
|
|
threadCreateMessageStore.set(message)
|
|
showPopup(CreateCardFromMessagePopup, { message, card }, undefined, () => {
|
|
threadCreateMessageStore.set(undefined)
|
|
})
|
|
}
|
|
|
|
export const canCreateCard: MessageActionVisibilityTester = (message: Message): boolean => {
|
|
return canReplyInThread(message)
|
|
}
|
|
|
|
let allMessageActions: MessageAction[] | undefined
|
|
|
|
addRefreshListener(() => {
|
|
allMessageActions = undefined
|
|
})
|
|
|
|
export async function getMessageActions (message: Message): Promise<MessageAction[]> {
|
|
const client = getClient()
|
|
const actions: MessageAction[] =
|
|
allMessageActions ?? client.getModel().findAllSync(communication.class.MessageAction, {})
|
|
|
|
if (allMessageActions === undefined) {
|
|
allMessageActions = actions
|
|
}
|
|
|
|
const filteredActions = await filterActions(message, actions)
|
|
|
|
return filteredActions.sort((a, b) => a.order - b.order)
|
|
}
|
|
|
|
async function filterActions (message: Message, actions: MessageAction[]): Promise<MessageAction[]> {
|
|
const result: MessageAction[] = []
|
|
for (const action of actions) {
|
|
if (action.visibilityTester == null) {
|
|
result.push(action)
|
|
} else {
|
|
const visibilityTester = await getResource(action.visibilityTester)
|
|
|
|
if (visibilityTester(message)) {
|
|
result.push(action)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export async function createMessage (
|
|
card: Card,
|
|
markdown: string,
|
|
blobs: BlobDraft[],
|
|
applets: AppletDraft[],
|
|
appletModels: Applet[],
|
|
links: LinkPreviewDraft[],
|
|
urlsToLoad: string[],
|
|
linksData: Map<string, LinkPreviewParams | null>
|
|
): Promise<void> {
|
|
const communicationClient = getCommunicationClient()
|
|
const client = getClient()
|
|
|
|
const { messageId } = await communicationClient.createMessage(card._id, card._class, markdown)
|
|
void client.update(card, {}, false, Date.now())
|
|
|
|
void attachApplets(card, messageId, applets, appletModels)
|
|
|
|
if (blobs.length > 0) {
|
|
void communicationClient.attachmentPatch<BlobParams>(card._id, messageId, {
|
|
add: blobs.map((it) => ({
|
|
id: it.blobId as any as AttachmentID,
|
|
mimeType: it.mimeType,
|
|
params: it
|
|
}))
|
|
})
|
|
}
|
|
|
|
if (links.length > 0) {
|
|
void communicationClient.attachmentPatch<LinkPreviewParams>(card._id, messageId, {
|
|
add: links.map((it) => ({
|
|
mimeType: linkPreviewType,
|
|
params: it
|
|
}))
|
|
})
|
|
}
|
|
|
|
for (const url of urlsToLoad) {
|
|
if (links.some((it) => it.url === url)) continue
|
|
const fetchedData = linksData.get(url)
|
|
if (fetchedData === null) continue
|
|
const params = fetchedData ?? (await loadLinkPreviewParams(url))
|
|
if (params === undefined) continue
|
|
void communicationClient.attachmentPatch<LinkPreviewParams>(card._id, messageId, {
|
|
add: [
|
|
{
|
|
mimeType: linkPreviewType,
|
|
params
|
|
}
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
export async function updateMessage (
|
|
card: Card,
|
|
message: Message,
|
|
markdown: string,
|
|
blobs: BlobDraft[],
|
|
applets: AppletDraft[],
|
|
appletModels: Applet[],
|
|
links: LinkPreviewDraft[]
|
|
): Promise<void> {
|
|
const communicationClient = getCommunicationClient()
|
|
|
|
await communicationClient.updateMessage(card._id, message.id, markdown)
|
|
|
|
const attachBlobs = blobs.filter(
|
|
(b) => !message.attachments.some((it) => isBlobAttachment(it) && it.params.blobId === b.blobId)
|
|
)
|
|
|
|
void attachApplets(
|
|
card,
|
|
message.id,
|
|
applets.filter((a) => !message.attachments.some((it) => it.id === a.id)),
|
|
appletModels
|
|
)
|
|
|
|
if (attachBlobs.length > 0) {
|
|
void communicationClient.attachmentPatch<BlobParams>(card._id, message.id, {
|
|
add: attachBlobs.map((it) => ({
|
|
id: it.blobId as any as AttachmentID,
|
|
mimeType: it.mimeType,
|
|
params: it
|
|
}))
|
|
})
|
|
}
|
|
|
|
const detachBlobs = message.attachments.filter(
|
|
(it) => isBlobAttachment(it) && !blobs.some((b) => b.blobId === it.params.blobId)
|
|
) as BlobAttachment[]
|
|
if (detachBlobs.length > 0) {
|
|
detachBlobs.forEach((it) => {
|
|
void deleteFile(it.params.blobId)
|
|
})
|
|
void communicationClient.attachmentPatch(card._id, message.id, {
|
|
remove: detachBlobs.map((it) => it.id)
|
|
})
|
|
}
|
|
|
|
const detachApplets = message.attachments.filter(
|
|
(it) => isAppletAttachment(it) && !applets.some((a) => a.id === it.id)
|
|
)
|
|
|
|
if (detachApplets.length > 0) {
|
|
void communicationClient.attachmentPatch(card._id, message.id, {
|
|
remove: detachApplets.map((it) => it.id)
|
|
})
|
|
}
|
|
|
|
const attachLinks = links.filter(
|
|
(l) => !message.attachments.some((it) => isLinkPreviewAttachment(it) && it.params.url === l.url)
|
|
)
|
|
|
|
void communicationClient.attachmentPatch(card._id, message.id, {
|
|
add: attachLinks.map((it) => ({
|
|
mimeType: linkPreviewType,
|
|
params: it
|
|
}))
|
|
})
|
|
}
|
|
|
|
async function attachApplets (
|
|
card: Card,
|
|
messageId: MessageID,
|
|
applets: AppletDraft[],
|
|
models: Applet[]
|
|
): Promise<void> {
|
|
if (applets.length === 0) return
|
|
|
|
const communicationClient = getCommunicationClient()
|
|
const toAttach: AppletDraft[] = []
|
|
|
|
for (const appletDraft of applets) {
|
|
try {
|
|
const model = models.find((it) => it._id === appletDraft.appletId)
|
|
if (model?.createFn == null) {
|
|
toAttach.push(appletDraft)
|
|
continue
|
|
}
|
|
const r = await getResource(model.createFn)
|
|
await r(card, messageId, appletDraft.params)
|
|
toAttach.push(appletDraft)
|
|
} catch (err: any) {
|
|
Analytics.handleError(err)
|
|
}
|
|
}
|
|
|
|
if (toAttach.length > 0) {
|
|
void communicationClient.attachmentPatch<AppletParams>(card._id, messageId, {
|
|
add: toAttach.map((it) => ({
|
|
mimeType: it.mimeType,
|
|
params: it.params
|
|
}))
|
|
})
|
|
}
|
|
}
|