Add more chat/cards ui fixes (#9358)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2025-06-25 14:06:01 +07:00
committed by GitHub
parent 8d10d7a991
commit 161905dc0f
60 changed files with 1464 additions and 646 deletions
+4 -2
View File
@@ -59,7 +59,7 @@ import presentation from '@hcengineering/model-presentation'
import setting from '@hcengineering/model-setting'
import view, { createAction, type Viewlet } from '@hcengineering/model-view'
import workbench, { WidgetType } from '@hcengineering/model-workbench'
import { type Asset, getEmbeddedLabel, type IntlString } from '@hcengineering/platform'
import { type Asset, getEmbeddedLabel, type IntlString, type Resource } from '@hcengineering/platform'
import time, { type ToDo } from '@hcengineering/time'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import { type BuildModelKey } from '@hcengineering/view'
@@ -132,6 +132,7 @@ export class TCardSection extends TDoc implements CardSection {
component!: AnyComponent
order!: number
navigation!: CardNavigation[]
checkVisibility?: Resource<(doc: Card) => Promise<boolean>>
}
@Mixin(card.mixin.CardViewDefaults, card.class.MasterTag)
@@ -902,7 +903,8 @@ function defineTabs (builder: Builder): void {
label: core.string.Relations,
component: card.sectionComponent.RelationsSection,
order: 500,
navigation: []
navigation: [],
checkVisibility: card.function.CheckRelationsSectionVisibility
},
card.section.Relations
)
+3 -2
View File
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { cardId } from '@hcengineering/card'
import { type Card, cardId } from '@hcengineering/card'
import card from '@hcengineering/card-resources/src/plugin'
import type { Client, Doc, Ref } from '@hcengineering/core'
import {} from '@hcengineering/core'
@@ -54,7 +54,8 @@ export default mergeIds(cardId, card, {
CardIdProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
GetCardLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
CardCustomLinkMatch: '' as Resource<(doc: Doc) => boolean>,
CardCustomLinkEncode: '' as Resource<(doc: Doc) => Location>
CardCustomLinkEncode: '' as Resource<(doc: Doc) => Location>,
CheckRelationsSectionVisibility: '' as Resource<(doc: Card) => Promise<boolean>>
},
label: {
Subscribed: '' as Ref<TagElement>,
+2 -1
View File
@@ -32,11 +32,12 @@
"@hcengineering/card": "^0.6.0",
"@hcengineering/communication": "^0.6.0",
"@hcengineering/communication-resources": "^0.6.0",
"@hcengineering/model-card": "^0.6.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",
"@hcengineering/model": "^0.6.11",
"@hcengineering/model-card": "^0.6.0",
"@hcengineering/model-core": "^0.6.0",
"@hcengineering/model-emoji": "^0.6.0",
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/ui": "^0.6.15"
+144
View File
@@ -0,0 +1,144 @@
// 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 view, { createAction } from '@hcengineering/model-view'
import card from '@hcengineering/model-card'
import core from '@hcengineering/model-core'
import emoji from '@hcengineering/model-emoji'
import communication from './plugin'
export function buildMessageActions (builder: Builder): void {
builder.createDoc(
communication.class.MessageAction,
core.space.Model,
{
label: communication.string.AddReaction,
icon: emoji.icon.Emoji,
action: communication.messageActionImpl.AddReaction,
order: 100
},
communication.messageAction.AddReaction
)
builder.createDoc(
communication.class.MessageAction,
core.space.Model,
{
label: communication.string.ReplyInThread,
icon: communication.icon.MessageMultiple,
action: communication.messageActionImpl.ReplyInThread,
visibilityTester: communication.function.CanReplyInThread,
order: 200
},
communication.messageAction.ReplyInThread
)
builder.createDoc(
communication.class.MessageAction,
core.space.Model,
{
label: communication.string.CreateCard,
icon: card.icon.Card,
action: communication.messageActionImpl.CreateCard,
visibilityTester: communication.function.CanCreateCard,
order: 300
},
communication.messageAction.CreateCard
)
builder.createDoc(
communication.class.MessageAction,
core.space.Model,
{
label: communication.string.TranslateMessage,
icon: view.icon.Translate,
action: communication.messageActionImpl.TranslateMessage,
visibilityTester: communication.function.CanTranslateMessage,
order: 400
},
communication.messageAction.TranslateMessage
)
builder.createDoc(
communication.class.MessageAction,
core.space.Model,
{
label: communication.string.ShowOriginalMessage,
icon: view.icon.Undo,
action: communication.messageActionImpl.ShowOriginalMessage,
visibilityTester: communication.function.CanShowOriginalMessage,
order: 400
},
communication.messageAction.ShowOriginalMessage
)
builder.createDoc(
communication.class.MessageAction,
core.space.Model,
{
label: communication.string.EditMessage,
icon: view.icon.Edit,
action: communication.messageActionImpl.EditMessage,
order: 500,
visibilityTester: communication.function.CanEditMessage,
menu: true
},
communication.messageAction.EditMessage
)
builder.createDoc(
communication.class.MessageAction,
core.space.Model,
{
label: communication.string.RemoveMessage,
icon: view.icon.Delete,
action: communication.messageActionImpl.RemoveMessage,
order: 9999,
visibilityTester: communication.function.CanRemoveMessage,
menu: true
},
communication.messageAction.RemoveMessage
)
}
export function buildCardActions (builder: Builder): void {
createAction(builder, {
action: communication.action.Subscribe,
label: communication.string.Subscribe,
icon: communication.icon.Bell,
visibilityTester: communication.function.CanSubscribe,
input: 'focus',
category: card.category.Card,
target: card.class.Card,
context: {
mode: ['context'],
group: 'associate'
}
})
createAction(builder, {
action: communication.action.Unsubscribe,
label: communication.string.Unsubscribe,
icon: communication.icon.BellCrossed,
visibilityTester: communication.function.CanUnsubscribe,
input: 'focus',
category: card.category.Card,
target: card.class.Card,
context: {
mode: ['context'],
group: 'associate'
}
})
}
+5 -28
View File
@@ -14,42 +14,19 @@
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/core'
import card from '@hcengineering/model-card'
import { createAction } from '@hcengineering/model-view'
import { MessagesNavigationAnchors } from '@hcengineering/communication'
import communication from './plugin'
import { buildTypes } from './types'
import { buildCardActions, buildMessageActions } from './actions'
export { communicationId } from '@hcengineering/communication'
export * from './migration'
export function createModel (builder: Builder): void {
createAction(builder, {
action: communication.action.Subscribe,
label: communication.string.Subscribe,
icon: communication.icon.Bell,
visibilityTester: communication.function.CanSubscribe,
input: 'focus',
category: card.category.Card,
target: card.class.Card,
context: {
mode: ['context'],
group: 'associate'
}
})
createAction(builder, {
action: communication.action.Unsubscribe,
label: communication.string.Unsubscribe,
icon: communication.icon.BellCrossed,
visibilityTester: communication.function.CanUnsubscribe,
input: 'focus',
category: card.category.Card,
target: card.class.Card,
context: {
mode: ['context'],
group: 'associate'
}
})
buildTypes(builder)
buildMessageActions(builder)
buildCardActions(builder)
builder.createDoc(
card.class.CardSection,
+37
View File
@@ -0,0 +1,37 @@
// 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, Model } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import { DOMAIN_MODEL } from '@hcengineering/core'
import { type Asset, type IntlString } from '@hcengineering/platform'
import communication, {
type MessageAction,
type MessageActionFunctionResource,
type MessageActionVisibilityTesterResource
} from '@hcengineering/communication'
@Model(communication.class.MessageAction, core.class.Doc, DOMAIN_MODEL)
class TMessageAction extends TDoc implements MessageAction {
label!: IntlString
icon!: Asset
action!: MessageActionFunctionResource
order!: number
visibilityTester?: MessageActionVisibilityTesterResource
menu?: boolean
}
export function buildTypes (builder: Builder): void {
builder.createModel(TMessageAction)
}
+1
View File
@@ -124,6 +124,7 @@
{#if $$slots.buttons}
<slot name="buttons" />
{/if}
<slot name="footer" />
</div>
{/if}
</div>
@@ -31,10 +31,6 @@
const dispatch = createEventDispatcher()
function getColorDefinition (color: number | undefined): ColorDefinition {
return getPlatformColorDef(color ?? 0, $themeStore.dark)
}
function getTagStyle (color: ColorDefinition): string {
return `
background: ${color.color + '33'};
@@ -48,7 +44,7 @@
<div
class="tag"
class:removable
style={`${getTagStyle(getColorDefinition(color))}`}
style={`${getTagStyle(getPlatformColorDef(color ?? 0, $themeStore.dark))}`}
on:click
on:keydown
use:tooltip={{
@@ -51,7 +51,6 @@
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.divider {
@@ -179,9 +179,7 @@
listProvider.updateSelection(event.detail.docs, event.detail.value)
}}
on:content={(evt) => {
if (evt.detail.length > 0) {
dispatch('loaded')
}
dispatch('loaded')
listProvider.update(evt.detail)
}}
/>
@@ -24,6 +24,7 @@
import { Heading } from '@hcengineering/text-editor'
import { CardSectionAction } from '../types'
import { getResource } from '@hcengineering/platform'
export let doc: Card
export let context: NotificationContext | undefined = undefined
@@ -33,7 +34,8 @@
const client = getClient()
const sections: CardSection[] = client
let sections: CardSection[] = []
const _sections: CardSection[] = client
.getModel()
.findAllSync(card.class.CardSection, {})
.sort((a, b) => a.order - b.order)
@@ -188,6 +190,24 @@
}
}
async function filterSections (s: CardSection[], doc: Card): Promise<void> {
const newSections: CardSection[] = []
for (const section of s) {
if (section.checkVisibility !== undefined) {
const isVisibleFn = await getResource(section.checkVisibility)
const isVisible = await isVisibleFn(doc)
if (isVisible) {
newSections.push(section)
}
} else {
newSections.push(section)
}
}
sections = newSections
}
$: void filterSections(_sections, doc)
$: updateToc(sections, subTocBySection)
$: showOverlay = !isScrollInitialized || Object.values(sectionOverlays).some((it) => it)
</script>
@@ -273,7 +293,7 @@
//inset: 0;
z-index: 1;
top: 0;
right: 0.75rem;
right: 0.25rem;
width: 2rem;
height: fit-content;
@@ -15,7 +15,7 @@
<script lang="ts">
import { Card, CardSpace, FavoriteCard, MasterTag } from '@hcengineering/card'
import { Ref, SortingOrder } from '@hcengineering/core'
import { Ref, SortingOrder, Timestamp } from '@hcengineering/core'
import { createNotificationContextsQuery, createQuery } from '@hcengineering/presentation'
import { Label, NotificationContext, NotificationType } from '@hcengineering/communication-types'
import ui, { ModernButton } from '@hcengineering/ui'
@@ -48,13 +48,39 @@
$: ids = (config.labelFilter?.length ?? 0) > 0 ? labels.map((it) => it.cardId) : undefined
function parseLookbackDuration (input: string): Timestamp {
if (input.length < 2) throw new Error('Invalid duration format')
const unit = input.slice(-1)
const numberPart = input.slice(0, -1)
const value = Number(numberPart)
if (isNaN(value) || value <= 0) throw new Error('Invalid numeric value')
const multipliers: Record<string, number> = {
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000
}
const multiplier = multipliers[unit]
if (!multiplier) throw new Error(`Unsupported time unit: ${unit}`)
return value * multiplier
}
$: if ((ids && ids.length > 0) || (config.labelFilter?.length ?? 0) === 0) {
cardsQuery.query<Card>(
type._id,
{
// TODO: Should be join instead of $in. But for now labels and cards in different api.
...(ids === undefined ? {} : { _id: { $in: ids } }),
...(space !== undefined ? { space: space._id } : {})
...(space !== undefined ? { space: space._id } : {}),
...(config.lookback !== undefined
? { modifiedOn: { $gte: Date.now() - parseLookbackDuration(config.lookback) } }
: {})
},
(res) => {
const cardsResult = res
@@ -46,6 +46,6 @@
display: flex;
flex-direction: column;
width: 100%;
padding: 0 2rem;
padding: 0 1rem;
}
</style>
@@ -40,6 +40,6 @@
display: flex;
flex-direction: column;
width: 100%;
padding: 0 2rem;
padding: 0 1rem;
}
</style>
@@ -42,6 +42,6 @@
display: flex;
flex-direction: column;
width: 100%;
padding: 0 2rem;
padding: 0 1rem;
}
</style>
@@ -30,6 +30,6 @@
display: flex;
flex-direction: column;
width: 100%;
padding: 0 2rem;
padding: 0 1rem;
}
</style>
+4 -2
View File
@@ -28,7 +28,8 @@ import {
editSpace,
cardCustomLinkEncode,
cardCustomLinkMatch,
openCardInSidebar
openCardInSidebar,
checkRelationsSectionVisibility
} from './utils'
import ManageMasterTagsContent from './components/settings/ManageMasterTagsContent.svelte'
import ManageMasterTagsTools from './components/settings/ManageMasterTagsTools.svelte'
@@ -130,6 +131,7 @@ export default async (): Promise<Resources> => ({
GetCardLink: getCardLink,
CardCustomLinkMatch: cardCustomLinkMatch,
CardCustomLinkEncode: cardCustomLinkEncode,
OpenCardInSidebar: openCardInSidebar
OpenCardInSidebar: openCardInSidebar,
CheckRelationsSectionVisibility: checkRelationsSectionVisibility
}
})
+1
View File
@@ -34,6 +34,7 @@ type Sorting = 'alphabetical' | 'recent'
export interface CardsNavigatorConfig extends BaseNavigatorConfig {
variant: 'cards'
limit: number
lookback?: string // e.g. 1m, 1h, 1d, 1w
hideEmpty?: boolean
labelFilter?: LabelID[]
fixedTypes?: Array<Ref<MasterTag>>
+24 -1
View File
@@ -12,7 +12,7 @@
// limitations under the License.
import { type Card, CardEvents, cardId, type CardSpace, type MasterTag } from '@hcengineering/card'
import {
import core, {
type Class,
type Client,
type Data,
@@ -262,3 +262,26 @@ export function cardCustomLinkEncode (doc: Card): Location {
loc.path[3] = encodeObjectURI(doc._id, card.class.Card)
return loc
}
export async function checkRelationsSectionVisibility (doc: Card): Promise<boolean> {
const client = getClient()
const h = client.getHierarchy()
const parents = h.getAncestors(doc._class)
const mixins = h.findAllMixins(doc)
const associationsB = client
.getModel()
.findAllSync(core.class.Association, { classA: { $in: [...parents, ...mixins] } })
.filter((a) => a.nameB.trim().length > 0)
if (associationsB.length > 0) {
return true
}
return (
client
.getModel()
.findAllSync(core.class.Association, { classB: { $in: [...parents, ...mixins] } })
.filter((a) => a.nameA.trim().length > 0).length > 0
)
}
+1
View File
@@ -85,6 +85,7 @@ export interface CardSection extends Doc {
component: AnyComponent
order: number
navigation: CardNavigation[]
checkVisibility?: Resource<(doc: Card) => Promise<boolean>>
}
export interface CardViewDefaults extends MasterTag {
@@ -32,7 +32,7 @@
savedViews: true,
groupBySpace: false,
hideEmpty: true,
limit: 10,
limit: 5,
labelFilter: [SubscriptionLabelID],
preorder: [
{ type: chat.masterTag.Thread, order: 1 },
@@ -40,9 +40,7 @@
],
fixedTypes: [chat.masterTag.Thread, chat.masterTag.Channel],
defaultSorting: 'recent',
specialSorting: {
[chat.masterTag.Channel]: 'alphabetical'
},
lookback: '1w',
showTypeIcon: false,
showCardIcon: true
}}
+15 -3
View File
@@ -13,13 +13,25 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="file" viewBox="0 0 16 16">
<path d="M12.9,2.1c-1.6-1.7-4.1-1.7-5.7,0L2.1,7.6c-0.3,0.3-0.3,0.7,0,1c0.3,0.3,0.7,0.3,0.9,0l5.1-5.4C9.2,2,10.9,2,12,3.1C13,4.2,13,6,12,7.1l-5.9,6.3c-0.4,0.4-1,0.4-1.4,0c-0.4-0.4-0.4-1.1,0-1.5l5.9-6.3c0.3-0.3,0.3-0.7,0-1c-0.3-0.3-0.7-0.3-0.9,0l-5.9,6.3c-0.9,1-0.9,2.5,0,3.5c0.9,1,2.4,1,3.3,0l5.9-6.3C14.5,6.5,14.5,3.8,12.9,2.1z" />
<path d="M12.9,2.1c-1.6-1.7-4.1-1.7-5.7,0L2.1,7.6c-0.3,0.3-0.3,0.7,0,1c0.3,0.3,0.7,0.3,0.9,0l5.1-5.4C9.2,2,10.9,2,12,3.1C13,4.2,13,6,12,7.1l-5.9,6.3c-0.4,0.4-1,0.4-1.4,0c-0.4-0.4-0.4-1.1,0-1.5l5.9-6.3c0.3-0.3,0.3-0.7,0-1c-0.3-0.3-0.7-0.3-0.9,0l-5.9,6.3c-0.9,1-0.9,2.5,0,3.5c0.9,1,2.4,1,3.3,0l5.9-6.3C14.5,6.5,14.5,3.8,12.9,2.1z"/>
</symbol>
<symbol id="bell" viewBox="0 0 16 16">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.5 1.5C8.5 1.22386 8.27614 1 8 1C7.72386 1 7.5 1.22386 7.5 1.5V2.02746C5.25002 2.27619 3.5 4.18372 3.5 6.5V7.44766C3.5 8.01534 3.3068 8.56611 2.95217 9.00939L2.09004 10.0871C1.70809 10.5645 1.5 11.1577 1.5 11.7691C1.5 12.4489 2.05108 13 2.73087 13H6C6 13.2626 6.05173 13.5227 6.15224 13.7654C6.25275 14.008 6.40007 14.2285 6.58579 14.4142C6.7715 14.5999 6.99198 14.7472 7.23463 14.8478C7.47728 14.9483 7.73736 15 8 15C8.26264 15 8.52272 14.9483 8.76537 14.8478C9.00802 14.7472 9.2285 14.5999 9.41421 14.4142C9.59993 14.2285 9.74725 14.008 9.84776 13.7654C9.94827 13.5227 10 13.2626 10 13H13.2691C13.9489 13 14.5 12.4489 14.5 11.7691C14.5 11.1577 14.2919 10.5645 13.91 10.0871L13.0478 9.00939C12.6932 8.56611 12.5 8.01534 12.5 7.44766V6.5C12.5 4.18372 10.75 2.27619 8.5 2.02746V1.5ZM9 13H7C7 13.1313 7.02587 13.2614 7.07612 13.3827C7.12638 13.504 7.20003 13.6142 7.29289 13.7071C7.38575 13.8 7.49599 13.8736 7.61732 13.9239C7.73864 13.9741 7.86868 14 8 14C8.13132 14 8.26136 13.9741 8.38268 13.9239C8.50401 13.8736 8.61425 13.8 8.70711 13.7071C8.79997 13.6142 8.87362 13.504 8.92388 13.3827C8.97413 13.2614 9 13.1313 9 13ZM2.5 11.7691C2.5 11.8966 2.60336 12 2.73087 12H13.2691C13.3966 12 13.5 11.8966 13.5 11.7691C13.5 11.3848 13.3692 11.0119 13.1291 10.7118L12.267 9.63409C11.7705 9.01349 11.5 8.24241 11.5 7.44766V6.5C11.5 4.567 9.933 3 8 3C6.067 3 4.5 4.567 4.5 6.5V7.44766C4.5 8.24241 4.22952 9.01349 3.73304 9.63409L2.87091 10.7118C2.63081 11.0119 2.5 11.3848 2.5 11.7691Z" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.5 1.5C8.5 1.22386 8.27614 1 8 1C7.72386 1 7.5 1.22386 7.5 1.5V2.02746C5.25002 2.27619 3.5 4.18372 3.5 6.5V7.44766C3.5 8.01534 3.3068 8.56611 2.95217 9.00939L2.09004 10.0871C1.70809 10.5645 1.5 11.1577 1.5 11.7691C1.5 12.4489 2.05108 13 2.73087 13H6C6 13.2626 6.05173 13.5227 6.15224 13.7654C6.25275 14.008 6.40007 14.2285 6.58579 14.4142C6.7715 14.5999 6.99198 14.7472 7.23463 14.8478C7.47728 14.9483 7.73736 15 8 15C8.26264 15 8.52272 14.9483 8.76537 14.8478C9.00802 14.7472 9.2285 14.5999 9.41421 14.4142C9.59993 14.2285 9.74725 14.008 9.84776 13.7654C9.94827 13.5227 10 13.2626 10 13H13.2691C13.9489 13 14.5 12.4489 14.5 11.7691C14.5 11.1577 14.2919 10.5645 13.91 10.0871L13.0478 9.00939C12.6932 8.56611 12.5 8.01534 12.5 7.44766V6.5C12.5 4.18372 10.75 2.27619 8.5 2.02746V1.5ZM9 13H7C7 13.1313 7.02587 13.2614 7.07612 13.3827C7.12638 13.504 7.20003 13.6142 7.29289 13.7071C7.38575 13.8 7.49599 13.8736 7.61732 13.9239C7.73864 13.9741 7.86868 14 8 14C8.13132 14 8.26136 13.9741 8.38268 13.9239C8.50401 13.8736 8.61425 13.8 8.70711 13.7071C8.79997 13.6142 8.87362 13.504 8.92388 13.3827C8.97413 13.2614 9 13.1313 9 13ZM2.5 11.7691C2.5 11.8966 2.60336 12 2.73087 12H13.2691C13.3966 12 13.5 11.8966 13.5 11.7691C13.5 11.3848 13.3692 11.0119 13.1291 10.7118L12.267 9.63409C11.7705 9.01349 11.5 8.24241 11.5 7.44766V6.5C11.5 4.567 9.933 3 8 3C6.067 3 4.5 4.567 4.5 6.5V7.44766C4.5 8.24241 4.22952 9.01349 3.73304 9.63409L2.87091 10.7118C2.63081 11.0119 2.5 11.3848 2.5 11.7691Z"/>
</symbol>
<symbol id="bell-crossed" viewBox="0 0 16 16">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.5 1.5C8.5 1.22386 8.27614 1 8 1C7.72386 1 7.5 1.22386 7.5 1.5V2.02746C5.25002 2.27619 3.5 4.18372 3.5 6.5V7.44766C3.5 8.01534 3.3068 8.56611 2.95217 9.00939L2.09004 10.0871C1.70809 10.5645 1.5 11.1577 1.5 11.7691C1.5 12.4489 2.05108 13 2.73087 13H6C6 13.2626 6.05173 13.5227 6.15224 13.7654C6.25275 14.008 6.40007 14.2285 6.58579 14.4142C6.7715 14.5999 6.99198 14.7472 7.23463 14.8478C7.47728 14.9483 7.73736 15 8 15C8.26264 15 8.52272 14.9483 8.76537 14.8478C9.00802 14.7472 9.2285 14.5999 9.41421 14.4142C9.59993 14.2285 9.74725 14.008 9.84776 13.7654C9.94827 13.5227 10 13.2626 10 13H13.2691C13.9489 13 14.5 12.4489 14.5 11.7691C14.5 11.1577 14.2919 10.5645 13.91 10.0871L13.0478 9.00939C12.6932 8.56611 12.5 8.01534 12.5 7.44766V6.5C12.5 4.18372 10.75 2.27619 8.5 2.02746V1.5ZM9 13H7C7 13.1313 7.02587 13.2614 7.07612 13.3827C7.12638 13.504 7.20003 13.6142 7.29289 13.7071C7.38575 13.8 7.49599 13.8736 7.61732 13.9239C7.73864 13.9741 7.86868 14 8 14C8.13132 14 8.26136 13.9741 8.38268 13.9239C8.50401 13.8736 8.61425 13.8 8.70711 13.7071C8.79997 13.6142 8.87362 13.504 8.92388 13.3827C8.97413 13.2614 9 13.1313 9 13ZM2.5 11.7691C2.5 11.8966 2.60336 12 2.73087 12H13.2691C13.3966 12 13.5 11.8966 13.5 11.7691C13.5 11.3848 13.3692 11.0119 13.1291 10.7118L12.267 9.63409C11.7705 9.01349 11.5 8.24241 11.5 7.44766V6.5C11.5 4.567 9.933 3 8 3C6.067 3 4.5 4.567 4.5 6.5V7.44766C4.5 8.24241 4.22952 9.01349 3.73304 9.63409L2.87091 10.7118C2.63081 11.0119 2.5 11.3848 2.5 11.7691Z"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.5 1.5C8.5 1.22386 8.27614 1 8 1C7.72386 1 7.5 1.22386 7.5 1.5V2.02746C5.25002 2.27619 3.5 4.18372 3.5 6.5V7.44766C3.5 8.01534 3.3068 8.56611 2.95217 9.00939L2.09004 10.0871C1.70809 10.5645 1.5 11.1577 1.5 11.7691C1.5 12.4489 2.05108 13 2.73087 13H6C6 13.2626 6.05173 13.5227 6.15224 13.7654C6.25275 14.008 6.40007 14.2285 6.58579 14.4142C6.7715 14.5999 6.99198 14.7472 7.23463 14.8478C7.47728 14.9483 7.73736 15 8 15C8.26264 15 8.52272 14.9483 8.76537 14.8478C9.00802 14.7472 9.2285 14.5999 9.41421 14.4142C9.59993 14.2285 9.74725 14.008 9.84776 13.7654C9.94827 13.5227 10 13.2626 10 13H13.2691C13.9489 13 14.5 12.4489 14.5 11.7691C14.5 11.1577 14.2919 10.5645 13.91 10.0871L13.0478 9.00939C12.6932 8.56611 12.5 8.01534 12.5 7.44766V6.5C12.5 4.18372 10.75 2.27619 8.5 2.02746V1.5ZM9 13H7C7 13.1313 7.02587 13.2614 7.07612 13.3827C7.12638 13.504 7.20003 13.6142 7.29289 13.7071C7.38575 13.8 7.49599 13.8736 7.61732 13.9239C7.73864 13.9741 7.86868 14 8 14C8.13132 14 8.26136 13.9741 8.38268 13.9239C8.50401 13.8736 8.61425 13.8 8.70711 13.7071C8.79997 13.6142 8.87362 13.504 8.92388 13.3827C8.97413 13.2614 9 13.1313 9 13ZM2.5 11.7691C2.5 11.8966 2.60336 12 2.73087 12H13.2691C13.3966 12 13.5 11.8966 13.5 11.7691C13.5 11.3848 13.3692 11.0119 13.1291 10.7118L12.267 9.63409C11.7705 9.01349 11.5 8.24241 11.5 7.44766V6.5C11.5 4.567 9.933 3 8 3C6.067 3 4.5 4.567 4.5 6.5V7.44766C4.5 8.24241 4.22952 9.01349 3.73304 9.63409L2.87091 10.7118C2.63081 11.0119 2.5 11.3848 2.5 11.7691Z"/>
<path d="M1 15L15 1" stroke="currentColor" stroke-linecap="round"/>
</symbol>
<symbol id="message-multiple" viewBox="0 0 24 24">
<path
d="M2.75571 10.3721C2.67505 8.16892 3.82845 6.21211 5.51842 4.81037C7.21886 3.39994 9.35454 2.64423 11.05 2.76018C11.9385 2.82094 12.8149 2.98182 13.6797 3.19064C14.7797 3.51647 16.6649 4.60069 17.7603 6.12395C18.6923 7.42004 19.0162 8.74543 19.0911 9.14216L20.5651 8.86401C20.4666 8.34186 20.0827 6.78423 18.9781 5.2482C17.6529 3.40536 15.4516 2.14469 14.0834 1.74586C14.0069 1.72679 13.9314 1.70778 13.8565 1.68894C12.9854 1.46987 12.2042 1.27339 11.127 1.26199C8.39779 1.23312 6.48237 2.06199 4.5608 3.65584C2.62441 5.26196 1.1593 7.62561 1.25622 10.4132C1.25812 12.2248 1.64051 13.5796 2.21557 14.6627C2.69647 15.5686 3.29897 16.2569 3.85452 16.8334L3.05433 19.7384C2.88102 20.3679 3.51935 20.8887 4.09404 20.6353L7.40538 19.1733C7.87457 19.3317 8.33732 19.4723 8.8072 19.586L9.16015 18.1281C8.54145 17.9784 7.93959 17.7727 7.3393 17.5628L4.91459 18.6333L5.53026 16.3982L5.22549 16.0898C4.60039 15.4573 3.9989 14.8229 3.54043 13.9594C3.08809 13.1073 2.75622 11.9931 2.75622 10.3996V10.3859L2.75571 10.3721Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
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>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "Tato vlákno byla smazána.",
"MessageWasRemoved": "Tato zpráva byla odstraněna.",
"JoinedThe": "Připojil se k",
"LeftThe": "Opustil se z"
"LeftThe": "Opustil se z",
"Translating": "Překládám...",
"ShowOriginal": "Zobrazit původní",
"AddReaction": "Přidat reakci",
"ReplyInThread": "Odpovědět v vlákně",
"TranslateMessage": "Přeložit zprávu",
"ShowOriginalMessage": "Zobrazit původní zprávu",
"EditMessage": "Upravit zprávu",
"RemoveMessage": "Odstranit zprávu",
"CreateCard": "Vytvořit kartu",
"MessageAlreadyHasCardAttached": "Jejda! Tato zpráva již má připojenou kartu."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "Dieser Thread wurde gelöscht.",
"MessageWasRemoved": "Diese Nachricht wurde entfernt.",
"JoinedThe": "Beigetreten",
"LeftThe": "Verließ"
"LeftThe": "Verließ",
"Translating": "Übersetzen...",
"ShowOriginal": "Original anzeigen",
"AddReaction": "Reaktion hinzufügen",
"ReplyInThread": "Im Thread antworten",
"TranslateMessage": "Nachricht übersetzen",
"ShowOriginalMessage": "Originalnachricht anzeigen",
"EditMessage": "Nachricht bearbeiten",
"RemoveMessage": "Nachricht entfernen",
"CreateCard": "Karte erstellen",
"MessageAlreadyHasCardAttached": "Oops! Diese Nachricht hat bereits eine Karte angehängt."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasRemoved": "This thread was removed.",
"MessageWasRemoved": "This message was removed.",
"JoinedThe": "Joined the",
"LeftThe": "Left the"
"LeftThe": "Left the",
"Translating": "Translating...",
"ShowOriginal": "Showing original",
"AddReaction": "Add reaction",
"ReplyInThread": "Reply in thread",
"TranslateMessage": "Translate message",
"ShowOriginalMessage": "Show original message",
"EditMessage": "Edit message",
"RemoveMessage": "Remove message",
"CreateCard": "Create card",
"MessageAlreadyHasCardAttached": "Oops! This message already has a card attached."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "Este hilo fue eliminado.",
"MessageWasRemoved": "Este mensaje fue eliminado.",
"JoinedThe": "Unido al",
"LeftThe": "Dejó el"
"LeftThe": "Dejó el",
"Translating": "Traduciendo...",
"ShowOriginal": "Mostrar original",
"AddReaction": "Añadir reacción",
"ReplyInThread": "Responder en el hilo",
"TranslateMessage": "Traducir mensaje",
"ShowOriginalMessage": "Mostrar mensaje original",
"EditMessage": "Editar mensaje",
"RemoveMessage": "Eliminar mensaje",
"CreateCard": "Crear tarjeta",
"MessageAlreadyHasCardAttached": "¡Vaya! Este mensaje ya tiene una tarjeta adjunta."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "Ce fil de discussion a été supprimé.",
"MessageWasRemoved": "Ce message a été supprimé.",
"JoinedThe": "A rejoint le",
"LeftThe": "A quitté le"
"LeftThe": "A quitté le",
"Translating": "Traduction...",
"ShowOriginal": "Afficher l'original",
"AddReaction": "Ajouter une réaction",
"ReplyInThread": "Répondre dans le fil de discussion",
"TranslateMessage": "Traduire le message",
"ShowOriginalMessage": "Afficher le message original",
"EditMessage": "Modifier le message",
"RemoveMessage": "Supprimer le message",
"CreateCard": "Créer une carte",
"MessageAlreadyHasCardAttached": "Oups! Ce message a déjà une carte attachée."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "Questo thread è stato eliminato.",
"MessageWasRemoved": "Questo messaggio è stato rimosso.",
"JoinedThe": "Unisciti al",
"LeftThe": "Abbandonato dal"
"LeftThe": "Abbandonato dal",
"Translating": "Traduzione...",
"ShowOriginal": "Mostra l'originale",
"AddReaction": "Aggiungi reazione",
"ReplyInThread": "Rispondi nel thread",
"TranslateMessage": "Traduci messaggio",
"ShowOriginalMessage": "Mostra messaggio originale",
"EditMessage": "Modifica messaggio",
"RemoveMessage": "Rimuovi messaggio",
"CreateCard": "Crea scheda",
"MessageAlreadyHasCardAttached": "Oops! Questo messaggio ha già una scheda allegata."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "このスレッドは削除されました。",
"MessageWasRemoved": "このメッセージは削除されました。",
"JoinedThe": "参加",
"LeftThe": "退出"
"LeftThe": "退出",
"Translating": "翻訳中...",
"ShowOriginal": "元のメッセージを表示",
"AddReaction": "リアクションを追加",
"ReplyInThread": "スレッドに返信",
"TranslateMessage": "メッセージを翻訳",
"ShowOriginalMessage": "元のメッセージを表示",
"EditMessage": "メッセージを編集",
"RemoveMessage": "メッセージを削除",
"CreateCard": "カードを作成",
"MessageAlreadyHasCardAttached": "おっと!このメッセージはすでにカードが添付されています。"
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "Este thread foi excluído.",
"MessageWasRemoved": "Este mensagem foi removida.",
"JoinedThe": "Juntou-se ao",
"LeftThe": "Deixou o"
"LeftThe": "Deixou o",
"Translating": "Traduzindo...",
"ShowOriginal": "Mostrar original",
"AddReaction": "Adicionar reação",
"ReplyInThread": "Responder no thread",
"TranslateMessage": "Traduzir mensagem",
"ShowOriginalMessage": "Mostrar mensagem original",
"EditMessage": "Editar mensagem",
"RemoveMessage": "Remover mensagem",
"CreateCard": "Criar cartão",
"MessageAlreadyHasCardAttached": "Oops! Este mensagem já tem um cartão anexado."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasRemoved": "Этот поток был удален.",
"MessageWasRemoved": "Это сообщение было удалено.",
"JoinedThe": "Присоединился(aсь) к",
"LeftThe": "Покинул(a) к"
"LeftThe": "Покинул(a) к",
"Translating": "Перевод...",
"ShowOriginal": "Показать оригинал",
"AddReaction": "Добавить реакцию",
"ReplyInThread": "Ответить в потоке",
"TranslateMessage": "Перевести сообщение",
"ShowOriginalMessage": "Показать оригинальное сообщение",
"EditMessage": "Редактировать сообщение",
"RemoveMessage": "Удалить сообщение",
"CreateCard": "Создать карточку",
"MessageAlreadyHasCardAttached": "Упс! Это сообщение уже имеет прикрепленную карточку."
}
}
+11 -1
View File
@@ -41,6 +41,16 @@
"ThreadWasDeleted": "此线程已被删除。",
"MessageWasRemoved": "此消息已被删除。",
"JoinedThe": "加入",
"LeftThe": "离开"
"LeftThe": "离开",
"Translating": "翻译中...",
"ShowOriginal": "显示原文",
"AddReaction": "添加反应",
"ReplyInThread": "在线程中回复",
"TranslateMessage": "翻译消息",
"ShowOriginalMessage": "显示原始消息",
"EditMessage": "编辑消息",
"RemoveMessage": "删除消息",
"CreateCard": "创建卡片",
"MessageAlreadyHasCardAttached": "哎呀!此消息已经有一张卡片附加。"
}
}
+2 -1
View File
@@ -20,6 +20,7 @@ const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(communication.icon, {
Bell: `${icons}#bell`,
BellCrossed: `${icons}#bell-crossed`,
File: `${icons}#file`
File: `${icons}#file`,
MessageMultiple: `${icons}#message-multiple`
})
addStringsLoader(communicationId, async (lang: string) => await import(`../lang/${lang}.json`))
@@ -42,6 +42,8 @@
"svelte-eslint-parser": "^0.33.1"
},
"dependencies": {
"@hcengineering/ai-bot": "^0.6.0",
"@hcengineering/ai-bot-resources": "^0.6.0",
"@hcengineering/attachment-resources": "^0.6.0",
"@hcengineering/card": "^0.6.0",
"@hcengineering/chat": "^0.6.0",
@@ -0,0 +1,256 @@
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import communication, {
type MessageAction,
type MessageActionFunction,
type MessageActionVisibilityTester
} from '@hcengineering/communication'
import { languageStore, showPopup } from '@hcengineering/ui'
import emojiPlugin from '@hcengineering/emoji'
import { type Message, MessageType, SortingOrder } from '@hcengineering/communication-types'
import cardPlugin, { type Card, type MasterTag } from '@hcengineering/card'
import { addRefreshListener, getClient, getCommunicationClient } from '@hcengineering/presentation'
import { fillDefaults, generateId, getCurrentAccount, type MarkupBlobRef, type Ref } from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import { employeeByPersonIdStore } from '@hcengineering/contact-resources'
import { getEmployeeBySocialId } from '@hcengineering/contact'
import { makeRank } from '@hcengineering/rank'
import chat from '@hcengineering/chat'
import { markupToText } from '@hcengineering/text'
import { get } from 'svelte/store'
import { translate as aiTranslate } from '@hcengineering/ai-bot-resources'
import aiBot from '@hcengineering/ai-bot'
import CreateCardFromMessagePopup from './components/CreateCardFromMessagePopup.svelte'
import { toggleReaction, toMarkup } from './utils'
import { isMessageTranslating, messageEditingStore, threadCreateMessageStore, translateMessagesStore } from './stores'
export const addReaction: MessageActionFunction = async (message, _: Card, evt, onOpen, onClose) => {
if (onOpen !== undefined) onOpen()
showPopup(
emojiPlugin.component.EmojiPopup,
{},
evt?.target as HTMLElement,
async (result) => {
if (onClose !== undefined) onClose()
const emoji = result?.text
if (emoji == null) return
await toggleReaction(message, emoji)
},
() => {}
)
}
export const replyInThread: MessageActionFunction = async (message: Message, parentCard: Card): Promise<void> => {
await attachCardToMessage(message, parentCard, createThreadTitle(message, parentCard), chat.masterTag.Thread)
}
export async function attachCardToMessage (
message: Message,
parentCard: Card,
title: string,
type: Ref<MasterTag>
): Promise<void> {
const client = getClient()
const communicationClient = getCommunicationClient()
const hierarchy = client.getHierarchy()
const thread = message.thread
if (thread != null) {
const _id = thread.threadId
const card = await client.findOne(cardPlugin.class.Card, { _id: _id as Ref<Card> })
if (card === undefined) return
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
await r(_id, card)
return
}
const threadCardID = generateId<Card>()
await communicationClient.attachThread(parentCard._id, message.id, threadCardID, type)
const author =
get(employeeByPersonIdStore).get(message.creator) ?? (await getEmployeeBySocialId(client, message.creator))
const lastOne = await client.findOne(cardPlugin.class.Card, {}, { sort: { rank: SortingOrder.Descending } })
const data = fillDefaults<Card>(
hierarchy,
{
title,
rank: makeRank(lastOne?.rank, undefined),
content: '' as MarkupBlobRef,
parent: parentCard._id,
blobs: {},
parentInfo: [
...(parentCard.parentInfo ?? []),
{
_id: parentCard._id,
_class: parentCard._class,
title: parentCard.title
}
]
},
type
)
const apply = client.apply('create thread', undefined, true)
await apply.createDoc(type, cardPlugin.space.Default, data, threadCardID)
await apply.commit()
if (author?.active === true && author?.personUuid !== undefined) {
await communicationClient.addCollaborators(threadCardID, type, [author.personUuid])
}
const threadCard = await client.findOne(cardPlugin.class.Card, { _id: threadCardID })
if (threadCard === undefined) return
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
await r(threadCard._id, threadCard)
}
function createThreadTitle (message: Message, parent: Card): string {
const markup = toMarkup(message.content)
const messageText = markupToText(markup).trim()
return messageText.length > 0 ? messageText : `Thread from ${parent.title}`
}
export const canReplyInThread: MessageActionVisibilityTester = (message: Message): boolean => {
return (
message.type === MessageType.Message &&
message.extra?.threadRoot !== true &&
(!message.removed || message.thread != null)
)
}
export const translateMessage: MessageActionFunction = async (message: Message): Promise<void> => {
if (isMessageTranslating(message.id)) return
const result = get(translateMessagesStore).get(message.id)
if (result?.result != null) {
translateMessagesStore.update((store) => {
store.set(message.id, { ...result, shown: true })
return store
})
return
}
translateMessagesStore.update((store) => {
store.set(message.id, { inProgress: true, shown: false })
return store
})
const markup = toMarkup(message.content)
const response = await aiTranslate(markup, get(languageStore))
if (response !== undefined) {
translateMessagesStore.update((store) => {
store.set(message.id, { inProgress: false, result: response.text, shown: true })
return store
})
} else {
translateMessagesStore.update((store) => {
store.delete(message.id)
return store
})
}
}
export const canTranslateMessage: MessageActionVisibilityTester = (message: Message): boolean => {
const url = getMetadata(aiBot.metadata.EndpointURL) ?? ''
if (url === '') return false
return message.type === MessageType.Message && !message.removed
}
export const showOriginalMessage: MessageActionFunction = async (message: Message): Promise<void> => {
const messageId = message.id
translateMessagesStore.update((store) => {
const status = store.get(messageId)
if (status == null) return store
store.set(messageId, { ...status, shown: false })
return store
})
}
export const canShowOriginalMessage: MessageActionVisibilityTester = (message: Message): boolean => {
return canTranslateMessage(message)
}
export const editMessage: MessageActionFunction = async (message: Message): Promise<void> => {
messageEditingStore.set(message.id)
}
export const canEditMessage: MessageActionVisibilityTester = (message: Message): boolean => {
if (message.type !== MessageType.Message || message.removed) return false
const me = getCurrentAccount()
return me.socialIds.includes(message.creator)
}
export const removeMessage: MessageActionFunction = async (message: Message): Promise<void> => {
const communicationClient = getCommunicationClient()
message.removed = true
await communicationClient.removeMessage(message.cardId, message.id)
}
export const canRemoveMessage: MessageActionVisibilityTester = (message: Message): boolean => {
if (message.type !== MessageType.Message || message.removed) return false
const me = getCurrentAccount()
return me.socialIds.includes(message.creator)
}
export const createCard: MessageActionFunction = async (message: Message, card: Card): Promise<void> => {
threadCreateMessageStore.set(message)
showPopup(CreateCardFromMessagePopup, { message, card }, undefined, () => {
threadCreateMessageStore.set(undefined)
})
}
export const canCreateCard: MessageActionVisibilityTester = (message: Message): boolean => {
return canReplyInThread(message) && message.thread == null
}
let allMessageActions: MessageAction[] | undefined
addRefreshListener(() => {
allMessageActions = undefined
})
export async function getMessageActions (message: Message): Promise<MessageAction[]> {
const client = getClient()
const actions: MessageAction[] =
allMessageActions ?? client.getModel().findAllSync(communication.class.MessageAction, {})
if (allMessageActions === undefined) {
allMessageActions = actions
}
const filteredActions = await filterActions(message, actions)
return filteredActions.sort((a, b) => a.order - b.order)
}
async function filterActions (message: Message, actions: MessageAction[]): Promise<MessageAction[]> {
const result: MessageAction[] = []
for (const action of actions) {
if (action.visibilityTester == null) {
result.push(action)
} else {
const visibilityTester = await getResource(action.visibilityTester)
if (visibilityTester(message)) {
result.push(action)
}
}
}
return result
}
@@ -0,0 +1,147 @@
<!--
// 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 cardPlugin, { Card, MasterTag } from '@hcengineering/card'
import { Class, ClassifierKind, Doc, Ref } from '@hcengineering/core'
import presentation, { getClient } from '@hcengineering/presentation'
import { DropdownIntlItem, Label, Modal, ModernEditbox, NestedDropdown } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { Message } from '@hcengineering/communication-types'
import { markupToText } from '@hcengineering/text'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { toMarkup } from '../utils'
import { attachCardToMessage } from '../actions'
import MessagePresenter from './message/MessagePresenter.svelte'
import { threadCreateMessageStore } from '../stores'
import communication from '../plugin'
export let message: Message
export let card: Card
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
$: _message = $threadCreateMessageStore ?? message
let title = getDefaultTitle(message)
let selectedType: Ref<MasterTag> | undefined
function getDefaultTitle (message: Message): string {
const markup = toMarkup(message.content)
const messageText = markupToText(markup).trim()
return messageText.length > 0 ? messageText : ''
}
async function attachCard (): Promise<void> {
if (selectedType === undefined || title.trim() === '') return
await attachCardToMessage(_message, card, title, selectedType)
dispatch('close')
}
function filterClasses (): [DropdownIntlItem, DropdownIntlItem[]][] {
const descendants = hierarchy.getDescendants(cardPlugin.class.Card).filter((p) => p !== cardPlugin.class.Card)
const added = new Set<Ref<Class<Doc>>>()
const base = new Map<Ref<Class<Doc>>, Class<Doc>[]>()
for (const _id of descendants) {
if (added.has(_id)) continue
const _class = hierarchy.getClass(_id)
if (_class.label === undefined) continue
if (_class.kind !== ClassifierKind.CLASS) continue
if ((_class as MasterTag).removed === true) continue
added.add(_id)
const descendants = hierarchy.getDescendants(_id)
const toAdd: Class<Doc>[] = []
for (const desc of descendants) {
if (added.has(desc)) continue
const _class = hierarchy.getClass(desc)
if (_class.label === undefined) continue
if (_class.kind !== ClassifierKind.CLASS) continue
if ((_class as MasterTag).removed === true) continue
added.add(desc)
toAdd.push(_class)
}
base.set(_id, toAdd)
}
const result: [DropdownIntlItem, DropdownIntlItem[]][] = []
for (const [key, value] of base) {
try {
const clazz = hierarchy.getClass(key)
result.push([
{ id: key, label: clazz.label, icon: clazz.icon },
value
.map((it) => ({ id: it._id, label: it.label, icon: it.icon }))
.sort((a, b) => a.label.localeCompare(b.label))
])
} catch {}
}
return result
}
const classes = filterClasses()
</script>
<Modal
label={getEmbeddedLabel('Create card from message')}
type="type-popup"
okLabel={presentation.string.Create}
okAction={attachCard}
canSave={selectedType !== undefined && title.trim() !== '' && _message.thread == null}
onCancel={() => dispatch('close')}
on:close
>
<div class="hulyModal-content__titleGroup" style="padding: 0">
<ModernEditbox bind:value={title} label={getEmbeddedLabel('Title')} size="large" kind="ghost" />
</div>
<div class="hulyModal-content__settingsSet">
<div class="hulyModal-content__settingsSet-line">
<span class="label"><Label label={getEmbeddedLabel('Card type')} /></span>
<NestedDropdown
items={classes}
label={getEmbeddedLabel('Card type')}
on:selected={(e) => {
selectedType = e.detail
}}
/>
</div>
<div class="mt-4" />
<MessagePresenter
{card}
message={{ ..._message, reactions: [], linkPreviews: [], thread: undefined }}
readonly={true}
padding="0"
/>
</div>
<svelte:fragment slot="footer">
{#if _message.thread != null}
<div class="footer-error">
<Label label={communication.string.MessageAlreadyHasCardAttached} />
</div>
{/if}
</svelte:fragment>
</Modal>
<style lang="scss">
.footer-error {
display: flex;
color: var(--global-error-TextColor);
font-size: 0.875rem;
font-weight: 400;
}
</style>
@@ -55,7 +55,7 @@
.date-separator {
display: flex;
width: 100%;
padding: 0 2rem;
padding: 0 1rem;
font-size: 0.75rem;
height: 2.25rem;
position: relative;
@@ -84,9 +84,9 @@
flex: 1;
height: 1px;
min-height: 1px;
width: calc(100% - 4rem);
width: calc(100% - 2rem);
background: var(--highlight-select-border);
margin: 0 2rem;
margin: 0 1rem;
margin-top: -2rem;
}
</style>
@@ -82,7 +82,9 @@
$: query.query(queryDef, (res: Window<Message>) => {
window = res
messages = queryDef.order === SortingOrder.Ascending ? res.getResult() : res.getResult().reverse()
messages = (queryDef.order === SortingOrder.Ascending ? res.getResult() : res.getResult().reverse()).filter(
(it) => !it.removed || it.thread != null
)
if (messages.length < limit && res.hasNextPage()) {
void window.loadNextPage()
@@ -14,110 +14,70 @@
-->
<script lang="ts">
import ui, { showPopup, ButtonIcon, IconDelete, IconEdit } from '@hcengineering/ui'
import { ButtonIcon, IconMoreV, showPopup, Action, getEventPositionElement, Menu } from '@hcengineering/ui'
import { MessageAction } from '@hcengineering/communication'
import { getResource } from '@hcengineering/platform'
import { Message } from '@hcengineering/communication-types'
import { createEventDispatcher } from 'svelte'
import emojiPlugin from '@hcengineering/emoji'
import IconMessageMultiple from '../icons/MessageMultiple.svelte'
import communication from '../../plugin'
import { toggleReaction } from '../../utils'
import { Action } from '../../types'
import { Card } from '@hcengineering/card'
import view from '@hcengineering/view'
export let message: Message
export let editable: boolean = true
export let canReply: boolean = true
export let canReact: boolean = true
export let isOpened: boolean = false
export let canRemove: boolean = false
export let card: Card
export let actions: MessageAction[]
export let onClose: () => void
export let onOpen: () => void
const dispatch = createEventDispatcher()
let menuActions: MessageAction[] = []
let inlineActions: MessageAction[] = []
function getActions (): Action[] {
const actions: Action[] = []
if (canReact) {
actions.push({
id: 'emoji',
label: communication.string.Emoji,
$: menuActions = actions.filter((a) => a.menu)
$: inlineActions = actions.filter((a) => !(a.menu ?? false))
icon: emojiPlugin.icon.Emoji,
order: 10,
action: (event: MouseEvent): void => {
isOpened = true
showPopup(
emojiPlugin.component.EmojiPopup,
{},
event.target as HTMLElement,
async (result) => {
isOpened = false
const emoji = result?.text
if (emoji == null) {
return
}
await toggleReaction(message, emoji)
},
() => {
isOpened = false
}
)
}
})
}
if (canReply) {
actions.push({
id: 'reply',
label: communication.string.Reply,
icon: IconMessageMultiple,
order: 20,
action: (): void => {
dispatch('reply')
}
})
}
if (editable) {
actions.push({
id: 'edit',
label: communication.string.Edit,
icon: IconEdit,
order: 30,
action: () => {
dispatch('edit')
}
})
}
if (canRemove) {
actions.push({
id: 'remove',
label: ui.string.Remove,
icon: IconDelete,
order: 999,
action: () => {
dispatch('remove')
}
})
}
return actions.sort((a, b) => a.order - b.order)
async function handleAction (action: MessageAction, ev: MouseEvent): Promise<void> {
const actionFn = await getResource(action.action)
await actionFn(message, card, ev, onOpen, onClose)
}
const actions: Action[] = getActions()
function showMenu (ev: MouseEvent): void {
onOpen()
const actions: Action[] = menuActions.map((action) => ({
id: action._id,
label: action.label,
icon: action.icon,
action: async () => {
await handleAction(action, ev)
}
}))
showPopup(Menu, { actions }, getEventPositionElement(ev), onClose)
}
</script>
<div class="message-actions-panel">
{#each actions as action (action.id)}
{#each inlineActions as action}
{#if action.icon}
<ButtonIcon
icon={action.icon}
iconSize="small"
size="small"
kind="tertiary"
tooltip={{ label: action.label, direction: 'bottom' }}
on:click={(ev) => handleAction(action, ev)}
/>
{/if}
{/each}
{#if menuActions.length > 0}
<ButtonIcon
icon={action.icon}
icon={IconMoreV}
iconSize="small"
size="small"
kind="tertiary"
tooltip={{ label: action.label, direction: 'bottom' }}
on:click={action.action}
tooltip={{ label: view.string.MoreActions, direction: 'bottom' }}
on:click={showMenu}
/>
{/each}
{/if}
</div>
<style lang="scss">
@@ -24,6 +24,8 @@
import MessageInput from './MessageInput.svelte'
import MessageContentViewer from './MessageContentViewer.svelte'
import MessageFooter from './MessageFooter.svelte'
import { translateMessagesStore, messageEditingStore } from '../../stores'
import { showOriginalMessage } from '../../actions'
export let card: Card
export let author: Person | undefined
@@ -60,10 +62,10 @@
{card}
{message}
onCancel={() => {
isEditing = false
messageEditingStore.set(undefined)
}}
on:edited={() => {
isEditing = false
messageEditingStore.set(undefined)
}}
/>
{/if}
@@ -102,6 +104,16 @@
(<Label label={communication.string.Edited} />)
</div>
{/if}
{#if !message.removed && $translateMessagesStore.get(message.id)?.inProgress === true}
<div class="message__translating">
<Label label={communication.string.Translating} />
</div>
{/if}
{#if !message.removed && $translateMessagesStore.get(message.id)?.shown === true}
<div class="message__show-original" on:click={() => showOriginalMessage(message, card)}>
<Label label={communication.string.ShowOriginal} />
</div>
{/if}
</div>
{#if !isEditing}
<div class="message__text">
@@ -112,10 +124,10 @@
{card}
{message}
onCancel={() => {
isEditing = false
messageEditingStore.set(undefined)
}}
on:edited={() => {
isEditing = false
messageEditingStore.set(undefined)
}}
/>
{/if}
@@ -178,10 +190,26 @@
.message__edited-marker {
text-transform: lowercase;
color: var(--global-tertiary-TextColor);
font-size: 0.625rem;
font-size: 0.75rem;
font-weight: 400;
}
.message__translating {
color: var(--global-tertiary-TextColor);
font-size: 0.75rem;
font-weight: 400;
}
.message__show-original {
font-size: 0.75rem;
color: var(--global-tertiary-TextColor);
cursor: pointer;
&:hover {
color: var(--global-secondary-TextColor);
}
}
.message__text {
color: var(--global-primary-TextColor);
font-size: 0.875rem;
@@ -15,20 +15,48 @@
<script lang="ts">
import { MessageViewer as MarkupMessageViewer } from '@hcengineering/presentation'
import { Message, MessageType } from '@hcengineering/communication-types'
import { Markdown, Message, MessageID } from '@hcengineering/communication-types'
import { Card } from '@hcengineering/card'
import { Label } from '@hcengineering/ui'
import { Person } from '@hcengineering/contact'
import { Markup } from '@hcengineering/core'
import ActivityMessageViewer from './ActivityMessageViewer.svelte'
import { toMarkup } from '../../utils'
import { isActivityMessage } from '../../activity'
import ThreadMessageViewer from './ThreadMessageViewer.svelte'
import communication from '../../plugin'
import { isShownTranslatedMessage, TranslateMessagesStatus, translateMessagesStore } from '../../stores'
import { translateMessage } from '../../actions'
export let card: Card
export let message: Message
export let author: Person | undefined
let displayMarkup: Markup = toMarkup(message.content)
let prevContent: Markdown | undefined = undefined
$: updateDisplayMarkup(message, $translateMessagesStore)
function updateDisplayMarkup (message: Message, translateMessages: Map<MessageID, TranslateMessagesStatus>): void {
const translateResult = translateMessages.get(message.id)
if (translateResult?.shown === true && translateResult?.result != null) {
displayMarkup = translateResult.result
} else {
displayMarkup = toMarkup(message.content)
}
}
$: if (prevContent !== message.content) {
prevContent = message.content
if (isShownTranslatedMessage(message.id)) {
void translateMessage(message, card)
} else {
translateMessagesStore.update((store) => {
store.delete(message.id)
return store
})
}
}
</script>
{#if isActivityMessage(message)}
@@ -38,7 +66,7 @@
<Label label={communication.string.MessageWasRemoved} />
</span>
{:else}
<MarkupMessageViewer message={toMarkup(message.content)} />
<MarkupMessageViewer message={displayMarkup} />
{/if}
<style lang="scss">
@@ -22,7 +22,7 @@
import { getResource } from '@hcengineering/platform'
import ReactionsList from '../ReactionsList.svelte'
import MessageReplies from './MessageReplies.svelte'
import MessageThread from '../thread/Thread.svelte'
import { toggleReaction } from '../../utils'
export let message: Message
@@ -108,12 +108,7 @@
{/if}
{#if message.thread && message.thread.threadId}
<div class="message__replies overflow-label">
<MessageReplies
thread={message.thread}
count={message.thread.repliesCount}
lastReply={message.thread.lastReply}
on:click={handleReply}
/>
<MessageThread thread={message.thread} on:click={handleReply} />
</div>
{/if}
@@ -381,7 +381,7 @@
function hasChanges (blobs: BlobData[], message: Message | undefined): boolean {
if (isEmptyDraft()) return false
if (message === undefined) return blobs.length > 0
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
@@ -16,64 +16,41 @@
<script lang="ts">
import { Person } from '@hcengineering/contact'
import { employeeByPersonIdStore, getPersonByPersonId } from '@hcengineering/contact-resources'
import { getClient, getCommunicationClient } from '@hcengineering/presentation'
import { Card } from '@hcengineering/card'
import { getCurrentAccount } from '@hcengineering/core'
import ui, {
getEventPositionElement,
showPopup,
Action as MenuAction,
IconDelete,
IconEdit,
Menu
} from '@hcengineering/ui'
import type { SocialID } from '@hcengineering/communication-types'
import { getEventPositionElement, showPopup, Action, Menu } from '@hcengineering/ui'
import type { MessageID, SocialID } from '@hcengineering/communication-types'
import { Message, MessageType } from '@hcengineering/communication-types'
import emojiPlugin from '@hcengineering/emoji'
import { getResource } from '@hcengineering/platform'
import { MessageAction } from '@hcengineering/communication'
import { Ref } from '@hcengineering/core'
import communication from '../../plugin'
import { toggleReaction, replyToThread } from '../../utils'
import MessageActionsPanel from './MessageActionsPanel.svelte'
import IconMessageMultiple from '../icons/MessageMultiple.svelte'
import MessageBody from './MessageBody.svelte'
import OneRowMessageBody from './OneRowMessageBody.svelte'
import {
messageEditingStore,
TranslateMessagesStatus,
translateMessagesStore,
threadCreateMessageStore
} from '../../stores'
import { getMessageActions } from '../../actions'
import communication from '../../plugin'
export let card: Card
export let message: Message
export let editable: boolean = true
export let padding: string | undefined = undefined
export let compact: boolean = false
export let hideAvatar: boolean = false
const communicationClient = getCommunicationClient()
const me = getCurrentAccount()
export let readonly: boolean = false
let isEditing = false
let isDeleted = false
let author: Person | undefined
$: isDeleted = message.removed
$: isEditing = $messageEditingStore === message.id
$: void updateAuthor(message.creator)
function canEdit (): boolean {
if (!editable) return false
if (message.type !== MessageType.Message) return false
if (message.thread != null) return false
return me.socialIds.includes(message.creator)
}
function canRemove (): boolean {
if (!editable) return false
if (message.type !== MessageType.Message) return false
return me.socialIds.includes(message.creator)
}
function canReply (): boolean {
return message.type === MessageType.Message && message.extra?.threadRoot !== true
}
async function updateAuthor (socialId: SocialID): Promise<void> {
author = $employeeByPersonIdStore.get(socialId)
@@ -82,15 +59,11 @@
}
}
async function handleEdit (): Promise<void> {
if (!canEdit()) return
isEditing = true
}
async function handleRemove (): Promise<void> {
if (!canRemove()) return
message.removed = true
await communicationClient.removeMessage(message.cardId, message.id)
$: updateStore(message)
function updateStore (message: Message): void {
if (!readonly && message.id === $threadCreateMessageStore?.id) {
threadCreateMessageStore.set(message)
}
}
function isInside (x: number, y: number, rect: DOMRect): boolean {
@@ -119,67 +92,51 @@
return false
}
function handleContextMenu (event: MouseEvent): void {
const showCustomPopup = !isContentClicked(event.target as HTMLElement, event.clientX, event.clientY)
if (showCustomPopup) {
event.preventDefault()
event.stopPropagation()
let allActions: MessageAction[] = []
let excludedActions: Ref<MessageAction>[] = []
let actions: MessageAction[] = []
const actions: MenuAction[] = []
$: excludedActions = getExcludedActions($translateMessagesStore)
actions.push({
label: communication.string.Emoji,
icon: emojiPlugin.icon.Emoji,
action: async (): Promise<void> => {
showPopup(
emojiPlugin.component.EmojiPopup,
{},
event.target as HTMLElement,
async (result) => {
const emoji = result?.emoji
if (emoji == null) {
return
}
$: void getMessageActions(message).then((res) => {
allActions = res
})
await toggleReaction(message, emoji)
},
() => {}
)
}
})
$: actions = allActions.filter((it) => !excludedActions.includes(it._id))
if (canReply()) {
actions.push({
label: communication.string.Reply,
icon: IconMessageMultiple,
action: async (): Promise<void> => {
await replyToThread(message, card)
}
})
}
if (canEdit()) {
actions.push({
label: communication.string.Edit,
icon: IconEdit,
action: handleEdit
})
}
if (canRemove()) {
actions.push({
label: ui.string.Remove,
icon: IconDelete,
action: handleRemove
})
}
showPopup(Menu, { actions }, getEventPositionElement(event), () => {})
function getExcludedActions (translatedMessages: Map<MessageID, TranslateMessagesStatus>): Ref<MessageAction>[] {
const result: TranslateMessagesStatus | undefined = translatedMessages.get(message.id)
if (result === undefined || result.inProgress || !result.shown) {
return [communication.messageAction.ShowOriginalMessage]
} else if (result.shown) {
return [communication.messageAction.TranslateMessage]
}
return []
}
let isActionsOpened = false
function handleContextMenu (event: MouseEvent): void {
const showCustomPopup = !isContentClicked(event.target as HTMLElement, event.clientX, event.clientY)
if (!showCustomPopup) return
event.preventDefault()
event.stopPropagation()
const contextMenuActions: Action[] = actions.map((action) => ({
label: action.label,
icon: action.icon,
action: async () => {
const actionFn = await getResource(action.action)
await actionFn(message, card, event)
}
}))
showPopup(Menu, { actions: contextMenuActions }, getEventPositionElement(event), () => {})
}
let isActionsPanelOpened = false
$: showActions = !isEditing && !isDeleted && !readonly
$: isThread = message.thread != null
</script>
@@ -188,29 +145,28 @@
<div
class="message"
id={`${message.id}`}
on:contextmenu={editable && !isEditing && !isDeleted ? handleContextMenu : undefined}
class:active={isActionsOpened && !isEditing}
class:noHover={!editable}
on:contextmenu={showActions ? handleContextMenu : undefined}
class:active={isActionsPanelOpened && !isEditing}
class:noHover={readonly}
style:padding
>
{#if message.type === MessageType.Activity || (message.removed && message.thread?.threadId === undefined)}
<OneRowMessageBody {message} {card} {author} {hideAvatar} />
{:else}
<MessageBody {message} {card} {author} bind:isEditing compact={compact && !isThread} {hideAvatar} />
<MessageBody {message} {card} {author} {isEditing} compact={compact && !isThread} {hideAvatar} />
{/if}
{#if !isEditing && editable && !isDeleted}
<div class="message__actions" class:opened={isActionsOpened}>
{#if showActions}
<div class="message__actions" class:opened={isActionsPanelOpened}>
<MessageActionsPanel
{message}
editable={canEdit()}
canReply={canReply()}
canRemove={canRemove()}
bind:isOpened={isActionsOpened}
on:edit={handleEdit}
on:remove={handleRemove}
on:reply={() => {
void replyToThread(message, card)
{card}
{actions}
onOpen={() => {
isActionsPanelOpened = true
}}
onClose={() => {
isActionsPanelOpened = false
}}
/>
</div>
@@ -225,7 +181,7 @@
align-self: stretch;
min-width: 0;
position: relative;
padding: 0.5rem 2rem;
padding: 0.5rem 1em;
&:hover:not(.noHover) {
background: var(--global-ui-BackgroundColor);
@@ -1,261 +0,0 @@
<!--
// 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 { ticker, DAY, HOUR, MINUTE, languageStore, Label, Component } from '@hcengineering/ui'
import { createQuery, createCollaboratorsQuery } from '@hcengineering/presentation'
import { translateCB } from '@hcengineering/platform'
import { Collaborator, Thread } from '@hcengineering/communication-types'
import contact, { Person } from '@hcengineering/contact'
import { Avatar } from '@hcengineering/contact-resources'
import cardPlugin, { Card } from '@hcengineering/card'
import communication from '../../plugin'
export let thread: Thread
export let count: number
export let lastReply: Date
const displayPersonsNumber = 4
const threadCardQuery = createQuery()
const personsQuery = createQuery()
const collaboratorsQuery = createCollaboratorsQuery()
let displayDate: string = ''
let collaborators: Collaborator[] = []
let persons: Person[] = []
let threadCard: Card | undefined
$: thread.threadId &&
collaboratorsQuery.query({ card: thread.threadId }, (res) => {
collaborators = res
})
$: if (collaborators.length > 0) {
personsQuery.query(
contact.class.Person,
{
personUuid: {
$in: collaborators.map((it) => it.account).slice(0, displayPersonsNumber)
}
},
(res) => {
persons = res
}
)
} else {
personsQuery.unsubscribe()
persons = []
}
$: if (thread?.threadId !== undefined) {
threadCardQuery.query(
cardPlugin.class.Card,
{ _id: thread.threadId },
(res) => {
threadCard = res[0]
},
{ limit: 1 }
)
} else {
threadCard = undefined
threadCardQuery.unsubscribe()
}
$: if (thread?.threadId !== threadCard?._id) {
threadCard = undefined
}
$: formatDate($ticker, lastReply, $languageStore)
function formatDate (now: number, date: Date, lang: string): void {
const nowDate = new Date(now)
let diff = now - date.getTime()
if (diff < 0) diff = 0
if (diff < MINUTE) {
translateCB(communication.string.JustNow, {}, lang, (res) => {
displayDate = res
})
return
}
if (diff < HOUR) {
translateCB(communication.string.MinutesAgo, { minutes: Math.floor(diff / MINUTE) }, lang, (res) => {
displayDate = res
})
return
}
if (diff < DAY) {
translateCB(communication.string.HoursAgo, { hours: Math.floor(diff / HOUR) }, lang, (res) => {
displayDate = res
})
return
}
const yesterday = new Date()
yesterday.setDate(nowDate.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) {
const time = date.toLocaleString('default', {
hour: 'numeric',
minute: 'numeric',
hour12: true
})
translateCB(communication.string.YesterdayAt, { time }, lang, (res) => {
displayDate = res
})
return
}
const startOfWeek = new Date()
startOfWeek.setDate(nowDate.getDate() - nowDate.getDay())
startOfWeek.setHours(0, 0, 0, 0)
if (date >= startOfWeek) {
const weekday = date.toLocaleString('default', {
weekday: 'long'
})
const time = date.toLocaleString('default', {
hour: 'numeric',
minute: 'numeric',
hour12: true
})
translateCB(communication.string.WeekdayAt, { weekday, time }, lang, (res) => {
displayDate = res
})
return
}
const startOfYear = new Date(nowDate.getFullYear(), 0, 1, 0, 0, 0, 0)
if (date >= startOfYear) {
const month = date.toLocaleString('default', {
month: 'short',
day: '2-digit'
})
const time = date.toLocaleString('default', {
hour: 'numeric',
minute: 'numeric',
hour12: true
})
translateCB(communication.string.MonthAt, { month, time }, lang, (res) => {
displayDate = res
})
return
}
const year = date.toLocaleString('default', {
year: 'numeric',
month: 'short',
day: '2-digit'
})
const time = date.toLocaleString('default', {
hour: 'numeric',
minute: 'numeric',
hour12: true
})
translateCB(communication.string.YearAt, { year, time }, lang, (res) => {
displayDate = res
})
}
let clientWidth = 0
</script>
<div class="replies-container flex-grow" bind:clientWidth>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="replies" on:click style:max-width={`${clientWidth}px`}>
<div class="avatars">
{#each persons as person}
<Avatar size="x-small" {person} name={person.name} />
{/each}
</div>
{#if collaborators.length > displayPersonsNumber}
<div class="plus">
+{collaborators.length - displayPersonsNumber}
</div>
{/if}
<span class="text overflow-label">
<span class="replies__count">
<Label label={communication.string.RepliesCount} params={{ count }} />
</span>
{#if count > 0}
<span class="replies__last-reply">
<Label label={communication.string.LastReply} />
<span class="lower">
{displayDate}
</span>
</span>
{/if}
</span>
{#if threadCard && clientWidth > 300}
<Component is={cardPlugin.component.CardTagsColored} props={{ value: threadCard }} />
{/if}
</div>
</div>
<style lang="scss">
.replies-container {
display: flex;
flex-shrink: 1;
min-width: 0;
}
.replies {
display: flex;
padding: 0.5rem 0.5rem;
align-items: center;
gap: 0.5rem;
border-radius: 0.5rem;
font-size: 0.75rem;
cursor: pointer;
min-width: 0;
border: 1px solid var(--global-ui-BorderColor);
min-height: 2.375rem;
&:hover {
background-color: var(--theme-bg-color);
}
}
.replies__count {
color: var(--global-secondary-TextColor);
font-size: 0.75rem;
font-weight: 500;
}
.text {
flex: 1 1 auto;
}
.replies__last-reply {
color: var(--global-tertiary-TextColor);
font-size: 0.75rem;
font-weight: 400;
}
.avatars {
display: flex;
gap: 0.25rem;
}
</style>
@@ -60,7 +60,7 @@
{#if separatorIndex !== 0 && index === separatorIndex}
<MessagesSeparator bind:element={separatorDiv} />
{/if}
<MessagePresenter {message} {card} editable={!readonly} {compact} />
<MessagePresenter {message} {card} {readonly} {compact} />
{/each}
</div>
</div>
@@ -0,0 +1,101 @@
<!--
// 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 { createQuery } from '@hcengineering/presentation'
import { Thread } from '@hcengineering/communication-types'
import cardPlugin, { Card } from '@hcengineering/card'
import ThreadCollaborators from './ThreadCollaborators.svelte'
import ThreadRepliesCount from './ThreadRepliesCount.svelte'
import ThreadLastReply from './ThreadLastReply.svelte'
import ThreadTags from './ThreadTags.svelte'
import ThreadTitle from './ThreadTitle.svelte'
export let thread: Thread
const threadCardQuery = createQuery()
let threadCard: Card | undefined
$: if (thread?.threadId !== undefined) {
threadCardQuery.query(
cardPlugin.class.Card,
{ _id: thread.threadId },
(res) => {
threadCard = res[0]
},
{ limit: 1 }
)
} else {
threadCard = undefined
threadCardQuery.unsubscribe()
}
$: if (thread?.threadId !== threadCard?._id) {
threadCard = undefined
}
let clientWidth = 0
</script>
<div class="replies-container flex-grow" bind:clientWidth>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="replies" on:click style:max-width={`${clientWidth}px`}>
<ThreadCollaborators threadId={thread.threadId} />
{#if thread.repliesCount > 0}
<span class="text overflow-label">
<ThreadRepliesCount count={thread.repliesCount} />
<ThreadLastReply lastReply={thread.lastReply} />
</span>
{/if}
{#if threadCard && clientWidth > 300}
<ThreadTags card={threadCard} />
{/if}
{#if threadCard && clientWidth > 300}
<ThreadTitle title={threadCard.title} />
{/if}
</div>
</div>
<style lang="scss">
.replies-container {
display: flex;
flex-shrink: 1;
min-width: 0;
}
.replies {
display: flex;
padding: 0.5rem 0.5rem;
align-items: center;
gap: 0.5rem;
border-radius: 0.5rem;
font-size: 0.75rem;
cursor: pointer;
min-width: 0;
border: 1px solid var(--global-ui-BorderColor);
min-height: 2.375rem;
&:hover {
background-color: var(--theme-bg-color);
}
}
.text {
flex: 1 1 auto;
}
</style>
@@ -0,0 +1,71 @@
<!-- 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 { AccountUuid, Ref } from '@hcengineering/core'
import { Card } from '@hcengineering/card'
import { Avatar, employeeByAccountStore } from '@hcengineering/contact-resources'
import { createCollaboratorsQuery } from '@hcengineering/presentation'
import { Collaborator } from '@hcengineering/communication-types'
import { Person } from '@hcengineering/contact'
export let threadId: Ref<Card>
const displayPersonsNumber = 4
const collaboratorsQuery = createCollaboratorsQuery()
let collaborators: Collaborator[] = []
let persons: Person[] = []
$: collaboratorsQuery.query({ card: threadId }, (res) => {
collaborators = res
})
$: updatePersons(collaborators, $employeeByAccountStore)
function updatePersons (collaborators: Collaborator[], employeeByAccount: Map<AccountUuid, Person>): void {
const newPersons: Person[] = []
for (const collaborator of collaborators) {
const person = employeeByAccount.get(collaborator.account)
if (person !== undefined) {
newPersons.push(person)
}
if (newPersons.length >= displayPersonsNumber) {
break
}
}
persons = newPersons
}
</script>
{#if persons.length > 0}
<div class="thread__avatars">
{#each persons as person}
<Avatar size="x-small" {person} name={person.name} />
{/each}
</div>
{#if collaborators.length > displayPersonsNumber}
+{collaborators.length - displayPersonsNumber}
{/if}
{/if}
<style lang="scss">
.thread__avatars {
display: flex;
gap: 0.25rem;
}
</style>
@@ -0,0 +1,45 @@
<!-- 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, TimeSince } from '@hcengineering/ui'
import { Timestamp } from '@hcengineering/core'
import communication from '../../plugin'
export let lastReply: Date | string | number | undefined = undefined
function getTime (date: Date | string | number | undefined): Timestamp | undefined {
if (date === undefined) return undefined
if (date instanceof Date) return date.getTime()
if (typeof date === 'number') return date
return new Date(date).getTime()
}
$: time = getTime(lastReply)
</script>
{#if time !== undefined}
<span class="thread__last-reply">
<Label label={communication.string.LastReply} />
<TimeSince value={time} />
</span>
{/if}
<style lang="scss">
.thread__last-reply {
color: var(--global-tertiary-TextColor);
font-size: 0.75rem;
font-weight: 400;
}
</style>
@@ -0,0 +1,34 @@
<!-- 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 } from '@hcengineering/ui'
import communication from '../../plugin'
export let count: number
</script>
{#if count > 0}
<span class="thread__replies-count">
<Label label={communication.string.RepliesCount} params={{ count }} />
</span>
{/if}
<style lang="scss">
.thread__replies-count {
color: var(--global-secondary-TextColor);
font-size: 0.75rem;
font-weight: 500;
}
</style>
@@ -0,0 +1,21 @@
<!-- 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 cardPlugin, { Card } from '@hcengineering/card'
import { Component } from '@hcengineering/ui'
export let card: Card
</script>
<Component is={cardPlugin.component.CardTagsColored} props={{ value: card }} />
@@ -0,0 +1,32 @@
<!-- 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 { tooltip } from '@hcengineering/ui'
import { getEmbeddedLabel } from '@hcengineering/platform'
export let title: string
</script>
<span class="text overflow-label" use:tooltip={{ label: getEmbeddedLabel(title) }}>
{title}
</span>
<style lang="scss">
.text {
flex: 1 1 auto;
max-width: 20rem;
min-width: 0;
flex-shrink: 100;
}
</style>
+31 -1
View File
@@ -17,6 +17,21 @@ import { type Resources } from '@hcengineering/platform'
import CardMessagesSection from './components/CardMessagesSection.svelte'
import { unsubscribe, subscribe, canSubscribe, canUnsubscribe } from './utils'
import {
addReaction,
canCreateCard,
canEditMessage,
canRemoveMessage,
canReplyInThread,
canShowOriginalMessage,
canTranslateMessage,
createCard,
editMessage,
removeMessage,
replyInThread,
showOriginalMessage,
translateMessage
} from './actions'
export { isActivityMessage } from './activity'
export * from './stores'
@@ -29,12 +44,27 @@ export default async (): Promise<Resources> => ({
component: {
CardMessagesSection
},
messageActionImpl: {
AddReaction: addReaction,
ReplyInThread: replyInThread,
TranslateMessage: translateMessage,
ShowOriginalMessage: showOriginalMessage,
EditMessage: editMessage,
RemoveMessage: removeMessage,
CreateCard: createCard
},
action: {
Unsubscribe: unsubscribe,
Subscribe: subscribe
},
function: {
CanSubscribe: canSubscribe,
CanUnsubscribe: canUnsubscribe
CanUnsubscribe: canUnsubscribe,
CanReplyInThread: canReplyInThread,
CanTranslateMessage: canTranslateMessage,
CanShowOriginalMessage: canShowOriginalMessage,
CanEditMessage: canEditMessage,
CanRemoveMessage: canRemoveMessage,
CanCreateCard: canCreateCard
}
})
+44 -2
View File
@@ -12,8 +12,14 @@
// limitations under the License.
import { type IntlString, mergeIds } from '@hcengineering/platform'
import communication, { communicationId } from '@hcengineering/communication'
import communication, {
communicationId,
type MessageAction,
type MessageActionFunctionResource,
type MessageActionVisibilityTesterResource
} from '@hcengineering/communication'
import { type AnyComponent } from '@hcengineering/ui'
import { type Ref } from '@hcengineering/core'
export default mergeIds(communicationId, communication, {
component: {
@@ -54,6 +60,42 @@ export default mergeIds(communicationId, communication, {
ThreadWasRemoved: '' as IntlString,
MessageWasRemoved: '' as IntlString,
JoinedThe: '' as IntlString,
LeftThe: '' as IntlString
LeftThe: '' as IntlString,
Translating: '' as IntlString,
ShowOriginal: '' as IntlString,
AddReaction: '' as IntlString,
ReplyInThread: '' as IntlString,
TranslateMessage: '' as IntlString,
ShowOriginalMessage: '' as IntlString,
EditMessage: '' as IntlString,
RemoveMessage: '' as IntlString,
CreateCard: '' as IntlString,
MessageAlreadyHasCardAttached: '' as IntlString
},
messageActionImpl: {
AddReaction: '' as MessageActionFunctionResource,
ReplyInThread: '' as MessageActionFunctionResource,
TranslateMessage: '' as MessageActionFunctionResource,
ShowOriginalMessage: '' as MessageActionFunctionResource,
EditMessage: '' as MessageActionFunctionResource,
RemoveMessage: '' as MessageActionFunctionResource,
CreateCard: '' as MessageActionFunctionResource
},
messageAction: {
AddReaction: '' as Ref<MessageAction>,
ReplyInThread: '' as Ref<MessageAction>,
TranslateMessage: '' as Ref<MessageAction>,
ShowOriginalMessage: '' as Ref<MessageAction>,
EditMessage: '' as Ref<MessageAction>,
RemoveMessage: '' as Ref<MessageAction>,
CreateCard: '' as Ref<MessageAction>
},
function: {
CanReplyInThread: '' as MessageActionVisibilityTesterResource,
CanTranslateMessage: '' as MessageActionVisibilityTesterResource,
CanShowOriginalMessage: '' as MessageActionVisibilityTesterResource,
CanEditMessage: '' as MessageActionVisibilityTesterResource,
CanRemoveMessage: '' as MessageActionVisibilityTesterResource,
CanCreateCard: '' as MessageActionVisibilityTesterResource
}
})
+34 -2
View File
@@ -11,11 +11,43 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { writable } from 'svelte/store'
import { get, writable } from 'svelte/store'
import { createLabelsQuery, onCommunicationClient } from '@hcengineering/presentation'
import type { Label } from '@hcengineering/communication-types'
import type { Label, Message, MessageID } from '@hcengineering/communication-types'
import type { Markup } from '@hcengineering/core'
import { languageStore } from '@hcengineering/ui'
export const labelsStore = writable<Label[]>([])
export const messageEditingStore = writable<MessageID | undefined>(undefined)
export const translateMessagesStore = writable<Map<MessageID, TranslateMessagesStatus>>(new Map())
export const threadCreateMessageStore = writable<Message | undefined>(undefined)
export interface TranslateMessagesStatus {
inProgress: boolean
shown: boolean
result?: Markup
}
languageStore.subscribe(() => {
translateMessagesStore.set(new Map())
})
export function isMessageTranslated (messageId: MessageID): boolean {
return get(translateMessagesStore).get(messageId)?.result != null
}
export function isMessageTranslating (messageId: MessageID): boolean {
return get(translateMessagesStore).get(messageId)?.inProgress === true
}
export function getMessageTranslatedMarkup (messageId: MessageID): Markup | undefined {
return get(translateMessagesStore).get(messageId)?.result
}
export function isShownTranslatedMessage (messageId: MessageID): boolean {
const result = get(translateMessagesStore).get(messageId)
return result?.shown === true && result?.result != null
}
const query = createLabelsQuery(true)
+5 -82
View File
@@ -11,33 +11,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import {
canDisplayLinkPreview,
fetchLinkPreviewDetails,
getClient,
getCommunicationClient
} from '@hcengineering/presentation'
import cardPlugin, { type Card } from '@hcengineering/card'
import {
fillDefaults,
generateId,
getCurrentAccount,
type Markup,
type MarkupBlobRef,
type Ref,
SortingOrder
} from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import { canDisplayLinkPreview, fetchLinkPreviewDetails, getCommunicationClient } from '@hcengineering/presentation'
import { type Card } from '@hcengineering/card'
import { getCurrentAccount, type Markup } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import { showPopup } from '@hcengineering/ui'
import { employeeByPersonIdStore } from '@hcengineering/contact-resources'
import { getEmployeeBySocialId } from '@hcengineering/contact'
import { type LinkPreviewData, type Message } from '@hcengineering/communication-types'
import emoji from '@hcengineering/emoji'
import { markdownToMarkup, markupToMarkdown } from '@hcengineering/text-markdown'
import { jsonToMarkup, markupToJSON, markupToText } from '@hcengineering/text'
import { get } from 'svelte/store'
import chat from '@hcengineering/chat'
import { makeRank } from '@hcengineering/rank'
import { jsonToMarkup, markupToJSON } from '@hcengineering/text'
import IconAt from './components/icons/At.svelte'
@@ -127,65 +109,6 @@ export async function toggleReaction (message: Message, emoji: string): Promise<
}
}
export async function replyToThread (message: Message, parentCard: Card): Promise<void> {
const client = getClient()
const communicationClient = getCommunicationClient()
const hierarchy = client.getHierarchy()
const thread = message.thread
if (thread != null) {
const _id = thread.threadId
const card = await client.findOne(cardPlugin.class.Card, { _id: _id as Ref<Card> })
if (card === undefined) return
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
await r(_id, card)
return
}
const author =
get(employeeByPersonIdStore).get(message.creator) ?? (await getEmployeeBySocialId(client, message.creator))
const lastOne = await client.findOne(cardPlugin.class.Card, {}, { sort: { rank: SortingOrder.Descending } })
const title = createThreadTitle(message, parentCard)
const data = fillDefaults<Card>(
hierarchy,
{
title,
rank: makeRank(lastOne?.rank, undefined),
content: '' as MarkupBlobRef,
parent: parentCard._id,
blobs: {},
parentInfo: [
...(parentCard.parentInfo ?? []),
{
_id: parentCard._id,
_class: parentCard._class,
title: parentCard.title
}
]
},
chat.masterTag.Thread
)
const apply = client.apply('create thread', undefined, true)
const threadCardID = generateId<Card>()
await apply.createDoc(chat.masterTag.Thread, cardPlugin.space.Default, data, threadCardID)
await apply.commit()
await communicationClient.attachThread(parentCard._id, message.id, threadCardID, chat.masterTag.Thread)
if (author?.active === true && author?.personUuid !== undefined) {
await communicationClient.addCollaborators(threadCardID, chat.masterTag.Thread, [author.personUuid])
}
const threadCard = await client.findOne(cardPlugin.class.Card, { _id: threadCardID })
if (threadCard === undefined) return
const r = await getResource(cardPlugin.function.OpenCardInSidebar)
await r(threadCard._id, threadCard)
}
function createThreadTitle (message: Message, parent: Card): string {
const markup = jsonToMarkup(markdownToMarkup(message.content))
const messageText = markupToText(markup).trim()
return messageText.length > 0 ? messageText : `Thread from ${parent.title}`
}
export async function loadLinkPreviewData (url: string): Promise<LinkPreviewData | undefined> {
try {
const meta = await fetchLinkPreviewDetails(url)
+1
View File
@@ -38,6 +38,7 @@
},
"dependencies": {
"@hcengineering/platform": "^0.6.11",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/card": "^0.6.0"
+8 -2
View File
@@ -13,7 +13,9 @@
import { Asset, IntlString, Metadata, plugin, Plugin } from '@hcengineering/platform'
import { CardSection } from '@hcengineering/card'
import { Ref } from '@hcengineering/core'
import { Class, Ref } from '@hcengineering/core'
import { MessageAction } from './types'
export * from './types'
@@ -23,10 +25,14 @@ export * from './types'
export const communicationId = 'communication' as Plugin
export default plugin(communicationId, {
class: {
MessageAction: '' as Ref<Class<MessageAction>>
},
icon: {
Bell: '' as Asset,
BellCrossed: '' as Asset,
File: '' as Asset
File: '' as Asset,
MessageMultiple: '' as Asset
},
metadata: {
Enabled: '' as Metadata<boolean>
+27
View File
@@ -11,7 +11,34 @@
// 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 { Asset, IntlString, Resource } from '@hcengineering/platform'
import { Card } from '@hcengineering/card'
export enum MessagesNavigationAnchors {
ConversationStart = 'conversationStart',
LatestMessages = 'latestMessages'
}
export type MessageActionFunction = (
message: Message,
card: Card,
evt?: Event,
onOpen?: () => void,
onClose?: () => void
) => Promise<void>
export type MessageActionFunctionResource = Resource<MessageActionFunction>
export type MessageActionVisibilityTester = (message: Message) => boolean
export type MessageActionVisibilityTesterResource = Resource<MessageActionVisibilityTester>
export interface MessageAction extends Doc {
label: IntlString
icon: Asset
action: MessageActionFunctionResource
visibilityTester?: MessageActionVisibilityTesterResource
order: number
menu?: boolean
}
@@ -28,7 +28,7 @@
.processes__section {
display: flex;
flex-direction: column;
padding: 0 2rem;
padding: 0 1rem;
width: 100%;
}
</style>