Implement @everyone/@here (#8890)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2025-05-12 14:42:58 +07:00
committed by GitHub
parent 137260fe5b
commit 3295adee57
47 changed files with 675 additions and 351 deletions
+18 -22
View File
@@ -63,10 +63,9 @@ import {
DOMAIN_DOC_NOTIFY,
type ActivityInboxNotification,
type ActivityNotificationViewlet,
type BaseNotificationType,
type NotificationType,
type BrowserNotification,
type CommonInboxNotification,
type CommonNotificationType,
type DocNotifyContext,
type InboxNotification,
type MentionInboxNotification,
@@ -79,7 +78,6 @@ import {
type NotificationProviderDefaults,
type NotificationProviderSetting,
type NotificationTemplate,
type NotificationType,
type NotificationTypeSetting,
type PushSubscription,
type PushSubscriptionKeys
@@ -116,26 +114,19 @@ export class TPushSubscription extends TDoc implements PushSubscription {
keys!: PushSubscriptionKeys
}
@Model(notification.class.BaseNotificationType, core.class.Doc, DOMAIN_MODEL)
export class TBaseNotificationType extends TDoc implements BaseNotificationType {
@Model(notification.class.NotificationType, core.class.Doc, DOMAIN_MODEL)
export class TNotificationType extends TDoc implements NotificationType {
generated!: boolean
label!: IntlString
group!: Ref<NotificationGroup>
defaultEnabled!: boolean
hidden!: boolean
templates?: NotificationTemplate
}
@Model(notification.class.NotificationType, notification.class.BaseNotificationType)
export class TNotificationType extends TBaseNotificationType implements NotificationType {
txClasses!: Ref<Class<Tx>>[]
objectClass!: Ref<Class<Doc>>
onlyOwn?: boolean
}
@Model(notification.class.CommonNotificationType, notification.class.BaseNotificationType)
export class TCommonNotificationType extends TBaseNotificationType implements CommonNotificationType {}
@Model(notification.class.NotificationGroup, core.class.Doc, DOMAIN_MODEL)
export class TNotificationGroup extends TDoc implements NotificationGroup {
label!: IntlString
@@ -154,7 +145,7 @@ export class TNotificationPreferencesGroup extends TDoc implements NotificationP
@Model(notification.class.NotificationTypeSetting, preference.class.Preference)
export class TNotificationTypeSetting extends TPreference implements NotificationTypeSetting {
declare attachedTo: Ref<TNotificationProvider>
type!: Ref<BaseNotificationType>
type!: Ref<NotificationType>
enabled!: boolean
}
@@ -247,6 +238,8 @@ export class TInboxNotification extends TDoc implements InboxNotification {
declare space: Ref<PersonSpace>
types?: Ref<NotificationType>[]
title?: IntlString
body?: IntlString
intlParams?: Record<string, string | number>
@@ -319,9 +312,9 @@ export class TNotificationProvider extends TDoc implements NotificationProvider
@Model(notification.class.NotificationProviderDefaults, core.class.Doc)
export class TNotificationProviderDefaults extends TDoc implements NotificationProviderDefaults {
provider!: Ref<NotificationProvider>
excludeIgnore?: Ref<BaseNotificationType>[]
ignoredTypes!: Ref<BaseNotificationType>[]
enabledTypes!: Ref<BaseNotificationType>[]
excludeIgnore?: Ref<NotificationType>[]
ignoredTypes!: Ref<NotificationType>[]
enabledTypes!: Ref<NotificationType>[]
}
export const notificationActionTemplates = template({
@@ -363,8 +356,7 @@ export function createModel (builder: Builder): void {
TCommonInboxNotification,
TNotificationContextPresenter,
TActivityNotificationViewlet,
TBaseNotificationType,
TCommonNotificationType,
TNotificationType,
TMentionInboxNotification,
TPushSubscription,
TNotificationProvider,
@@ -558,15 +550,20 @@ export function createModel (builder: Builder): void {
builder.mixin(notification.class.CommonInboxNotification, core.class.Class, view.mixin.ObjectPresenter, {
presenter: notification.component.CommonInboxNotificationPresenter
})
builder.mixin(notification.class.MentionInboxNotification, core.class.Class, view.mixin.ObjectPresenter, {
presenter: notification.component.MentionInboxNotificationPresenter
})
builder.createDoc(
notification.class.CommonNotificationType,
notification.class.NotificationType,
core.space.Model,
{
label: activity.string.Mentions,
generated: false,
hidden: false,
group: notification.ids.NotificationGroup,
txClasses: [core.class.TxCreateDoc, core.class.TxUpdateDoc],
objectClass: core.class.Doc,
defaultEnabled: true,
templates: {
textTemplate: '{sender} mentioned you in {doc}: {message}',
@@ -574,9 +571,8 @@ export function createModel (builder: Builder): void {
subjectTemplate: 'You were mentioned in {doc}'
}
},
notification.ids.MentionCommonNotificationType
notification.ids.MentionNotificationType
)
createAction(
builder,
{
@@ -808,7 +804,7 @@ export function generateClassNotificationTypes (
hierarchy.isDerived(_class, core.class.AttachedDoc) ? core.class.AttachedDoc : core.class.Doc
)
const filtered = Array.from(attributes.values()).filter((p) => p.hidden !== true && p.readonly !== true)
const enabledInboxTypes: Ref<BaseNotificationType>[] = []
const enabledInboxTypes: Ref<NotificationType>[] = []
for (const attribute of filtered) {
if (ignoreKeys.includes(attribute.name)) continue
+2 -1
View File
@@ -47,7 +47,8 @@ export default mergeIds(notificationId, notification, {
component: {
NotificationSettings: '' as AnyComponent,
ActivityInboxNotificationPresenter: '' as AnyComponent,
CommonInboxNotificationPresenter: '' as AnyComponent
CommonInboxNotificationPresenter: '' as AnyComponent,
MentionInboxNotificationPresenter: '' as AnyComponent
},
function: {
HasDocNotifyContextPinAction: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
+2 -1
View File
@@ -59,7 +59,8 @@ export function createModel (builder: Builder): void {
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverActivity.trigger.OnDocRemoved
trigger: serverActivity.trigger.OnDocRemoved,
isAsync: true
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
+2 -1
View File
@@ -80,7 +80,8 @@ export function createModel (builder: Builder): void {
txMatch: {
_class: core.class.TxRemoveDoc,
objectClass: chunter.class.ChatMessage
}
},
isAsync: true
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
+9
View File
@@ -98,4 +98,13 @@ export function createModel (builder: Builder): void {
objectClass: notification.class.InboxNotification
}
})
builder.mixin(
notification.ids.MentionNotificationType,
notification.class.NotificationType,
serverNotification.mixin.TypeMatch,
{
func: serverNotification.function.MentionTypeMatch
}
)
}
+2
View File
@@ -244,6 +244,8 @@ export interface SearchResultDoc {
shortTitleComponent?: SearchComponentWithProps
title?: string
titleComponent?: SearchComponentWithProps
description?: string
emojiIcon?: string
score?: number
doc: Pick<Doc, '_id' | '_class'>
}
+1
View File
@@ -42,6 +42,7 @@
"dependencies": {
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/client": "^0.6.18",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/collaborator-client": "^0.6.4",
"@hcengineering/communication-client-query": "0.1.180",
"@hcengineering/communication-sdk-types": "0.1.180",
@@ -14,9 +14,9 @@
// limitations under the License.
-->
<script lang="ts">
import type { SearchResultDoc } from '@hcengineering/core'
import { notEmpty, SearchResultDoc } from '@hcengineering/core'
import { getResourceC } from '@hcengineering/platform'
import { Icon, type AnySvelteComponent } from '@hcengineering/ui'
import { Icon, type AnySvelteComponent, IconWithEmoji } from '@hcengineering/ui'
export let value: SearchResultDoc
@@ -45,6 +45,15 @@
<div class="icon-place">
<svelte:component this={iconComponent} size={'smaller'} {...value.iconComponent?.props} />
</div>
{:else if value.emojiIcon}
<div class="emoji">
<IconWithEmoji
icon={Array.from(value.emojiIcon)
.map((c) => c.codePointAt(0))
.filter(notEmpty)}
size={'small'}
/>
</div>
{:else if icon !== undefined}
<Icon {icon} size={'small'} />
{/if}
@@ -60,6 +69,10 @@
{:else}
<span class="name">{value.title}</span>
{/if}
{#if value.description !== undefined}
<span class="description">{value.description}</span>
{/if}
</span>
</div>
@@ -67,6 +80,14 @@
.icon-place {
width: 1.75rem;
}
.emoji {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
}
.searchResult {
display: flex;
flex-direction: row;
@@ -80,5 +101,10 @@
display: flex;
flex: 1;
}
.description {
padding-left: 0.5rem;
color: var(--global-secondary-TextColor);
}
}
</style>
@@ -16,6 +16,7 @@
import { Class, Doc, Ref } from '@hcengineering/core'
import { Component, Icon, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import contact from '@hcengineering/contact'
import { createQuery, getClient } from '../../utils'
import MessageBox from '../MessageBox.svelte'
@@ -31,10 +32,14 @@
let doc: Doc | undefined = undefined
let broken: boolean = false
const withoutDoc = [contact.mention.Here, contact.mention.Everyone]
$: icon = _class !== undefined && hierarchy.hasClass(_class) ? hierarchy.getClass(_class).icon : null
$: icon =
_class !== undefined && hierarchy.hasClass(_class) && !withoutDoc.includes(_id as any)
? hierarchy.getClass(_class).icon
: null
$: if (_class != null && _id != null && hierarchy.hasClass(_class)) {
$: if (_class != null && _id != null && hierarchy.hasClass(_class) && !withoutDoc.includes(_id as any)) {
docQuery.query(_class, { _id }, (r) => {
doc = r.shift()
broken = doc === undefined
@@ -51,6 +51,7 @@
export let objectId: Ref<Doc>
export let space: Ref<Space>
export let _class: Ref<Class<Doc>>
export let docClass: Ref<Class<Doc>> | undefined = undefined
export let content: Markup = EmptyMarkup
export let iconSend: Asset | AnySvelteComponent | undefined = undefined
export let labelSend: IntlString | undefined = undefined
@@ -441,6 +442,7 @@
autofocus={autofocus ? 'end' : false}
loading={loading || progress}
{boundary}
{docClass}
canEmbedFiles={false}
canEmbedImages={false}
extraActions={[
@@ -230,6 +230,7 @@
{focusIndex}
bind:this={inputRef}
bind:content={inputContent}
docClass={object._class}
{_class}
space={getChannelSpace(object._class, object._id, object.space)}
skipAttachmentsPreload={(currentMessage.attachments ?? 0) === 0}
+5 -1
View File
@@ -111,6 +111,10 @@
"ViberPlaceholder": "Viber",
"UserProfile": "Uživatelský profil",
"DeactivatedAccount": "Deaktivovaný účet",
"LocalTime": "místní čas"
"LocalTime": "místní čas",
"Everyone": "Všichni",
"Here": "Zde",
"EveryoneDescription": "Upozornit všechny v tomto {title}",
"HereDescription": "Upozornit všechny v tomto {title}"
}
}
+5 -1
View File
@@ -111,6 +111,10 @@
"ViberPlaceholder": "Viber",
"UserProfile": "Benutzerprofil",
"DeactivatedAccount": "Deaktivierter Account",
"LocalTime": "Ortszeit"
"LocalTime": "Ortszeit",
"Everyone": "Jeder",
"Here": "Hier",
"EveryoneDescription": "Benachrichtigen Sie alle in diesem {title}",
"HereDescription": "Benachrichtigen Sie alle in diesem {title}"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "Confirmed",
"UserProfile": "User profile",
"DeactivatedAccount": "Deactivated account",
"LocalTime": "local time"
"LocalTime": "local time",
"Everyone": "Everyone",
"Here": "Here",
"EveryoneDescription": "Notify everyone in this {title}",
"HereDescription": "Notify every online member in this {title}"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "Confirmado",
"UserProfile": "Perfil de usuario",
"DeactivatedAccount": "Cuenta desactivada",
"LocalTime": "hora local"
"LocalTime": "hora local",
"Everyone": "Todos",
"Here": "Aquí",
"EveryoneDescription": "Notificar a todos en este {title}",
"HereDescription": "Notificar a todos en este {title}"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "Confirmé",
"UserProfile": "Profil utilisateur",
"DeactivatedAccount": "Compte désactivé",
"LocalTime": "heure locale"
"LocalTime": "heure locale",
"Everyone": "Tout le monde",
"Here": "Ici",
"EveryoneDescription": "Notifier tout le monde dans ce {title}",
"HereDescription": "Notifier tout le monde dans ce {title}"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "Confermato",
"UserProfile": "Profilo utente",
"DeactivatedAccount": "Account disattivato",
"LocalTime": "ora locale"
"LocalTime": "ora locale",
"Everyone": "Tutti",
"Here": "Qui",
"EveryoneDescription": "Notifica tutti in questo {title}",
"HereDescription": "Notifica tutti in questo {title}"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "確認済み",
"UserProfile": "ユーザープロフィール",
"DeactivatedAccount": "アカウントは無効化されました",
"LocalTime": "現地時間"
"LocalTime": "現地時間",
"Everyone": "全員",
"Here": "ここ",
"EveryoneDescription": "この{title}のすべてに通知する",
"HereDescription": "この{title}のすべてに通知する"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "Confirmado",
"UserProfile": "Perfil do usuário",
"DeactivatedAccount": "Conta desativada",
"LocalTime": "hora local"
"LocalTime": "hora local",
"Everyone": "Todos",
"Here": "Aqui",
"EveryoneDescription": "Notificar todos neste {title}",
"HereDescription": "Notificar todos neste {title}"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "Подтвержден",
"UserProfile": "Профиль пользователя",
"DeactivatedAccount": "Деактивированный аккаунт",
"LocalTime": "местного времени"
"LocalTime": "местного времени",
"Everyone": "Все",
"Here": "Здесь",
"EveryoneDescription": "Уведомить всех в этом {title}",
"HereDescription": "Уведомить всех в этом{title}"
}
}
+5 -1
View File
@@ -115,6 +115,10 @@
"Confirmed": "已确认",
"UserProfile": "用户资料",
"DeactivatedAccount": "已停用账户",
"LocalTime": "当地时间"
"LocalTime": "当地时间",
"Everyone": "所有人",
"Here": "这里",
"EveryoneDescription": "通知所有人在此{title}",
"HereDescription": "通知所有人在此{title}"
}
}
+9 -1
View File
@@ -342,7 +342,11 @@ export const contactPlugin = plugin(contactId, {
Confirmed: '' as IntlString,
UserProfile: '' as IntlString,
DeactivatedAccount: '' as IntlString,
LocalTime: '' as IntlString
LocalTime: '' as IntlString,
Everyone: '' as IntlString,
Here: '' as IntlString,
EveryoneDescription: '' as IntlString,
HereDescription: '' as IntlString
},
viewlet: {
TableMember: '' as Ref<Viewlet>,
@@ -375,6 +379,10 @@ export const contactPlugin = plugin(contactId, {
ids: {
MentionCommonNotificationType: '' as Ref<Doc>
},
mention: {
Everyone: '' as Ref<Employee>,
Here: '' as Ref<Employee>
},
extension: {
EmployeePopupActions: '' as ComponentExtensionId,
PersonAchievementsPresenter: '' as ComponentExtensionId
@@ -61,11 +61,12 @@
function updateDisplayData (data: InboxData): void {
let result: [Ref<DocNotifyContext>, DisplayInboxNotification[]][] = Array.from(data.entries())
if (archivedContexts.size > 0) {
result = result.filter(([contextId]) => {
result = result.filter(([contextId, d]) => {
const context = $contextByIdStore.get(contextId)
return (
!archivedContexts.has(contextId) ||
(context?.lastUpdateTimestamp ?? 0) > (archivedContexts.get(contextId) ?? 0)
(context?.lastUpdateTimestamp ?? 0) > (archivedContexts.get(contextId) ?? 0) ||
(d[0]?.createdOn ?? 0) > (archivedContexts.get(contextId) ?? 0)
)
})
}
@@ -0,0 +1,47 @@
<!--
// Copyright © 2023 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 { ActivityMessagePreview } from '@hcengineering/activity-resources'
import { MentionInboxNotification } from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import activity, { ActivityMessage } from '@hcengineering/activity'
import { Doc, Ref, Space } from '@hcengineering/core'
import CommonInboxNotificationPresenter from './CommonInboxNotificationPresenter.svelte'
export let object: Doc | undefined
export let value: MentionInboxNotification
export let space: Ref<Space> | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const query = createQuery()
let message: ActivityMessage | undefined = undefined
$: if (hierarchy.isDerived(value.mentionedInClass, activity.class.ActivityMessage)) {
query.query(value.mentionedInClass, { _id: value.mentionedIn }, (res) => {
message = res[0] as ActivityMessage
})
} else {
query.unsubscribe()
}
</script>
{#if message}
<ActivityMessagePreview value={message} {space} doc={object} on:click />
{:else}
<CommonInboxNotificationPresenter {value} on:click />
{/if}
@@ -15,10 +15,9 @@
<script lang="ts">
import core, { IdMap, Ref, toIdMap } from '@hcengineering/core'
import {
BaseNotificationType,
NotificationType,
NotificationProvider,
NotificationGroup,
NotificationType,
NotificationTypeSetting,
NotificationProviderDefaults,
NotificationProviderSetting
@@ -31,7 +30,7 @@
import { providersSettings } from '../../utils'
export let group: Ref<NotificationGroup>
export let settings: Map<Ref<BaseNotificationType>, NotificationTypeSetting[]>
export let settings: Map<Ref<NotificationType>, NotificationTypeSetting[]>
const client = getClient()
@@ -44,12 +43,12 @@
.findAllSync(notification.class.NotificationProviderDefaults, {})
const providersMap: IdMap<NotificationProvider> = toIdMap(providers)
$: types = client.getModel().findAllSync(notification.class.BaseNotificationType, { group })
$: types = client.getModel().findAllSync(notification.class.NotificationType, { group })
$: typesMap = toIdMap(types)
function getStatus (
settings: Map<Ref<BaseNotificationType>, NotificationTypeSetting[]>,
type: Ref<BaseNotificationType>,
settings: Map<Ref<NotificationType>, NotificationTypeSetting[]>,
type: Ref<NotificationType>,
provider: Ref<NotificationProvider>
): boolean {
const setting = getTypeSetting(settings, type, provider)
@@ -64,7 +63,7 @@
}
async function onToggle (
typeId: Ref<BaseNotificationType>,
typeId: Ref<NotificationType>,
providerId: Ref<NotificationProvider>,
value: boolean
): Promise<void> {
@@ -96,8 +95,8 @@
}
function getTypeSetting (
map: Map<Ref<BaseNotificationType>, NotificationTypeSetting[]>,
type: Ref<BaseNotificationType>,
map: Map<Ref<NotificationType>, NotificationTypeSetting[]>,
type: Ref<NotificationType>,
provider: Ref<NotificationProvider>
): NotificationTypeSetting | undefined {
const typeMap = map.get(type)
@@ -105,11 +104,11 @@
return typeMap.find((p) => p.attachedTo === provider)
}
const isNotificationType = (type: BaseNotificationType): type is NotificationType => {
const isNotificationType = (type: NotificationType): type is NotificationType => {
return type._class === notification.class.NotificationType
}
function getLabel (type: BaseNotificationType): IntlString {
function getLabel (type: NotificationType): IntlString {
if (isNotificationType(type) && type.attachedToClass !== undefined) {
return notification.string.AddedRemoved
}
@@ -117,7 +116,7 @@
return notification.string.Change
}
function isIgnored (type: Ref<BaseNotificationType>, provider: NotificationProvider): boolean {
function isIgnored (type: Ref<NotificationType>, provider: NotificationProvider): boolean {
const ignored = providerDefaults.some((it) => provider._id === it.provider && it.ignoredTypes.includes(type))
if (ignored) return true
@@ -133,7 +132,7 @@
async function getFilteredProviders (
providers: NotificationProvider[],
types: BaseNotificationType[],
types: NotificationType[],
providersSettings: NotificationProviderSetting[]
): Promise<NotificationProvider[]> {
const result: NotificationProvider[] = []
@@ -16,7 +16,7 @@
import { onDestroy } from 'svelte'
import { Ref } from '@hcengineering/core'
import type {
BaseNotificationType,
NotificationType,
NotificationGroup,
NotificationPreferencesGroup,
NotificationTypeSetting
@@ -48,7 +48,7 @@
.getModel()
.findAllSync(notification.class.NotificationPreferencesGroup, {})
let settings = new Map<Ref<BaseNotificationType>, NotificationTypeSetting[]>()
let settings = new Map<Ref<NotificationType>, NotificationTypeSetting[]>()
let isProviderSettingLoading = true
let isTypeSettingLoading = true
@@ -23,6 +23,7 @@ import DocNotifyContextPresenter from './components/DocNotifyContextPresenter.sv
import CollaboratorsChanged from './components/activity/CollaboratorsChanged.svelte'
import ActivityInboxNotificationPresenter from './components/inbox/ActivityInboxNotificationPresenter.svelte'
import CommonInboxNotificationPresenter from './components/inbox/CommonInboxNotificationPresenter.svelte'
import MentionInboxNotificationPresenter from './components/inbox/MentionInboxNotificationPresenter.svelte'
import NotificationCollaboratorsChanged from './components/NotificationCollaboratorsChanged.svelte'
import ReactionNotificationPresenter from './components/ReactionNotificationPresenter.svelte'
import GeneralPreferencesGroup from './components/settings/GeneralPreferencesGroup.svelte'
@@ -65,6 +66,7 @@ export default async (): Promise<Resources> => ({
DocNotifyContextPresenter,
ActivityInboxNotificationPresenter,
CommonInboxNotificationPresenter,
MentionInboxNotificationPresenter,
NotificationCollaboratorsChanged,
ReactionNotificationPresenter,
GeneralPreferencesGroup
+2 -2
View File
@@ -40,7 +40,7 @@ import core, {
import notification, {
notificationId,
type ActivityInboxNotification,
type BaseNotificationType,
type NotificationType,
type Collaborators,
type DisplayInboxNotification,
type DocNotifyContext,
@@ -821,7 +821,7 @@ export function notificationsComparator (notifications1: InboxNotification, noti
return 0
}
export function isNotificationAllowed (type: BaseNotificationType, providerId: Ref<NotificationProvider>): boolean {
export function isNotificationAllowed (type: NotificationType, providerId: Ref<NotificationProvider>): boolean {
const client = getClient()
const provider = client.getModel().findAllSync(notification.class.NotificationProvider, { _id: providerId })[0]
if (provider === undefined) return false
+11 -18
View File
@@ -124,7 +124,10 @@ export interface NotificationContent {
intlParamsNotLocalized?: Record<string, IntlString>
}
export interface BaseNotificationType extends Doc {
/**
* @public
*/
export interface NotificationType extends Doc {
label: IntlString
// Is autogenerated
generated: boolean
@@ -134,11 +137,6 @@ export interface BaseNotificationType extends Doc {
defaultEnabled: boolean
// templates for email (and browser/push?)
templates?: NotificationTemplate
}
/**
* @public
*/
export interface NotificationType extends BaseNotificationType {
// For show/hide with attributes
attribute?: Ref<AnyAttribute>
txClasses: Ref<Class<Tx>>[]
@@ -156,8 +154,6 @@ export interface NotificationType extends BaseNotificationType {
allowedForAuthor?: boolean
}
export interface CommonNotificationType extends BaseNotificationType {}
export interface NotificationProvider extends Doc {
label: IntlString
description: IntlString
@@ -173,9 +169,9 @@ export interface NotificationProvider extends Doc {
export interface NotificationProviderDefaults extends Doc {
provider: Ref<NotificationProvider>
excludeIgnore?: Ref<BaseNotificationType>[]
ignoredTypes: Ref<BaseNotificationType>[]
enabledTypes: Ref<BaseNotificationType>[]
excludeIgnore?: Ref<NotificationType>[]
ignoredTypes: Ref<NotificationType>[]
enabledTypes: Ref<NotificationType>[]
}
export interface NotificationProviderSetting extends Preference {
@@ -185,7 +181,7 @@ export interface NotificationProviderSetting extends Preference {
export interface NotificationTypeSetting extends Preference {
attachedTo: Ref<NotificationProvider>
type: Ref<BaseNotificationType>
type: Ref<NotificationType>
enabled: boolean
}
@@ -239,6 +235,7 @@ export interface InboxNotification extends Doc<PersonSpace> {
docNotifyContext: Ref<DocNotifyContext>
objectId: Ref<Doc>
objectClass: Ref<Class<Doc>>
types?: Ref<NotificationType>[]
// For browser notifications
title?: IntlString
@@ -351,9 +348,7 @@ const notification = plugin(notificationId, {
class: {
BrowserNotification: '' as Ref<Class<BrowserNotification>>,
PushSubscription: '' as Ref<Class<PushSubscription>>,
BaseNotificationType: '' as Ref<Class<BaseNotificationType>>,
NotificationType: '' as Ref<Class<NotificationType>>,
CommonNotificationType: '' as Ref<Class<CommonNotificationType>>,
NotificationGroup: '' as Ref<Class<NotificationGroup>>,
NotificationPreferencesGroup: '' as Ref<Class<NotificationPreferencesGroup>>,
DocNotifyContext: '' as Ref<Class<DocNotifyContext>>,
@@ -371,7 +366,7 @@ const notification = plugin(notificationId, {
NotificationSettings: '' as Ref<Doc>,
NotificationGroup: '' as Ref<NotificationGroup>,
CollaboratoAddNotification: '' as Ref<NotificationType>,
MentionCommonNotificationType: '' as Ref<CommonNotificationType>
MentionNotificationType: '' as Ref<NotificationType>
},
metadata: {
PushPublicKey: '' as Metadata<string>
@@ -449,9 +444,7 @@ const notification = plugin(notificationId, {
HasInboxNotifications: '' as Resource<
(notificationsByContext: Map<Ref<DocNotifyContext>, InboxNotification[]>) => Promise<boolean>
>,
IsNotificationAllowed: '' as Resource<
(type: BaseNotificationType, providerId: Ref<NotificationProvider>) => boolean
>
IsNotificationAllowed: '' as Resource<(type: NotificationType, providerId: Ref<NotificationProvider>) => boolean>
},
resolver: {
Location: '' as Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
@@ -15,11 +15,14 @@
-->
<script lang="ts">
import { showPopup, resizeObserver, deviceOptionsStore as deviceInfo, PopupResult } from '@hcengineering/ui'
import { Ref, Class, Doc } from '@hcengineering/core'
import { onDestroy, onMount } from 'svelte'
import MentionPopup from './MentionPopup.svelte'
import DummyPopup from './DummyPopup.svelte'
export let docClass: Ref<Class<Doc>> | undefined = undefined
export let query: string = ''
export let multipleMentions: boolean = false
export let clientRect: () => ClientRect
export let command: (props: any) => void
export let close: () => void
@@ -115,7 +118,9 @@
>
<MentionPopup
bind:this={searchPopup}
{docClass}
{query}
{multipleMentions}
on:close={(evt) => {
dispatchItem(evt.detail)
}}
@@ -14,23 +14,95 @@
// limitations under the License.
-->
<script lang="ts">
import { SearchResultDoc } from '@hcengineering/core'
import presentation, { SearchResult, reduceCalls, searchFor, type SearchItem } from '@hcengineering/presentation'
import core, { SearchResultDoc, Ref, Class, Doc } from '@hcengineering/core'
import presentation, {
SearchResult,
reduceCalls,
searchFor,
type SearchItem,
getClient
} from '@hcengineering/presentation'
import { Label, ListView, resizeObserver } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import contact from '@hcengineering/contact'
import { getReferenceLabel, getReferenceObject } from './extension/reference'
import { translate } from '@hcengineering/platform'
export let query: string = ''
export let multipleMentions: boolean = false
export let docClass: Ref<Class<Doc>> | undefined = undefined
let items: SearchItem[] = []
const dispatch = createEventDispatcher()
const client = getClient()
let list: ListView
let scrollContainer: HTMLElement
let selection = 0
const employeeSearchCategory = client
.getModel()
.findAllSync(presentation.class.ObjectSearchCategory, { classToSearch: contact.mixin.Employee })[0]
async function getMultipleEmployeeSearchItems (localQuery: string, lastIndex: number): Promise<SearchItem[]> {
if (!multipleMentions) return []
const clazz =
docClass && client.getHierarchy().hasClass(docClass) ? client.getHierarchy().getClass(docClass) : undefined
const docTitle = await translate(clazz?.label ?? core.string.Object, {})
const everyoneDescription = await translate(contact.string.EveryoneDescription, {
title: docTitle.toLowerCase()
})
const hereDescription = await translate(contact.string.HereDescription, {
title: docTitle.toLowerCase()
})
const everyoneTitle = await translate(contact.string.Everyone, {})
const hereTitle = await translate(contact.string.Here, {})
return [
{
num: 0,
category: employeeSearchCategory,
item: {
id: contact.mention.Everyone,
title: everyoneTitle,
description: everyoneDescription,
emojiIcon: '📢',
doc: {
_id: contact.mention.Everyone,
_class: contact.mixin.Employee
}
}
},
{
num: 0,
category: employeeSearchCategory,
item: {
id: contact.mention.Here,
title: hereTitle,
description: hereDescription,
emojiIcon: '📢',
doc: {
_id: contact.mention.Here,
_class: contact.mixin.Employee
}
}
}
]
.filter((it) => it.item.title.toLowerCase().includes(localQuery.toLowerCase()))
.map((it, idx) => ({ ...it, num: lastIndex + 1 + idx }))
}
async function handleSelectItem (item: SearchResultDoc): Promise<void> {
if ([contact.mention.Here, contact.mention.Everyone].includes(item.id as any)) {
dispatch('close', {
id: item.doc._id,
label: item.title?.toLowerCase() ?? '',
objectclass: item.doc._class
})
return
}
const obj = (await getReferenceObject(item.doc._class, item.doc._id)) ?? item.doc
const label = await getReferenceLabel(obj._class, obj._id)
dispatch('close', {
@@ -73,7 +145,13 @@
const updateItems = reduceCalls(async function (localQuery: string): Promise<void> {
const r = await searchFor('mention', localQuery)
if (r.query === query) {
items = r.items
const latestIndex = r.items.findLastIndex((it) => it.category.classToSearch === contact.mixin.Employee)
const multipleEmployeeSearchItems = await getMultipleEmployeeSearchItems(localQuery, latestIndex)
items =
latestIndex === -1
? [...multipleEmployeeSearchItems, ...r.items]
: [...r.items.slice(0, latestIndex + 1), ...multipleEmployeeSearchItems, ...r.items.slice(latestIndex + 1)]
}
})
$: void updateItems(query)
@@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Markup } from '@hcengineering/core'
import { Class, Doc, Markup, Ref } from '@hcengineering/core'
import { Asset, IntlString } from '@hcengineering/platform'
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
import textEditor, { RefAction, TextEditorHandler } from '@hcengineering/text-editor'
@@ -61,6 +61,7 @@
export let canEmbedImages = true
export let onPaste: ((view: EditorView, event: ClipboardEvent) => boolean) | undefined = undefined
export let onCancel: (() => void) | undefined = undefined
export let docClass: Ref<Class<Doc>> | undefined = undefined
const dispatch = createEventDispatcher()
const buttonSize = 'medium'
@@ -142,6 +143,8 @@
}
const completionPlugin = ReferenceExtension.configure({
...referenceConfig,
docClass,
multipleMentions: true,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}
@@ -32,6 +32,8 @@ import workbench, { type Application } from '@hcengineering/workbench'
export interface ReferenceExtensionOptions extends ReferenceOptions {
suggestion: Omit<SuggestionOptions, 'editor'>
docClass?: Ref<Class<Doc>>
multipleMentions?: boolean
showDoc?: (event: MouseEvent, _id: string, _class: string) => void
}
@@ -96,10 +98,12 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
this.options.HTMLAttributes,
HTMLAttributes
)
const withoutDoc = [contact.mention.Everyone, contact.mention.Here].includes(node.attrs.id)
const id = node.attrs.id
const objectclass: Ref<Class<Doc>> = node.attrs.objectclass
root.addEventListener('click', (event) => {
if (withoutDoc) return
if (event.button !== 0) return
if (broken) {
showPopup(MessageBox, {
@@ -164,7 +168,7 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
const titleSpan = root.appendChild(document.createElement('span'))
renderLabel({ id, objectclass, label: node.attrs.label })
if (id !== undefined && objectclass !== undefined) {
if (id !== undefined && objectclass !== undefined && !withoutDoc) {
query.query(objectclass, { _id: id }, async (result) => {
const obj = result[0]
broken = obj === undefined
@@ -180,6 +184,8 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
renderLabel({ id, objectclass, label })
}
})
} else if (withoutDoc) {
query.unsubscribe()
}
return {
@@ -228,6 +234,8 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
return [
Suggestion({
editor: this.editor,
docClass: this.options.docClass,
multipleMentions: this.options.multipleMentions,
...this.options.suggestion
}),
// ReferenceClickHandler(this.options),
@@ -2,8 +2,8 @@ import { type Editor, type Range, escapeForRegEx } from '@tiptap/core'
import { type EditorState, Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
import { ReplaceStep } from '@tiptap/pm/transform'
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
import { type ResolvedPos } from '@tiptap/pm/model'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
export interface Trigger {
char: string
@@ -129,6 +129,8 @@ export interface SuggestionOptions<I = any> {
startOfLine?: boolean
decorationTag?: string
decorationClass?: string
multipleMentions?: boolean
docClass?: Ref<Class<Doc>>
command?: (props: { editor: Editor, range: Range, props: I }) => void
items?: (props: { query: string, editor: Editor }) => I[] | Promise<I[]>
render?: () => {
@@ -147,6 +149,8 @@ export interface SuggestionProps<I = any> {
range: Range
query: string
text: string
multipleMentions: boolean
docClass?: Ref<Class<Doc>>
items: I[]
command: (props: I) => void
decorationNode: Element | null
@@ -172,6 +176,8 @@ export default function Suggestion<I = any> ({
startOfLine = false,
decorationTag = 'span',
decorationClass = 'suggestion',
multipleMentions = false,
docClass,
command = () => null,
items = () => [],
render = () => ({}),
@@ -226,7 +232,9 @@ export default function Suggestion<I = any> ({
range: state.range,
query: state.query,
text: state.text,
docClass,
items: [],
multipleMentions,
command: (commandProps) => {
command({
editor,
@@ -37,19 +37,18 @@ import core, {
type MeasureContext,
AccountUuid
} from '@hcengineering/core'
import notification, { MentionInboxNotification } from '@hcengineering/notification'
import notification, { MentionInboxNotification, NotificationType } from '@hcengineering/notification'
import { getPerson } from '@hcengineering/server-contact'
import { StorageAdapter, TriggerControl } from '@hcengineering/server-core'
import {
getCommonNotificationTxes,
getNotificationProviderControl,
getPushCollaboratorTx,
NotifyResult,
shouldNotifyCommon,
type NotificationProviderControl,
isShouldNotifyTx
getAllowedProviders,
type NotificationProviderControl
} from '@hcengineering/server-notification-resources'
import { areEqualJson, extractReferences, jsonToMarkup, markupToJSON } from '@hcengineering/text-core'
import { getReceiversInfo } from '@hcengineering/server-notification-resources'
export function isDocMentioned (doc: Ref<Doc>, content: string): boolean {
const references = []
@@ -75,51 +74,9 @@ export async function getPersonNotificationTxes (
originTx: TxCUD<Doc>,
notificationControl: NotificationProviderControl
): Promise<Tx[]> {
const receiverPersonRef = reference.attachedTo as Ref<Person>
const receiverSocialIdentity = await control.findAll(ctx, contact.class.SocialIdentity, {
attachedTo: receiverPersonRef
})
const receiverSocialIds = receiverSocialIdentity.map((si) => si._id) as PersonId[]
if (receiverSocialIds.includes(senderId)) {
return []
}
const receiverEmployee = (
await control.findAll(
ctx,
contact.mixin.Employee,
{ _id: receiverPersonRef as Ref<Employee>, active: true },
{ limit: 1 }
)
)[0]
const receiverAccount = receiverEmployee?.personUuid
if (receiverAccount == null) {
return []
}
const { hierarchy } = control
const res: Tx[] = []
const isAvailable = await checkSpace(receiverAccount, space, control, res)
if (!isAvailable) {
return []
}
const doc = (await control.findAll(ctx, reference.srcDocClass, { _id: reference.srcDocId }))[0]
const receiverSpace = (
await control.findAll(ctx, contact.class.PersonSpace, { person: receiverPersonRef }, { limit: 1 })
)[0]
if (receiverSpace === undefined) return res
const collaboratorsTx = await getCollaboratorsTxes(reference, control, receiverAccount, doc)
res.push(...collaboratorsTx)
if (doc === undefined) {
return res
}
const receiverPersonRef = reference.attachedTo as Ref<Person>
const info = (
await control.findAll<UserMentionInfo>(ctx, activity.class.UserMentionInfo, {
user: receiverPersonRef,
@@ -127,101 +84,138 @@ export async function getPersonNotificationTxes (
})
)[0]
if (info === undefined) {
res.push(
control.txFactory.createTxCreateDoc(activity.class.UserMentionInfo, space, {
attachedTo: reference.attachedDocId ?? reference.srcDocId,
attachedToClass: reference.attachedDocClass ?? reference.srcDocClass,
user: receiverPersonRef,
content: reference.message,
collection: 'mentions'
})
)
res.push(getUpdateMentionInfoTx(control, reference, space, info))
if (
originTx._class === core.class.TxCreateDoc &&
hierarchy.isDerived(originTx.objectClass, activity.class.ActivityMessage)
) {
return res
}
if (info !== undefined && hierarchy.isDerived(originTx.objectClass, activity.class.ActivityMessage)) {
return res
}
const doc = (await control.findAll(ctx, reference.srcDocClass, { _id: reference.srcDocId }))[0]
if (doc === undefined) return res
const docSpace = (await control.findAll<Space>(control.ctx, core.class.Space, { _id: space }, { limit: 1 }))[0]
if (docSpace === undefined) return res
const senderAccount = control.ctx.contextData.socialStringsToUsers.get(senderId)
let collaborators: AccountUuid[] = []
if ([contact.mention.Everyone, contact.mention.Here].includes(reference.attachedTo as Ref<Employee>)) {
collaborators = await getMultipleMentionCollaborators(reference, control, doc)
} else {
res.push(
control.txFactory.createTxUpdateDoc(info._class, info.space, info._id, {
content: reference.message
})
)
const employee = (
await control.findAll(ctx, contact.mixin.Employee, { _id: reference.attachedTo as Ref<Employee> })
)[0]
if (employee?.personUuid != null && employee.personUuid !== senderAccount) {
collaborators = [employee.personUuid]
const collaboratorsTx = getCollaboratorsTxes(control, employee.personUuid, doc)
res.push(...collaboratorsTx)
}
}
const data: Omit<Data<MentionInboxNotification>, 'docNotifyContext'> = {
header: activity.string.MentionedYouIn,
messageHtml: reference.message,
mentionedIn: reference.attachedDocId ?? reference.srcDocId,
mentionedInClass: reference.attachedDocClass ?? reference.srcDocClass,
objectId: reference.srcDocId,
objectClass: reference.srcDocClass,
user: receiverAccount,
isViewed: false,
archived: false
}
const filteredCollaborators = collaborators.filter(
(c) => c !== senderAccount && checkSpace(c, docSpace, control, res)
)
if (filteredCollaborators.length === 0) return res
const receivers = await getReceiversInfo(ctx, filteredCollaborators, control)
if (receivers.length === 0) return []
const senderPerson = await getPerson(control, senderId)
const receiver = {
account: receiverAccount,
socialIds: receiverSocialIds,
space: receiverSpace._id,
employee: receiverEmployee._id
}
const sender = {
socialId: senderId,
person: senderPerson
}
const type: NotificationType = control.modelDb.findAllSync(notification.class.NotificationType, {
_id: notification.ids.MentionNotificationType
})[0]
for (const receiver of receivers) {
const data: Omit<Data<MentionInboxNotification>, 'docNotifyContext'> = {
header: activity.string.MentionedYouIn,
messageHtml: reference.message,
mentionedIn: reference.attachedDocId ?? reference.srcDocId,
mentionedInClass: reference.attachedDocClass ?? reference.srcDocClass,
objectId: reference.srcDocId,
objectClass: reference.srcDocClass,
user: receiver.account,
isViewed: false,
archived: false
}
const notifyResult = await shouldNotifyCommon(
control,
receiverSocialIds,
notification.ids.MentionCommonNotificationType,
notificationControl
)
const messageNotifyResult = await getMessageNotifyResult(
reference,
receiverAccount,
receiverEmployee,
receiverSocialIds,
control,
originTx,
doc,
notificationControl
)
const allowedProviders = getAllowedProviders(control, receiver.socialIds, type, notificationControl)
const notifyResult = new Map(allowedProviders.map((it) => [it, [type]]))
for (const [provider] of messageNotifyResult.entries()) {
if (notifyResult.has(provider)) {
notifyResult.delete(provider)
if (notifyResult.has(notification.providers.InboxNotificationProvider)) {
const txes = await getCommonNotificationTxes(
control.ctx,
control,
doc,
data,
receiver,
sender,
reference.srcDocId,
reference.srcDocClass,
doc.space,
originTx.modifiedOn,
notifyResult,
notification.class.MentionInboxNotification,
originTx
)
res.push(...txes)
}
}
if (notifyResult.has(notification.providers.InboxNotificationProvider)) {
const txes = await getCommonNotificationTxes(
ctx,
control,
doc,
data,
receiver,
sender,
reference.srcDocId,
reference.srcDocClass,
doc.space,
originTx.modifiedOn,
notifyResult,
notification.class.MentionInboxNotification,
originTx
)
res.push(...txes)
}
return res
}
async function checkSpace (
account: AccountUuid,
spaceId: Ref<Space>,
function getUpdateMentionInfoTx (
control: TriggerControl,
res: Tx[]
): Promise<boolean> {
const space = (await control.findAll<Space>(control.ctx, core.class.Space, { _id: spaceId }, { limit: 1 }))[0]
reference: Data<ActivityReference>,
space: Ref<Space>,
info?: UserMentionInfo
): Tx {
if (info === undefined) {
return control.txFactory.createTxCreateDoc(activity.class.UserMentionInfo, space, {
attachedTo: reference.attachedDocId ?? reference.srcDocId,
attachedToClass: reference.attachedDocClass ?? reference.srcDocClass,
user: reference.attachedTo as Ref<Person>,
content: reference.message,
collection: 'mentions'
})
}
return control.txFactory.createTxUpdateDoc(info._class, info.space, info._id, {
content: reference.message
})
}
async function getMultipleMentionCollaborators (
reference: Data<ActivityReference>,
control: TriggerControl,
doc: Doc
): Promise<AccountUuid[]> {
const { hierarchy } = control
const personRef = reference.attachedTo as Ref<Person>
const mixin = hierarchy.classHierarchyMixin(doc._class, notification.mixin.ClassCollaborators)
if (mixin === undefined) return []
const collaborators = hierarchy.as(doc, notification.mixin.Collaborators).collaborators
if (collaborators.length === 0) return []
const statuses = Array.from(control.userStatusMap.values())
return personRef === contact.mention.Here
? collaborators.filter((it) => statuses.some((s) => s.online && s.user === it))
: collaborators
}
function checkSpace (account: AccountUuid, space: Space, control: TriggerControl, res: Tx[]): boolean {
const isMember = space.members.includes(account)
if (space.private) {
@@ -235,13 +229,7 @@ async function checkSpace (
return true
}
async function getCollaboratorsTxes (
reference: Data<ActivityReference>,
control: TriggerControl,
receiver: AccountUuid,
object?: Doc
): Promise<TxMixin<Doc, Doc>[]> {
const { hierarchy } = control
function getCollaboratorsTxes (control: TriggerControl, receiver: AccountUuid, object?: Doc): TxMixin<Doc, Doc>[] {
const res: TxMixin<Doc, Doc>[] = []
if (object !== undefined) {
@@ -253,72 +241,9 @@ async function getCollaboratorsTxes (
}
}
if (reference.attachedDocClass === undefined || reference.attachedDocId === undefined) {
return res
}
if (!hierarchy.isDerived(reference.attachedDocClass, activity.class.ActivityMessage)) {
return res
}
const message = (
await control.findAll<ActivityMessage>(
control.ctx,
reference.attachedDocClass,
{
_id: reference.attachedDocId as Ref<ActivityMessage>
},
{ limit: 1 }
)
)[0]
if (message === undefined) {
return res
}
// Add user to collaborators of message where user is mentioned
const messageTx = getPushCollaboratorTx(control, receiver, message)
if (messageTx !== undefined) {
res.push(messageTx)
}
return res
}
async function getMessageNotifyResult (
reference: Data<ActivityReference>,
account: AccountUuid,
person: Person,
personIds: PersonId[],
control: TriggerControl,
tx: TxCUD<Doc>,
doc: Doc,
notificationControl: NotificationProviderControl
): Promise<NotifyResult> {
const { hierarchy } = control
if (
reference.attachedDocClass === undefined ||
reference.attachedDocId === undefined ||
tx._class !== core.class.TxCreateDoc
) {
return new Map()
}
const mixin = control.hierarchy.as(doc, notification.mixin.Collaborators)
if (mixin === undefined || !mixin.collaborators.includes(account)) {
return new Map()
}
if (!hierarchy.isDerived(reference.attachedDocClass, activity.class.ActivityMessage)) {
return new Map()
}
return await isShouldNotifyTx(control, tx, doc, person._id, personIds, false, false, notificationControl, undefined)
}
function isMarkupType (type: Ref<Class<Type<any>>>): boolean {
return type === core.class.TypeMarkup
}
@@ -485,6 +410,41 @@ async function createReferenceTxes (
return [tx]
}
async function getRemoveMentionTxes (
control: TriggerControl,
mention: UserMentionInfo,
originTx: TxCUD<Doc>
): Promise<Tx[]> {
const res: Tx[] = []
res.push(control.txFactory.createTxRemoveDoc(mention._class, mention.space, mention._id))
if (control.hierarchy.isDerived(originTx.objectClass, activity.class.ActivityMessage)) {
const _id = originTx.objectId as Ref<ActivityMessage>
const person = (
await control.findAll(control.ctx, contact.mixin.Employee, { _id: mention.user as Ref<Employee> })
)[0]
if (person?.personUuid !== undefined) {
const activityNotification = await control.findAll(control.ctx, notification.class.ActivityInboxNotification, {
attachedTo: _id,
user: person.personUuid
})
const mentionNotifications = await control.findAll(control.ctx, notification.class.MentionInboxNotification, {
mentionedIn: _id,
user: person.personUuid
})
res.push(
...activityNotification
.filter((it) => it.types?.length === 1 && it.types[0] === notification.ids.MentionNotificationType)
.map((it) => control.txFactory.createTxRemoveDoc(it._class, it.space, it._id))
)
res.push(...mentionNotifications.map((it) => control.txFactory.createTxRemoveDoc(it._class, it.space, it._id)))
}
}
return res
}
async function getReferencesTxes (
ctx: MeasureContext,
control: TriggerControl,
@@ -535,7 +495,8 @@ async function getReferencesTxes (
references.splice(refIndex, 1)
}
} else {
txes.push(txFactory.createTxRemoveDoc(mention._class, mention.space, mention._id))
const removeTxes = await getRemoveMentionTxes(control, mention, originTx)
txes.push(...removeTxes)
}
}
@@ -562,6 +523,14 @@ async function getRemoveActivityReferenceTxes (
attachedTo: removedDocId
})
const notifications = await control.findAll(control.ctx, notification.class.MentionInboxNotification, {
mentionedIn: removedDocId
})
for (const notification of notifications) {
const removeTx = txFactory.createTxRemoveDoc(notification._class, notification.space, notification._id)
txes.push(removeTx)
}
for (const ref of refs) {
const removeTx = txFactory.createTxRemoveDoc(ref._class, ref.space, ref._id)
txes.push(txFactory.createTxCollectionCUD(ref.attachedToClass, ref.attachedTo, ref.space, ref.collection, removeTx))
@@ -727,12 +696,9 @@ export async function ReferenceTrigger (txes: TxCUD<Doc>[], control: TriggerCont
const result: Tx[] = []
for (const tx of txes) {
if (control.hierarchy.isDerived(tx.objectClass, activity.class.ActivityReference)) {
continue
}
if (control.hierarchy.isDerived(tx.objectClass, notification.class.InboxNotification)) {
continue
}
if (control.hierarchy.isDerived(tx.objectClass, activity.class.ActivityReference)) continue
if (control.hierarchy.isDerived(tx.objectClass, notification.class.InboxNotification)) continue
if (control.hierarchy.isDerived(tx.objectClass, activity.class.UserMentionInfo)) continue
if (tx._class === core.class.TxCreateDoc) {
result.push(...(await ActivityReferenceCreate(tx, control)))
+27 -16
View File
@@ -15,7 +15,7 @@
import activity, { ActivityMessage, ActivityReference } from '@hcengineering/activity'
import chunter, { Channel, ChatMessage, chunterId, ChunterSpace, ThreadMessage } from '@hcengineering/chunter'
import contact, { Person } from '@hcengineering/contact'
import contact, { Employee, Person } from '@hcengineering/contact'
import { getAccountBySocialId, getPerson } from '@hcengineering/server-contact'
import core, {
PersonId,
@@ -36,7 +36,8 @@ import core, {
UserStatus,
type MeasureContext,
combineAttributes,
AccountUuid
AccountUuid,
notEmpty
} from '@hcengineering/core'
import notification, { DocNotifyContext, NotificationContent } from '@hcengineering/notification'
import { getMetadata, IntlString, translate } from '@hcengineering/platform'
@@ -46,7 +47,7 @@ import {
getDocCollaborators,
getMixinTx
} from '@hcengineering/server-notification-resources'
import { markupToText, stripTags } from '@hcengineering/text-core'
import { extractReferences, markupToText, stripTags } from '@hcengineering/text-core'
import { jsonToHTML, markupToJSON } from '@hcengineering/text'
import { workbenchId } from '@hcengineering/workbench'
@@ -175,14 +176,21 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD<Doc>, contro
const isChannel = hierarchy.isDerived(targetDoc._class, chunter.class.Channel)
const res: Tx[] = []
const account = await getAccountBySocialId(control, message.modifiedBy)
if (account == null) {
return []
}
const node = markupToJSON(message.message)
const references = extractReferences(node)
const mentionedPersons = references
.filter(({ objectClass }) => control.hierarchy.isDerived(objectClass, contact.class.Person))
.map(({ objectId }) => objectId as Ref<Person>)
const employees =
mentionedPersons.length > 0
? await control.findAll(ctx, contact.mixin.Employee, { _id: { $in: mentionedPersons as Ref<Employee>[] } })
: []
const collaboratorsFromMessage = [...employees.map((it) => it.personUuid), account].filter(notEmpty)
if (hierarchy.hasMixin(targetDoc, notification.mixin.Collaborators)) {
const collaboratorsMixin = hierarchy.as(targetDoc, notification.mixin.Collaborators)
if (!collaboratorsMixin.collaborators.includes(account)) {
const newCollabs = collaboratorsFromMessage.filter((it) => !collaboratorsMixin.collaborators.includes(it))
if (newCollabs.length > 0) {
res.push(
control.txFactory.createTxMixin(
targetDoc._id,
@@ -190,22 +198,25 @@ async function OnChatMessageCreated (ctx: MeasureContext, tx: TxCUD<Doc>, contro
targetDoc.space,
notification.mixin.Collaborators,
{
$push: {
collaborators: account
}
$push: { collaborators: { $each: newCollabs, $position: 0 } }
}
)
)
}
} else {
const collaborators = await getDocCollaborators(ctx, targetDoc, mixin, control)
if (!collaborators.includes(account)) {
collaborators.push(account)
}
res.push(getMixinTx(tx, control, collaborators))
res.push(getMixinTx(tx, control, Array.from(new Set(collaborators.concat(collaboratorsFromMessage)))))
}
if (isChannel && !(targetDoc as Channel).members.includes(account)) {
if (collaboratorsFromMessage.length > 0) {
control.txFactory.createTxMixin(message._id, message._class, message.space, notification.mixin.Collaborators, {
$push: {
$push: { collaborators: { $each: collaboratorsFromMessage, $position: 0 } }
}
})
}
if (account != null && isChannel && !(targetDoc as Channel).members.includes(account)) {
res.push(...joinChannel(control, targetDoc as Channel, account))
}
+3 -3
View File
@@ -31,7 +31,7 @@ import {
} from '@hcengineering/core'
import gmail, { Message } from '@hcengineering/gmail'
import { TriggerControl } from '@hcengineering/server-core'
import { BaseNotificationType, InboxNotification, NotificationType } from '@hcengineering/notification'
import { NotificationType, InboxNotification } from '@hcengineering/notification'
import serverNotification, { ReceiverInfo, SenderInfo } from '@hcengineering/server-notification'
import { getContentByTemplate } from '@hcengineering/server-notification-resources'
import { getMetadata } from '@hcengineering/platform'
@@ -138,7 +138,7 @@ export async function sendEmailNotification (
async function notifyByEmail (
control: TriggerControl,
type: Ref<BaseNotificationType>,
type: Ref<NotificationType>,
doc: Doc | undefined,
sender: SenderInfo,
receiver: ReceiverInfo,
@@ -160,7 +160,7 @@ async function notifyByEmail (
const SendEmailNotifications = async (
control: TriggerControl,
types: BaseNotificationType[],
types: NotificationType[],
object: Doc,
data: InboxNotification,
receiver: ReceiverInfo,
@@ -47,7 +47,6 @@ import core, {
} from '@hcengineering/core'
import notification, {
ActivityInboxNotification,
BaseNotificationType,
ClassCollaborators,
Collaborators,
CommonInboxNotification,
@@ -87,6 +86,7 @@ import {
isMixinTx,
isShouldNotifyTx,
isUserEmployeeInFieldValueTypeMatch,
mentionTypeMatch,
messageToMarkup,
type NotificationProviderControl,
replaceAll,
@@ -147,6 +147,7 @@ export async function getCommonNotificationTxes (
data,
_class,
modifiedOn,
[],
true,
tx
)
@@ -191,7 +192,7 @@ function fillTemplate (
export async function getContentByTemplate (
doc: Doc | undefined,
sender: string,
type: Ref<BaseNotificationType>,
type: Ref<NotificationType>,
control: TriggerControl,
data: string,
notificationData?: InboxNotification,
@@ -352,6 +353,7 @@ export async function pushInboxNotifications (
data: Partial<Data<InboxNotification>>,
_class: Ref<Class<InboxNotification>>,
modifiedOn: Timestamp,
types: Ref<NotificationType>[],
shouldUpdateTimestamp = true,
tx?: TxCUD<Doc>
): Promise<TxCreateDoc<InboxNotification> | undefined> {
@@ -381,6 +383,7 @@ export async function pushInboxNotifications (
archived: false,
objectId,
objectClass,
types,
...data
}
const notificationTx = control.txFactory.createTxCreateDoc(_class, receiver.space, notificationData)
@@ -500,6 +503,7 @@ export async function pushActivityInboxNotifications (
object: Doc,
docNotifyContexts: DocNotifyContext[],
activityMessage: ActivityMessage,
types: Ref<NotificationType>[],
shouldUpdateTimestamp: boolean
): Promise<TxCreateDoc<InboxNotification> | undefined> {
const content = await getNotificationContent(originTx, receiver.employee, sender, object, control)
@@ -522,6 +526,7 @@ export async function pushActivityInboxNotifications (
data,
notification.class.ActivityInboxNotification,
activityMessage.modifiedOn,
types,
shouldUpdateTimestamp,
originTx
)
@@ -590,8 +595,7 @@ export async function getNotificationTxes (
control,
tx,
object,
receiver.employee,
receiver.socialIds,
receiver,
params.isOwn,
params.isSpace,
settings,
@@ -599,6 +603,7 @@ export async function getNotificationTxes (
)
if (notifyResult.has(notification.providers.InboxNotificationProvider)) {
const types = (notifyResult.get(notification.providers.InboxNotificationProvider) ?? []).map((it) => it._id)
const notificationTx = await pushActivityInboxNotifications(
ctx,
tx,
@@ -609,6 +614,7 @@ export async function getNotificationTxes (
object,
docNotifyContexts,
message,
types,
params.shouldUpdateTimestamp
)
@@ -753,7 +759,7 @@ export async function createCollabDocInfo (
cache.set(space._id, space)
const filteredCollaborators = control.hierarchy.isDerived(object._class, core.class.SystemSpace)
const filteredCollaborators = !space.private
? collaborators
: collaborators.filter(
(it) =>
@@ -1028,7 +1034,7 @@ async function updateCollaboratorsMixin (
prevCollabs = mixin !== undefined ? new Set(await getDocCollaborators(ctx, prevDoc, mixin, control)) : new Set()
}
const type = await control.modelDb.findOne(notification.class.BaseNotificationType, {
const type = await control.modelDb.findOne(notification.class.NotificationType, {
_id: notification.ids.CollaboratoAddNotification
})
@@ -1091,6 +1097,7 @@ async function updateCollaboratorsMixin (
prevDoc,
docNotifyContexts,
message,
[],
true
)
}
@@ -1712,6 +1719,7 @@ export default async () => ({
PushNotificationsHandler
},
function: {
IsUserEmployeeInFieldValueTypeMatch: isUserEmployeeInFieldValueTypeMatch
IsUserEmployeeInFieldValueTypeMatch: isUserEmployeeInFieldValueTypeMatch,
MentionTypeMatch: mentionTypeMatch
}
})
@@ -13,7 +13,7 @@
// limitations under the License.
//
import {
BaseNotificationType,
NotificationType,
DocNotifyContext,
InboxNotification,
NotificationProvider
@@ -32,7 +32,7 @@ export interface Content {
/**
* @public
*/
export type NotifyResult = Map<Ref<NotificationProvider>, BaseNotificationType[]>
export type NotifyResult = Map<Ref<NotificationProvider>, NotificationType[]>
export interface NotifyParams {
isOwn: boolean
@@ -40,14 +40,14 @@ import core, {
Ref,
Space,
Tx,
TxCreateDoc,
TxCUD,
TxMixin,
TxProcessor,
TxUpdateDoc
} from '@hcengineering/core'
import notification, {
BaseNotificationType,
Collaborators,
CommonNotificationType,
NotificationContent,
notificationId,
NotificationProvider,
@@ -67,9 +67,10 @@ import serverNotification, {
import serverView from '@hcengineering/server-view'
import { encodeObjectURI } from '@hcengineering/view'
import { workbenchId } from '@hcengineering/workbench'
import { extractReferences, markupToJSON, Reference } from '@hcengineering/text-core'
import { getPersonSpaces } from '@hcengineering/server-contact'
import { NotifyResult } from './types'
import { getPersonSpaces } from '@hcengineering/server-contact'
/**
* @public
@@ -94,6 +95,36 @@ export function isUserEmployeeInFieldValueTypeMatch (
}
}
export const mentionTypeMatch = (
tx: TxCreateDoc<ChatMessage>,
doc: Doc,
person: Ref<Person>,
socialIds: PersonId[],
type: NotificationType,
control: TriggerControl,
account: AccountUuid
): boolean => {
const hierarchy = control.hierarchy
if (tx._class !== core.class.TxCreateDoc) return false
if (!hierarchy.isDerived(tx.objectClass, chunter.class.ChatMessage)) return false
const message = TxProcessor.createDoc2Doc(tx)
const content: string = message.message
const references: Reference[] =
control.contextCache.get(`${message._id}_references`) ?? extractReferences(markupToJSON(content))
control.contextCache.set(`${message._id}_references`, references)
if (references.length === 0) return false
if (references.some(({ objectId }) => objectId === contact.mention.Everyone)) return true
if (references.some(({ objectId }) => objectId === contact.mention.Here)) {
const isOnline = Array.from(control.userStatusMap.values()).some(({ user, online }) => user === account && online)
if (isOnline) return true
}
return references.some(({ objectId }) => objectId === person)
}
/**
* @public
*/
@@ -119,27 +150,20 @@ function escapeRegExp (str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export async function shouldNotifyCommon (
export function getAllowedProviders (
control: TriggerControl,
socialIds: PersonId[],
typeId: Ref<CommonNotificationType>,
type: NotificationType,
notificationControl: NotificationProviderControl
): Promise<NotifyResult> {
const type = (await control.modelDb.findAll(notification.class.CommonNotificationType, { _id: typeId }))[0]
if (type === undefined) {
return new Map()
}
const result = new Map<Ref<NotificationProvider>, BaseNotificationType[]>()
const providers = await control.modelDb.findAll(notification.class.NotificationProvider, {})
): Ref<NotificationProvider>[] {
const result: Ref<NotificationProvider>[] = []
const providers = control.modelDb.findAllSync(notification.class.NotificationProvider, {})
for (const provider of providers) {
const allowed = isAllowed(control, socialIds, type, provider, notificationControl)
if (allowed) {
const cur = result.get(provider._id) ?? []
result.set(provider._id, [...cur, type])
result.push(provider._id)
}
}
@@ -149,7 +173,7 @@ export async function shouldNotifyCommon (
export function isAllowed (
control: TriggerControl,
receiverIds: PersonId[],
type: BaseNotificationType,
type: NotificationType,
provider: NotificationProvider,
notificationControl: NotificationProviderControl
): boolean {
@@ -192,8 +216,7 @@ export async function isShouldNotifyTx (
control: TriggerControl,
tx: TxCUD<Doc>,
object: Doc,
person: Ref<Person>,
personIds: PersonId[],
receiver: ReceiverInfo,
isOwn: boolean,
isSpace: boolean,
notificationControl: NotificationProviderControl,
@@ -201,7 +224,7 @@ export async function isShouldNotifyTx (
): Promise<NotifyResult> {
const types = getMatchedTypes(control, tx, isOwn, isSpace, docUpdateMessage?.attributeUpdates?.attrKey)
const modifiedByPersonId = tx.modifiedBy
const result = new Map<Ref<NotificationProvider>, BaseNotificationType[]>()
const result = new Map<Ref<NotificationProvider>, NotificationType[]>()
let providers: NotificationProvider[] = control.modelDb.findAllSync(notification.class.NotificationProvider, {})
if (getMetadata(serverNotification.metadata.InboxOnlyNotifications) === true) {
@@ -209,7 +232,7 @@ export async function isShouldNotifyTx (
}
for (const type of types) {
if (type.allowedForAuthor !== true && personIds.includes(modifiedByPersonId)) {
if (type.allowedForAuthor !== true && receiver.socialIds.includes(modifiedByPersonId)) {
continue
}
@@ -217,7 +240,7 @@ export async function isShouldNotifyTx (
const mixin = control.hierarchy.as(type, serverNotification.mixin.TypeMatch)
if (mixin.func !== undefined) {
const f = await getResource(mixin.func)
let res = f(tx, object, person, personIds, type, control)
let res = f(tx, object, receiver.employee, receiver.socialIds, type, control, receiver.account)
if (res instanceof Promise) {
res = await res
}
@@ -225,7 +248,7 @@ export async function isShouldNotifyTx (
}
}
for (const provider of providers) {
const allowed = isAllowed(control, personIds, type, provider, notificationControl)
const allowed = isAllowed(control, receiver.socialIds, type, provider, notificationControl)
if (allowed) {
const cur = result.get(provider._id) ?? []
+4 -2
View File
@@ -55,7 +55,8 @@ export type TypeMatchFunc = Resource<
person: Ref<Person>,
socialIds: PersonId[],
type: NotificationType,
control: TriggerControl
control: TriggerControl,
account: AccountUuid
) => boolean | Promise<boolean>
>
@@ -123,6 +124,7 @@ export default plugin(serverNotificationId, {
PushNotificationsHandler: '' as Resource<TriggerFunc>
},
function: {
IsUserEmployeeInFieldValueTypeMatch: '' as TypeMatchFunc
IsUserEmployeeInFieldValueTypeMatch: '' as TypeMatchFunc,
MentionTypeMatch: '' as TypeMatchFunc
}
})
+1 -10
View File
@@ -307,16 +307,7 @@ export async function OnToDoCreate (txes: TxCUD<Doc>[], control: TriggerControl)
const senderInfo: SenderInfo = await getSenderInfo(control.ctx, tx.modifiedBy, control)
const notificationControl = await getNotificationProviderControl(control.ctx, control)
const notifyResult = await isShouldNotifyTx(
control,
createTx,
todo,
employee._id,
socialIds,
true,
false,
notificationControl
)
const notifyResult = await isShouldNotifyTx(control, createTx, todo, receiverInfo, true, false, notificationControl)
const content = await getNotificationContent(tx, employee._id, senderInfo, todo, control)
const data: Partial<Data<CommonInboxNotification>> = {
...content,
+3
View File
@@ -52,6 +52,7 @@ import {
type Tx,
type TxFactory,
type TxResult,
type UserStatus,
type WorkspaceIds,
type WorkspaceUuid
} from '@hcengineering/core'
@@ -205,6 +206,7 @@ export interface PipelineContext {
broadcastEvent?: (ctx: MeasureContext, tx: Tx[]) => Promise<void>
communicationApi: CommunicationApi | null
userStatusMap?: Map<Ref<UserStatus>, { online: boolean, user: AccountUuid }>
}
/**
* @public
@@ -269,6 +271,7 @@ export interface TriggerControl {
lowLevel: LowLevelStorage
modelDb: ModelDb
removedMap: Map<Ref<Doc>, Doc>
userStatusMap: Map<Ref<UserStatus>, { online: boolean, user: AccountUuid }>
queue?: PlatformQueue
+1
View File
@@ -37,3 +37,4 @@ export * from './txPush'
export * from './queue'
export * from './identity'
export * from './pluginConfig'
export * from './userStatus'
+1
View File
@@ -139,6 +139,7 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware {
hierarchy: this.context.hierarchy,
cache: this.cache,
communicationApi: this.context.communicationApi,
userStatusMap: this.context.userStatusMap ?? new Map(),
apply: async (ctx, tx, needResult) => {
if (needResult === true) {
return (await this.context.derived?.tx(ctx, tx)) ?? {}
+79
View File
@@ -0,0 +1,79 @@
//
// 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 core, {
MeasureContext,
Tx,
type SessionData,
type TxApplyIf,
TxCUD,
Doc,
TxProcessor,
UserStatus,
TxCreateDoc,
TxUpdateDoc,
Ref
} from '@hcengineering/core'
import { BaseMiddleware, Middleware, TxMiddlewareResult, type PipelineContext } from '@hcengineering/server-core'
/**
* @public
*/
export class UserStatusMiddleware extends BaseMiddleware implements Middleware {
private constructor (context: PipelineContext, next?: Middleware) {
super(context, next)
}
static async create (
_: MeasureContext,
context: PipelineContext,
next: Middleware | undefined
): Promise<UserStatusMiddleware> {
return new UserStatusMiddleware(context, next)
}
tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
for (const tx of txes) {
if (tx._class === core.class.TxApplyIf) {
const atx = tx as TxApplyIf
atx.txes.forEach((it) => {
this.processTx(it)
})
} else {
this.processTx(tx as TxCUD<Doc>)
}
}
return this.provideTx(ctx, txes)
}
private processTx (tx: TxCUD<Doc>): void {
if (tx._class === core.class.TxCreateDoc && tx.objectClass === core.class.UserStatus) {
const status = TxProcessor.createDoc2Doc(tx as TxCreateDoc<UserStatus>)
const map = this.context.userStatusMap ?? new Map()
map.set(status._id, { online: status.online, user: status.user })
this.context.userStatusMap = map
} else if (tx._class === core.class.TxUpdateDoc && tx.objectClass === core.class.UserStatus) {
const uTx = tx as TxUpdateDoc<UserStatus>
if ('online' in uTx.operations) {
const current = this.context.userStatusMap?.get(uTx.objectId)
const online = uTx.operations.online
if (current !== undefined && online !== undefined) {
this.context.userStatusMap?.set(uTx.objectId, { online, user: current.user })
}
}
} else if (tx._class === core.class.TxRemoveDoc && tx.objectClass === core.class.UserStatus) {
this.context.userStatusMap?.delete(tx.objectId as Ref<UserStatus>)
}
}
}
+3 -1
View File
@@ -37,7 +37,8 @@ import {
SpacePermissionsMiddleware,
SpaceSecurityMiddleware,
TriggersMiddleware,
TxMiddleware
TxMiddleware,
UserStatusMiddleware
} from '@hcengineering/middleware'
import {
createBenchmarkAdapter,
@@ -127,6 +128,7 @@ export function createServerPipeline (
ConfigurationMiddleware.create,
ContextNameMiddleware.create,
MarkDerivedEntryMiddleware.create,
UserStatusMiddleware.create,
ApplyTxMiddleware.create, // Extract apply
TxMiddleware.create, // Store tx into transaction domain
...(opt.disableTriggers === true ? [] : [TriggersMiddleware.create]),