diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index 9079933f47..7636528b71 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -425,7 +425,7 @@ export function createModel (builder: Builder, options = { addApplication: true textProvider: chunter.function.GetLink }, label: chunter.string.CopyLink, - icon: chunter.icon.Thread, + icon: chunter.icon.Copy, keyBinding: [], input: 'none', category: chunter.category.Chunter, @@ -693,6 +693,14 @@ export function createModel (builder: Builder, options = { addApplication: true ofMessage: activity.class.ActivityInfoMessage, components: [{ kind: 'footer', component: chunter.component.Replies }] }) + + builder.mixin(chunter.class.Channel, core.class.Class, chunter.mixin.ObjectChatPanel, { + ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description'] + }) + + builder.mixin(chunter.class.DirectMessage, core.class.Class, chunter.mixin.ObjectChatPanel, { + ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description'] + }) } export default chunter diff --git a/models/server-chunter/src/index.ts b/models/server-chunter/src/index.ts index f929ed86d1..a4a889629a 100644 --- a/models/server-chunter/src/index.ts +++ b/models/server-chunter/src/index.ts @@ -58,6 +58,14 @@ export function createModel (builder: Builder): void { trigger: serverChunter.trigger.ChunterTrigger }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverChunter.trigger.OnChannelMembersChanged, + txMatch: { + _class: core.class.TxUpdateDoc, + objectClass: chunter.class.Channel + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverChunter.trigger.OnDirectMessageSent, txMatch: { diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index a910396d49..34e425d5b2 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -13,3 +13,13 @@ export function groupByArray (array: T[], keyProvider: (item: T) => K): Ma return result } + +export function flipSet (set: Set, item: T): Set { + if (set.has(item)) { + set.delete(item) + } else { + set.add(item) + } + + return set +} diff --git a/packages/presentation/lang/en.json b/packages/presentation/lang/en.json index 48dbcb2140..71bc55b089 100644 --- a/packages/presentation/lang/en.json +++ b/packages/presentation/lang/en.json @@ -29,6 +29,7 @@ "MakePrivate": "Make private", "MakePrivateDescription": "Only members can see it", "Created": "Created", - "NoResults": "No results to show" + "NoResults": "No results to show", + "Next": "Next" } } diff --git a/packages/presentation/lang/ru.json b/packages/presentation/lang/ru.json index 1c72ee82b7..42aecb5748 100644 --- a/packages/presentation/lang/ru.json +++ b/packages/presentation/lang/ru.json @@ -29,6 +29,7 @@ "MakePrivate": "Сделать личным", "MakePrivateDescription": "Только пользователи могут видеть это", "Created": "Созданные", - "NoResults": "Нет результатов" + "NoResults": "Нет результатов", + "Next": "Далее" } } diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 3d4f0b1067..69ba828440 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -70,7 +70,8 @@ export default plugin(presentationId, { MakePrivateDescription: '' as IntlString, OpenInANewTab: '' as IntlString, Created: '' as IntlString, - NoResults: '' as IntlString + NoResults: '' as IntlString, + Next: '' as IntlString }, metadata: { RequiredVersion: '' as Metadata, diff --git a/packages/theme/styles/_layouts.scss b/packages/theme/styles/_layouts.scss index df22cc5ac8..5215aa7acb 100644 --- a/packages/theme/styles/_layouts.scss +++ b/packages/theme/styles/_layouts.scss @@ -749,6 +749,10 @@ input.search { .square-36 { width: 2.25rem; height: 2.25rem; } /* --------- */ +.svg-xx-small { + width: .5rem; + height: .5rem; +} .svg-tiny { width: .75rem; height: .75rem; @@ -789,7 +793,7 @@ input.search { width: inherit; height: inherit; } -.svg-card, .svg-x-small, .svg-small, .svg-medium, .svg-large, .svg-x-large { flex-shrink: 0; } +.svg-card, .svg-xx-small, .svg-x-small, .svg-small, .svg-medium, .svg-large, .svg-x-large { flex-shrink: 0; } .svg-mask { position: absolute; diff --git a/packages/theme/styles/components.scss b/packages/theme/styles/components.scss index 89bcb7a4e5..0256035704 100644 --- a/packages/theme/styles/components.scss +++ b/packages/theme/styles/components.scss @@ -184,6 +184,11 @@ .hulyModal-container { height: 100%; border-top: 1px solid transparent; + visibility: visible; + + &.hidden { + visibility: hidden; + } .hulyModal-content { height: 100%; diff --git a/packages/ui/src/components/ButtonBase.svelte b/packages/ui/src/components/ButtonBase.svelte index 77276c4f2f..813c25f851 100644 --- a/packages/ui/src/components/ButtonBase.svelte +++ b/packages/ui/src/components/ButtonBase.svelte @@ -14,7 +14,7 @@ --> + + + + + diff --git a/plugins/contact-resources/src/components/icons/AddMember.svelte b/plugins/contact-resources/src/components/icons/AddMember.svelte new file mode 100644 index 0000000000..d9d3b1eb6d --- /dev/null +++ b/plugins/contact-resources/src/components/icons/AddMember.svelte @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/plugins/contact-resources/src/index.ts b/plugins/contact-resources/src/index.ts index 591d15c00f..fc9e28a0e1 100644 --- a/plugins/contact-resources/src/index.ts +++ b/plugins/contact-resources/src/index.ts @@ -99,6 +99,10 @@ import TxNameChange from './components/activity/TxNameChange.svelte' import NameChangedActivityMessage from './components/activity/NameChangedActivityMessage.svelte' import SystemAvatar from './components/SystemAvatar.svelte' import PersonIcon from './components/PersonIcon.svelte' +import UsersList from './components/UsersList.svelte' +import SelectUsersPopup from './components/SelectUsersPopup.svelte' +import IconAddMember from './components/icons/AddMember.svelte' +import UserDetails from './components/UserDetails.svelte' import contact from './plugin' import { @@ -160,7 +164,11 @@ export { MembersBox, PersonRefPresenter, SystemAvatar, - PersonIcon + PersonIcon, + UsersList, + SelectUsersPopup, + IconAddMember, + UserDetails } const toObjectSearchResult = (e: WithLookup): ObjectSearchResult => ({ diff --git a/plugins/contact/src/index.ts b/plugins/contact/src/index.ts index d62f794083..2aef4f282f 100644 --- a/plugins/contact/src/index.ts +++ b/plugins/contact/src/index.ts @@ -269,7 +269,8 @@ export const contactPlugin = plugin(contactId, { PersonLastNamePlaceholder: '' as IntlString, NumberMembers: '' as IntlString, Position: '' as IntlString, - For: '' as IntlString + For: '' as IntlString, + SelectUsers: '' as IntlString }, viewlet: { TableMember: '' as Ref, diff --git a/plugins/view-assets/assets/icons.svg b/plugins/view-assets/assets/icons.svg index a23739e92e..a7b788e813 100644 --- a/plugins/view-assets/assets/icons.svg +++ b/plugins/view-assets/assets/icons.svg @@ -100,4 +100,7 @@ + + + diff --git a/plugins/view-assets/src/index.ts b/plugins/view-assets/src/index.ts index f7d89cb94c..0efacd92b7 100644 --- a/plugins/view-assets/src/index.ts +++ b/plugins/view-assets/src/index.ts @@ -39,5 +39,6 @@ loadMetadata(view.icon, { DevModel: `${icons}#devmodel`, ViewButton: `${icons}#viewButton`, Filter: `${icons}#filter`, - Configure: `${icons}#configure` + Configure: `${icons}#configure`, + Database: `${icons}#database` }) diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index 785df41765..164836d454 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -925,7 +925,8 @@ const view = plugin(viewId, { DevModel: '' as Asset, ViewButton: '' as Asset, Filter: '' as Asset, - Configure: '' as Asset + Configure: '' as Asset, + Database: '' as Asset }, category: { General: '' as Ref, diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index e6c38c075b..7e3840ac9c 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -15,6 +15,7 @@ import chunter, { Backlink, + Channel, ChatMessage, chunterId, ChunterSpace, @@ -230,36 +231,47 @@ async function OnChatMessageCreated (tx: TxCUD, control: TriggerControl): P return [] } - const res: Tx[] = [] const targetDoc = ( await control.findAll(chatMessage.attachedToClass, { _id: chatMessage.attachedTo }, { limit: 1 }) )[0] - if (targetDoc !== undefined) { - if (hierarchy.hasMixin(targetDoc, notification.mixin.Collaborators)) { - const collaboratorsMixin = hierarchy.as(targetDoc, notification.mixin.Collaborators) - if (!collaboratorsMixin.collaborators.includes(chatMessage.modifiedBy)) { - res.push( - control.txFactory.createTxMixin( - targetDoc._id, - targetDoc._class, - targetDoc.space, - notification.mixin.Collaborators, - { - $push: { - collaborators: chatMessage.modifiedBy - } + if (targetDoc === undefined) { + return [] + } + const res: Tx[] = [] + const isChannel = hierarchy.isDerived(targetDoc._class, chunter.class.Channel) + + if (hierarchy.hasMixin(targetDoc, notification.mixin.Collaborators)) { + const collaboratorsMixin = hierarchy.as(targetDoc, notification.mixin.Collaborators) + if (!collaboratorsMixin.collaborators.includes(chatMessage.modifiedBy)) { + res.push( + control.txFactory.createTxMixin( + targetDoc._id, + targetDoc._class, + targetDoc.space, + notification.mixin.Collaborators, + { + $push: { + collaborators: chatMessage.modifiedBy } - ) + } ) - } - } else { - const collaborators = await getDocCollaborators(targetDoc, mixin, control) - if (!collaborators.includes(chatMessage.modifiedBy)) { - collaborators.push(chatMessage.modifiedBy) - } - res.push(getMixinTx(tx, control, collaborators)) + ) } + } else { + const collaborators = await getDocCollaborators(targetDoc, mixin, control) + if (!collaborators.includes(chatMessage.modifiedBy)) { + collaborators.push(chatMessage.modifiedBy) + } + res.push(getMixinTx(tx, control, collaborators)) + } + + if (isChannel && !(targetDoc as Channel).members.includes(chatMessage.modifiedBy)) { + res.push( + control.txFactory.createTxUpdateDoc(targetDoc._class, targetDoc.space, targetDoc._id, { + $push: { members: chatMessage.modifiedBy } + }) + ) } return res @@ -532,13 +544,89 @@ async function OnChatMessageRemoved (tx: TxCollectionCUD, cont return res } +function combineAttributes (attributes: any[], key: string, operator: string, arrayKey: string): any[] { + return Array.from( + new Set( + attributes.flatMap((attr) => + Array.isArray(attr[operator]?.[key]?.[arrayKey]) ? attr[operator]?.[key]?.[arrayKey] : attr[operator]?.[key] + ) + ) + ).filter((v) => v != null) +} + +async function OnChannelMembersChanged (tx: TxUpdateDoc, control: TriggerControl): Promise { + const changedAttributes = Object.entries(tx.operations) + .flatMap(([id, val]) => (['$push', '$pull'].includes(id) ? Object.keys(val) : id)) + .filter((id) => !id.startsWith('$')) + + if (!changedAttributes.includes('members')) { + return [] + } + + const added = combineAttributes([tx.operations], 'members', '$push', '$each') + const removed = combineAttributes([tx.operations], 'members', '$pull', '$in') + + const res: Tx[] = [] + const allContexts = await control.findAll(notification.class.DocNotifyContext, { attachedTo: tx.objectId }) + + if (removed.length > 0) { + res.push( + control.txFactory.createTxMixin(tx.objectId, tx.objectClass, tx.objectSpace, notification.mixin.Collaborators, { + $pull: { + collaborators: { $in: removed } + } + }) + ) + } + + for (const addedMember of added) { + const context = allContexts.find(({ user }) => user === addedMember) + + if (context === undefined) { + res.push( + control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, tx.objectSpace, { + attachedTo: tx.objectId, + attachedToClass: tx.objectClass, + user: addedMember, + hidden: false, + lastViewedTimestamp: tx.modifiedOn + }) + ) + } else { + res.push( + control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, { + hidden: false, + lastViewedTimestamp: tx.modifiedOn + }) + ) + } + } + + const contextsToRemove = allContexts.filter(({ user }) => removed.includes(user)) + + if (contextsToRemove.length === 0) { + return res + } + + const channel = (await control.findAll(chunter.class.Channel, { _id: tx.objectId }))[0] + + if (channel !== undefined) { + for (const context of contextsToRemove) { + res.push(control.txFactory.createTxRemoveDoc(context._class, context.space, context._id)) + } + } + + return res +} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { BacklinkTrigger, ChunterTrigger, OnDirectMessageSent, - OnChatMessageRemoved + OnChatMessageRemoved, + OnChannelMembersChanged }, function: { CommentRemove, diff --git a/server-plugins/chunter/src/index.ts b/server-plugins/chunter/src/index.ts index 7ad1d1aa21..d8ddc9f234 100644 --- a/server-plugins/chunter/src/index.ts +++ b/server-plugins/chunter/src/index.ts @@ -31,7 +31,8 @@ export default plugin(serverChunterId, { BacklinkTrigger: '' as Resource, ChunterTrigger: '' as Resource, OnDirectMessageSent: '' as Resource, - OnChatMessageRemoved: '' as Resource + OnChatMessageRemoved: '' as Resource, + OnChannelMembersChanged: '' as Resource }, function: { CommentRemove: '' as Resource,