diff --git a/changelog.md b/changelog.md index 068f338168..665748398f 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ Core: - Allow to leave workspace - Allow to kick employee +- Browser notifications - Allow to create employee HR: diff --git a/models/notification/src/index.ts b/models/notification/src/index.ts index 76ce6e4b40..29f6dd6199 100644 --- a/models/notification/src/index.ts +++ b/models/notification/src/index.ts @@ -51,6 +51,10 @@ export class TNotification extends TAttachedDoc implements Notification { @Prop(TypeString(), 'Status' as IntlString) status!: NotificationStatus + + text!: string + + type!: Ref<NotificationType> } @Model(notification.class.EmailNotification, core.class.Doc, DOMAIN_NOTIFICATION) @@ -137,6 +141,16 @@ export function createModel (builder: Builder): void { notification.ids.PlatformNotification ) + builder.createDoc( + notification.class.NotificationProvider, + core.space.Model, + { + label: notification.string.BrowserNotification, + default: false + }, + notification.ids.BrowserNotification + ) + builder.createDoc( notification.class.NotificationProvider, core.space.Model, diff --git a/models/notification/src/migration.ts b/models/notification/src/migration.ts index 643c7a5d6a..bf6fbbee68 100644 --- a/models/notification/src/migration.ts +++ b/models/notification/src/migration.ts @@ -13,9 +13,56 @@ // limitations under the License. // +import core, { DOMAIN_TX, Ref, TxCreateDoc, TxOperations } from '@anticrm/core' import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model' +import notification, { Notification, NotificationType } from '@anticrm/notification' +import { DOMAIN_NOTIFICATION } from '.' + +async function fillNotificationText (client: MigrationClient): Promise<void> { + await client.update( + DOMAIN_NOTIFICATION, + { _class: notification.class.Notification, text: { $exists: false } }, + { + text: '' + } + ) + await client.update( + DOMAIN_TX, + { + _class: core.class.TxCreateDoc, + objectClass: notification.class.Notification, + 'attributes.text': { $exists: false } + }, + { + 'attributes.text': '' + } + ) +} + +async function fillNotificationType (client: MigrationUpgradeClient): Promise<void> { + const notifications = await client.findAll(notification.class.Notification, { type: { $exists: false } }) + const txOp = new TxOperations(client, core.account.System) + const promises = notifications.map(async (doc) => { + const tx = await client.findOne(core.class.TxCUD, { _id: doc.tx }) + if (tx === undefined) return + const type = + tx._class === core.class.TxMixin + ? ('calendar:ids:ReminderNotification' as Ref<NotificationType>) + : notification.ids.MentionNotification + const objectTx = txOp.update(doc, { type }) + const ctx = await client.findOne<TxCreateDoc<Notification>>(core.class.TxCreateDoc, { objectId: doc._id }) + if (ctx === undefined) return await objectTx + const updateTx = txOp.update(ctx, { 'attributes.type': type } as any) + return await Promise.all([objectTx, updateTx]) + }) + await Promise.all(promises) +} export const notificationOperation: MigrateOperation = { - async migrate (client: MigrationClient): Promise<void> {}, - async upgrade (client: MigrationUpgradeClient): Promise<void> {} + async migrate (client: MigrationClient): Promise<void> { + await fillNotificationText(client) + }, + async upgrade (client: MigrationUpgradeClient): Promise<void> { + await fillNotificationType(client) + } } diff --git a/models/notification/src/plugin.ts b/models/notification/src/plugin.ts index ba81088122..4d90fb72eb 100644 --- a/models/notification/src/plugin.ts +++ b/models/notification/src/plugin.ts @@ -23,6 +23,7 @@ export default mergeIds(notificationId, notification, { LastView: '' as IntlString, MentionNotification: '' as IntlString, PlatformNotification: '' as IntlString, + BrowserNotification: '' as IntlString, EmailNotification: '' as IntlString }, component: { diff --git a/plugins/notification-assets/lang/en.json b/plugins/notification-assets/lang/en.json index b3dae1168a..74064664d1 100644 --- a/plugins/notification-assets/lang/en.json +++ b/plugins/notification-assets/lang/en.json @@ -8,6 +8,7 @@ "EmailNotification": "by email", "PlatformNotification": "in platform", "Track": "Track", - "DontTrack": "Don't track" + "DontTrack": "Don't track", + "BrowserNotification": "in browser" } } \ No newline at end of file diff --git a/plugins/notification-assets/lang/ru.json b/plugins/notification-assets/lang/ru.json index c773ab0590..37d64f395f 100644 --- a/plugins/notification-assets/lang/ru.json +++ b/plugins/notification-assets/lang/ru.json @@ -8,6 +8,7 @@ "EmailNotification": "по email", "PlatformNotification": "в системе", "Track": "Отслеживать", - "DontTrack": "Не отслеживать" + "DontTrack": "Не отслеживать", + "BrowserNotification": "в браузере" } } \ No newline at end of file diff --git a/plugins/notification-resources/src/components/BrowserNotificatator.svelte b/plugins/notification-resources/src/components/BrowserNotificatator.svelte new file mode 100644 index 0000000000..f9abd69007 --- /dev/null +++ b/plugins/notification-resources/src/components/BrowserNotificatator.svelte @@ -0,0 +1,119 @@ +<!-- +// 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. +--> +<script lang="ts"> + import contact, { EmployeeAccount } from '@anticrm/contact' + import { Doc, getCurrentAccount, Ref, Space } from '@anticrm/core' + import { + Notification as PlatformNotification, + NotificationProvider, + NotificationSetting, + NotificationStatus, + NotificationType + } from '@anticrm/notification' + import { createQuery } from '@anticrm/presentation' + import notification from '../plugin' + import { NotificationClientImpl } from '../utils' + + const query = createQuery() + const settingQuery = createQuery() + const providersQuery = createQuery() + const accountId = getCurrentAccount()._id + const space = accountId as string as Ref<Space> + const notificationClient = NotificationClientImpl.getClient() + const lastViews = notificationClient.getLastViews() + const lastViewId: Ref<Doc> = ((getCurrentAccount() as EmployeeAccount).employee + 'notification') as Ref<Doc> + + let settingsReceived = false + let settings: Map<Ref<NotificationType>, NotificationSetting> = new Map<Ref<NotificationType>, NotificationSetting>() + let provider: NotificationProvider | undefined + + const enabled = 'Notification' in window && Notification.permission !== 'denied' + + $: enabled && + providersQuery.query( + notification.class.NotificationProvider, + { _id: notification.ids.BrowserNotification }, + (res) => { + provider = res[0] + } + ) + + $: enabled && + settingQuery.query( + notification.class.NotificationSetting, + { + space + }, + (res) => { + settings = new Map( + res.map((setting) => { + return [setting.type, setting] + }) + ) + settingsReceived = true + } + ) + + $: enabled && + settingsReceived && + provider !== undefined && + query.query( + notification.class.Notification, + { + attachedTo: (getCurrentAccount() as EmployeeAccount).employee, + status: NotificationStatus.New + }, + (res) => { + process(res) + } + ) + + async function process (notifications: PlatformNotification[]): Promise<void> { + for (const notification of notifications) { + await tryNotify(notification) + } + } + + async function tryNotify (notification: PlatformNotification): Promise<void> { + const text = notification.text.replace(/<[^>]*>/g, '').trim() + if (text === '') return + const setting = settings.get(notification.type) + const enabled = setting?.enabled ?? provider?.default + if (!enabled) return + if (setting?.modifiedOn ?? notification.modifiedOn < 0) return + if (Notification.permission === 'granted') { + await notify(text, notification) + } else if (Notification.permission !== 'denied') { + const permission = await Notification.requestPermission() + if (permission === 'granted') { + await notify(text, notification) + } + } + } + + async function notify (text: string, notification: PlatformNotification): Promise<void> { + const lastView = $lastViews.get(lastViewId) + if (lastView ?? notification.modifiedOn > 0) { + // eslint-disable-next-line + new Notification(text, { tag: notification._id }) + await notificationClient.updateLastView( + lastViewId, + contact.class.Employee, + notification.modifiedOn, + lastView === undefined + ) + } + } +</script> diff --git a/plugins/notification-resources/src/components/NotificationSettings.svelte b/plugins/notification-resources/src/components/NotificationSettings.svelte index bbaf3c693e..b28f1d73c6 100644 --- a/plugins/notification-resources/src/components/NotificationSettings.svelte +++ b/plugins/notification-resources/src/components/NotificationSettings.svelte @@ -26,6 +26,8 @@ const client = getClient() const space = accountId as string as Ref<Space> + let disabled = true + let types: NotificationType[] = [] let providers: NotificationProvider[] = [] let settings: Map<Ref<NotificationType>, Map<Ref<NotificationProvider>, NotificationSetting>> = new Map< @@ -67,6 +69,7 @@ } else { current.enabled = value } + disabled = false } function getSetting ( @@ -92,6 +95,7 @@ } async function save (): Promise<void> { + disabled = true const promises: Promise<any>[] = [] for (const type of settings.values()) { for (const setting of type.values()) { @@ -107,7 +111,11 @@ } } } - await Promise.all(promises) + try { + await Promise.all(promises) + } catch (e) { + console.log(e) + } } $: column = providers.length + 1 @@ -152,6 +160,7 @@ <div class="flex-row-reverse"> <Button label={presentation.string.Save} + {disabled} kind={'primary'} on:click={() => { save() diff --git a/plugins/notification-resources/src/index.ts b/plugins/notification-resources/src/index.ts index d437af8bfe..827dc7dc54 100644 --- a/plugins/notification-resources/src/index.ts +++ b/plugins/notification-resources/src/index.ts @@ -23,6 +23,8 @@ import { NotificationClientImpl } from './utils' export * from './utils' +export { default as BrowserNotificatator } from './components/BrowserNotificatator.svelte' + export default async (): Promise<Resources> => ({ component: { NotificationsPopup, diff --git a/plugins/notification/src/index.ts b/plugins/notification/src/index.ts index 98318c3596..7f44ef4743 100644 --- a/plugins/notification/src/index.ts +++ b/plugins/notification/src/index.ts @@ -33,6 +33,8 @@ export interface LastView extends AttachedDoc { export interface Notification extends AttachedDoc { tx: Ref<TxCUD<Doc>> status: NotificationStatus + text: string + type: Ref<NotificationType> } /** @@ -137,6 +139,7 @@ const notification = plugin(notificationId, { ids: { MentionNotification: '' as Ref<NotificationType>, PlatformNotification: '' as Ref<NotificationProvider>, + BrowserNotification: '' as Ref<NotificationProvider>, EmailNotification: '' as Ref<NotificationProvider>, NotificationSettings: '' as Ref<Doc> }, diff --git a/plugins/workbench-resources/src/components/Workbench.svelte b/plugins/workbench-resources/src/components/Workbench.svelte index dd99a2a655..c3139febb9 100644 --- a/plugins/workbench-resources/src/components/Workbench.svelte +++ b/plugins/workbench-resources/src/components/Workbench.svelte @@ -17,7 +17,7 @@ import contact, { Employee, EmployeeAccount } from '@anticrm/contact' import core, { Class, Client, Doc, getCurrentAccount, Ref, Space } from '@anticrm/core' import notification, { NotificationStatus } from '@anticrm/notification' - import { NotificationClientImpl } from '@anticrm/notification-resources' + import { NotificationClientImpl, BrowserNotificatator } from '@anticrm/notification-resources' import { getMetadata, getResource, IntlString } from '@anticrm/platform' import { Avatar, createQuery, setClient } from '@anticrm/presentation' import { @@ -119,9 +119,6 @@ }, (res) => { hasNotification = res.length > 0 - }, - { - limit: 1 } ) @@ -517,6 +514,7 @@ </svelte:fragment> </Popup> <DatePickerPopup /> + <BrowserNotificatator /> {:else} <div class="flex-col-center justify-center h-full flex-grow"> <h1><Label label={workbench.string.AccountDisabled} /></h1> diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index 0607a8b627..17e9426343 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -34,7 +34,12 @@ import core, { TxCUD, TxProcessor } from '@anticrm/core' -import notification, { EmailNotification, Notification, NotificationStatus } from '@anticrm/notification' +import notification, { + EmailNotification, + Notification, + NotificationProvider, + NotificationStatus +} from '@anticrm/notification' import { getResource } from '@anticrm/platform' import type { TriggerControl } from '@anticrm/server-core' import { extractTx } from '@anticrm/server-core' @@ -46,33 +51,52 @@ import view, { HTMLPresenter, TextPresenter } from '@anticrm/view' */ export async function OnBacklinkCreate (tx: Tx, control: TriggerControl): Promise<Tx[]> { const hierarchy = control.hierarchy - if (tx._class !== core.class.TxCollectionCUD) { - return [] + const ptx = tx as TxCollectionCUD<Doc, Backlink> + + if (!checkTx(ptx, hierarchy)) return [] + + const result: Tx[] = [] + + const receiver = await getReceiver(ptx, control) + if (receiver === undefined) return [] + const sender = await getSender(ptx, control) + const backlink = getBacklink(ptx) + const doc = await getBacklinkDoc(backlink, control) + const textPart = doc !== undefined ? await getTextPart(doc, hierarchy) : undefined + const htmlPart = doc !== undefined ? await getHtmlPart(doc, hierarchy) : undefined + + const createNotificationTx = await getPlatformNotificationTx(ptx, backlink, textPart, sender) + + if (createNotificationTx !== undefined) { + result.push(createNotificationTx) } - const ptx = tx as TxCollectionCUD<Doc, Backlink> + if ( + sender !== undefined && + textPart !== undefined && + (await isAllowed(control, receiver, notification.ids.EmailNotification)) + ) { + const emailTx = await getEmailTx(ptx, backlink, sender, textPart, htmlPart, receiver) + if (emailTx !== undefined) { + result.push(emailTx) + } + } + return result +} + +function checkTx (ptx: TxCollectionCUD<Doc, Backlink>, hierarchy: Hierarchy): boolean { + if (ptx._class !== core.class.TxCollectionCUD) { + return false + } if ( ptx.tx._class !== core.class.TxCreateDoc || !hierarchy.isDerived(ptx.tx.objectClass, chunter.class.Backlink) || !hierarchy.isDerived(ptx.objectClass, contact.class.Employee) ) { - return [] + return false } - - const result: Tx[] = [] - - const createNotificationTx = await getPlatformNotificationTx(ptx, control) - - if (createNotificationTx !== undefined) { - result.push(createNotificationTx) - } - - const emailTx = await getEmailTx(ptx, control) - if (emailTx !== undefined) { - result.push(emailTx) - } - return result + return true } async function getUpdateLastViewTxes ( @@ -170,11 +194,11 @@ export async function UpdateLastView (tx: Tx, control: TriggerControl): Promise< return result } -async function getPlatformNotificationTx ( +async function getReceiver ( ptx: TxCollectionCUD<Doc, Backlink>, control: TriggerControl -): Promise<TxCollectionCUD<Doc, Notification> | undefined> { - const attached = ( +): Promise<EmployeeAccount | undefined> { + return ( await control.modelDb.findAll( contact.class.EmployeeAccount, { @@ -183,29 +207,42 @@ async function getPlatformNotificationTx ( { limit: 1 } ) )[0] - if (attached === undefined) return +} +async function isAllowed ( + control: TriggerControl, + receiver: EmployeeAccount, + providerId: Ref<NotificationProvider> +): Promise<boolean> { const setting = ( await control.findAll( notification.class.NotificationSetting, { - provider: notification.ids.PlatformNotification, + provider: providerId, type: notification.ids.MentionNotification, - space: attached._id as unknown as Ref<Space> + space: receiver._id as unknown as Ref<Space> }, { limit: 1 } ) )[0] - if (setting === undefined) { - const provider = ( - await control.modelDb.findAll(notification.class.NotificationProvider, { - _id: notification.ids.PlatformNotification - }) - )[0] - if (provider === undefined) return - if (!provider.default) return + if (setting !== undefined) { + return setting.enabled } + const provider = ( + await control.modelDb.findAll(notification.class.NotificationProvider, { + _id: providerId + }) + )[0] + if (provider === undefined) return false + return provider.default +} +async function getPlatformNotificationTx ( + ptx: TxCollectionCUD<Doc, Backlink>, + backlink: Backlink, + textPart: string | undefined, + sender: string | undefined +): Promise<TxCollectionCUD<Doc, Notification> | undefined> { const createTx: TxCreateDoc<Notification> = { objectClass: notification.class.Notification, objectSpace: notification.space.Notifications, @@ -217,10 +254,16 @@ async function getPlatformNotificationTx ( _class: core.class.TxCreateDoc, attributes: { tx: ptx._id, - status: NotificationStatus.New + status: NotificationStatus.New, + type: notification.ids.MentionNotification } as unknown as Data<Notification> } + if (sender !== undefined && textPart !== undefined) { + const text = `${sender} mentioned you in ${textPart} ${backlink.message}` + createTx.attributes.text = text + } + const createNotificationTx: TxCollectionCUD<Doc, Notification> = { ...ptx, _id: generateId(), @@ -231,12 +274,35 @@ async function getPlatformNotificationTx ( return createNotificationTx } -async function getEmailTx ( - ptx: TxCollectionCUD<Doc, Backlink>, - control: TriggerControl -): Promise<TxCreateDoc<EmailNotification> | undefined> { - const hierarchy = control.hierarchy - const backlink = TxProcessor.createDoc2Doc(ptx.tx as TxCreateDoc<Backlink>) +function getBacklink (ptx: TxCollectionCUD<Doc, Backlink>): Backlink { + return TxProcessor.createDoc2Doc(ptx.tx as TxCreateDoc<Backlink>) +} + +async function getBacklinkDoc (backlink: Backlink, control: TriggerControl): Promise<Doc | undefined> { + return ( + await control.findAll( + backlink.backlinkClass, + { + _id: backlink.backlinkId + }, + { limit: 1 } + ) + )[0] +} + +async function getTextPart (doc: Doc, hierarchy: Hierarchy): Promise<string | undefined> { + const TextPresenter = getTextPresenter(doc._class, hierarchy) + if (TextPresenter === undefined) return + return (await getResource(TextPresenter.presenter))(doc) +} + +async function getHtmlPart (doc: Doc, hierarchy: Hierarchy): Promise<string | undefined> { + const HTMLPresenter = getHTMLPresenter(doc._class, hierarchy) + const htmlPart = HTMLPresenter !== undefined ? (await getResource(HTMLPresenter.presenter))(doc) : undefined + return htmlPart +} + +async function getSender (ptx: TxCollectionCUD<Doc, Backlink>, control: TriggerControl): Promise<string | undefined> { const account = ( await control.modelDb.findAll( contact.class.EmployeeAccount, @@ -248,57 +314,17 @@ async function getEmailTx ( )[0] if (account === undefined) return undefined - const sender = formatName(account.name) - const attached = ( - await control.modelDb.findAll( - contact.class.EmployeeAccount, - { - employee: ptx.objectId as Ref<Employee> - }, - { limit: 1 } - ) - )[0] - if (attached === undefined) return undefined + return formatName(account.name) +} - const setting = ( - await control.findAll( - notification.class.NotificationSetting, - { - provider: notification.ids.EmailNotification, - type: notification.ids.MentionNotification, - space: attached._id as unknown as Ref<Space> - }, - { limit: 1 } - ) - )[0] - if (setting === undefined) { - const provider = ( - await control.modelDb.findAll(notification.class.NotificationProvider, { - _id: notification.ids.PlatformNotification - }) - )[0] - if (provider === undefined) return - if (!provider.default) return - } - - const receiver = attached.email - const doc = ( - await control.findAll( - backlink.backlinkClass, - { - _id: backlink.backlinkId - }, - { limit: 1 } - ) - )[0] - if (doc === undefined) return undefined - - const TextPresenter = getTextPresenter(doc._class, hierarchy) - if (TextPresenter === undefined) return - - const HTMLPresenter = getHTMLPresenter(doc._class, hierarchy) - const htmlPart = HTMLPresenter !== undefined ? (await getResource(HTMLPresenter.presenter))(doc) : undefined - const textPart = (await getResource(TextPresenter.presenter))(doc) +async function getEmailTx ( + ptx: TxCollectionCUD<Doc, Backlink>, + backlink: Backlink, + sender: string, + textPart: string, + htmlPart: string | undefined, + receiver: EmployeeAccount +): Promise<TxCreateDoc<EmailNotification> | undefined> { const html = `<p><b>${sender}</b> mentioned you in ${htmlPart !== undefined ? htmlPart : textPart}</p> ${ backlink.message }` @@ -315,7 +341,7 @@ async function getEmailTx ( attributes: { status: 'new', sender, - receivers: [receiver], + receivers: [receiver.email], subject: `You was mentioned in ${textPart}`, text, html