mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Init poll applet (#9586)
Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
+1
-1
Submodule communication updated: 88ecdd1be7...ffed29fc6b
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 |
@@ -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í"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "投票を取り消し"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Отменил(а) голос"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "撤销投票"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-9
@@ -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}
|
||||
|
||||
+2
-2
@@ -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" />
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
+9
-7
@@ -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}
|
||||
+6
-3
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]);`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user