Browser notification (#2178)

Signed-off-by: Denis Bykhov <80476319+BykhovDenis@users.noreply.github.com>
This commit is contained in:
Denis Bykhov 2022-06-30 17:24:19 +06:00 committed by GitHub
parent 643544c4cb
commit 7653c1cf83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 322 additions and 100 deletions

View File

@ -6,6 +6,7 @@ Core:
- Allow to leave workspace
- Allow to kick employee
- Browser notifications
- Allow to create employee
HR:

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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: {

View File

@ -8,6 +8,7 @@
"EmailNotification": "by email",
"PlatformNotification": "in platform",
"Track": "Track",
"DontTrack": "Don't track"
"DontTrack": "Don't track",
"BrowserNotification": "in browser"
}
}

View File

@ -8,6 +8,7 @@
"EmailNotification": "по email",
"PlatformNotification": "в системе",
"Track": "Отслеживать",
"DontTrack": "Не отслеживать"
"DontTrack": "Не отслеживать",
"BrowserNotification": "в браузере"
}
}

View File

@ -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>

View File

@ -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()

View File

@ -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,

View File

@ -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>
},

View File

@ -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>

View File

@ -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