Init poll applet (#9586)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2025-07-23 19:05:48 +07:00
committed by GitHub
parent fed9ef4e5a
commit 679a3df09f
56 changed files with 2500 additions and 307 deletions
+1
View File
@@ -32,6 +32,7 @@
"@hcengineering/card": "^0.6.0",
"@hcengineering/communication": "^0.6.0",
"@hcengineering/communication-resources": "^0.6.0",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/model": "^0.6.11",
+35
View File
@@ -0,0 +1,35 @@
// 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 { type Builder } from '@hcengineering/model'
import core from '@hcengineering/core'
import communication from './plugin'
export function buildApplets (builder: Builder): void {
builder.createDoc(
communication.class.Applet,
core.space.Model,
{
type: 'application/vnd.huly.applet.poll',
label: communication.string.Poll,
icon: communication.icon.Poll,
component: communication.poll.PollPresenter,
createLabel: communication.string.CreatePoll,
createComponent: communication.poll.CreatePoll,
previewComponent: communication.poll.PollPreview,
createFn: communication.poll.CreatePollFn
},
communication.ids.PollApplet
)
}
+2
View File
@@ -19,6 +19,7 @@ import { MessagesNavigationAnchors } from '@hcengineering/communication'
import communication from './plugin'
import { buildTypes } from './types'
import { buildCardActions, buildMessageActions } from './actions'
import { buildApplets } from './applets'
export { communicationId } from '@hcengineering/communication'
export * from './migration'
@@ -27,6 +28,7 @@ export function createModel (builder: Builder): void {
buildTypes(builder)
buildMessageActions(builder)
buildCardActions(builder)
buildApplets(builder)
builder.createDoc(
card.class.CardSection,
+5 -2
View File
@@ -13,9 +13,9 @@
// limitations under the License.
//
import { communicationId } from '@hcengineering/communication'
import { communicationId, type Poll } from '@hcengineering/communication'
import communication from '@hcengineering/communication-resources/src/plugin'
import {} from '@hcengineering/core'
import { type Attribute, type Ref } from '@hcengineering/core'
import {} from '@hcengineering/ui'
import { mergeIds, type Resource } from '@hcengineering/platform'
import { type ViewAction } from '@hcengineering/model-view'
@@ -29,5 +29,8 @@ export default mergeIds(communicationId, communication, {
function: {
CanSubscribe: '' as Resource<(doc: Card | Card[] | undefined) => Promise<boolean>>,
CanUnsubscribe: '' as Resource<(doc: Card | Card[] | undefined) => Promise<boolean>>
},
ids: {
UserVotesAttribute: '' as Ref<Attribute<Poll>>
}
})
+90 -7
View File
@@ -11,15 +11,30 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { type Builder, Model } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import { DOMAIN_MODEL } from '@hcengineering/core'
import { type Builder, Model, TypeAny, TypeNumber } from '@hcengineering/model'
import core, { TAttachedDoc, TDoc } from '@hcengineering/model-core'
import { type Class, type Domain, DOMAIN_MODEL, type Ref } from '@hcengineering/core'
import { type Asset, type IntlString } from '@hcengineering/platform'
import communication, {
import {
type Applet,
type MessageAction,
type MessageActionFunctionResource,
type MessageActionVisibilityTesterResource
type MessageActionVisibilityTesterResource,
type AppletCreateFnResource,
type PollAnswer,
type Poll,
type CustomActivityPresenter
} from '@hcengineering/communication'
import { PaletteColorIndexes } from '@hcengineering/ui/src/colors'
import { type AppletType } from '@hcengineering/communication-types'
import { createSystemType } from '@hcengineering/model-card'
import type { AnyComponent } from '@hcengineering/ui'
import { type PersonSpace } from '@hcengineering/contact'
import communication from './plugin'
import { type MasterTag } from '@hcengineering/card'
export const DOMAIN_POLL = 'poll' as Domain
@Model(communication.class.MessageAction, core.class.Doc, DOMAIN_MODEL)
class TMessageAction extends TDoc implements MessageAction {
@@ -32,6 +47,74 @@ class TMessageAction extends TDoc implements MessageAction {
menu?: boolean
}
export function buildTypes (builder: Builder): void {
builder.createModel(TMessageAction)
@Model(communication.class.Applet, core.class.Doc, DOMAIN_MODEL)
class TApplet extends TDoc implements Applet {
type!: AppletType
icon!: Asset
label!: IntlString
component!: AnyComponent
createLabel!: IntlString
createComponent!: AnyComponent
previewComponent!: AnyComponent
createFn?: AppletCreateFnResource
}
@Model(communication.class.PollAnswer, core.class.Doc, DOMAIN_POLL)
class TPollAnswer extends TAttachedDoc implements PollAnswer {
options!: string[]
declare attachedTo: Ref<Poll>
declare attachedToClass: Ref<Class<Poll>>
declare space: Ref<PersonSpace>
}
@Model(communication.class.CustomActivityPresenter, core.class.Doc, DOMAIN_MODEL)
class TCustomActivityPresenter extends TDoc implements CustomActivityPresenter {
attribute!: string
component!: AnyComponent
type!: Ref<MasterTag>
}
export function buildTypes (builder: Builder): void {
builder.createModel(TMessageAction, TApplet, TPollAnswer, TCustomActivityPresenter)
createSystemType(
builder,
communication.type.Poll,
communication.icon.Poll,
communication.string.Poll,
communication.string.Polls,
undefined,
PaletteColorIndexes.Cerulean
)
builder.createDoc(core.class.Attribute, core.space.Model, {
name: 'totalVotes',
attributeOf: communication.type.Poll,
type: TypeNumber(),
label: communication.string.TotalVotes,
readonly: true
})
builder.createDoc(
core.class.Attribute,
core.space.Model,
{
name: 'userVotes',
attributeOf: communication.type.Poll,
type: TypeAny(
communication.poll.UserVotesPresenter,
communication.string.Voted,
communication.poll.UserVotesPresenter
),
label: communication.string.Voted,
readonly: true
},
communication.ids.UserVotesAttribute
)
builder.createDoc(communication.class.CustomActivityPresenter, core.space.Model, {
attribute: 'userVotes',
type: communication.type.Poll,
component: communication.poll.UserVoteActivityPresenter
})
}
+41 -81
View File
@@ -14,27 +14,23 @@
//
import { initLiveQueries, refreshLiveQueries } from '@hcengineering/communication-client-query'
import {
type AddAttachmentsOperation,
type AddCollaboratorsEvent,
type AttachBlobsOperation,
type AttachLinkPreviewsOperation,
type BlobPatchEvent,
type AttachmentPatchEvent,
type CreateMessageEvent,
type CreateMessageResult,
type DetachBlobsOperation,
type UpdateBlobsOperation,
type DetachLinkPreviewsOperation,
type Event,
type EventResult,
type LinkPreviewPatchEvent,
MessageEventType,
NotificationEventType,
type ReactionPatchEvent,
type RemoveAttachmentsOperation,
type RemoveCollaboratorsEvent,
type RemoveNotificationContextEvent,
type RemovePatchEvent,
type SetBlobsOperation,
type SetLinkPreviewsOperation,
type SetAttachmentsOperation,
type ThreadPatchEvent,
type UpdateAttachmentsOperation,
type UpdateNotificationContextEvent,
type UpdateNotificationEvent,
type UpdateNotificationQuery,
@@ -42,8 +38,6 @@ import {
} from '@hcengineering/communication-sdk-types'
import {
type AccountID,
type BlobData,
type BlobID,
type CardID,
type CardType,
type Collaborator,
@@ -55,8 +49,6 @@ import {
type FindNotificationContextParams,
type FindNotificationsParams,
type Label,
type LinkPreviewData,
type LinkPreviewID,
type Markdown,
type Message,
type MessageID,
@@ -65,7 +57,10 @@ import {
type Notification,
type NotificationContext,
type SocialID,
type BlobUpdateData
type AttachmentID,
type AttachmentData,
type AttachmentParams,
type AttachmentUpdateData
} from '@hcengineering/communication-types'
import core, {
generateId,
@@ -78,10 +73,10 @@ import core, {
AccountRole
} from '@hcengineering/core'
import { onDestroy } from 'svelte'
import { generateLinkPreviewId } from '@hcengineering/communication-shared'
import { addNotification, NotificationSeverity, languageStore } from '@hcengineering/ui'
import { translate } from '@hcengineering/platform'
import view from '@hcengineering/view'
import { v4 as uuid } from 'uuid'
import { getCurrentWorkspaceUuid, getFilesUrl } from './file'
import { addTxListener, removeTxListener, type TxListener } from './utils'
@@ -118,6 +113,13 @@ export async function setCommunicationClient (platformClient: PlatformClient): P
})
}
export type AttachmentDataWithOptionalId<P extends AttachmentParams = AttachmentParams> = Omit<
AttachmentData<P>,
'id'
> & {
id?: AttachmentID
}
const COMMUNICATION = 'communication' as OperationDomain
class Client {
@@ -223,100 +225,58 @@ class Client {
await this.sendEvent(event)
}
async blobPatch (
async attachmentPatch<P extends AttachmentParams>(
cardId: CardID,
messageId: MessageID,
ops: {
attach?: BlobData[]
detach?: BlobID[]
set?: BlobData[]
update?: BlobUpdateData[]
add?: Array<AttachmentDataWithOptionalId<P>>
remove?: AttachmentID[]
set?: Array<AttachmentDataWithOptionalId<P>>
update?: Array<AttachmentUpdateData<P>>
}
): Promise<void> {
const operations: Array<AttachBlobsOperation | DetachBlobsOperation | SetBlobsOperation | UpdateBlobsOperation> = []
const operations: Array<
AddAttachmentsOperation | RemoveAttachmentsOperation | SetAttachmentsOperation | UpdateAttachmentsOperation
> = []
if (ops.attach != null && ops.attach.length > 0) {
if (ops.add != null && ops.add.length > 0) {
operations.push({
opcode: 'attach',
blobs: ops.attach
opcode: 'add',
attachments: ops.add.map((it) => ({
...it,
id: it.id ?? (uuid() as AttachmentID)
}))
})
}
if (ops.detach != null && ops.detach.length > 0) {
if (ops.remove != null && ops.remove.length > 0) {
operations.push({
opcode: 'detach',
blobIds: ops.detach
opcode: 'remove',
ids: ops.remove
})
}
if (ops.set != null && ops.set.length > 0) {
operations.push({
opcode: 'set',
blobs: ops.set
attachments: ops.set.map((it) => ({
...it,
id: it.id ?? (uuid() as AttachmentID)
}))
})
}
if (ops.update != null && ops.update.length > 0) {
operations.push({
opcode: 'update',
blobs: ops.update
attachments: ops.update
})
}
if (operations.length === 0) return
const event: BlobPatchEvent = {
type: MessageEventType.BlobPatch,
cardId,
messageId,
operations,
socialId: this.getSocialId()
}
await this.sendEvent(event)
}
async linkPreviewPatch (
cardId: CardID,
messageId: MessageID,
ops: {
attach?: LinkPreviewData[]
detach?: LinkPreviewID[]
set?: LinkPreviewData[]
}
): Promise<void> {
const operations: Array<AttachLinkPreviewsOperation | DetachLinkPreviewsOperation | SetLinkPreviewsOperation> = []
if (ops.attach != null && ops.attach.length > 0) {
operations.push({
opcode: 'attach',
previews: ops.attach.map((it) => ({
...it,
previewId: generateLinkPreviewId()
}))
})
}
if (ops.detach != null && ops.detach.length > 0) {
operations.push({
opcode: 'detach',
previewIds: ops.detach
})
}
if (ops.set != null && ops.set.length > 0) {
operations.push({
opcode: 'set',
previews: ops.set.map((it) => ({
...it,
previewId: generateLinkPreviewId()
}))
})
}
if (operations.length === 0) return
const event: LinkPreviewPatchEvent = {
type: MessageEventType.LinkPreviewPatch,
const event: AttachmentPatchEvent = {
type: MessageEventType.AttachmentPatch,
cardId,
messageId,
operations,
+14 -1
View File
@@ -22,6 +22,7 @@
export let kind: 'default' | 'primary' | 'positive' | 'negative' | 'todo' = 'default'
export let color: string | undefined = undefined
export let readonly: boolean = false
export let disabled: boolean = false
const dispatch = createEventDispatcher()
@@ -50,9 +51,16 @@
class:circle
class:readonly
class:checked
class:disabled
on:click|stopPropagation
>
<input class="chBox" disabled={readonly} type="checkbox" bind:checked on:change|capture={handleValueChanged} />
<input
class="chBox"
disabled={readonly || disabled}
type="checkbox"
bind:checked
on:change|capture={handleValueChanged}
/>
<div class="checkSVG" />
</label>
@@ -63,6 +71,11 @@
align-items: center;
flex-shrink: 0;
&.disabled {
pointer-events: none;
cursor: default;
}
.checkSVG {
position: relative;
+3 -1
View File
@@ -19,13 +19,14 @@
import Label from './Label.svelte'
import ButtonBase from './ButtonBase.svelte'
import Scroller from './Scroller.svelte'
import ui from '..'
import ui, { LabelAndProps } from '..'
export let type: 'type-aside' | 'type-popup' | 'type-component'
export let width: 'large' | 'medium' | 'small' | 'x-small' | 'menu' | undefined = undefined
export let label: IntlString | undefined = undefined
export let labelProps: any | undefined = undefined
export let okAction: () => Promise<void> | void = () => {}
export let okTooltip: LabelAndProps | undefined = undefined
export let onCancel: (() => void) | undefined = undefined
export let canSave: boolean = false
export let okLabel: IntlString = ui.string.Ok
@@ -109,6 +110,7 @@
type={'type-button'}
kind={'primary'}
size={type === 'type-aside' ? 'large' : 'medium'}
tooltip={okTooltip}
label={okLabel}
on:click={okAction}
disabled={!canSave}
@@ -119,6 +119,7 @@
}}
/>
{/if}
<slot name="after" />
{#if labeled}<div class="font-regular-14 label"><Label {label} /></div>{/if}
</svelte:element>
@@ -34,4 +34,8 @@
d="M22.2645 18.4346C23.8127 15.061 21.4542 11.3919 18.3526 10.5041C16.8345 10.0007 15.1435 10.203 13.7238 10.8708C12.2987 11.5412 11.0662 12.7196 10.5459 14.2602C9.54062 17.2366 11.4209 20.8545 14.6395 21.8814C15.9617 22.3032 17.5346 22.1569 18.1927 21.9491C18.431 22.0161 18.7158 22.1372 19.0197 22.2664C19.7954 22.5963 20.6952 22.979 21.2615 22.6468C21.5475 22.479 21.6604 22.1705 21.6471 21.8546C21.6294 21.4301 21.573 21.0054 21.5167 20.5813C21.4805 20.3088 21.4443 20.0367 21.4185 19.7651C21.7475 19.356 22.0453 18.9123 22.2645 18.4346ZM11.9671 14.7402C12.3308 13.6633 13.226 12.7627 14.3623 12.2282C15.4988 11.6936 16.7959 11.564 17.8928 11.932L17.9099 11.9377L17.9273 11.9426C20.3341 12.6228 21.9859 15.4235 20.9068 17.7968C20.633 18.3376 20.2858 18.8072 19.8658 19.2409C19.9138 19.791 19.9655 20.3406 20.03 20.889C19.7776 20.8166 19.5238 20.7496 19.2701 20.6826C18.8945 20.5833 18.5189 20.4841 18.148 20.3677L17.9034 20.4622C16.9745 20.8209 15.9738 20.7129 15.0414 20.436C12.3639 19.7416 11.2065 16.9918 11.9671 14.7402Z"
/>
</symbol>
<symbol id="poll" viewBox="0 0 24 24">
<path d="M6 16.5H3V21H6V16.5ZM13.5 10.5H10.5V21H13.5V10.5ZM21 3V21H18V3H21ZM18 1.5C17.6022 1.5 17.2206 1.65804 16.9393 1.93934C16.658 2.22064 16.5 2.60218 16.5 3V21C16.5 21.3978 16.658 21.7794 16.9393 22.0607C17.2206 22.342 17.6022 22.5 18 22.5H21C21.3978 22.5 21.7794 22.342 22.0607 22.0607C22.342 21.7794 22.5 21.3978 22.5 21V3C22.5 2.60218 22.342 2.22064 22.0607 1.93934C21.7794 1.65804 21.3978 1.5 21 1.5H18ZM9 10.5C9 10.1022 9.15804 9.72064 9.43934 9.43934C9.72064 9.15804 10.1022 9 10.5 9H13.5C13.8978 9 14.2794 9.15804 14.5607 9.43934C14.842 9.72064 15 10.1022 15 10.5V21C15 21.3978 14.842 21.7794 14.5607 22.0607C14.2794 22.342 13.8978 22.5 13.5 22.5H10.5C10.1022 22.5 9.72064 22.342 9.43934 22.0607C9.15804 21.7794 9 21.3978 9 21V10.5ZM1.5 16.5C1.5 16.1022 1.65804 15.7206 1.93934 15.4393C2.22064 15.158 2.60218 15 3 15H6C6.39782 15 6.77936 15.158 7.06066 15.4393C7.34196 15.7206 7.5 16.1022 7.5 16.5V21C7.5 21.3978 7.34196 21.7794 7.06066 22.0607C6.77936 22.342 6.39782 22.5 6 22.5H3C2.60218 22.5 2.22064 22.342 1.93934 22.0607C1.65804 21.7794 1.5 21.3978 1.5 21V16.5Z"
fill="currentColor"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Upravit zprávu",
"RemoveMessage": "Odstranit zprávu",
"CreateCard": "Vytvořit kartu",
"MessageAlreadyHasCardAttached": "Jejda! Tato zpráva již má připojenou kartu."
"MessageAlreadyHasCardAttached": "Jejda! Tato zpráva již má připojenou kartu.",
"CreatePoll": "Vytvořit anketu",
"Poll": "Anketa",
"QuestionIsRequired": "Je vyžadována otázka.",
"AnswerIsRequired": "Je vyžadována odpověď.",
"OptionIsRequired": "Je vyžadována možnost.",
"StartDateMustBeInTheFuture": "Datum zahájení musí být v budoucnosti.",
"EndDateMustBeInTheFuture": "Datum ukončení musí být v budoucnosti.",
"Question": "Otázka",
"AskQuestion": "Zadejte otázku",
"PollOptions": "Možnosti ankety",
"Option": "Možnost",
"AnonymousVoting": "Anonymní hlasování",
"MultipleChoice": "Vícenásobný výběr",
"QuizMode": "Režim testování",
"StartTime": "Čas zahájení",
"EndTime": "Čas ukončení",
"OpenPoll": "Otevřít anketu",
"RetractVote": "Zrušit hlasování",
"Quiz": "Test",
"VotesCount": "{count, plural, =0 {žádné hlasy} =1 {1 hlas} other {# hlasů}}",
"Vote": "Hlasovat",
"ShowResults": "Zobrazit výsledky",
"Ended": "Končí",
"StartsAt": "Začíná v {date}",
"EndsAt": "Končí v {date}",
"StartsTomorrow": "Začíná v zítra v {date}",
"EndsTomorrow": "Končí v zítra v {date}",
"PollResults": "Výsledky ankety",
"Polls": "Ankety",
"TotalVotes": "Celkem hlasů",
"Voted": "Hlasováno",
"VotedFor": "Hlasováno pro",
"RevokedVote": "Zrušeno hlasování"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Nachricht bearbeiten",
"RemoveMessage": "Nachricht entfernen",
"CreateCard": "Karte erstellen",
"MessageAlreadyHasCardAttached": "Oops! Diese Nachricht hat bereits eine Karte angehängt."
"MessageAlreadyHasCardAttached": "Oops! Diese Nachricht hat bereits eine Karte angehängt.",
"CreatePoll": "Umfrage erstellen",
"Poll": "Umfrage",
"QuestionIsRequired": "Eine Frage ist erforderlich.",
"AnswerIsRequired": "Eine Antwort ist erforderlich.",
"OptionIsRequired": "Eine Option ist erforderlich.",
"StartDateMustBeInTheFuture": "Das Startdatum muss in der Zukunft liegen.",
"EndDateMustBeInTheFuture": "Das Enddatum muss in der Zukunft liegen.",
"Question": "Frage",
"AskQuestion": "Frage stellen",
"PollOptions": "Umfrageoptionen",
"Option": "Option",
"AnonymousVoting": "Anonymes Abstimmen",
"MultipleChoice": "Multiple-Choice",
"QuizMode": "Quiz-Modus",
"StartTime": "Startzeit",
"EndTime": "Endzeit",
"OpenPoll": "Umfrage öffnen",
"RetractVote": "Abstimmung widerrufen",
"Quiz": "Quiz",
"VotesCount": "{count, plural, =0 {keine Stimmen} =1 {1 Stimme} other {# Stimmen}}",
"Vote": "Abstimmen",
"ShowResults": "Ergebnisse anzeigen",
"Ended": "Endet",
"StartsAt": "Beginnt um {date}",
"EndsAt": "Endet um {date}",
"StartsTomorrow": "Beginnt morgen um {date}",
"EndsTomorrow": "Endet morgen um {date}",
"PollResults": "Umfrageergebnisse",
"Polls": "Umfragen",
"TotalVotes": "Gesamtstimmen",
"Voted": "Abgestimmt",
"VotedFor": "Für abgestimmt",
"RevokedVote": "Stimme widerrufen"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Edit message",
"RemoveMessage": "Remove message",
"CreateCard": "Create card",
"MessageAlreadyHasCardAttached": "Oops! This message already has a card attached."
"MessageAlreadyHasCardAttached": "Oops! This message already has a card attached.",
"CreatePoll": "Create poll",
"Poll": "Poll",
"QuestionIsRequired": "Question is required.",
"AnswerIsRequired": "Answer is required.",
"OptionIsRequired": "Option is required.",
"StartDateMustBeInTheFuture": "Start date must be in the future.",
"EndDateMustBeInTheFuture": "End date must be in the future.",
"Question": "Question",
"AskQuestion": "Ask a question",
"PollOptions": "Poll options",
"Option": "Option",
"AnonymousVoting": "Anonymous voting",
"MultipleChoice": "Multiple choice",
"QuizMode": "Quiz mode",
"StartTime": "Start time",
"EndTime": "End time",
"OpenPoll": "Open poll",
"RetractVote": "Retract vote",
"Quiz": "Quiz",
"VotesCount": "{count, plural, =0 {no votes} =1 {1 vote} other {# votes}}",
"Vote": "Vote",
"ShowResults": "Show results",
"Ended": "Ended",
"StartsAt": "Starts at {date}",
"EndsAt": "Ends at {date}",
"StartsTomorrow": "Starts tomorrow at {date}",
"EndsTomorrow": "Ends tomorrow at {date}",
"PollResults": "Poll results",
"Polls": "Polls",
"TotalVotes": "Total votes",
"Voted": "Voted",
"VotedFor": "Voted for",
"RevokedVote": "Revoked vote"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Editar mensaje",
"RemoveMessage": "Eliminar mensaje",
"CreateCard": "Crear tarjeta",
"MessageAlreadyHasCardAttached": "¡Vaya! Este mensaje ya tiene una tarjeta adjunta."
"MessageAlreadyHasCardAttached": "¡Vaya! Este mensaje ya tiene una tarjeta adjunta.",
"CreatePoll": "Crear encuesta",
"Poll": "Encuesta",
"QuestionIsRequired": "Se requiere una pregunta.",
"AnswerIsRequired": "Se requiere una respuesta.",
"OptionIsRequired": "Se requiere una opción.",
"StartDateMustBeInTheFuture": "La fecha de inicio debe ser en el futuro.",
"EndDateMustBeInTheFuture": "La fecha de finalización debe ser en el futuro.",
"Question": "Pregunta",
"AskQuestion": "Hacer una pregunta",
"PollOptions": "Opciones de encuesta",
"Option": "Opción",
"AnonymousVoting": "Votación anónima",
"MultipleChoice": "Elección múltiple",
"QuizMode": "Modo de prueba",
"StartTime": "Hora de inicio",
"EndTime": "Hora de finalización",
"OpenPoll": "Abrir encuesta",
"RetractVote": "Retractar voto",
"Quiz": "Prueba",
"VotesCount": "{count, plural, =0 {no hay votos} =1 {1 voto} other {# votos}}",
"Vote": "Votar",
"ShowResults": "Mostrar resultados",
"Ended": "Termina",
"StartsAt": "Empieza a las {date}",
"EndsAt": "Termina a las {date}",
"StartsTomorrow": "Empieza mañana a las {date}",
"EndsTomorrow": "Termina mañana a las {date}",
"PollResults": "Resultados de la encuesta",
"Polls": "Encuestas",
"TotalVotes": "Votos totales",
"Voted": "Votado",
"VotedFor": "Votado por",
"RevokedVote": "Revocar voto"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Modifier le message",
"RemoveMessage": "Supprimer le message",
"CreateCard": "Créer une carte",
"MessageAlreadyHasCardAttached": "Oups! Ce message a déjà une carte attachée."
"MessageAlreadyHasCardAttached": "Oups! Ce message a déjà une carte attachée.",
"CreatePoll": "Créer un sondage",
"Poll": "Sondage",
"QuestionIsRequired": "Une question est requise.",
"AnswerIsRequired": "Une réponse est requise.",
"OptionIsRequired": "Une option est requise.",
"StartDateMustBeInTheFuture": "La date de début doit être dans le futur.",
"EndDateMustBeInTheFuture": "La date de fin doit être dans le futur.",
"Question": "Question",
"AskQuestion": "Poser une question",
"PollOptions": "Options du sondage",
"Option": "Option",
"AnonymousVoting": "Vote anonyme",
"MultipleChoice": "Choix multiple",
"QuizMode": "Mode de test",
"StartTime": "Heure de début",
"EndTime": "Heure de fin",
"OpenPoll": "Ouvrir le sondage",
"RetractVote": "Retraiter le vote",
"Quiz": "Test",
"VotesCount": "{count, plural, =0 {aucun vote} =1 {1 vote} other {# votes}}",
"Vote": "Voter",
"ShowResults": "Afficher les résultats",
"Ended": "Se termine",
"StartsAt": "Commence à {date}",
"EndsAt": "Se termine à {date}",
"StartsTomorrow": "Commence demain à {date}",
"EndsTomorrow": "Se termine demain à {date}",
"PollResults": "Résultats du sondage",
"Polls": "Sondages",
"TotalVotes": "Total des votes",
"Voted": "A voté",
"VotedFor": "A voté pour",
"RevokedVote": "Révoquer le vote"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Modifica messaggio",
"RemoveMessage": "Rimuovi messaggio",
"CreateCard": "Crea scheda",
"MessageAlreadyHasCardAttached": "Oops! Questo messaggio ha già una scheda allegata."
"MessageAlreadyHasCardAttached": "Oops! Questo messaggio ha già una scheda allegata.",
"CreatePoll": "Crea sondaggio",
"Poll": "Sondaggio",
"QuestionIsRequired": "È richiesta una domanda.",
"AnswerIsRequired": "È richiesta una risposta.",
"OptionIsRequired": "È richiesta un'opzione.",
"StartDateMustBeInTheFuture": "La data di inizio deve essere nel futuro.",
"EndDateMustBeInTheFuture": "La data di fine deve essere nel futuro.",
"Question": "Domanda",
"AskQuestion": "Fai una domanda",
"PollOptions": "Opzioni del sondaggio",
"Option": "Opzione",
"AnonymousVoting": "Votazione anonima",
"MultipleChoice": "Scelta multipla",
"QuizMode": "Modalità quiz",
"StartTime": "Ora di inizio",
"EndTime": "Ora di fine",
"OpenPoll": "Apri sondaggio",
"RetractVote": "Ritira il voto",
"Quiz": "Quiz",
"VotesCount": "{count, plural, =0 {nessun voto} =1 {1 voto} other {# voti}}",
"Vote": "Vota",
"ShowResults": "Mostra i risultati",
"Ended": "Termina",
"StartsAt": "Inizia alle {date}",
"EndsAt": "Termina alle {date}",
"StartsTomorrow": "Inizia domani alle {date}",
"EndsTomorrow": "Termina domani alle {date}",
"PollResults": "Risultati del sondaggio",
"Polls": "Sondaggi",
"TotalVotes": "Voti totali",
"Voted": "Votato",
"VotedFor": "Votato per",
"RevokedVote": "Revoca il voto"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "メッセージを編集",
"RemoveMessage": "メッセージを削除",
"CreateCard": "カードを作成",
"MessageAlreadyHasCardAttached": "おっと!このメッセージはすでにカードが添付されています。"
"MessageAlreadyHasCardAttached": "おっと!このメッセージはすでにカードが添付されています。",
"CreatePoll": "アンケートを作成",
"Poll": "アンケート",
"QuestionIsRequired": "質問が必要です。",
"AnswerIsRequired": "回答が必要です。",
"OptionIsRequired": "オプションが必要です。",
"StartDateMustBeInTheFuture": "開始日は未来の日付である必要があります。",
"EndDateMustBeInTheFuture": "終了日は未来の日付である必要があります。",
"Question": "質問",
"AskQuestion": "質問を投稿",
"PollOptions": "アンケートオプション",
"Option": "オプション",
"AnonymousVoting": "匿名投票",
"MultipleChoice": "複数選択",
"QuizMode": "クイズモード",
"StartTime": "開始時間",
"EndTime": "終了時間",
"OpenPoll": "アンケートを開く",
"RetractVote": "投票を取り消す",
"Quiz": "クイズ",
"VotesCount": "{count, plural, =0 {投票なし} =1 {1件の投票} other {#件の投票}}",
"Vote": "投票",
"ShowResults": "結果を表示",
"Ended": "終了",
"StartsAt": "{date}に開始",
"EndsAt": "{date}に終了",
"StartsTomorrow": "明日{date}に開始",
"EndsTomorrow": "明日{date}に終了",
"PollResults": "アンケート結果",
"Polls": "アンケート",
"TotalVotes": "合計投票数",
"Voted": "投票済み",
"VotedFor": "投票したユーザ",
"RevokedVote": "投票を取り消し"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Editar mensagem",
"RemoveMessage": "Remover mensagem",
"CreateCard": "Criar cartão",
"MessageAlreadyHasCardAttached": "Oops! Este mensagem já tem um cartão anexado."
"MessageAlreadyHasCardAttached": "Oops! Este mensagem já tem um cartão anexado.",
"CreatePoll": "Criar enquete",
"Poll": "Enquete",
"QuestionIsRequired": "É necessária uma pergunta.",
"AnswerIsRequired": "É necessária uma resposta.",
"OptionIsRequired": "É necessária uma opção.",
"StartDateMustBeInTheFuture": "A data de início deve estar no futuro.",
"EndDateMustBeInTheFuture": "A data de término deve estar no futuro.",
"Question": "Pergunta",
"AskQuestion": "Fazer uma pergunta",
"PollOptions": "Opções do enquete",
"Option": "Opção",
"AnonymousVoting": "Votação anônima",
"MultipleChoice": "Escolha múltipla",
"QuizMode": "Modo de quiz",
"StartTime": "Hora de início",
"EndTime": "Hora de término",
"OpenPoll": "Abrir enquete",
"RetractVote": "Retirar voto",
"Quiz": "Quiz",
"VotesCount": "{count, plural, =0 {nenhum voto} =1 {1 voto} other {# votos}}",
"Vote": "Votar",
"ShowResults": "Mostrar resultados",
"Ended": "Termina",
"StartsAt": "Começa às {date}",
"EndsAt": "Termina às {date}",
"StartsTomorrow": "Começa amanhã às {date}",
"EndsTomorrow": "Termina amanhã às {date}",
"PollResults": "Resultados do enquete",
"Polls": "Enquetes",
"TotalVotes": "Total de votos",
"Voted": "Votado",
"VotedFor": "Votado para",
"RevokedVote": "Revogar voto"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "Редактировать сообщение",
"RemoveMessage": "Удалить сообщение",
"CreateCard": "Создать карточку",
"MessageAlreadyHasCardAttached": "Упс! Это сообщение уже имеет прикрепленную карточку."
"MessageAlreadyHasCardAttached": "Упс! Это сообщение уже имеет прикрепленную карточку.",
"CreatePoll": "Создать опрос",
"Poll": "Опрос",
"QuestionIsRequired": "Заполните вопрос.",
"AnswerIsRequired": "Выбертте правильный ответ.",
"OptionIsRequired": "Добавьте хотя бы один вариант.",
"StartDateMustBeInTheFuture": "Дата начала должна быть в будущем.",
"EndDateMustBeInTheFuture": "Дата окончания должна быть в будущем.",
"Question": "Вопрос",
"AskQuestion": "Вопрос",
"PollOptions": "Варианты опроса",
"Option": "Вариант",
"AnonymousVoting": "Анонимное голосование",
"MultipleChoice": "Множественный выбор",
"QuizMode": "Режим викторины",
"StartTime": "Время начала",
"EndTime": "Время окончания",
"OpenPoll": "Открыть опрос",
"RetractVote": "Отменить голос",
"Quiz": "Викторина",
"VotesCount": "{count, plural, =0 {нет голосов} =1 {1 голос} other {# голосов}}",
"Vote": "Голосовать",
"ShowResults": "Показать результаты",
"Ended": "Закончилось",
"StartsAt": "Начинается {date}",
"EndsAt": "Заканчивается {date}",
"StartsTomorrow": "Начинается завтра {date}",
"EndsTomorrow": "Заканчивается завтра {date}",
"PollResults": "Результаты опроса",
"Polls": "Опросы",
"TotalVotes": "Всего голосов",
"Voted": "Проголосовавшие",
"VotedFor": "Голосовал(а) за",
"RevokedVote": "Отменил(а) голос"
}
}
+34 -1
View File
@@ -51,6 +51,39 @@
"EditMessage": "编辑消息",
"RemoveMessage": "删除消息",
"CreateCard": "创建卡片",
"MessageAlreadyHasCardAttached": "哎呀!此消息已经有一张卡片附加。"
"MessageAlreadyHasCardAttached": "哎呀!此消息已经有一张卡片附加。",
"CreatePoll": "创建投票",
"Poll": "投票",
"QuestionIsRequired": "问题是必需的。",
"AnswerIsRequired": "回答是必需的。",
"OptionIsRequired": "选项是必需的。",
"StartDateMustBeInTheFuture": "开始日期必须是未来的。",
"EndDateMustBeInTheFuture": "结束日期必须是未来的。",
"Question": "问题",
"AskQuestion": "提问",
"PollOptions": "投票选项",
"Option": "选项",
"AnonymousVoting": "匿名投票",
"MultipleChoice": "多选",
"QuizMode": "测验模式",
"StartTime": "开始时间",
"EndTime": "结束时间",
"OpenPoll": "开放投票",
"RetractVote": "撤销投票",
"Quiz": "测验",
"VotesCount": "{count, plural, =0 {无投票} =1 {1 次投票} other {# 次投票}}",
"Vote": "投票",
"ShowResults": "显示结果",
"Ended": "结束",
"StartsAt": "开始于 {date}",
"EndsAt": "结束于 {date}",
"StartsTomorrow": "开始明天 {date}",
"EndsTomorrow": "结束明天 {date}",
"PollResults": "投票结果",
"Polls": "投票",
"TotalVotes": "总投票数",
"Voted": "已投票",
"VotedFor": "投票于",
"RevokedVote": "撤销投票"
}
}
+2 -1
View File
@@ -21,6 +21,7 @@ loadMetadata(communication.icon, {
Bell: `${icons}#bell`,
BellCrossed: `${icons}#bell-crossed`,
File: `${icons}#file`,
MessageMultiple: `${icons}#message-multiple`
MessageMultiple: `${icons}#message-multiple`,
Poll: `${icons}#poll`
})
addStringsLoader(communicationId, async (lang: string) => await import(`../lang/${lang}.json`))
@@ -49,6 +49,7 @@
"@hcengineering/chat": "^0.6.0",
"@hcengineering/communication": "^0.6.0",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/communication-shared": "^0.1.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/core": "^0.6.32",
@@ -127,12 +127,7 @@
/>
</div>
<div class="mt-4" />
<MessagePresenter
{card}
message={{ ..._message, reactions: [], linkPreviews: [], thread: undefined }}
readonly={true}
padding="0"
/>
<MessagePresenter {card} message={{ ..._message, reactions: [], thread: undefined }} readonly={true} padding="0" />
</div>
<svelte:fragment slot="footer">
{#if _message.thread != null && !inProgress}
@@ -195,9 +195,8 @@
return {
card: card._id,
replies: true,
files: true,
attachments: true,
reactions: true,
links: true,
order: SortingOrder.Ascending,
limit
}
@@ -209,9 +208,8 @@
return {
card: card._id,
replies: true,
files: true,
attachments: true,
reactions: true,
links: true,
order,
limit,
from: unread && !shouldScrollToEnd && initialLastView != null ? initialLastView : undefined
@@ -18,17 +18,21 @@
import cardPlugin from '@hcengineering/card'
import { getCurrentAccount } from '@hcengineering/core'
import { AttachmentPreview, LinkPreview } from '@hcengineering/attachment-resources'
import { LinkPreviewID, Message, MessageType } from '@hcengineering/communication-types'
import { AttachmentID, Message, MessageType } from '@hcengineering/communication-types'
import { getResource } from '@hcengineering/platform'
import { isAppletAttachment, isBlobAttachment, isLinkPreviewAttachment } from '@hcengineering/communication-shared'
import { Component } from '@hcengineering/ui'
import ReactionsList from '../ReactionsList.svelte'
import MessageThread from '../thread/Thread.svelte'
import { toggleReaction } from '../../utils'
import communication from '../../plugin'
export let message: Message
const me = getCurrentAccount()
const communicationClient = getCommunicationClient()
const client = getClient()
function canReply (): boolean {
return message.type !== MessageType.Activity && message.extra?.threadRoot !== true
@@ -53,49 +57,69 @@
await r(_id, c)
}
async function removeLinkPreview (id: LinkPreviewID): Promise<void> {
await communicationClient.linkPreviewPatch(message.cardId, message.id, {
detach: [id]
async function removeLinkPreview (id: AttachmentID): Promise<void> {
await communicationClient.attachmentPatch(message.cardId, message.id, {
remove: [id]
})
}
const appletsModels = client.getModel().findAllSync(communication.class.Applet, {})
$: blobs = message.attachments.filter(isBlobAttachment) ?? []
$: links = message.attachments.filter(isLinkPreviewAttachment) ?? []
$: applets = message.attachments.filter(isAppletAttachment) ?? []
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if message.blobs.length > 0 && !message.removed}
{#if applets.length > 0 && !message.removed}
<div class="message__applets">
{#each applets as applet (applet.id)}
{@const appletModel = appletsModels.find((it) => it.type === applet.type)}
{#if appletModel}
<Component
is={appletModel.component}
props={{
applet: appletModel,
attachment: applet
}}
/>
{/if}
{/each}
</div>
{/if}
{#if blobs.length > 0 && !message.removed}
<div class="message__files">
{#each message.blobs as blob (blob.blobId)}
{#each blobs as blob (blob.id)}
<AttachmentPreview
value={{
file: blob.blobId,
type: blob.mimeType,
name: blob.fileName,
size: blob.size,
metadata: blob.metadata
file: blob.params.blobId,
type: blob.params.mimeType,
name: blob.params.fileName,
size: blob.params.size,
metadata: blob.params.metadata
}}
imageSize="x-large"
/>
{/each}
</div>
{/if}
{#if (message.linkPreviews ?? []).length > 0 && !message.removed}
{#if links.length > 0 && !message.removed}
<div class="message__links">
{#each message.linkPreviews as link (link.id)}
{#each links as link (link.id)}
<LinkPreview
isOwn={me.socialIds.includes(message.creator)}
on:delete={() => {
void removeLinkPreview(link.id)
}}
linkPreview={{
url: link.url,
host: link.host,
title: link.title,
description: link.description,
hostname: link.siteName,
image: link.previewImage?.url,
imageWidth: link.previewImage?.width,
imageHeight: link.previewImage?.height,
icon: link.iconUrl
url: link.params.url,
host: link.params.host,
title: link.params.title,
description: link.params.description,
hostname: link.params.siteName,
image: link.params.previewImage?.url,
imageWidth: link.params.previewImage?.width,
imageHeight: link.params.previewImage?.height,
icon: link.params.iconUrl
}}
/>
{/each}
@@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Markup, RateLimiter, Ref } from '@hcengineering/core'
import { generateId, Markup, RateLimiter, Ref } from '@hcengineering/core'
import { tick, createEventDispatcher } from 'svelte'
import {
uploadFile,
@@ -24,20 +24,30 @@
getFileMetadata,
isLinkPreviewEnabled
} from '@hcengineering/presentation'
import { Message, MessageID, BlobData, LinkPreviewData } from '@hcengineering/communication-types'
import {
Message,
MessageID,
LinkPreviewParams,
BlobParams,
AttachmentID,
linkPreviewType,
BlobAttachment,
AppletParams
} from '@hcengineering/communication-types'
import { AttachmentPresenter, LinkPreviewCard } from '@hcengineering/attachment-resources'
import { areEqualMarkups, isEmptyMarkup } from '@hcengineering/text'
import { updateMyPresence } from '@hcengineering/presence-resources'
import { ThrottledCaller } from '@hcengineering/ui'
import { Component, showPopup, ThrottledCaller } from '@hcengineering/ui'
import { getCurrentEmployee } from '@hcengineering/contact'
import { Card } from '@hcengineering/card'
import { setPlatformStatus, unknownError } from '@hcengineering/platform'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { isAppletAttachment, isBlobAttachment, isLinkPreviewAttachment } from '@hcengineering/communication-shared'
import TextInput from '../TextInput.svelte'
import IconAttach from '../icons/Attach.svelte'
import { defaultMessageInputActions, toMarkdown, toMarkup, loadLinkPreviewData } from '../../utils'
import { defaultMessageInputActions, toMarkdown, toMarkup, loadLinkPreviewParams } from '../../utils'
import communication from '../../plugin'
import { type TextInputAction, type PresenceTyping, MessageDraft } from '../../types'
import { type TextInputAction, type PresenceTyping, MessageDraft, AppletDraft } from '../../types'
import TypingPresenter from '../TypingPresenter.svelte'
import { getDraft, messageToDraft, saveDraft, getEmptyDraft, removeDraft } from '../../draft'
@@ -45,7 +55,7 @@
export let message: Message | undefined = undefined
export let title: string = ''
export let onCancel: (() => void) | undefined = undefined
export let onSubmit: ((markdown: string, blobs: BlobData[]) => Promise<void>) | undefined = undefined
export let onSubmit: ((markdown: string, blobs: BlobParams[]) => Promise<void>) | undefined = undefined
const throttle = new ThrottledCaller(500)
const dispatch = createEventDispatcher()
@@ -55,7 +65,7 @@
const maxLinkPreviewCount = 4
const previewUrls = new Map<string, boolean>()
const linksData = new Map<string, LinkPreviewData | null>()
const linksData = new Map<string, LinkPreviewParams | null>()
let prevCard: Ref<Card> | undefined = card._id
let prevMessage: MessageID | undefined = message?.id
@@ -99,6 +109,7 @@
const markup = event.detail
const blobsToLoad = draft.blobs
const linksToLoad = draft.links
const appletsToLoad = draft.applets
const urlsToLoad = Array.from(previewUrls.keys()).filter((it) => previewUrls.get(it) === true)
draft = getEmptyDraft()
@@ -115,31 +126,69 @@
}
if (message === undefined) {
await createMessage(markdown, blobsToLoad, linksToLoad, urlsToLoad)
await createMessage(markdown, blobsToLoad, linksToLoad, urlsToLoad, appletsToLoad)
dispatch('sent')
} else {
await editMessage(message, markdown, blobsToLoad, linksToLoad)
await editMessage(message, markdown, blobsToLoad, linksToLoad, appletsToLoad)
}
}
async function attachApplets (messageId: MessageID, appletDrafts: AppletDraft[]): Promise<void> {
if (appletDrafts.length === 0) return
const toAttach: AppletDraft[] = []
for (const appletDraft of appletDrafts) {
try {
const ap = applets.find((it) => it._id === appletDraft.appletId)
if (ap?.createFn == null) {
toAttach.push(appletDraft)
continue
}
const r = await getResource(ap.createFn)
await r(card, messageId, appletDraft.params)
toAttach.push(appletDraft)
} catch (err) {
console.error(err)
}
}
if (toAttach.length > 0) {
void communicationClient.attachmentPatch<AppletParams>(card._id, messageId, {
add: toAttach.map((it) => ({
type: it.type,
params: it.params
}))
})
}
}
async function createMessage (
markdown: string,
blobs: BlobData[],
links: LinkPreviewData[],
urlsToLoad: string[]
blobs: BlobParams[],
links: LinkPreviewParams[],
urlsToLoad: string[],
appletDrafts: AppletDraft[]
): Promise<void> {
const { messageId } = await communicationClient.createMessage(card._id, card._class, markdown)
void client.update(card, {}, false, Date.now())
void attachApplets(messageId, appletDrafts)
if (blobs.length > 0) {
void communicationClient.blobPatch(card._id, messageId, {
attach: blobs
void communicationClient.attachmentPatch<BlobParams>(card._id, messageId, {
add: blobs.map((it) => ({
id: it.blobId as any as AttachmentID,
type: it.mimeType,
params: it
}))
})
}
if (links.length > 0) {
void communicationClient.linkPreviewPatch(card._id, messageId, {
attach: links
void communicationClient.attachmentPatch<LinkPreviewParams>(card._id, messageId, {
add: links.map((it) => ({
type: linkPreviewType,
params: it
}))
})
}
@@ -147,10 +196,15 @@
if (links.some((it) => it.url === url)) continue
const fetchedData = linksData.get(url)
if (fetchedData === null) continue
const data = fetchedData ?? (await loadLinkPreviewData(url))
if (data === undefined) continue
void communicationClient.linkPreviewPatch(card._id, messageId, {
attach: [data]
const params = fetchedData ?? (await loadLinkPreviewParams(url))
if (params === undefined) continue
void communicationClient.attachmentPatch<LinkPreviewParams>(card._id, messageId, {
add: [
{
type: linkPreviewType,
params
}
]
})
}
}
@@ -158,31 +212,62 @@
async function editMessage (
message: Message,
markdown: string,
blobs: BlobData[],
links: LinkPreviewData[]
blobs: BlobParams[],
links: LinkPreviewParams[],
appletDrafts: AppletDraft[]
): Promise<void> {
await communicationClient.updateMessage(card._id, message.id, markdown)
const attachBlobs = blobs.filter((it) => !message.blobs.some((b) => b.blobId === it.blobId))
const attachBlobs = blobs.filter(
(b) => !message.attachments.some((it) => isBlobAttachment(it) && it.params.blobId === b.blobId)
)
void attachApplets(
message.id,
appletDrafts.filter((a) => !message.attachments.some((it) => it.id === a.id))
)
if (attachBlobs.length > 0) {
void communicationClient.blobPatch(card._id, message.id, {
attach: attachBlobs
void communicationClient.attachmentPatch<BlobParams>(card._id, message.id, {
add: attachBlobs.map((it) => ({
id: it.blobId as any as AttachmentID,
type: it.mimeType,
params: it
}))
})
}
const detachBlobs = message.blobs.filter((it) => !blobs.some((b) => b.blobId === it.blobId))
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.blobId)
void deleteFile(it.params.blobId)
})
void communicationClient.blobPatch(card._id, message.id, {
detach: detachBlobs.map((it) => it.blobId)
void communicationClient.attachmentPatch(card._id, message.id, {
remove: detachBlobs.map((it) => it.id)
})
}
const attachLinks = links.filter((it) => !message.linkPreviews.some((b) => b.url === it.url))
void communicationClient.linkPreviewPatch(card._id, message.id, {
attach: attachLinks
const detachApplets = message.attachments.filter(
(it) => isAppletAttachment(it) && !appletDrafts.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) => ({
type: linkPreviewType,
params: it
}))
})
dispatch('edited')
@@ -237,7 +322,8 @@
async function handleCancel (): Promise<void> {
onCancel?.()
for (const blob of draft.blobs) {
const fromMessage = message?.blobs.some((it) => it.blobId === blob.blobId)
const fromMessage =
message?.attachments.some((it) => isBlobAttachment(it) && it.params.blobId === blob.blobId) ?? false
if (!fromMessage) {
void deleteFile(blob.blobId)
}
@@ -342,7 +428,7 @@
for (const url of urls) {
try {
const data = linksData.get(url) ?? (await loadLinkPreviewData(url))
const data = linksData.get(url) ?? (await loadLinkPreviewParams(url))
linksData.set(url, data ?? null)
if (data === undefined || !previewUrls.has(url)) continue
if (draftId !== draft._id) return
@@ -376,17 +462,41 @@
}
function isEmptyDraft (): boolean {
return isEmptyMarkup(draft.content) && draft.blobs.length === 0
return isEmptyMarkup(draft.content) && draft.blobs.length === 0 && draft.applets.length === 0
}
function hasChanges (blobs: BlobData[], message: Message | undefined): boolean {
function hasChanges (blobs: BlobParams[], message: Message | undefined, appletDrafts: any[]): boolean {
if (isEmptyDraft()) return false
if (message === undefined) return blobs.length > 0 || !isEmptyMarkup(draft.content)
if (message.blobs.length !== blobs.length) return true
if (message.blobs.some((it) => !blobs.some((f) => f.blobId === it.blobId))) return true
if (message === undefined) return blobs.length > 0 || !isEmptyMarkup(draft.content) || appletDrafts.length > 0
const messageBlobs = message?.attachments.filter(isBlobAttachment) ?? []
const messageApplets = message?.attachments.filter(isAppletAttachment) ?? []
if (messageBlobs.length !== blobs.length) return true
if (messageBlobs.some((it) => !blobs.some((b) => b.blobId === it.params.blobId))) return true
if (messageApplets.length !== appletDrafts.length) return true
if (messageApplets.some((it) => !appletDrafts.some((a) => a.id === it.id))) return true
return !areEqualMarkups(draft.content, toMarkup(message.content))
}
const applets = client.getModel().findAllSync(communication.class.Applet, {})
const appletActions: TextInputAction[] = applets.map((applet) => {
return {
label: applet.label,
icon: applet.icon,
action: () => {
showPopup(applet.createComponent, { applet }, 'center', (result) => {
if (result != null) {
draft = {
...draft,
applets: [...draft.applets, { id: generateId(), type: applet.type, appletId: applet._id, params: result }]
}
}
})
},
order: 99999
}
})
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -413,16 +523,50 @@
placeholder={title !== '' ? communication.string.MessageIn : undefined}
placeholderParams={title !== '' ? { title } : undefined}
loading={progress}
hasChanges={hasChanges(draft.blobs, message)}
actions={[...defaultMessageInputActions, attachAction]}
hasChanges={hasChanges(draft.blobs, message, draft.applets)}
actions={[...defaultMessageInputActions, attachAction, ...appletActions]}
on:submit={handleSubmit}
on:update={onUpdate}
onCancel={onCancel ? handleCancel : undefined}
onPaste={pasteAction}
>
<div slot="header" class="header">
{#if draft.blobs.length > 0 || draft.links.length > 0}
{#if draft.blobs.length > 0 || draft.links.length > 0 || draft.applets.length > 0}
<div class="flex-row-center files-list scroll-divider-color flex-gap-2 mt-2">
{#each draft.applets as appletDraft}
{@const applet = applets.find((it) => it._id === appletDraft.appletId)}
{#if applet}
<Component
is={applet.previewComponent}
props={{
applet,
params: appletDraft.params,
editing: message !== undefined && message.attachments.some((it) => it.id === appletDraft.id)
}}
on:change={() => {
showPopup(applet.createComponent, { applet, params: appletDraft.params }, 'center', (result) => {
if (result != null) {
draft = {
...draft,
applets: draft.applets.map((it) => {
if (it.id === appletDraft.id) {
return { ...it, params: result }
}
return it
})
}
}
})
}}
on:delete={() => {
draft = {
...draft,
applets: draft.applets.filter((it) => it.id !== appletDraft.id)
}
}}
/>
{/if}
{/each}
{#each draft.blobs as attachedBlob (attachedBlob.blobId)}
<div class="item flex">
<AttachmentPresenter
@@ -442,7 +586,11 @@
blobs: draft.blobs.filter((it) => it.blobId !== attachedBlob.blobId)
}
if (!message?.blobs?.some((it) => it.blobId === attachedBlob.blobId)) {
if (
!message?.attachments?.some(
(it) => isBlobAttachment(it) && it.params.blobId === attachedBlob.blobId
)
) {
void deleteFile(attachedBlob.blobId)
}
}
@@ -35,6 +35,8 @@
} from '../../stores'
import { getMessageActions } from '../../actions'
import communication from '../../plugin'
import { isBlobAttachment } from '@hcengineering/communication-shared'
import { getCommunicationClient } from '@hcengineering/presentation'
export let card: Card
export let message: Message
@@ -15,23 +15,39 @@
<script lang="ts">
import { AttributeModel } from '@hcengineering/view'
import { ActivityAttributeUpdate } from '@hcengineering/communication-types'
import { getClient } from '@hcengineering/presentation'
import { Class, Ref } from '@hcengineering/core'
import { Card } from '@hcengineering/card'
import { Component } from '@hcengineering/ui'
import ActivitySetAttributesViewer from './ActivitySetAttributeViewer.svelte'
import ActivityAddAttributeViewer from './ActivityAddAttributeViewer.svelte'
import ActivityRemoveAttributeViewer from './ActivityRemoveAttributeViewer.svelte'
import communication from '../../../plugin'
export let model: AttributeModel
export let model: AttributeModel | undefined
export let update: ActivityAttributeUpdate
export let cardType: Ref<Class<Card>>
const client = getClient()
$: customPresenter = client.getModel().findAllSync(communication.class.CustomActivityPresenter, {
attribute: update.attrKey,
type: cardType
})[0]
</script>
{#if update?.set !== undefined}
<ActivitySetAttributesViewer {model} value={update.set} />
{/if}
{#if customPresenter}
<Component is={customPresenter.component} props={{ update }} />
{:else}
{#if update?.set !== undefined && model}
<ActivitySetAttributesViewer {model} value={update.set} />
{/if}
{#if (update.added?.length ?? 0) > 0}
<ActivityAddAttributeViewer {model} value={update.added ?? []} />
{/if}
{#if model && (update.added?.length ?? 0) > 0}
<ActivityAddAttributeViewer {model} value={update.added ?? []} />
{/if}
{#if (update.removed?.length ?? 0) > 0}
<ActivityRemoveAttributeViewer {model} value={update.removed ?? []} />
{#if model && (update.removed?.length ?? 0) > 0}
<ActivityRemoveAttributeViewer {model} value={update.removed ?? []} />
{/if}
{/if}
@@ -30,8 +30,8 @@
export let author: Person | undefined
</script>
{#if update.type === ActivityUpdateType.Attribute && model}
<ActivityUpdateAttributeViewer {model} {update} />
{#if update.type === ActivityUpdateType.Attribute}
<ActivityUpdateAttributeViewer {model} {update} cardType={card._class} />
{:else if update.type === ActivityUpdateType.Tag}
<ActivityUpdateTagViewer {update} {content} />
{:else if update.type === ActivityUpdateType.Collaborators}
@@ -0,0 +1,339 @@
<!-- 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. -->
<script lang="ts">
import {
CheckBox,
Label,
Modal,
ModernEditbox,
ModernToggle,
IconClose,
ButtonIcon,
showPopup,
DateTimePresenter
} from '@hcengineering/ui'
import { Applet } from '@hcengineering/communication'
import presentation from '@hcengineering/presentation'
import { createEventDispatcher } from 'svelte'
import { IntlString } from '@hcengineering/platform'
import { generateId } from '@hcengineering/core'
import emoji from '@hcengineering/emoji'
import communication from '../../plugin'
import { getEmptyPollConfig, PollConfig, PollOption } from '../../poll'
export let applet: Applet
export let params: PollConfig = getEmptyPollConfig()
const dispatch = createEventDispatcher()
const optionElements: Record<number, HTMLInputElement> = {}
let questionElement: HTMLInputElement | undefined = undefined
$: updateOptions(params.options)
function getErrorMessage (params: PollConfig): IntlString | undefined {
if (params.question.trim() === '') return communication.string.QuestionIsRequired
if (params.options.filter((it) => it.label.trim() !== '').length === 0) return communication.string.OptionIsRequired
if (params.quiz === true && params.quizAnswer == null) return communication.string.AnswerIsRequired
if (params.startAt != null && params.startAt < Date.now()) return communication.string.StartDateMustBeInTheFuture
if (params.endAt != null && params.endAt < Date.now()) return communication.string.EndDateMustBeInTheFuture
return undefined
}
function canSave (params: PollConfig): boolean {
if (params.question.trim() === '') return false
if (params.options.filter((it) => it.label.trim() !== '').length === 0) return false
if (params.quiz === true && params.quizAnswer == null) return false
if (params.startAt != null && params.startAt < Date.now()) return false
if (params.endAt != null && params.endAt < Date.now()) return false
return true
}
function okAction (): void {
const saveConfig = {
...params,
options: params.options.filter((it) => it.label.trim() !== '')
}
if (saveConfig.options.length === 0 || saveConfig.question.trim() === '') {
return
}
dispatch('close', saveConfig)
}
function handleCancel (): void {
dispatch('close')
}
function updateOptions (options: PollOption[]): void {
const lastOption = options[options.length - 1]
const prevOption = options[options.length - 2]
if (lastOption.label.trim() !== '') {
params = {
...params,
options: [...options, { id: generateId(), label: '' }]
}
} else if (
lastOption != null &&
prevOption != null &&
lastOption.label.trim() === '' &&
prevOption.label.trim() === ''
) {
params = {
...params,
options: options.slice(0, -1)
}
}
}
function handleKeydown (e: KeyboardEvent, option?: PollOption): void {
if (e.key === 'Enter' || e.key === 'ArrowDown') {
e.preventDefault()
e.stopPropagation()
if (option == null) {
optionElements[0]?.focus()
} else {
const currentIndex = params.options.indexOf(option)
if (currentIndex === -1) return
if (currentIndex === params.options.length - 1) return
optionElements[currentIndex + 1]?.focus()
}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
e.stopPropagation()
if (option == null) return
const currentIndex = params.options.indexOf(option)
if (currentIndex === -1) return
if (currentIndex === 0) {
questionElement?.focus()
} else {
optionElements[currentIndex - 1]?.focus()
}
} else if (e.key === 'Backspace') {
if (option == null || option.label.length > 0) return
const currentIndex = params.options.indexOf(option)
if (currentIndex === -1) return
if (currentIndex === 0 && questionElement != null) {
e.preventDefault()
e.stopPropagation()
questionElement.focus()
} else if (optionElements[currentIndex - 1] != null) {
e.preventDefault()
e.stopPropagation()
optionElements[currentIndex - 1].focus()
}
}
}
function removeOption (option: PollOption): void {
if (params.options.length === 1) return
const index = params.options.indexOf(option)
if (index === -1) return
params = {
...params,
options: params.options.filter((it) => it !== option)
}
}
function showEmojiPicker (evt: MouseEvent, optionId: string): void {
showPopup(
emoji.component.EmojiPopup,
{},
evt?.target as HTMLElement,
async (result) => {
const emoji = result?.text
if (emoji == null) return
const option = params.options.find((it) => it.id === optionId)
if (option != null) {
params = {
...params,
options: params.options.map((it) => {
if (it.id === optionId) {
return { ...it, label: it.label + emoji }
}
return it
})
}
}
},
() => {}
)
}
</script>
<Modal
label={applet.createLabel}
type="type-popup"
width="large"
okLabel={presentation.string.Create}
{okAction}
canSave={canSave(params)}
onCancel={handleCancel}
okTooltip={{ label: getErrorMessage(params) }}
on:close
>
<div class="poll">
<div class="poll__setting-item">
<span class="label"><Label label={communication.string.Question} /></span>
<ModernEditbox
bind:value={params.question}
bind:element={questionElement}
label={communication.string.AskQuestion}
size="medium"
kind="default"
width="100%"
autoFocus
on:keydown={handleKeydown}
/>
</div>
<div class="poll__setting-item">
<span class="label"><Label label={communication.string.PollOptions} /></span>
{#each params.options as option, i}
<ModernEditbox
bind:value={option.label}
bind:element={optionElements[i]}
autoAction={false}
label={communication.string.Option}
size="medium"
kind="default"
width="100%"
on:keydown={(e) => {
handleKeydown(e, option)
}}
>
{#if params.quiz === true}
<CheckBox
checked={params.quizAnswer === option.id}
kind="todo"
size="small"
on:value={() => {
params.quizAnswer = option.id
}}
disabled={params.quizAnswer === option.id}
/>
{/if}
<svelte:fragment slot="after">
<div class="option-actions">
<ButtonIcon
icon={emoji.icon.Emoji}
size="small"
iconSize="small"
kind="tertiary"
on:click={(e) => {
showEmojiPicker(e, option.id)
}}
/>
{#if params.options.length > 1 && i !== params.options.length - 1}
<ButtonIcon
icon={IconClose}
size="small"
iconSize="small"
kind="tertiary"
on:click={() => {
removeOption(option)
}}
/>
{/if}
</div>
</svelte:fragment>
</ModernEditbox>
{/each}
</div>
<div class="poll__setting-item">
<ModernToggle
label={communication.string.AnonymousVoting}
size="large"
checked={params.anonymous ?? false}
on:change={() => {
params = { ...params, anonymous: !(params.anonymous ?? false) }
}}
/>
<ModernToggle
label={communication.string.MultipleChoice}
size="large"
checked={params.mode === 'multiple'}
on:change={() => {
params = { ...params, mode: params.mode === 'multiple' ? 'single' : 'multiple' }
}}
disabled={params.quiz}
/>
<ModernToggle
label={communication.string.QuizMode}
size="large"
checked={params.quiz ?? false}
on:change={() => {
params = { ...params, quiz: !(params.quiz ?? false), mode: params.quiz ? params.mode : 'single' }
}}
/>
</div>
<div class="poll__setting-item line">
<span class="label"><Label label={communication.string.StartTime} /></span>
<DateTimePresenter bind:value={params.startAt} editable />
</div>
<div class="poll__setting-item line">
<span class="label"><Label label={communication.string.EndTime} /></span>
<DateTimePresenter bind:value={params.endAt} editable />
</div>
</div>
</Modal>
<style lang="scss">
.poll {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
flex-shrink: 0;
width: 100%;
min-width: 0;
}
.poll__setting-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
flex-shrink: 0;
padding: 1rem 0;
width: 100%;
min-width: 0;
&.line {
flex-direction: row;
align-items: center;
padding-bottom: 0;
}
}
.label {
text-transform: uppercase;
font-weight: 500;
font-size: 0.75rem;
font-style: normal;
line-height: 1rem;
color: var(--global-secondary-TextColor);
}
.option-actions {
display: flex;
align-items: center;
gap: 0.125rem;
margin-right: -0.5rem;
}
</style>
@@ -0,0 +1,196 @@
<!-- 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. -->
<script lang="ts">
import { CheckBox } from '@hcengineering/ui'
import { getCurrentAccount, WithLookup } from '@hcengineering/core'
import { Poll, PollAnswer } from '@hcengineering/communication'
import { createEventDispatcher } from 'svelte'
import { PollOption } from '../../poll'
export let option: PollOption
export let result: WithLookup<Poll> | undefined
export let privateAnswers: PollAnswer[]
export let answer: string | undefined
export let anonymous: boolean
export let isVoted: boolean
export let started: boolean
export let ended: boolean
const me = getCurrentAccount()
const dispatch = createEventDispatcher()
function getOptionPercentage (optionId: string, result?: Poll): number {
if (result == null) return 0
const votes: number = (result as any)[optionId] ?? 0
if (votes === 0) return 0
const total = result.totalVotes ?? 0
return Math.round((votes / total) * 100)
}
function isOptionVotedByMe (optionId: string, result?: WithLookup<Poll>, privateAnswers: PollAnswer[] = []): boolean {
if (result == null) return false
if (anonymous) {
return privateAnswers.some((it: PollAnswer) => it.options.includes(optionId)) ?? false
}
const myVote = result.userVotes?.find((it) => it.account === me.uuid)
if (myVote == null) return false
return myVote.options.some((it) => it.id === optionId)
}
$: percentage = getOptionPercentage(option.id, result)
$: isVotedByMe = isOptionVotedByMe(option.id, result, privateAnswers)
$: voteKind = getVoteKind(option.id, result)
function getVoteKind (optionId: string, result: Poll | undefined): 'todo' | 'positive' | 'negative' {
if (result == null) return 'todo'
if (answer == null) return 'todo'
if (optionId === answer) return 'positive'
return 'negative'
}
</script>
{#if isVoted || ended}
<div class="poll-option">
<div class="poll-option__info">
<span class="poll-option__percentage">
{percentage}%
</span>
<span class="poll-option__label">
{option.label}
</span>
</div>
<div class="poll-option__result">
<span class="option_checkbox">
{#if isVotedByMe}
<CheckBox
checked={true}
kind={voteKind}
size="small"
disabled
circle
symbol={voteKind === 'negative' ? 'minus' : 'check'}
/>
{/if}
</span>
{#if percentage > 0}
<div class="progress-bar {voteKind}" style="width: {percentage}%" />
{:else}
<div class="progress-bar zero {voteKind}" />
{/if}
</div>
</div>
{:else}
<div class="poll-option">
<div class="poll-option__answer">
<span class="option_checkbox">
<CheckBox
checked={false}
kind="todo"
size="small"
disabled={!started || ended}
on:value={() => {
dispatch('toggle')
}}
/>
</span>
<span class="option_label">
{option.label}
</span>
</div>
</div>
{/if}
<style lang="scss">
.poll-option {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.25rem;
&__answer {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
&__percentage {
display: flex;
align-items: flex-start;
justify-content: flex-end;
width: 2rem;
max-width: 2rem;
min-width: 2rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--global-primary-TextColor);
}
&__label {
font-size: 0.75rem;
}
&__info {
display: flex;
gap: 0.5rem;
}
&__result {
display: flex;
align-items: center;
gap: 0.5rem;
height: 1.5rem;
min-height: 1.5rem;
}
}
.progress-bar {
background: var(--global-accent-IconColor);
width: 0;
border-radius: 1rem;
height: 0.5rem;
transition: width 0.4s ease;
&.positive {
background: var(--bg-positive-default);
}
&.negative {
background: var(--bg-negative-default);
}
&.zero {
width: 0.5rem;
}
}
.option_checkbox {
display: flex;
justify-content: flex-end;
align-items: center;
min-width: 2rem;
width: 2rem;
height: 1.5rem;
}
.option_label {
display: flex;
justify-content: flex-start;
align-items: center;
height: 100%;
min-height: 1.5rem;
}
</style>
@@ -0,0 +1,415 @@
<!-- 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. -->
<script lang="ts">
import { Applet, Poll, PollAnswer, UserVote } from '@hcengineering/communication'
import { AppletAttachment } from '@hcengineering/communication-types'
import { DAY, getEventPositionElement, Label, Menu, showPopup, ticker, TimeSince } from '@hcengineering/ui'
import contact, { getCurrentEmployeeSpace } from '@hcengineering/contact'
import { employeeByAccountStore, CombineAvatars } from '@hcengineering/contact-resources'
import { notEmpty, getCurrentAccount, isOtherDay, Timestamp, getDay } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import communication from '../../plugin'
import { isVotedByMe, PollConfig, PollOption } from '../../poll'
import PollOptionPresenter from './PollOptionPresenter.svelte'
import PollResults from './PollResults.svelte'
import { openDoc } from '@hcengineering/view-resources'
import { IntlString } from '@hcengineering/platform'
export let applet: Applet
export let attachment: AppletAttachment<PollConfig>
let result: Poll | undefined = undefined
const query = createQuery()
const privateAnswersQuery = createQuery()
let privateAnswers: PollAnswer[] = []
$: query.query(
communication.type.Poll,
{ _id: attachment.params.id },
(res) => {
result = res[0] as Poll
},
{ limit: 1 }
)
$: if (params.anonymous === true) {
privateAnswersQuery.query(
communication.class.PollAnswer,
{
attachedTo: attachment.params.id
},
(res) => {
privateAnswers = res
}
)
}
$: params = attachment.params
$: votedEmployees =
(result?.userVotes ?? [])?.map((it) => $employeeByAccountStore.get(it.account)).filter(notEmpty) ?? []
$: voted = isVotedByMe(result, params.anonymous, privateAnswers)
function showResults (): void {
showPopup(PollResults, { params, result }, 'center')
}
let selectedOptions: PollOption[] = []
function toggleOption (option: PollOption): void {
const index = selectedOptions.findIndex((it) => it.id === option.id)
if (index === -1) {
selectedOptions = [...selectedOptions, option]
} else {
selectedOptions = selectedOptions.filter((it) => it.id !== option.id)
}
}
$: if (!voted && result !== null && selectedOptions.length > 0 && params.mode !== 'multiple') {
void vote()
}
async function vote (): Promise<void> {
if (result == null || selectedOptions.length === 0 || voted) return
const client = getClient()
const me = getCurrentAccount()
try {
voted = true
const op = client.apply()
await op.update(result, { $inc: { totalVotes: 1 } })
for (const opt of selectedOptions) {
await op.update(result, { $inc: { [opt.id]: 1 } })
}
if (params.anonymous === true) {
const space = getCurrentEmployeeSpace()
await op.createDoc(communication.class.PollAnswer, space, {
attachedTo: result._id,
attachedToClass: result._class,
options: selectedOptions.map((it) => it.id),
collection: 'privateAnswers'
})
} else {
const date = new Date()
const myVote: UserVote = {
account: me.uuid,
options: selectedOptions.map((it) => ({ id: it.id, votedAt: date, label: it.label }))
}
await op.update(result, {
$push: {
userVotes: myVote
}
})
}
await op.commit()
selectedOptions = []
} catch (e) {
voted = false
console.log(e)
}
}
$: started = params.startAt == null || !isFuture($ticker, params.startAt)
$: ended = params.endAt != null && !isFuture($ticker, params.endAt)
function isFuture (now: number, time: number): boolean {
return time > now
}
function isTomorrow (time: Timestamp): boolean {
const todayDay = getDay(Date.now())
const targetDay = getDay(time)
return targetDay === todayDay + DAY
}
function getFormattedDate (date: number, type: 'start' | 'end'): { label: IntlString, date: string } {
if (!isOtherDay(date, Date.now())) {
return {
label: type === 'start' ? communication.string.StartsAt : communication.string.EndsAt,
date: new Date(date).toLocaleString('default', {
minute: '2-digit',
hour: 'numeric'
})
}
}
if (isTomorrow(date)) {
return {
label: type === 'start' ? communication.string.StartsTomorrow : communication.string.EndsTomorrow,
date: new Date(date).toLocaleString('default', {
minute: '2-digit',
hour: 'numeric'
})
}
}
return {
label: type === 'start' ? communication.string.StartsAt : communication.string.EndsAt,
date: new Date(date).toLocaleString('default', {
minute: '2-digit',
hour: 'numeric',
day: '2-digit',
month: 'short'
})
}
}
async function retractVote (): Promise<void> {
if (result == null || !voted || params.quiz === true) return
const client = getClient()
const me = getCurrentAccount()
const mySpace = getCurrentEmployeeSpace()
const op = client.apply()
await op.update(result, {
$inc: {
totalVotes: -1
}
})
if (params.anonymous === true) {
if (privateAnswers.length === 0) return
for (const answer of privateAnswers) {
if (answer.space === mySpace) {
await op.remove(answer)
for (const option of answer.options) {
await op.update(result, {
$inc: {
[option]: -1
}
})
}
}
}
} else {
const myVote = result.userVotes?.find((it) => it.account === me.uuid)
if (myVote == null) return
for (const option of myVote.options) {
await op.update(result, {
$inc: {
[option.id]: -1
}
})
}
await op.update(result, { userVotes: result.userVotes?.filter((it) => it.account !== me.uuid) ?? [] })
}
await op.commit()
selectedOptions = []
voted = false
}
function openPoll (): void {
if (result == null) return
void openDoc(getClient().getHierarchy(), result)
}
function onContextMenu (event: MouseEvent): void {
event.preventDefault()
showPopup(
Menu,
{
actions: [
{
label: communication.string.OpenPoll,
action: openPoll
},
...(params.quiz === true || ended || !voted
? []
: [
{
label: communication.string.RetractVote,
action: retractVote
}
])
]
},
getEventPositionElement(event)
)
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="poll-container" on:contextmenu|stopPropagation={onContextMenu}>
<div class="poll-question">
{params.question}
</div>
<div class="poll-type">
{#if params.anonymous}
<Label label={communication.string.AnonymousVoting} />
{:else if params.quiz}
<Label label={communication.string.Quiz} />
{:else}
<Label label={communication.string.Poll} />
{/if}
{#if votedEmployees.length > 0}
<div class="ml-1" />
<CombineAvatars
_class={contact.mixin.Employee}
items={votedEmployees.map((it) => it._id)}
size="tiny"
limit={8}
/>
{/if}
</div>
<div class="poll-options">
{#each params.options as option}
<PollOptionPresenter
{option}
bind:result
isVoted={voted}
answer={params.quizAnswer}
{started}
{ended}
{privateAnswers}
anonymous={params.anonymous ?? false}
on:toggle={() => {
toggleOption(option)
}}
/>
{/each}
</div>
{#if params.startAt != null || params.endAt != null}
<div class="poll-dates">
{#if params.startAt != null && !started}
{@const { label, date } = getFormattedDate(params.startAt, 'start')}
<span>
<Label {label} params={{ date }} />
</span>
{:else if params.endAt != null && !ended}
{@const { label, date } = getFormattedDate(params.endAt, 'end')}
<span>
<Label {label} params={{ date }} />
</span>
{:else if params.endAt != null && ended}
<span>
<Label label={communication.string.Ended} />
<TimeSince value={params.endAt} />
</span>
{/if}
</div>
{/if}
<div class="poll-footer">
<div class="mt-1" />
{#if !ended && !voted && selectedOptions.length > 0 && params.mode === 'multiple'}
<div class="footer-button" on:click={vote}>
<Label label={communication.string.Vote} />
</div>
{:else if !ended && (!voted || params.anonymous === true)}
<div class="votes-count">
<Label label={communication.string.VotesCount} params={{ count: result?.totalVotes ?? 0 }} />
</div>
{:else}
<div class="footer-button" on:click={showResults}>
<Label label={communication.string.ShowResults} />
</div>
{/if}
</div>
</div>
<style lang="scss">
.poll-container {
position: relative;
display: flex;
flex-direction: column;
padding: 0.5rem 0.5rem;
gap: 1rem;
border-radius: 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--global-ui-BorderColor);
min-width: 25rem;
max-width: 25rem;
width: 25rem;
user-select: text;
}
.poll-type {
display: flex;
align-items: center;
font-size: 0.675rem;
color: var(--global-tertiary-TextColor);
margin-top: -0.75rem;
}
.poll-question {
font-size: 0.875rem;
font-weight: 500;
color: var(--global-primary-TextColor);
}
.poll-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.poll-dates {
display: flex;
flex-direction: column;
font-size: 0.675rem;
color: var(--global-tertiary-TextColor);
margin-bottom: -0.75rem;
white-space: nowrap;
}
.poll-footer {
margin-top: 0.5rem;
border-top: 1px solid var(--theme-divider-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 500;
min-height: 34px;
.votes-count {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 0.75rem;
color: var(--global-secondary-TextColor);
font-weight: 500;
gap: 0.25rem;
}
.footer-button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 0.75rem;
color: var(--global-secondary-TextColor);
font-weight: 500;
gap: 0.25rem;
cursor: pointer;
&:hover {
color: var(--global-primary-TextColor);
}
}
}
</style>
@@ -0,0 +1,146 @@
<!-- 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. -->
<script lang="ts">
import { Applet } from '@hcengineering/communication'
import { createEventDispatcher } from 'svelte'
import { Label } from '@hcengineering/ui'
import presentation from '@hcengineering/presentation'
import { PollConfig } from '../../poll'
export let applet: Applet
export let params: PollConfig
export let editing: boolean = false
const dispatch = createEventDispatcher()
</script>
<div class="flex-row-center attachment-container">
<div class="flex-center icon">Poll</div>
<div class="flex-col info-container">
<div class="name overflow-label">
{params.question}
</div>
<div class="info-content flex-row-center">
<span class="actions inline-flex clear-mins flex-gap-1">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if !editing}
<span
class="edit-link"
on:click={(ev) => {
ev.stopPropagation()
ev.preventDefault()
dispatch('change')
}}
>
<Label label={presentation.string.Edit} />
</span>
<span></span>
{/if}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="remove-link"
on:click={(ev) => {
ev.stopPropagation()
ev.preventDefault()
dispatch('delete')
}}
>
<Label label={presentation.string.Delete} />
</span>
</span>
</div>
</div>
</div>
<style lang="scss">
.attachment-container {
flex-shrink: 0;
height: 3rem;
min-width: 17.25rem;
max-width: 17.25rem;
width: 17.25rem;
border-radius: 0.25rem;
.icon {
flex-shrink: 0;
width: 3rem;
height: 3rem;
object-fit: contain;
border: 1px solid var(--theme-button-border);
border-radius: 0.25rem 0 0 0.25rem;
cursor: pointer;
&:not(.image) {
color: var(--primary-button-color);
background-color: var(--primary-button-default);
}
}
.info-container {
padding: 0.5rem 0.75rem;
width: 100%;
height: 100%;
background-color: var(--theme-button-default);
border: 1px solid var(--theme-button-border);
border-left: none;
border-radius: 0 0.25rem 0.25rem 0;
}
.info-container:hover {
background-color: var(--theme-button-hovered);
.actions {
opacity: 1;
}
}
.name {
white-space: nowrap;
font-size: 0.8125rem;
color: var(--theme-caption-color);
cursor: pointer;
}
.info-content {
white-space: nowrap;
font-size: 0.6875rem;
color: var(--theme-darker-color);
&:hover .actions {
opacity: 1;
}
}
.remove-link {
color: var(--theme-error-color);
cursor: pointer;
&:hover {
text-decoration-line: underline;
}
}
.edit-link {
color: var(--theme-darker-color);
cursor: pointer;
font-weight: 500;
&:hover {
text-decoration-line: underline;
color: var(--theme-dark-color);
}
}
}
</style>
@@ -0,0 +1,158 @@
<!-- 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. -->
<script lang="ts">
import { Label, Modal, Scroller } from '@hcengineering/ui'
import { employeeByAccountStore, UserDetails } from '@hcengineering/contact-resources'
import { Poll } from '@hcengineering/communication'
import { AccountUuid, notEmpty } from '@hcengineering/core'
import { Employee } from '@hcengineering/contact'
import { PollConfig } from '../../poll'
import communication from '../../plugin'
export let params: PollConfig
export let result: Poll
$: total = result.totalVotes ?? 0
function getVotedPersons (optionId: string, result: Poll, employeeByAccount: Map<AccountUuid, Employee>): Employee[] {
return (result.userVotes ?? [])
.filter((it) => it.options.some((it) => it.id === optionId))
.map((it) => employeeByAccount.get(it.account))
.filter(notEmpty)
}
function getOptionResult (optionId: string, result: Poll): number {
return (result as any)[optionId] ?? 0
}
</script>
<Modal label={communication.string.PollResults} type="type-popup" width="large" hideFooter on:close>
<div class="hulyModal-content__titleGroup" style="padding: 0">
<div class="title">
{params.question}
</div>
<span class="votes-count">
<Label label={communication.string.VotesCount} params={{ count: result.totalVotes }} />
</span>
<div class="mt-8" />
{#each params.options as option}
{@const opResult = getOptionResult(option.id, result)}
{@const votedPersons = getVotedPersons(option.id, result, $employeeByAccountStore)}
{#if opResult > 0}
<div class="option">
<div class="option__header">
<span class="option__label overflow-label" title={option.label}>
{option.label}
</span>
<span class="option__percentage">
- {Math.round((opResult / total) * 100)}%
</span>
<span class="option__result">
<Label label={communication.string.VotesCount} params={{ count: opResult }} />
</span>
</div>
<div class="user-list">
<Scroller>
{#each votedPersons as person, index}
<div class="user-list__item" class:withoutBorder={index === votedPersons.length - 1}>
<div class="user-list__item__content">
<UserDetails {person} showStatus />
</div>
</div>
{/each}
</Scroller>
</div>
</div>
{/if}
{/each}
</div>
</Modal>
<style lang="scss">
.title {
font-size: 1rem;
font-weight: 500;
color: var(--global-primary-TextColor);
}
.votes-count {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--global-secondary-TextColor);
}
.option {
display: flex;
flex-direction: column;
gap: 0.5rem;
white-space: nowrap;
margin-bottom: 1rem;
&__header {
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
padding: 0 0.25rem;
}
&__label {
font-size: 0.875rem;
color: var(--global-secondary-TextColor);
flex-shrink: 1;
}
&__percentage {
font-size: 0.875rem;
color: var(--global-secondary-TextColor);
margin-left: -0.375rem;
margin-right: 1rem;
}
&__result {
font-size: 0.875rem;
color: var(--global-secondary-TextColor);
margin-left: auto;
}
}
.user-list {
display: flex;
flex-direction: column;
border-radius: 0.75rem;
background: var(--global-ui-highlight-BackgroundColor);
border: 1px solid var(--global-ui-BorderColor);
max-height: 30rem;
&__item {
padding: var(--spacing-0_75);
border-bottom: 1px solid var(--global-ui-BorderColor);
&.withoutBorder {
border: 0;
}
&__content {
display: flex;
align-items: center;
padding: var(--spacing-0_75);
border-radius: var(--small-BorderRadius);
}
}
}
</style>
@@ -0,0 +1,49 @@
<!-- 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. -->
<script lang="ts">
import { ActivityAttributeUpdate } from '@hcengineering/communication-types'
import communication, { UserVote } from '@hcengineering/communication'
import { Icon, Label } from '@hcengineering/ui'
export let update: ActivityAttributeUpdate
let addedVote: UserVote | undefined = undefined
$: addedVote = update.added?.[0] as UserVote | undefined
$: voteLabel = addedVote?.options?.map((it) => it.label).join(', ') ?? ''
</script>
<span class="icon mr-1"> <Icon icon={communication.icon.Poll} size="small" /> </span>
{#if addedVote}
<span class="overflow-label ml-1">
<Label label={communication.string.VotedFor} />
<span class="strong" title={voteLabel}>{voteLabel}</span>
</span>
{:else}
<span class="overflow-label ml-1">
<Label label={communication.string.RevokedVote} />
</span>
{/if}
<style lang="scss">
.strong {
font-weight: 500;
}
.icon {
display: flex;
align-items: center;
color: var(--global-secondary-TextColor);
fill: var(--global-secondary-TextColor);
}
</style>
@@ -0,0 +1,25 @@
<!-- 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. -->
<script lang="ts">
import { UserVote } from '@hcengineering/communication'
import { employeeByAccountStore, CombineAvatars } from '@hcengineering/contact-resources'
import { notEmpty } from '@hcengineering/core'
import contact from '@hcengineering/contact'
export let value: UserVote[]
$: employees = value.map((it) => $employeeByAccountStore.get(it.account)?._id).filter(notEmpty)
</script>
<CombineAvatars _class={contact.class.Person} items={employees} limit={5} size="card" />
+20 -10
View File
@@ -16,8 +16,12 @@ import { derived, get } from 'svelte/store'
import { type Location, location } from '@hcengineering/ui'
import { type Card } from '@hcengineering/card'
import { EmptyMarkup } from '@hcengineering/text'
import { getClient } from '@hcengineering/presentation'
import { type Applet } from '@hcengineering/communication'
import { type Message } from '@hcengineering/communication-types'
import { isBlobAttachment, isLinkPreviewAttachment, isAppletAttachment } from '@hcengineering/communication-shared'
import communication from './plugin'
import { type MessageDraft } from './types'
import { toMarkup } from './utils'
@@ -35,7 +39,8 @@ export function getEmptyDraft (): MessageDraft {
_id: generateId(),
content: EmptyMarkup,
blobs: [],
links: []
links: [],
applets: []
}
}
@@ -57,7 +62,8 @@ export function getDraft (card: Ref<Card>): MessageDraft {
_id: data._id ?? generateId(),
content: data.content ?? EmptyMarkup,
blobs: data.blobs ?? [],
links: data.links ?? []
links: data.links ?? [],
applets: data.applets ?? []
}
} catch (e) {
console.error(e)
@@ -84,16 +90,20 @@ export function saveDraft (card: Ref<Card>, draft: MessageDraft): void {
}
export function messageToDraft (message: Message): MessageDraft {
const applets: Applet[] = getClient().getModel().findAllSync(communication.class.Applet, {})
return {
_id: message.id,
content: toMarkup(message.content),
links: [...message.linkPreviews],
blobs: message.blobs.map((it) => ({
blobId: it.blobId,
mimeType: it.mimeType,
fileName: it.fileName,
size: it.size,
metadata: it.metadata
}))
blobs: message.attachments.filter(isBlobAttachment).map((it) => it.params),
links: message.attachments.filter(isLinkPreviewAttachment).map((it) => it.params),
applets: message.attachments
.filter(isAppletAttachment)
.map((it) => ({
id: it.id,
type: it.type,
appletId: applets.find((a) => a.type === it.type)?._id as any,
params: it.params
}))
.filter((it) => it.appletId !== undefined)
}
}
@@ -16,6 +16,12 @@
import { type Resources } from '@hcengineering/platform'
import CardMessagesSection from './components/CardMessagesSection.svelte'
import PollPresenter from './components/poll/PollPresenter.svelte'
import CreatePoll from './components/poll/CreatePoll.svelte'
import PollPreview from './components/poll/PollPreview.svelte'
import UserVoteActivityPresenter from './components/poll/UserVoteActivityPresenter.svelte'
import UserVotesPresenter from './components/poll/UserVotesPresenter.svelte'
import { unsubscribe, subscribe, canSubscribe, canUnsubscribe } from './utils'
import {
addReaction,
@@ -32,6 +38,7 @@ import {
showOriginalMessage,
translateMessage
} from './actions'
import { createPoll } from './poll'
export { isActivityMessage } from './activity'
export * from './stores'
@@ -44,6 +51,14 @@ export default async (): Promise<Resources> => ({
component: {
CardMessagesSection
},
poll: {
PollPresenter,
CreatePoll,
PollPreview,
CreatePollFn: createPoll,
UserVoteActivityPresenter,
UserVotesPresenter
},
messageActionImpl: {
AddReaction: addReaction,
ReplyInThread: replyInThread,
+10 -1
View File
@@ -16,7 +16,8 @@ import communication, {
communicationId,
type MessageAction,
type MessageActionFunctionResource,
type MessageActionVisibilityTesterResource
type MessageActionVisibilityTesterResource,
type AppletCreateFnResource
} from '@hcengineering/communication'
import { type AnyComponent } from '@hcengineering/ui'
import { type Ref } from '@hcengineering/core'
@@ -25,6 +26,14 @@ export default mergeIds(communicationId, communication, {
component: {
CardMessagesSection: '' as AnyComponent
},
poll: {
PollPresenter: '' as AnyComponent,
CreatePoll: '' as AnyComponent,
PollPreview: '' as AnyComponent,
CreatePollFn: '' as AppletCreateFnResource,
UserVoteActivityPresenter: '' as AnyComponent,
UserVotesPresenter: '' as AnyComponent
},
string: {
Added: '' as IntlString,
All: '' as IntlString,
@@ -0,0 +1,98 @@
// 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 {
type Data,
fillDefaults,
generateId,
getCurrentAccount,
type MarkupBlobRef,
type Ref,
SortingOrder,
type Timestamp
} from '@hcengineering/core'
import { type Poll, type PollAnswer } from '@hcengineering/communication'
import { getClient } from '@hcengineering/presentation'
import card, { type Card } from '@hcengineering/card'
import { makeRank } from '@hcengineering/rank'
import { type MessageID } from '@hcengineering/communication-types'
import communication from './plugin'
// Poll configuration
export interface PollConfig {
id: Ref<Poll>
question: string
options: PollOption[]
mode: PollMode
anonymous?: boolean
quiz?: boolean
quizAnswer?: string
startAt?: Timestamp
endAt?: Timestamp
}
export type PollMode = 'single' | 'multiple'
export interface PollOption {
id: string
label: string
}
export function getEmptyPollConfig (): PollConfig {
return {
id: generateId<Poll>(),
question: '',
options: [
{
id: generateId(),
label: ''
}
],
mode: 'single',
anonymous: false,
quiz: false
}
}
export async function createPoll (parent: Card, message: MessageID, params: PollConfig): Promise<void> {
const client = getClient()
const hierarchy = client.getHierarchy()
const lastOne = await client.findOne(card.class.Card, {}, { sort: { rank: SortingOrder.Descending } })
const data: Data<Poll> = {
title: params.question,
rank: makeRank(lastOne?.rank, undefined),
content: '' as MarkupBlobRef,
parentInfo: [],
blobs: {},
totalVotes: 0,
messageId: message,
userVotes: []
}
const filledData = fillDefaults(hierarchy, data, communication.type.Poll)
await client.createDoc(communication.type.Poll, parent.space, filledData, params.id)
}
export function isVotedByMe (result: Poll | undefined, anonymous: boolean = false, answers: PollAnswer[] = []): boolean {
if (result == null) return false
if (anonymous) {
return answers.some((it: PollAnswer) => it.options.length > 0) ?? false
}
const me = getCurrentAccount()
return result.userVotes?.some((it) => it.account === me.uuid && it.options.length > 0) ?? false
}
+12 -3
View File
@@ -15,10 +15,11 @@
import { type IntlString } from '@hcengineering/platform'
import { type TextEditorHandler } from '@hcengineering/text-editor'
import { type LinkPreviewData, type BlobData } from '@hcengineering/communication-types'
import { type LinkPreviewParams, type BlobParams, type AppletType } from '@hcengineering/communication-types'
import type { Markup, Ref, Timestamp } from '@hcengineering/core'
import type { Person } from '@hcengineering/contact'
import type { IconComponent } from '@hcengineering/ui'
import { type Applet } from '@hcengineering/communication'
export type TextInputActionFn = (element: HTMLElement, editor: TextEditorHandler, event?: MouseEvent) => void
@@ -44,9 +45,17 @@ export interface PresenceTyping {
lastTyping: Timestamp
}
export interface AppletDraft {
id: string
type: AppletType
appletId: Ref<Applet>
params: Record<string, any>
}
export interface MessageDraft {
_id: string
content: Markup
blobs: BlobData[]
links: LinkPreviewData[]
blobs: BlobParams[]
links: LinkPreviewParams[]
applets: AppletDraft[]
}
+2 -2
View File
@@ -16,7 +16,7 @@ import { type Card } from '@hcengineering/card'
import { getCurrentAccount, type Markup } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import { showPopup } from '@hcengineering/ui'
import { type LinkPreviewData, type Message } from '@hcengineering/communication-types'
import { type LinkPreviewParams, type Message } from '@hcengineering/communication-types'
import emoji from '@hcengineering/emoji'
import { markdownToMarkup, markupToMarkdown } from '@hcengineering/text-markdown'
import { jsonToMarkup, markupToJSON } from '@hcengineering/text'
@@ -109,7 +109,7 @@ export async function toggleReaction (message: Message, emoji: string): Promise<
}
}
export async function loadLinkPreviewData (url: string): Promise<LinkPreviewData | undefined> {
export async function loadLinkPreviewParams (url: string): Promise<LinkPreviewParams | undefined> {
try {
const meta = await fetchLinkPreviewDetails(url)
if (canDisplayLinkPreview(meta) && meta.url !== undefined && meta.host !== undefined) {
+1
View File
@@ -40,6 +40,7 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/card": "^0.6.0"
}
+47 -6
View File
@@ -12,10 +12,10 @@
// limitations under the License.
import { Asset, IntlString, Metadata, plugin, Plugin } from '@hcengineering/platform'
import { CardSection } from '@hcengineering/card'
import { CardSection, MasterTag } from '@hcengineering/card'
import { Class, Ref } from '@hcengineering/core'
import { MessageAction } from './types'
import { Applet, CustomActivityPresenter, MessageAction, PollAnswer } from './types'
export * from './types'
@@ -26,13 +26,20 @@ export const communicationId = 'communication' as Plugin
export default plugin(communicationId, {
class: {
MessageAction: '' as Ref<Class<MessageAction>>
MessageAction: '' as Ref<Class<MessageAction>>,
Applet: '' as Ref<Class<Applet>>,
PollAnswer: '' as Ref<Class<PollAnswer>>,
CustomActivityPresenter: '' as Ref<Class<CustomActivityPresenter>>
},
type: {
Poll: '' as Ref<MasterTag>
},
icon: {
Bell: '' as Asset,
BellCrossed: '' as Asset,
File: '' as Asset,
MessageMultiple: '' as Asset
MessageMultiple: '' as Asset,
Poll: '' as Asset
},
metadata: {
Enabled: '' as Metadata<boolean>
@@ -44,9 +51,43 @@ export default plugin(communicationId, {
Subscribe: '' as IntlString,
Unsubscribe: '' as IntlString,
File: '' as IntlString,
Files: '' as IntlString
Files: '' as IntlString,
CreatePoll: '' as IntlString,
Poll: '' as IntlString,
QuestionIsRequired: '' as IntlString,
AnswerIsRequired: '' as IntlString,
OptionIsRequired: '' as IntlString,
StartDateMustBeInTheFuture: '' as IntlString,
EndDateMustBeInTheFuture: '' as IntlString,
Question: '' as IntlString,
AskQuestion: '' as IntlString,
PollOptions: '' as IntlString,
Option: '' as IntlString,
AnonymousVoting: '' as IntlString,
MultipleChoice: '' as IntlString,
QuizMode: '' as IntlString,
StartTime: '' as IntlString,
EndTime: '' as IntlString,
OpenPoll: '' as IntlString,
RetractVote: '' as IntlString,
Quiz: '' as IntlString,
VotesCount: '' as IntlString,
Vote: '' as IntlString,
ShowResults: '' as IntlString,
Ended: '' as IntlString,
StartsAt: '' as IntlString,
EndsAt: '' as IntlString,
StartsTomorrow: '' as IntlString,
EndsTomorrow: '' as IntlString,
PollResults: '' as IntlString,
Polls: '' as IntlString,
TotalVotes: '' as IntlString,
Voted: '' as IntlString,
VotedFor: '' as IntlString,
RevokedVote: '' as IntlString
},
ids: {
CardMessagesSection: '' as Ref<CardSection>
CardMessagesSection: '' as Ref<CardSection>,
PollApplet: '' as Ref<Applet>
}
})
+42 -3
View File
@@ -11,10 +11,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Message } from '@hcengineering/communication-types'
import { Doc } from '@hcengineering/core'
import { AccountID, AppletParams, AppletType, Message, MessageID } from '@hcengineering/communication-types'
import { AttachedDoc, Doc, Ref } from '@hcengineering/core'
import { Asset, IntlString, Resource } from '@hcengineering/platform'
import { Card } from '@hcengineering/card'
import { Card, MasterTag } from '@hcengineering/card'
import { AnyComponent } from '@hcengineering/ui'
import { PersonSpace } from '@hcengineering/contact'
export enum MessagesNavigationAnchors {
ConversationStart = 'conversationStart',
@@ -42,3 +44,40 @@ export interface MessageAction extends Doc {
order: number
menu?: boolean
}
export interface CustomActivityPresenter extends Doc {
attribute: string
component: AnyComponent
type: Ref<MasterTag>
}
export interface Applet extends Doc {
type: AppletType
icon: Asset
label: IntlString
component: AnyComponent
createLabel: IntlString
createComponent: AnyComponent
previewComponent: AnyComponent
createFn?: AppletCreateFnResource
}
export type AppletCreateFn = (parent: Card, message: MessageID, params: AppletParams) => Promise<void>
export type AppletCreateFnResource = Resource<AppletCreateFn>
export interface PollAnswer extends AttachedDoc<Poll> {
options: string[]
space: Ref<PersonSpace>
}
export interface UserVote {
account: AccountID
options: { id: string, label: string, votedAt: Date }[]
}
export interface Poll extends Card {
messageId: MessageID
totalVotes: number
userVotes?: UserVote[]
}
+10
View File
@@ -45,6 +45,7 @@ import contact, {
Contact,
Employee,
Person,
PersonSpace,
SocialIdentity,
SocialIdentityRef
} from '.'
@@ -53,6 +54,7 @@ import { AVATAR_COLORS, GravatarPlaceholderType } from './types'
import ContactCache from './cache'
let currentEmployee: Ref<Employee>
let currentEmployeeSpace: Ref<PersonSpace>
const employeeListeners: ((ref: Ref<Employee>) => void)[] = []
/**
@@ -63,6 +65,10 @@ export function getCurrentEmployee (): Ref<Employee> {
return currentEmployee
}
export function getCurrentEmployeeSpace (): Ref<PersonSpace> {
return currentEmployeeSpace
}
export function addEmployeeListenrer (l: (ref: Ref<Employee>) => void): void {
employeeListeners.push(l)
}
@@ -78,6 +84,10 @@ export function setCurrentEmployee (employee: Ref<Employee>): void {
}
}
export function setCurrentEmployeeSpace (space: Ref<PersonSpace>): void {
currentEmployeeSpace = space
}
/**
* @public
*/
+1
View File
@@ -43,6 +43,7 @@
"@hcengineering/communication": "^0.6.0",
"@hcengineering/communication-resources": "^0.6.0",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/communication-shared": "^0.1.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/core": "^0.6.32",
@@ -12,28 +12,30 @@
<!-- limitations under the License. -->
<script lang="ts">
import { Message, MessageType } from '@hcengineering/communication-types'
import { Message } from '@hcengineering/communication-types'
import { Icon, Label, tooltip } from '@hcengineering/ui'
import communication from '@hcengineering/communication'
import { isBlobAttachment } from '@hcengineering/communication-shared'
import FilesTooltip from './FilesTooltip.svelte'
import AttachmentsTooltip from './AttachmentsTooltip.svelte'
export let message: Message
$: showFiles = message.blobs.length > 0
$: blobs = message.attachments.filter(isBlobAttachment) ?? []
$: showFiles = blobs.length > 0
</script>
{#if showFiles}
{#if message.content.trim().length > 0}
<span class="attachments" use:tooltip={{ component: FilesTooltip, props: { blobs: message.blobs } }}>
{message.blobs.length}
<span class="attachments" use:tooltip={{ component: AttachmentsTooltip, props: { attachments: blobs } }}>
{blobs.length}
<Icon icon={communication.icon.File} size="small" />
</span>
{:else}
<span class="attachments-text font-normal overflow-label">
<Label label={communication.string.Files} />:
<span class="ml-1 overflow-label" use:tooltip={{ component: FilesTooltip, props: { blobs: message.blobs } }}>
{message.blobs.map(({ fileName }) => fileName).join(', ')}
<span class="ml-1 overflow-label" use:tooltip={{ component: AttachmentsTooltip, props: { attachments: blobs } }}>
{blobs.map((it) => it.params.fileName).join(', ')}
</span>
</span>
{/if}
@@ -12,15 +12,18 @@
<!-- limitations under the License. -->
<script lang="ts">
import { AttachedBlob } from '@hcengineering/communication-types'
import { Attachment } from '@hcengineering/communication-types'
import { isBlobAttachment } from '@hcengineering/communication-shared'
export let blobs: AttachedBlob[] = []
export let attachments: Attachment[] = []
$: blobs = attachments.filter(isBlobAttachment)
</script>
<div class="tooltip">
{#each blobs as blob}
<div>
{blob.fileName}
{blob.params.fileName}
</div>
{/each}
</div>
@@ -23,7 +23,7 @@
import { jsonToMarkup, markupToText } from '@hcengineering/text'
import { ActivityMessageViewer, isActivityMessage } from '@hcengineering/communication-resources'
import FilesPreview from './FilesPreview.svelte'
import AttachmentPreview from './AttachmentPreview.svelte'
import PreviewTemplate from './PreviewTemplate.svelte'
export let card: Card
@@ -69,6 +69,6 @@
{/if}
<svelte:fragment slot="after">
<FilesPreview {message} />
<AttachmentPreview {message} />
</svelte:fragment>
</PreviewTemplate>
+9 -1
View File
@@ -1,7 +1,7 @@
import { getClient as getAccountClient } from '@hcengineering/account-client'
import { Analytics } from '@hcengineering/analytics'
import client from '@hcengineering/client'
import { ensureEmployee, setCurrentEmployee } from '@hcengineering/contact'
import contact, { ensureEmployee, setCurrentEmployee, setCurrentEmployeeSpace } from '@hcengineering/contact'
import core, {
type Account,
AccountRole,
@@ -385,7 +385,15 @@ export async function connect (title: string): Promise<Client | undefined> {
})
return
}
const space = await newClient.findOne(contact.class.PersonSpace, { person: employee }, { projection: { _id: 1 } })
setCurrentEmployee(employee)
if (space !== undefined) {
setCurrentEmployeeSpace(space._id)
} else {
console.error('Failed to find space for employee')
}
await setPlatformStatus(OK)
} else {
setCurrentEmployee(core.employee.System)
@@ -445,12 +445,6 @@ export async function getNewActivityUpdates (
continue
}
if (Array.isArray(attrValue)) {
const diff = await getAttributeDiff(control, card, undefined, key, mixin)
added.push(...diff.added)
removed.push(...diff.removed)
}
result.push({
type: ActivityUpdateType.Attribute,
attrKey: key,
+71 -64
View File
@@ -21,32 +21,32 @@ import core, {
type AttachedDoc,
type Blob,
type Class,
DOMAIN_MODEL,
type Doc,
type Domain,
type FullTextSearchContext,
type Hierarchy,
type IdMap,
type MeasureContext,
type ModelDb,
type Ref,
SortingOrder,
type Space,
type TxCUD,
type TxDomainEvent,
TxProcessor,
type WorkspaceIds,
type WorkspaceUuid,
docKey,
type Domain,
DOMAIN_MODEL,
type FullTextSearchContext,
getFullTextIndexableAttributes,
groupByArray,
type Hierarchy,
type IdMap,
isClassIndexable,
isFullTextAttribute,
isIndexedAttribute,
type MeasureContext,
type ModelDb,
platformNow,
type Ref,
SortingOrder,
type Space,
systemAccount,
toIdMap,
withContext
type TxCUD,
type TxDomainEvent,
TxProcessor,
withContext,
type WorkspaceIds,
type WorkspaceUuid
} from '@hcengineering/core'
import drivePlugin, { type FileVersion } from '@hcengineering/drive'
import type {
@@ -65,23 +65,30 @@ import { findSearchPresenter, updateDocWithPresenter } from '../mapper'
import { type FullTextPipeline } from './types'
import { blobPseudoClass, createIndexedDoc, createIndexedDocFromMessage, getContent, messagePseudoClass } from './utils'
import {
type AttachmentPatchEvent,
type BlobPatchEvent,
CardEventType,
type CreateMessageEvent,
type Event,
type EventType,
MessageEventType,
type RemoveCardEvent,
type RemovePatchEvent,
type ServerApi as CommunicationApi,
type SessionData as CommunicationSession,
type CreateMessageEvent,
type UpdatePatchEvent,
type RemovePatchEvent,
MessageEventType,
type Event,
CardEventType,
type UpdateCardTypeEvent,
type EventType,
type BlobPatchEvent,
type LinkPreviewPatchEvent,
type RemoveCardEvent
type UpdatePatchEvent
} from '@hcengineering/communication-sdk-types'
import { type AttachedBlob, type CardID, type Message, type MessageID } from '@hcengineering/communication-types'
import {
type AttachmentID,
type BlobAttachment,
type BlobParams,
type CardID,
type Message,
type MessageID
} from '@hcengineering/communication-types'
import { parseYaml } from '@hcengineering/communication-yaml'
import { applyPatches } from '@hcengineering/communication-shared'
import { applyPatches, isBlobAttachment, isLinkPreviewAttachment } from '@hcengineering/communication-shared'
import { markdownToMarkup } from '@hcengineering/text-markdown'
export * from './types'
@@ -102,7 +109,7 @@ type IndexableCommunicationEvent =
| QueueSourced<CreateMessageEvent>
| QueueSourced<UpdatePatchEvent>
| QueueSourced<BlobPatchEvent>
| QueueSourced<LinkPreviewPatchEvent>
| QueueSourced<AttachmentPatchEvent> // TODO: handle
| QueueSourced<RemovePatchEvent>
| QueueSourced<UpdateCardTypeEvent>
| QueueSourced<RemoveCardEvent>
@@ -623,8 +630,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
})
await ctx.with('process-messages', {}, async (ctx) => {
let messages = await communicationApi.findMessages(this.communicationSession, {
links: true,
files: true,
attachments: true,
limit: messagesLimit,
order: SortingOrder.Ascending
})
@@ -671,8 +677,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
}
}
messages = await communicationApi.findMessages(this.communicationSession, {
links: true,
files: true,
attachments: true,
limit: messagesLimit,
order: SortingOrder.Ascending,
created: {
@@ -697,7 +702,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
MessageEventType.CreateMessage,
MessageEventType.UpdatePatch,
MessageEventType.BlobPatch,
MessageEventType.LinkPreviewPatch,
MessageEventType.AttachmentPatch,
MessageEventType.RemovePatch,
CardEventType.UpdateCardType,
CardEventType.RemoveCard
@@ -793,8 +798,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
const messages = await communicationApi.findMessages(this.communicationSession, {
card: cardId,
id: msgId,
links: true,
files: true
attachments: true
})
if (messages.length === 1) {
return messages[0]
@@ -825,15 +829,8 @@ export class FullTextIndexPipeline implements FullTextPipeline {
// If message was already fully replaced, other transactions can skip the message
const messagesUpdated = new Set<MessageID>()
for (const tx of txes) {
if (
[MessageEventType.CreateMessage, MessageEventType.UpdatePatch, MessageEventType.LinkPreviewPatch].includes(
tx.event.type as any
)
) {
const event = tx.event as
| QueueSourced<CreateMessageEvent>
| QueueSourced<UpdatePatchEvent>
| QueueSourced<LinkPreviewPatchEvent>
if ([MessageEventType.CreateMessage, MessageEventType.UpdatePatch].includes(tx.event.type as any)) {
const event = tx.event as QueueSourced<CreateMessageEvent> | QueueSourced<UpdatePatchEvent>
if (event.messageId === undefined) {
continue
}
@@ -854,10 +851,13 @@ export class FullTextIndexPipeline implements FullTextPipeline {
for (const operation of event.operations) {
if (operation.opcode === 'attach' || operation.opcode === 'set' || operation.opcode === 'update') {
for (const blobData of operation.blobs) {
const attachedBlob = Object.assign(blobData, {
const blobAttachment: Omit<BlobAttachment, 'type'> = {
id: blobData.blobId as any as AttachmentID,
params: blobData as BlobParams,
creator: event.socialId,
created: new Date(Date.parse(event.date))
})
}
await this.processCommunicationBlob(
ctx,
pushQueue,
@@ -867,7 +867,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
space: cardDoc.space,
attachedTo: cardDoc._id
},
attachedBlob as AttachedBlob
blobAttachment
)
}
} else if (operation.opcode === 'detach') {
@@ -879,6 +879,8 @@ export class FullTextIndexPipeline implements FullTextPipeline {
}
}
}
} else if (tx.event.type === MessageEventType.AttachmentPatch) {
// TODO: implement
} else if (tx.event.type === MessageEventType.RemovePatch) {
const event = tx.event
messagesUpdated.add(event.messageId)
@@ -1039,10 +1041,7 @@ export class FullTextIndexPipeline implements FullTextPipeline {
cardId: CardID,
cardSpace: Ref<Space>,
cardClass: Ref<Class<Card>>,
message: Pick<
Message,
'id' | 'edited' | 'created' | 'creator' | 'content' | 'extra' | 'blobs' | 'thread' | 'linkPreviews'
>
message: Pick<Message, 'id' | 'edited' | 'created' | 'creator' | 'content' | 'extra' | 'thread' | 'attachments'>
): Promise<void> {
const indexedDoc = createIndexedDocFromMessage(cardId, cardSpace, cardClass, message)
const markup = markdownToMarkup(message.content)
@@ -1054,7 +1053,8 @@ export class FullTextIndexPipeline implements FullTextPipeline {
.split(/\n\n+/)
.join('\n')
indexedDoc.fulltextSummary = textContent
for (const linkPreview of message.linkPreviews) {
const linkPreviews = message.attachments.filter(isLinkPreviewAttachment).map((it) => it.params)
for (const linkPreview of linkPreviews) {
if (linkPreview.title !== undefined) {
indexedDoc.fulltextSummary += '\n' + linkPreview.title
}
@@ -1069,7 +1069,9 @@ export class FullTextIndexPipeline implements FullTextPipeline {
await this.listener.onIndexing(indexedDoc)
}
await pushQueue.push(indexedDoc)
for (const blob of message.blobs) {
const blobs = message.attachments.filter(isBlobAttachment)
for (const blob of blobs) {
await this.processCommunicationBlob(ctx, pushQueue, indexedDoc, blob)
}
}
@@ -1079,32 +1081,37 @@ export class FullTextIndexPipeline implements FullTextPipeline {
ctx: MeasureContext<any>,
pushQueue: ElasticPushQueue,
parentDoc: { id: Ref<Doc>, _class: Ref<Class<Doc>>[], space: Ref<Space>, attachedTo?: Ref<Doc> },
blob: AttachedBlob
blobAttachment: Omit<BlobAttachment, 'type'>
): Promise<void> {
try {
const indexedDoc: IndexedDoc = {
id: `${blob.blobId}@${parentDoc.attachedTo}` as any,
id: `${blobAttachment.id}@${parentDoc.attachedTo}` as any,
_class: [`${card.class.Card}%blob` as Ref<Class<Doc>>],
space: parentDoc.space,
[docKey('createdOn', core.class.Doc)]: blob.created.getTime(),
[docKey('createdBy', core.class.Doc)]: blob.creator,
modifiedBy: blob.creator,
modifiedOn: blob.created.getTime(),
[docKey('createdOn', core.class.Doc)]: blobAttachment.created.getTime(),
[docKey('createdBy', core.class.Doc)]: blobAttachment.creator,
modifiedBy: blobAttachment.creator,
modifiedOn: blobAttachment.created.getTime(),
attachedTo: parentDoc.id,
attachedToClass: parentDoc._class[0],
searchTitle: blob.fileName,
searchShortTitle: blob.fileName,
searchTitle: blobAttachment.params.fileName,
searchShortTitle: blobAttachment.params.fileName,
attachedToCard: parentDoc.attachedTo
}
indexedDoc.fulltextSummary = ''
await this.handleBlobRef(ctx, blob.blobId, indexedDoc, blob.mimeType)
await this.handleBlobRef(ctx, blobAttachment.params.blobId, indexedDoc, blobAttachment.params.mimeType)
if (this.listener?.onIndexing !== undefined) {
await this.listener.onIndexing(indexedDoc)
}
await pushQueue.push(indexedDoc)
} catch (err: any) {
Analytics.handleError(err)
ctx.error('failed to handle blob', { err, _id: blob.blobId, workspace: this.workspace.uuid })
ctx.error('failed to handle blob', {
err,
attachmentId: blobAttachment.id,
blobId: blobAttachment.params.blobId,
workspace: this.workspace.uuid
})
}
}
+3 -4
View File
@@ -15,6 +15,7 @@
import postgres from 'postgres'
import { MessageID, type CardID, type WorkspaceID } from '@hcengineering/communication-types'
import { Domain } from '@hcengineering/communication-sdk-types'
import config from './config'
@@ -39,8 +40,6 @@ export async function getDb (): Promise<PostgresDB> {
export class PostgresDB {
private readonly syncTable = 'msg2file.sync_record'
private readonly messagesTable = 'communication.messages'
private readonly patchesTable = 'communication.patch'
constructor (private readonly client: postgres.Sql) {}
@@ -114,7 +113,7 @@ export class PostgresDB {
if (ids.length === 0) return
const sql = `
DELETE
FROM ${this.messagesTable}
FROM ${Domain.Message}
WHERE workspace_id = $1::uuid
AND card_id = $2::varchar
AND id = ANY ($3::varchar[]);`
@@ -126,7 +125,7 @@ export class PostgresDB {
if (ids.length === 0) return
const sql = `
DELETE
FROM ${this.patchesTable}
FROM ${Domain.Patch}
WHERE workspace_id = $1::uuid
AND card_id = $2::varchar
AND message_id = ANY ($3::varchar[]);`
+2 -2
View File
@@ -229,7 +229,7 @@ async function newMessages2file (
limit: config.MessagesPerFile,
reactions: true,
replies: true,
files: true
attachments: true
})
).map(deserializeMessage)
@@ -291,7 +291,7 @@ async function createNewGroup (
created: lastCreated,
reactions: true,
replies: true,
files: true
attachments: true
})
)
.filter((it) => it.id !== lastMessage.id)