Init directs (#9715)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2025-08-25 14:36:59 +07:00
committed by GitHub
parent cdceb8c196
commit b618b3c42f
58 changed files with 1169 additions and 93 deletions
+10 -1
View File
@@ -19,6 +19,8 @@ import {
type CardSection,
type CardSpace,
type CardViewDefaults,
type CreateCardExtension,
type CanCreateCardResource,
DOMAIN_CARD,
type FavoriteCard,
type MasterTag,
@@ -168,6 +170,12 @@ export class TFavoriteCard extends TPreference implements FavoriteCard {
application!: string
}
@Mixin(card.mixin.CreateCardExtension, card.class.MasterTag)
export class TCreateCardExtension extends TMasterTag implements CreateCardExtension {
component?: AnyComponent
canCreate?: CanCreateCardResource
}
export * from './migration'
const listConfig: (BuildModelKey | string)[] = [
@@ -337,7 +345,8 @@ export function createModel (builder: Builder): void {
TRole,
TCardSection,
TCardViewDefaults,
TFavoriteCard
TFavoriteCard,
TCreateCardExtension
)
defineTabs(builder)
+50 -6
View File
@@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { type Builder, Model, TypeAny, TypeNumber } from '@hcengineering/model'
import { ArrOf, type Builder, Model, TypeAny, TypeNumber, TypeRef } from '@hcengineering/model'
import core, { TAttachedDoc, TConfiguration, 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'
@@ -25,17 +25,19 @@ import {
type Poll,
type CustomActivityPresenter,
type GuestCommunicationSettings,
type AppletGetTitleFnResource
type AppletGetTitleFnResource,
MessagesNavigationAnchors
} from '@hcengineering/communication'
import { PaletteColorIndexes } from '@hcengineering/ui/src/colors'
import { type AppletType } from '@hcengineering/communication-types'
import { createSystemType } from '@hcengineering/model-card'
import card, { createSystemType } from '@hcengineering/model-card'
import type { AnyComponent } from '@hcengineering/ui'
import { type PersonSpace } from '@hcengineering/contact'
import communication from './plugin'
import contact, { type PersonSpace } from '@hcengineering/contact'
import { type Card, type MasterTag } from '@hcengineering/card'
import { DOMAIN_SETTING } from '@hcengineering/setting'
import view from '@hcengineering/model-view'
import communication from './plugin'
export const DOMAIN_POLL = 'poll' as Domain
@@ -86,6 +88,48 @@ export class TGuestCommunicationSettings extends TConfiguration implements Guest
export function buildTypes (builder: Builder): void {
builder.createModel(TMessageAction, TApplet, TPollAnswer, TCustomActivityPresenter, TGuestCommunicationSettings)
defineDirect(builder)
definePoll(builder)
}
function defineDirect (builder: Builder): void {
createSystemType(
builder,
communication.type.Direct,
contact.icon.Contacts,
communication.string.Direct,
communication.string.Directs,
{
defaultSection: communication.ids.CardMessagesSection,
defaultNavigation: MessagesNavigationAnchors.LatestMessages
},
PaletteColorIndexes.Lavander
)
builder.createDoc(core.class.Attribute, core.space.Model, {
name: 'members',
readonly: true, // TODO: remove
attributeOf: communication.type.Direct,
type: ArrOf(TypeRef(contact.class.Person)),
label: communication.string.Members
})
builder.mixin(communication.type.Direct, core.class.Class, view.mixin.ObjectIcon, {
component: communication.component.DirectIcon
})
builder.mixin(communication.type.Direct, core.class.Class, card.mixin.CreateCardExtension, {
component: communication.component.CreateDirect,
canCreate: communication.function.CanCreateDirect,
disableTitle: true,
hideSpace: true
})
builder.mixin(communication.type.Direct, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Delete]
})
}
function definePoll (builder: Builder): void {
createSystemType(
builder,
communication.type.Poll,
+1
View File
@@ -33,6 +33,7 @@
"@hcengineering/model": "^0.6.11",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/card": "^0.6.0",
"@hcengineering/communication": "^0.6.0",
"@hcengineering/server-card": "^0.6.0",
"@hcengineering/server-core": "^0.6.1"
}
+19
View File
@@ -19,6 +19,7 @@ import core from '@hcengineering/core'
import serverCore from '@hcengineering/server-core'
import serverCard from '@hcengineering/server-card'
import card from '@hcengineering/card'
import communication from '@hcengineering/communication'
export { serverCardId } from '@hcengineering/server-card'
@@ -85,6 +86,24 @@ export function createModel (builder: Builder): void {
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverCard.trigger.OnDirectCreate,
isAsync: false,
txMatch: {
_class: core.class.TxCreateDoc,
objectClass: communication.type.Direct
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverCard.trigger.OnThreadCreate,
isAsync: false,
txMatch: {
_class: core.class.TxCreateDoc,
objectClass: card.class.Card
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverCard.trigger.OnCardUpdate,
isAsync: true,
+5 -1
View File
@@ -43,7 +43,7 @@
/* Dark Theme */
.theme-dark {
--global-ui-BackgroundColor: #A5BDFF0D;
--global-ui-BackgroundColor: 1530720D;
--global-ui-BorderColor: #A5BDFF1A;
--global-ui-hover-BackgroundColor: #A5BDFF1A;
--global-ui-active-BackgroundColor: #A5BDFF26;
@@ -134,6 +134,8 @@
--love-active-call-filter: blur(17px);
--global-offline-color: #d1d5de;
--avatar-counter-BackgroundColor: #141523
}
/* Light Theme */
@@ -229,4 +231,6 @@
--love-active-call-transform: scaleY(0.3) scaleX(0.42);
--global-offline-color: #5A667E;
--avatar-counter-BackgroundColor: #F1F1F5;
}
+13 -8
View File
@@ -1,14 +1,14 @@
//
// Copyright © 2022 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.
//
@@ -115,7 +115,7 @@
background-color: var(--theme-navpanel-selected);
border: 1px solid var(--global-subtle-ui-BorderColor);
border-radius: var(--extra-small-BorderRadius);
&.folder {
background-color: var(--theme-statusbar-color);
border-color: var(--global-surface-01-BorderColor);
@@ -131,7 +131,7 @@
color: var(--global-tertiary-TextColor);
border-radius: var(--extra-small-BorderRadius);
}
&__tools {
&__tools, &__actions {
display: none;
align-items: center;
flex-shrink: 0;
@@ -153,6 +153,11 @@
&:hover {
.hulyNavGroup-header__tools { display: flex; }
}
&:hover {
.hulyNavGroup-header__actions { display: flex; }
}
&.showMenu,
&.highlighted,
&.selected {
@@ -392,7 +397,7 @@
&__mobile {
flex-grow: 1;
}
&__main {
flex-grow: 2;
flex-basis: 760px;
@@ -474,7 +479,7 @@
&__aside {
width: 25%;
min-width: var(--panel-aside-width);
&.float {
position: absolute;
flex-direction: row;
@@ -576,7 +581,7 @@
width: 100%;
visibility: hidden;
}
.popupPanel-pageFooter {
@include watermark;
@@ -155,6 +155,11 @@
<slot name="tools" />
</div>
{/if}
{#if $$slots.actions}
<div class="hulyNavGroup-header__actions">
<slot name="actions" />
</div>
{/if}
{#if selected && type === 'nested-selectable'}
<div class="hulyNavGroup-header__arrow"><IconOpenedArrow size={'small'} /></div>
{/if}
+4 -2
View File
@@ -116,8 +116,10 @@
</button>
{/if}
{#if visibleIcon || (type === 'type-tag' && color)}
<div class="hulyNavItem-icon" class:withBackground class:w-auto={iconSize === 'x-small'}>
{#if type !== 'type-tag' && visibleIcon}
<div class="hulyNavItem-icon relative" class:withBackground class:w-auto={iconSize === 'x-small'}>
{#if $$slots.icon}
<slot name="icon" />
{:else if type !== 'type-tag' && visibleIcon}
<Icon icon={visibleIcon} size={iconSize} {iconProps} />
{:else if type === 'type-tag'}
<div style:background-color={color} class="hulyNavItem-icon__tag" />
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "Štítky",
"Properties": "Vlastnosti",
"NoChildren": "Žádné děti",
"ConfigDescription": "Rozšíření pro správu znalostí ve stylu databáze."
"ConfigDescription": "Rozšíření pro správu znalostí ve stylu databáze.",
"AddCollaborators": "Přidat spolupracovníky"
}
}
}
+2 -1
View File
@@ -39,6 +39,7 @@
"Labels": "Labels",
"Properties": "Eigenschaften",
"NoChildren": "Keine Kinder",
"ConfigDescription": "Erweiterung für Datenbank-basiertes Wissensmanagement."
"ConfigDescription": "Erweiterung für Datenbank-basiertes Wissensmanagement.",
"AddCollaborators": "Mitarbeiter hinzufügen"
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "Labels",
"Properties": "Properties",
"NoChildren": "No children",
"ConfigDescription": "Extension for database-style knowledge management."
"ConfigDescription": "Extension for database-style knowledge management.",
"AddCollaborators": "Add collaborators"
}
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "Etiquetas",
"Properties": "Propiedades",
"NoChildren": "No hay hijos",
"ConfigDescription": "Extensión para la gestión del conocimiento al estilo de una base de datos."
"ConfigDescription": "Extensión para la gestión del conocimiento al estilo de una base de datos.",
"AddCollaborators": "Agregar colaboradores"
}
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "Étiquettes",
"Properties": "Propriétés",
"NoChildren": "Pas d'enfants",
"ConfigDescription": "Extension pour la gestion des connaissances de style base de données."
"ConfigDescription": "Extension pour la gestion des connaissances de style base de données.",
"AddCollaborators": "Ajouter des collaborateurs"
}
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Properties": "Proprietà",
"NoChildren": "Nessun figlio",
"NumberTypes": "{count, plural, one {# tipo} other {# tipi}}",
"ConfigDescription": "Estensione per la gestione della conoscenza in stile database."
"ConfigDescription": "Estensione per la gestione della conoscenza in stile database.",
"AddCollaborators": "Aggiungi collaboratori"
}
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "ラベル",
"Properties": "プロパティ",
"NoChildren": "子がありません",
"ConfigDescription": "データベーススタイルのナレッジマネジメント用の拡張機能。"
"ConfigDescription": "データベーススタイルのナレッジマネジメント用の拡張機能。",
"AddCollaborators": "コラボレーターを追加"
}
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "Etiquetas",
"Properties": "Propriedades",
"NoChildren": "Sem filhos",
"ConfigDescription": "Extensão para gerenciamento de conhecimento no estilo de banco de dados."
"ConfigDescription": "Extensão para gerenciamento de conhecimento no estilo de banco de dados.",
"AddCollaborators": "Adicionar colaboradores"
}
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "Метки",
"Properties": "Свойства",
"NoChildren": "Нет потомков",
"ConfigDescription": "Расширение для управления знаниями в стиле базы данных."
"ConfigDescription": "Расширение для управления знаниями в стиле базы данных.",
"AddCollaborators": "Добавить сотрудников"
}
}
}
+3 -2
View File
@@ -39,6 +39,7 @@
"Labels": "标签",
"Properties": "属性",
"NoChildren": "没有子级",
"ConfigDescription": "用于数据库风格知识管理的扩展。"
"ConfigDescription": "用于数据库风格知识管理的扩展。",
"AddCollaborators": "添加协作者"
}
}
}
@@ -0,0 +1,110 @@
<!--
// 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 { Employee, Person } from '@hcengineering/contact'
import { ButtonIcon, IconDelete, ModernButton, Scroller } from '@hcengineering/ui'
import { employeeByIdStore, IconAddMember, UserDetails } from '@hcengineering/contact-resources'
import { notEmpty, Ref } from '@hcengineering/core'
import { createEventDispatcher } from 'svelte'
import card from '../plugin'
export let ids: Ref<Employee>[] = []
export let disableRemoveFor: Ref<Person>[] = []
const dispatch = createEventDispatcher()
$: employees = ids.map((_id) => $employeeByIdStore.get(_id)).filter(notEmpty)
</script>
<div class="root">
<div class="item" style:padding="var(--spacing-1_5)" class:withoutBorder={employees.length === 0}>
<ModernButton
label={card.string.AddCollaborators}
icon={IconAddMember}
iconSize="small"
kind="secondary"
size="small"
on:click={() => dispatch('add')}
/>
</div>
<Scroller>
{#each employees as employee, index (employee._id)}
<div class="item" class:withoutBorder={index === employees.length - 1}>
<div class="item__content" class:disabled={disableRemoveFor.includes(employee._id)}>
<UserDetails person={employee} showStatus />
{#if !disableRemoveFor.includes(employee._id)}
<div class="item__action">
<ButtonIcon
icon={IconDelete}
size="small"
on:click={() => {
dispatch('remove', employee._id)
}}
/>
</div>
{/if}
</div>
</div>
{/each}
</Scroller>
</div>
<style lang="scss">
.root {
display: flex;
flex-direction: column;
padding: 1px;
border-radius: 0.75rem;
background: var(--global-ui-highlight-BackgroundColor);
border: 1px solid var(--global-ui-BorderColor);
max-height: 30rem;
width: 100%;
}
.item {
padding: var(--spacing-0_75);
border-bottom: 1px solid var(--global-ui-BorderColor);
&.withoutBorder {
border: 0;
}
.item__content {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-0_75);
border-radius: var(--small-BorderRadius);
cursor: pointer;
&:hover {
background: var(--global-ui-highlight-BackgroundColor);
.item__action {
visibility: visible;
}
}
}
.item__action {
visibility: hidden;
&:hover {
visibility: visible;
}
}
}
</style>
@@ -15,7 +15,7 @@
<script lang="ts">
import { Card } from '@hcengineering/card'
import { getClient, createQuery } from '@hcengineering/presentation'
import { Button, ButtonSize, Icon, IconSize, showPopup } from '@hcengineering/ui'
import { Button, ButtonSize, Component, Icon, IconSize, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { IconPicker } from '@hcengineering/view-resources'
import { Ref } from '@hcengineering/core'
@@ -30,6 +30,7 @@
export let editable: boolean = false
const client = getClient()
const hierarchy = client.getHierarchy()
const query = createQuery()
let doc: Card | undefined = value
@@ -55,9 +56,12 @@
}
$: iconData = getCardIconInfo(doc)
$: iconMixin = doc ? hierarchy.classHierarchyMixin(doc._class, view.mixin.ObjectIcon) : undefined
</script>
{#if editable}
{#if iconMixin && iconMixin._id !== card.class.Card}
<Component is={iconMixin.component} props={{ value: doc, size, editable }} />
{:else if editable}
<Button
size={buttonSize}
kind={'ghost'}
@@ -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 card, { Card, CardSpace, MasterTag } from '@hcengineering/card'
import presentation, { getClient, getCommunicationClient, SpaceSelector } from '@hcengineering/presentation'
import { createEventDispatcher } from 'svelte'
import core, { Data, generateId, Ref, Markup, notEmpty } from '@hcengineering/core'
import { getResource, translate, getEmbeddedLabel } from '@hcengineering/platform'
import { Label, Modal, ModernEditbox, languageStore, showPopup, Component } from '@hcengineering/ui'
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import { EmptyMarkup } from '@hcengineering/text'
import { Employee, getCurrentEmployee } from '@hcengineering/contact'
import { SelectUsersPopup, employeeByIdStore } from '@hcengineering/contact-resources'
import view from '@hcengineering/view'
import { createCard } from '../utils'
import CardCollaborators from './CardCollaborators.svelte'
export let type: Ref<MasterTag>
export let space: CardSpace | undefined = undefined
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
const communicationClient = getCommunicationClient()
const me = getCurrentEmployee()
const _id = generateId<Card>()
const extension = client
.getModel()
.findAllSync(card.mixin.CreateCardExtension, {})
.find((it) => hierarchy.isDerived(type, it._id))
let data: Partial<Data<Card>> = {}
let _space: Ref<CardSpace> | undefined = space?._id
let description: Markup = EmptyMarkup
let collaborators: Ref<Employee>[] = [me]
async function addCollaborators (): Promise<void> {
const accounts = collaborators
.filter((it) => it !== me)
.map((it) => $employeeByIdStore.get(it)?.personUuid)
.filter(notEmpty)
if (accounts.length > 0) {
await communicationClient.addCollaborators(_id, type, accounts)
}
}
async function okAction (): Promise<void> {
if (_space === undefined) return
if (extension?.canCreate) {
const fn = await getResource(extension.canCreate)
const res = await fn(_space, data)
if (res === false) {
dispatch('close')
return
} else if (typeof res === 'string') {
dispatch('close', res)
return
}
}
await createCard(type, _space, data, description, _id)
await addCollaborators()
dispatch('close', _id)
}
function handleCancel (): void {
dispatch('close')
}
let label: string = ''
$: void updateLabel($languageStore)
async function updateLabel (lang: string): Promise<void> {
const _clazz = hierarchy.getClass(type)
const typeString = await translate(_clazz.label, {}, lang)
const createString = await translate(presentation.string.Create, {}, lang)
label = `${createString} ${typeString}`
}
function openSelectUsersPopup (): void {
showPopup(
SelectUsersPopup,
{
okLabel: presentation.string.Ok,
disableDeselectFor: [me],
skipCurrentAccount: false,
skipInactive: true,
selected: collaborators,
showStatus: true
},
'top',
(result?: Ref<Employee>[]) => {
if (result != null) {
collaborators = result
}
}
)
}
function handleChange (event: CustomEvent<{ data: Partial<Data<Card>>, space?: Ref<CardSpace> }>): void {
data = {
...data,
...event.detail.data
}
if (event.detail.space != null) {
_space = event.detail.space
}
}
</script>
<Modal
label={getEmbeddedLabel(label)}
type="type-popup"
width="large"
okLabel={presentation.string.Create}
{okAction}
canSave={data.title != null && data.title.trim().length > 0 && _space != null}
onCancel={handleCancel}
on:close
>
<div class="hulyModal-content__titleGroup" style="padding: 0">
<ModernEditbox
bind:value={data.title}
label={view.string.Title}
size="large"
kind="ghost"
disabled={extension?.disableTitle ?? false}
autoFocus={!(extension?.disableTitle ?? false)}
/>
<AttachmentStyledBox
objectId={_id}
_class={type}
space={_space ?? space?._id}
alwaysEdit
showButtons={false}
bind:content={description}
placeholder={core.string.Description}
kind="indented"
isScrollable={false}
kitOptions={{ reference: true }}
enableAttachments={false}
/>
</div>
<div class="hulyModal-content__settingsSet">
{#if space == null && !(extension?.hideSpace ?? false)}
<div class="hulyModal-content__settingsSet-line">
<span class="label"><Label label={core.string.Space} /></span>
<SpaceSelector
_class={card.class.CardSpace}
query={{ archived: false }}
label={core.string.Space}
bind:space={_space}
focus={false}
kind={'regular'}
size={'large'}
/>
</div>
{/if}
<CardCollaborators
ids={collaborators}
disableRemoveFor={[me]}
on:add={openSelectUsersPopup}
on:remove={(ev) => {
collaborators = collaborators.filter((id) => id !== ev.detail)
}}
/>
{#if extension?.component}
<Component is={extension.component} props={{ collaborators, data, space: _space }} on:change={handleChange} />
{/if}
</div>
</Modal>
<style lang="scss">
</style>
@@ -27,6 +27,7 @@
import cardPlugin from '../../plugin'
import { CardsNavigatorConfig } from '../../types'
import { getCardIconInfo } from '../../utils'
import { CardIcon } from '../../index'
export let type: Ref<MasterTag> | undefined = undefined
export let card: Card
@@ -110,6 +111,9 @@
}}
on:contextmenu
>
<svelte:fragment slot="icon">
<CardIcon value={card} _id={card._id} size="small" editable={false} />
</svelte:fragment>
<svelte:fragment slot="actions">
{#each actions as action}
{#if action.icon}
@@ -15,15 +15,16 @@
<script lang="ts">
import view from '@hcengineering/view'
import { IconAdd, NavGroup, Action, NavItem } from '@hcengineering/ui'
import { IconAdd, NavGroup, Action, NavItem, ButtonIcon, showPopup, languageStore } from '@hcengineering/ui'
import { Ref } from '@hcengineering/core'
import { createEventDispatcher } from 'svelte'
import { CardSpace, MasterTag } from '@hcengineering/card'
import { IconWithEmoji, getClient } from '@hcengineering/presentation'
import presentation, { IconWithEmoji, getClient } from '@hcengineering/presentation'
import { translate, getEmbeddedLabel } from '@hcengineering/platform'
import type { NavigatorConfig } from '../../types'
import cardPlugin from '../../plugin'
import { createCard } from '../../utils'
import CreateCardPopup from '../CreateCardPopup.svelte'
export let type: MasterTag
export let level: number = -1
@@ -35,24 +36,30 @@
export let showIcon: boolean = false
const dispatch = createEventDispatcher()
let activeAction: string | undefined = undefined
async function handleCreateCard (): Promise<void> {
if (space === undefined) return
const _id = await createCard(type._id, space._id)
const card = await getClient().findOne(cardPlugin.class.Card, { _id })
if (card === undefined) return
dispatch('selectCard', card)
showPopup(CreateCardPopup, { type: type._id, space }, 'center', async (result) => {
if (result !== undefined) {
const card = await getClient().findOne(cardPlugin.class.Card, { _id: result })
if (card === undefined) return
dispatch('selectCard', card)
}
})
}
function getActions (): Action[] {
async function getActions (lang: string): Promise<Action[]> {
const result: Action[] = []
const typeString = await translate(type.label, {}, lang)
const createString = await translate(presentation.string.Create, {}, lang)
if (config.allowCreate === true && space !== undefined) {
if (config.allowCreate === true) {
result.push({
id: 'create-card',
label: cardPlugin.string.CreateCard,
label: getEmbeddedLabel(`${createString} ${typeString}`),
icon: IconAdd,
action: async (): Promise<void> => {
activeAction = 'create-card'
await handleCreateCard()
}
})
@@ -60,6 +67,11 @@
return result
}
let actions: Action[] = []
$: void getActions($languageStore).then((res) => {
actions = res
})
</script>
{#if level > -1}
@@ -95,7 +107,7 @@
isFold
visible={active}
type="selectable-header"
actions={getActions()}
{actions}
on:click={(e) => {
e.stopPropagation()
e.preventDefault()
@@ -104,6 +116,22 @@
>
<div class="mt-0-5" />
<slot />
<svelte:fragment slot="actions">
{#each actions as action}
<ButtonIcon
icon={action.icon ?? view.icon.Edit}
size="extra-small"
kind="tertiary"
pressed={activeAction === action.id}
tooltip={{ label: action.label }}
on:click={(e) => {
e.stopPropagation()
e.preventDefault()
void action.action(action.props, e)
}}
/>
{/each}
</svelte:fragment>
<svelte:fragment slot="visible" let:isOpen>
<div class="mt-0-5" />
<slot name="visible" {isOpen} />
+2 -1
View File
@@ -99,6 +99,7 @@ export default mergeIds(cardId, card, {
CreateSpace: '' as IntlString,
NumberTypes: '' as IntlString,
Properties: '' as IntlString,
NoChildren: '' as IntlString
NoChildren: '' as IntlString,
AddCollaborators: '' as IntlString
}
})
+1 -1
View File
@@ -20,7 +20,7 @@ interface BaseNavigatorConfig {
types: Array<Ref<MasterTag>>
groupBySpace?: boolean
savedViews?: boolean
allowCreate?: boolean // for now works only when groupBySpace is true
allowCreate?: boolean
preorder?: Array<{ type: Ref<MasterTag>, order: number }>
}
+31 -9
View File
@@ -19,7 +19,10 @@ import core, {
type Doc,
type DocumentQuery,
fillDefaults,
generateId,
type Hierarchy,
makeCollabId,
type Markup,
type MarkupBlobRef,
type Ref,
type RelatedDocument,
@@ -28,7 +31,13 @@ import core, {
type TxOperations,
type WithLookup
} from '@hcengineering/core'
import { getClient, IconWithEmoji, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation'
import {
createMarkup,
getClient,
IconWithEmoji,
MessageBox,
type ObjectSearchResult
} from '@hcengineering/presentation'
import {
getCurrentResolvedLocation,
getPanelURI,
@@ -45,6 +54,7 @@ import { translate } from '@hcengineering/platform'
import { makeRank } from '@hcengineering/rank'
import { Analytics } from '@hcengineering/analytics'
import { createWidgetTab } from '@hcengineering/workbench-resources'
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
import CardSearchItem from './components/CardSearchItem.svelte'
import CreateSpace from './components/navigator/CreateSpace.svelte'
@@ -189,23 +199,35 @@ const toCardObjectSearchResult = (e: WithLookup<Card>): ObjectSearchResult => ({
component: CardSearchItem
})
export async function createCard (type: Ref<MasterTag>, space: Ref<Space>): Promise<Ref<Card>> {
export async function createCard (
type: Ref<MasterTag>,
space: Ref<Space>,
data: Partial<Data<Card>> = {},
contentMarkup: Markup = EmptyMarkup,
id?: Ref<Card>
): Promise<Ref<Card>> {
const client = getClient()
const hierarchy = client.getHierarchy()
const lastOne = await client.findOne(card.class.Card, {}, { sort: { rank: SortingOrder.Descending } })
const title = await translate(card.string.Card, {})
const title = data.title ?? (await translate(card.string.Card, {}))
const data: Data<Card> = {
const _id = id ?? generateId()
const content = isEmptyMarkup(contentMarkup)
? ('' as MarkupBlobRef)
: await createMarkup(makeCollabId(type, _id, 'content'), contentMarkup)
const _data: Data<Card> = {
parentInfo: [],
blobs: {},
...data,
title,
rank: makeRank(lastOne?.rank, undefined),
content: '' as MarkupBlobRef,
parentInfo: [],
blobs: {}
content
}
const filledData = fillDefaults(hierarchy, data, type)
const filledData = fillDefaults(hierarchy, _data, type)
const _id = await client.createDoc(type, space, filledData)
await client.createDoc(type, space, filledData, _id)
Analytics.handleEvent(CardEvents.CardCreated)
return _id
+13 -1
View File
@@ -16,6 +16,7 @@ import {
Blobs,
Class,
CollectionSize,
Data,
Doc,
Domain,
MarkupBlobRef,
@@ -98,6 +99,16 @@ export interface FavoriteCard extends Preference {
application: string
}
export interface CreateCardExtension extends MasterTag {
component?: AnyComponent
canCreate?: CanCreateCardResource
disableTitle?: boolean
hideSpace?: boolean
}
export type CanCreateCardFn = (space: Ref<Space>, data: Partial<Data<Card>>) => Promise<boolean | Ref<Card>>
export type CanCreateCardResource = Resource<CanCreateCardFn>
/**
* @public
*/
@@ -120,7 +131,8 @@ const cardPlugin = plugin(cardId, {
FavoriteCard: '' as Ref<Class<FavoriteCard>>
},
mixin: {
CardViewDefaults: '' as Ref<Mixin<CardViewDefaults>>
CardViewDefaults: '' as Ref<Mixin<CardViewDefaults>>,
CreateCardExtension: '' as Ref<Mixin<CreateCardExtension>>
},
space: {
Default: '' as Ref<CardSpace>
+1
View File
@@ -43,6 +43,7 @@
"@hcengineering/card-resources": "^0.6.0",
"@hcengineering/chat": "^0.6.0",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/communication": "^0.6.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/core": "^0.6.32",
@@ -19,6 +19,7 @@
import { SubscriptionLabelID } from '@hcengineering/communication-types'
import chat, { chatId } from '@hcengineering/chat'
import { Navigator } from '@hcengineering/card-resources'
import communication from '@hcengineering/communication'
export let card: Card | undefined = undefined
export let type: Ref<MasterTag> | undefined = undefined
@@ -36,11 +37,16 @@
labelFilter: [SubscriptionLabelID],
preorder: [
{ type: chat.masterTag.Thread, order: 1 },
{ type: chat.masterTag.Channel, order: 2 }
{ type: chat.masterTag.Channel, order: 2 },
{ type: communication.type.Direct, order: 3 }
],
fixedTypes: [chat.masterTag.Thread, chat.masterTag.Channel],
fixedTypes: [chat.masterTag.Thread, chat.masterTag.Channel, communication.type.Direct],
specialSorting: {
[communication.type.Direct]: 'alphabetical'
},
allowCreate: true,
defaultSorting: 'recent',
lookback: '1w',
lookback: '2w',
showTypeIcon: false,
showCardIcon: true
}}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "Hlasováno",
"VotedFor": "Hlasováno pro",
"RevokedVote": "Zrušeno hlasování",
"AnonymousQuiz": "Anonymní test"
"AnonymousQuiz": "Anonymní test",
"Direct": "Přímý",
"Directs": "Přímé",
"Members": "Členové"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "Abgestimmt",
"VotedFor": "Für abgestimmt",
"RevokedVote": "Stimme widerrufen",
"AnonymousQuiz": "Anonyme quiz"
"AnonymousQuiz": "Anonyme quiz",
"Direct": "Direkt",
"Directs": "Direkte",
"Members": "Mitglieder"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "Voted",
"VotedFor": "Voted for",
"RevokedVote": "Revoked vote",
"AnonymousQuiz": "Anonymous quiz"
"AnonymousQuiz": "Anonymous quiz",
"Direct": "Direct",
"Directs": "Directs",
"Members": "Members"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "Votado",
"VotedFor": "Votado por",
"RevokedVote": "Revocar voto",
"AnonymousQuiz": "Quiz anónimo"
"AnonymousQuiz": "Quiz anónimo",
"Direct": "Directo",
"Directs": "Directos",
"Members": "Miembros"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "A voté",
"VotedFor": "A voté pour",
"RevokedVote": "Révoquer le vote",
"AnonymousQuiz": "Quiz anonyme"
"AnonymousQuiz": "Quiz anonyme",
"Direct": "Direct",
"Directs": "Directs",
"Members": "Membres"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "Votato",
"VotedFor": "Votato per",
"RevokedVote": "Revoca il voto",
"AnonymousQuiz": "Quiz anonimo"
"AnonymousQuiz": "Quiz anonimo",
"Direct": "Diretto",
"Directs": "Diretti",
"Members": "Membri"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "投票済み",
"VotedFor": "投票したユーザ",
"RevokedVote": "投票を取り消し",
"AnonymousQuiz": "匿名クイズ"
"AnonymousQuiz": "匿名クイズ",
"Direct": "ダイレクト",
"Directs": "ダイレクト",
"Members": "メンバー"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "Votado",
"VotedFor": "Votado para",
"RevokedVote": "Revogar voto",
"AnonymousQuiz": "Quiz anônimo"
"AnonymousQuiz": "Quiz anônimo",
"Direct": "Direto",
"Directs": "Diretos",
"Members": "Membros"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "Проголосовавшие",
"VotedFor": "Голосовал(а) за",
"RevokedVote": "Отменил(а) голос",
"AnonymousQuiz": "Анонимная викторина"
"AnonymousQuiz": "Анонимная викторина",
"Direct": "Личные сообщения",
"Directs": "Личные сообщения",
"Members": "Участники"
}
}
+4 -1
View File
@@ -85,6 +85,9 @@
"Voted": "已投票",
"VotedFor": "投票于",
"RevokedVote": "撤销投票",
"AnonymousQuiz": "匿名测验"
"AnonymousQuiz": "匿名测验",
"Direct": "私信",
"Directs": "私信",
"Members": "成员"
}
}
@@ -121,9 +121,7 @@ export async function attachCardToMessage (
},
type
)
const apply = client.apply('create thread', undefined, true)
await apply.createDoc(type, parentCard.space, data, threadCardID)
await apply.commit()
await client.createDoc(type, parentCard.space, data, threadCardID)
if (author?.active === true && author?.personUuid !== undefined) {
await communicationClient.addCollaborators(threadCardID, type, [author.personUuid])
@@ -0,0 +1,56 @@
<!-- 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 { createEventDispatcher, onMount } from 'svelte'
import { Data, notEmpty, Ref } from '@hcengineering/core'
import { Employee, formatName, getCurrentEmployee, getCurrentEmployeeSpace } from '@hcengineering/contact'
import { Direct } from '@hcengineering/communication'
import { employeeByIdStore } from '@hcengineering/contact-resources'
export let collaborators: Ref<Employee>[] = []
export let data: Partial<Data<Direct>>
const dispatch = createEventDispatcher()
$: updateData(collaborators)
function updateData (collaborators: Ref<Employee>[]): void {
const title = getTitle(collaborators)
if (data.title === title) return
dispatch('change', { data: { title, members: collaborators } })
}
function getTitle (ids: Ref<Employee>[]): string {
const me = getCurrentEmployee()
const employees = ids.map((id) => $employeeByIdStore.get(id)).filter(notEmpty)
if (employees.length === 1) {
return employees.map((e) => formatName(e.name)).join(', ')
} else {
return employees
.filter((it) => it._id !== me)
.map((e) => formatName(e.name))
.join(', ')
}
}
onMount(() => {
const space = getCurrentEmployeeSpace()
const members = collaborators
const title = getTitle(collaborators)
dispatch('change', { data: { members, title }, space })
})
</script>
@@ -0,0 +1,183 @@
<!-- 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 { Direct } from '@hcengineering/communication'
import { Icon, IconSize } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import contact, { Employee, getCurrentEmployee, Person } from '@hcengineering/contact'
import { classIcon } from '@hcengineering/view-resources'
import { Ref } from '@hcengineering/core'
import { Avatar, employeeByIdStore, getPersonByPersonRef } from '@hcengineering/contact-resources'
import communication from '../plugin'
export let value: Direct | undefined = undefined
export let size: IconSize = 'small'
export let showStatus = true
const visiblePersons = 4
const client = getClient()
const me = getCurrentEmployee()
let persons: Person[] = []
$: void updatePersons(value?.members ?? [], $employeeByIdStore)
async function updatePersons (members: Ref<Person>[], employeeById: Map<Ref<Employee>, Employee>): Promise<void> {
const res: Person[] = []
for (const member of members) {
const p = employeeById.get(member as Ref<Employee>)
if (p !== undefined) {
res.push(p)
} else {
const person = await getPersonByPersonRef(member)
if (person != null) {
res.push(person)
}
}
}
persons = res
}
let avatarSize = size
$: if (size === 'small') {
avatarSize = 'x-small'
} else if (size === 'medium') {
avatarSize = 'small'
} else if (size === 'large') {
avatarSize = 'medium'
}
$: withoutMe = persons.filter((it) => it._id !== me)
</script>
{#if persons.length === 0}
<Icon icon={classIcon(client, communication.type.Direct) ?? contact.icon.Contacts} {size} />
{:else if withoutMe.length === 1 || persons.length === 1}
{@const p = withoutMe[0] ?? persons[0]}
<Avatar person={p} size={avatarSize} name={p.name} {showStatus} />
{:else if persons.length > 1 && (size === 'medium' || avatarSize === 'medium')}
<div class="group">
{#each persons.slice(0, visiblePersons - 1) as person}
<Avatar {person} size="tiny" name={person.name} />
{/each}
{#if persons.length === visiblePersons}
{@const person = persons[persons.length - 1]}
<Avatar {person} size="tiny" name={person.name} />
{:else if persons.length > visiblePersons}
<div class="rect">
+{persons.length - visiblePersons + 1}
</div>
{:else if persons.length < visiblePersons}
{#each Array(visiblePersons - persons.length) as _}
<div class="rect" />
{/each}
{/if}
</div>
{:else if persons.length > 1}
<span class="relative">
<svg width="0" height="0" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="direct-count-marker" clipPathUnits="objectBoundingBox">
<path d="M1,0 H0 V1 H0.48 V0.73 C0.48,0.58 0.58,0.48 0.73,0.48 H1 Z" />
</clipPath>
</defs>
</svg>
<Avatar
person={persons[0]}
size={avatarSize}
name={persons[0].name}
showStatus={false}
clipPath="url(#direct-count-marker)"
/>
<span class="persons-count {avatarSize}">
{#if persons.length > 9}
9+
{:else}
{persons.length}
{/if}
</span>
</span>
{/if}
<style lang="scss">
.group {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
height: 2.5rem;
width: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
border: 1px solid transparent;
border-radius: 0.5rem;
}
.rect {
display: flex;
align-items: center;
justify-content: center;
width: 1.13rem;
height: 1.13rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background-color: var(--theme-button-hovered);
font-size: 0.688rem;
font-weight: 500;
}
.group-ava {
color: var(--theme-caption-color);
background-color: var(--theme-bg-color);
border: 1px solid var(--theme-divider-color);
border-radius: 0.25rem;
opacity: 0.9;
&.inline,
&.tiny,
&.card,
&.x-small {
font-size: 0.625rem;
}
}
.persons-count {
position: absolute;
right: -0.188rem;
bottom: -0.188rem;
width: 0.938rem;
height: 0.938rem;
background: var(--avatar-counter-BackgroundColor);
border: 1px solid var(--global-ui-BorderColor);
font-size: 0.625rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.313rem;
color: var(--global-secondary-TextColor);
font-weight: 500;
&.medium {
right: -0.313rem;
bottom: -0.313rem;
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
border-radius: 0.5rem;
}
}
</style>
+8 -3
View File
@@ -21,8 +21,10 @@ 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 DirectIcon from './components/DirectIcon.svelte'
import CreateDirect from './components/CreateDirect.svelte'
import { unsubscribe, subscribe, canSubscribe, canUnsubscribe } from './utils'
import { unsubscribe, subscribe, canSubscribe, canUnsubscribe, canCreateDirect } from './utils'
import {
addReaction,
canCreateCard,
@@ -49,7 +51,9 @@ export { default as ActivityMessageViewer } from './components/message/ActivityM
export default async (): Promise<Resources> => ({
component: {
CardMessagesSection
CardMessagesSection,
DirectIcon,
CreateDirect
},
poll: {
PollPresenter,
@@ -81,6 +85,7 @@ export default async (): Promise<Resources> => ({
CanShowOriginalMessage: canShowOriginalMessage,
CanEditMessage: canEditMessage,
CanRemoveMessage: canRemoveMessage,
CanCreateCard: canCreateCard
CanCreateCard: canCreateCard,
CanCreateDirect: canCreateDirect
}
})
+10 -3
View File
@@ -22,10 +22,13 @@ import communication, {
} from '@hcengineering/communication'
import { type AnyComponent } from '@hcengineering/ui'
import { type Ref } from '@hcengineering/core'
import { type CanCreateCardResource } from '@hcengineering/card'
export default mergeIds(communicationId, communication, {
component: {
CardMessagesSection: '' as AnyComponent
CardMessagesSection: '' as AnyComponent,
DirectIcon: '' as AnyComponent,
CreateDirect: '' as AnyComponent
},
poll: {
PollPresenter: '' as AnyComponent,
@@ -81,7 +84,10 @@ export default mergeIds(communicationId, communication, {
EditMessage: '' as IntlString,
RemoveMessage: '' as IntlString,
CreateCard: '' as IntlString,
MessageAlreadyHasCardAttached: '' as IntlString
MessageAlreadyHasCardAttached: '' as IntlString,
Direct: '' as IntlString,
Directs: '' as IntlString,
Members: '' as IntlString
},
messageActionImpl: {
AddReaction: '' as MessageActionFunctionResource,
@@ -107,6 +113,7 @@ export default mergeIds(communicationId, communication, {
CanShowOriginalMessage: '' as MessageActionVisibilityTesterResource,
CanEditMessage: '' as MessageActionVisibilityTesterResource,
CanRemoveMessage: '' as MessageActionVisibilityTesterResource,
CanCreateCard: '' as MessageActionVisibilityTesterResource
CanCreateCard: '' as MessageActionVisibilityTesterResource,
CanCreateDirect: '' as CanCreateCardResource
}
})
+37 -2
View File
@@ -11,9 +11,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { canDisplayLinkPreview, fetchLinkPreviewDetails, getCommunicationClient } from '@hcengineering/presentation'
import {
canDisplayLinkPreview,
fetchLinkPreviewDetails,
getClient,
getCommunicationClient
} from '@hcengineering/presentation'
import { type Card } from '@hcengineering/card'
import { AccountRole, getCurrentAccount, type Markup } from '@hcengineering/core'
import { AccountRole, type Data, getCurrentAccount, type Ref, type Space, type Markup } from '@hcengineering/core'
import { getMetadata, translate } from '@hcengineering/platform'
import { addNotification, languageStore, NotificationSeverity, showPopup } from '@hcengineering/ui'
import { type LinkPreviewParams, type Message } from '@hcengineering/communication-types'
@@ -28,6 +33,8 @@ import { type TextInputAction } from './types'
import { guestCommunicationAllowedCards } from './stores'
import { get } from 'svelte/store'
import view from '@hcengineering/view'
import { type Direct } from '@hcengineering/communication'
import { type Employee } from '@hcengineering/contact'
export async function unsubscribe (card: Card): Promise<void> {
const client = getCommunicationClient()
@@ -160,3 +167,31 @@ export async function showForbidden (): Promise<void> {
NotificationSeverity.Info
)
}
export async function canCreateDirect (space: Ref<Space>, data: Partial<Data<Direct>>): Promise<boolean | Ref<Card>> {
const members = data.members ?? []
if (members.length === 0) return false
const client = getClient()
if (members.length > 2) {
return true
}
const myDirects = await client.findAll<Direct>(communication.type.Direct, { space })
const direct = myDirects.find((it) => {
const directMembers = new Set(it.members)
const createMembers = new Set(members)
if (directMembers.size !== createMembers.size) return false
for (const item of directMembers) {
if (!createMembers.has(item as Ref<Employee>)) return false
}
return true
})
if (direct != null) {
return direct._id
}
return true
}
+21
View File
@@ -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.
import { Card } from '@hcengineering/card'
import { Ref } from '@hcengineering/core'
import { Person } from '@hcengineering/contact'
export interface Direct extends Card {
// TODO: Do we neet it? Can we just reuse collaborators?
members?: Ref<Person>[]
}
+2 -1
View File
@@ -33,7 +33,8 @@ export default plugin(communicationId, {
GuestCommunicationSettings: '' as Ref<Class<GuestCommunicationSettings>>
},
type: {
Poll: '' as Ref<MasterTag>
Poll: '' as Ref<MasterTag>,
Direct: '' as Ref<MasterTag>
},
icon: {
Bell: '' as Asset,
+34
View File
@@ -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.
import { AttachedDoc, Ref } from '@hcengineering/core'
import { PersonSpace } from '@hcengineering/contact'
import { AccountID, MessageID } from '@hcengineering/communication-types'
import { Card } from '@hcengineering/card'
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[]
}
+3
View File
@@ -25,6 +25,9 @@ import { Card, MasterTag } from '@hcengineering/card'
import { AnyComponent } from '@hcengineering/ui'
import { PersonSpace } from '@hcengineering/contact'
export * from './poll'
export * from './direct'
export enum MessagesNavigationAnchors {
ConversationStart = 'conversationStart',
LatestMessages = 'latestMessages'
@@ -59,6 +59,7 @@
export let disabled: boolean = false
export let style: 'modern' | undefined = undefined
export let clickable: boolean = false
export let clipPath: string | undefined = undefined
export function pulse (): void {
avatarInst.pulse()
@@ -141,6 +142,7 @@
{disabled}
{style}
withStatus
{clipPath}
/>
<div
class="hulyAvatar-statusMarker {statusSize ?? size} {style}"
@@ -163,6 +165,7 @@
{adaptiveName}
{disabled}
{style}
{clipPath}
/>
{/if}
</div>
@@ -34,6 +34,7 @@
export let adaptiveName: boolean = false
export let disabled: boolean = false
export let style: 'modern' | undefined = undefined
export let clipPath: string | undefined = undefined
function handleClick (): void {
dispatch('click')
@@ -79,6 +80,7 @@
class:withStatus
style:--border-color={bColor ?? 'var(--primary-button-default)'}
style:background-color={background}
style:clip-path={clipPath}
use:resizeObserver={(element) => {
fontSize = element.clientWidth * 0.6
}}
@@ -103,6 +105,7 @@
class:withStatus
style:--border-color={bColor ?? 'var(--primary-button-default)'}
style:background-color={background}
style:clip-path={clipPath}
on:click={handleClick}
>
{#if url && !imgError}
+2
View File
@@ -119,6 +119,8 @@ export function start (
findCollaborators: async () => [],
findNotifications: async () => [],
findLabels: async () => [],
findThreads: async () => [],
findPeers: async () => [],
unsubscribeQuery: async () => {},
event: async () => {
return {}
@@ -31,7 +31,7 @@ export async function generateActivity (
): Promise<void> {
const { hierarchy } = control
if (tx.space === core.space.DerivedTx) return
// if (tx.space === core.space.DerivedTx) return
if (
hierarchy.isDerived(tx.objectClass, activity.class.ActivityMessage) ||
@@ -43,6 +43,9 @@ export async function generateActivity (
switch (tx._class) {
case core.class.TxCreateDoc: {
const card = TxProcessor.createDoc2Doc(tx as TxCreateDoc<Card>)
if (card._class === 'chat:masterTag:Thread') {
break
}
await createMessages(tx, control, card)
break
}
@@ -43,7 +43,9 @@
"@hcengineering/core": "^0.6.32",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-core": "^0.6.1",
"@hcengineering/communication-types": "^0.1.0",
"@hcengineering/communication-sdk-types": "^0.1.0",
"@hcengineering/communication": "^0.6.0",
"@hcengineering/server-contact": "^0.6.1",
"@hcengineering/contact": "^0.6.24"
}
+200 -3
View File
@@ -20,10 +20,13 @@ import core, {
Data,
Doc,
fillDefaults,
generateId,
getDiffUpdate,
Mixin,
notEmpty,
OperationDomain,
Ref,
Space,
splitMixinUpdate,
Tx,
TxCreateDoc,
@@ -39,11 +42,17 @@ import {
AddCollaboratorsEvent,
CardEventType,
NotificationEventType,
PeerEventType,
RemoveCardEvent,
UpdateCardTypeEvent
UpdateCardTypeEvent,
CreatePeerEvent,
ThreadPatchEvent,
MessageEventType
} from '@hcengineering/communication-sdk-types'
import { getEmployee, getPersonSpaces } from '@hcengineering/server-contact'
import contact from '@hcengineering/contact'
import contact, { Employee, Person } from '@hcengineering/contact'
import communication, { Direct } from '@hcengineering/communication'
import { CardPeer } from '@hcengineering/communication-types'
async function OnAttribute (ctx: TxCreateDoc<AnyAttribute>[], control: TriggerControl): Promise<Tx[]> {
const attr = TxProcessor.createDoc2Doc(ctx[0])
@@ -400,6 +409,192 @@ async function updateParentInfoName (
return res
}
async function OnThreadCreate (ctx: TxCreateDoc<Card>[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
for (const tx of ctx) {
if (tx.space === core.space.DerivedTx) continue
const doc = TxProcessor.createDoc2Doc(tx)
const parent = doc.parentInfo?.[0]
if (parent == null) continue
if (!control.hierarchy.isDerived(parent._class, communication.type.Direct)) continue
const direct = (await control.findAll(control.ctx, parent._class, { _id: parent._id }, { limit: 1 }))[0] as Direct
if (direct == null) continue
res.push(...(await createThreadCardPeers(direct, doc, control)))
}
return res
}
async function createThreadCardPeers (direct: Direct, doc: Card, control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
const cardIds = new Map<Ref<Card>, Ref<Space>>([[doc._id, doc.space]])
const members = direct.members ?? []
if (members.length === 0) return []
const thread = (
await control.domainRequest(control.ctx, 'communication' as OperationDomain, {
findThreads: { params: { threadId: doc._id } }
})
).value[0]
if (thread === undefined) return []
const messageId = thread.messageId
const directPeer = (
(
await control.domainRequest(control.ctx, 'communication' as OperationDomain, {
findPeers: { params: { kind: 'card', cardId: direct._id } }
})
).value as CardPeer[]
)[0]
const personSpaces = (await getPersonSpaces(control)).filter(
(it) => it._id !== doc.space && members.includes(it.person)
)
if (personSpaces.length === 0) return []
const accounts = (
await control.findAll(control.ctx, contact.mixin.Employee, {
_id: { $in: personSpaces.map((it) => it.person) as Ref<Employee>[] }
})
)
.map((it) => it.personUuid)
.filter(notEmpty)
if (accounts.length === 0) return []
// TODO: create directs in person_workspace
for (const personSpace of personSpaces) {
const _id = generateId<Card>()
const _class = doc._class
cardIds.set(_id, personSpace._id)
res.push(
control.txFactory.createTxCreateDoc(
_class,
personSpace._id,
{
...doc
},
_id
)
)
const parentDirect = directPeer?.members?.find((m) => m.extra?.space === personSpace._id)
if (parentDirect !== undefined) {
const threadPatchEvent: ThreadPatchEvent = {
type: MessageEventType.ThreadPatch,
cardId: parentDirect.cardId,
messageId,
operation: {
opcode: 'attach',
threadId: _id,
threadType: _class
},
socialId: doc.modifiedBy
}
await control.domainRequest(control.ctx, 'communication' as OperationDomain, {
event: threadPatchEvent
})
}
}
if (cardIds.size > 1) {
const group = generateId()
for (const [cardId, spaceId] of cardIds.entries()) {
const event: CreatePeerEvent = {
type: PeerEventType.CreatePeer,
workspaceId: control.workspace.uuid, // TODO: person_workspace
cardId,
kind: 'card',
value: group,
extra: { space: spaceId },
date: new Date(doc.modifiedOn)
}
await control.domainRequest(control.ctx, 'communication' as OperationDomain, { event })
}
}
return res
}
async function createDirectCardPeers (doc: Card, members: Ref<Person>[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
const cardIds = new Map<Ref<Card>, Ref<Space>>([[doc._id, doc.space]])
if (members.length === 0) return []
const personSpaces = (await getPersonSpaces(control)).filter(
(it) => it._id !== doc.space && members.includes(it.person)
)
if (personSpaces.length === 0) return []
const accounts = (
await control.findAll(control.ctx, contact.mixin.Employee, {
_id: { $in: personSpaces.map((it) => it.person) as Ref<Employee>[] }
})
)
.map((it) => it.personUuid)
.filter(notEmpty)
if (accounts.length === 0) return []
// TODO: create directs in person_workspace
for (const personSpace of personSpaces) {
const _id = generateId<Card>()
const _class = doc._class
cardIds.set(_id, personSpace._id)
res.push(
control.txFactory.createTxCreateDoc(
_class,
personSpace._id,
{
...doc
},
_id
)
)
const event: AddCollaboratorsEvent = {
type: NotificationEventType.AddCollaborators,
cardId: _id,
cardType: _class,
collaborators: accounts,
socialId: doc.modifiedBy,
date: new Date(doc.modifiedOn + 1)
}
await control.domainRequest(control.ctx, 'communication' as OperationDomain, { event })
}
if (cardIds.size > 1) {
const group = generateId()
for (const [cardId, spaceId] of cardIds.entries()) {
const event: CreatePeerEvent = {
type: PeerEventType.CreatePeer,
workspaceId: control.workspace.uuid, // TODO: person_workspace
cardId,
kind: 'card',
value: group,
extra: { space: spaceId },
date: new Date(doc.modifiedOn)
}
await control.domainRequest(control.ctx, 'communication' as OperationDomain, { event })
}
}
return res
}
async function OnDirectCreate (ctx: TxCreateDoc<Direct>[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
for (const tx of ctx) {
if (tx.space === core.space.DerivedTx) continue
const doc = TxProcessor.createDoc2Doc(tx)
const members = doc.members ?? []
res.push(...(await createDirectCardPeers(doc, members, control)))
}
return res
}
async function OnCardCreate (ctx: TxCreateDoc<Card>[], control: TriggerControl): Promise<Tx[]> {
const createTx = ctx[0]
const doc = TxProcessor.createDoc2Doc(createTx)
@@ -515,6 +710,8 @@ export default async () => ({
OnCardRemove,
OnCardCreate,
OnCardUpdate,
OnCardTag
OnCardTag,
OnDirectCreate,
OnThreadCreate
}
})
+2
View File
@@ -33,6 +33,8 @@ export default plugin(serverCardId, {
OnTagRemove: '' as Resource<TriggerFunc>,
OnMasterTagRemove: '' as Resource<TriggerFunc>,
OnCardCreate: '' as Resource<TriggerFunc>,
OnDirectCreate: '' as Resource<TriggerFunc>,
OnThreadCreate: '' as Resource<TriggerFunc>,
OnCardUpdate: '' as Resource<TriggerFunc>,
OnCardTag: '' as Resource<TriggerFunc>,
OnCardRemove: '' as Resource<TriggerFunc>
@@ -129,10 +129,18 @@ export class CommunicationMiddleware extends BaseMiddleware implements Middlewar
const { params } = args.findLabels
return await this.communicationApi.findLabels(ctx, params)
}
if (args.findThreads !== undefined) {
const { params } = args.findThreads
return await this.communicationApi.findThreads(ctx, params)
}
if (args.findCollaborators !== undefined) {
const { params } = args.findCollaborators
return await this.communicationApi.findCollaborators(ctx, params)
}
if (args.findPeers !== undefined) {
const { params } = args.findPeers
return await this.communicationApi.findPeers(ctx, params)
}
if (args.unsubscribeQuery !== undefined) {
const { id } = args.unsubscribeQuery
await this.communicationApi.unsubscribeQuery(ctx, id)