Improve push notifications (#5397)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2024-04-18 22:11:24 +05:00 committed by GitHub
parent a948b20155
commit 83725fc541
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 317 additions and 139 deletions

View File

@ -20,6 +20,7 @@ import {
DOMAIN_MODEL, DOMAIN_MODEL,
Hierarchy, Hierarchy,
IndexKind, IndexKind,
type Space,
type Account, type Account,
type AttachedDoc, type AttachedDoc,
type Class, type Class,
@ -60,8 +61,8 @@ import {
type CommonInboxNotification, type CommonInboxNotification,
type CommonNotificationType, type CommonNotificationType,
type DocNotifyContext, type DocNotifyContext,
type DocUpdates,
type DocUpdateTx, type DocUpdateTx,
type DocUpdates,
type InboxNotification, type InboxNotification,
type MentionInboxNotification, type MentionInboxNotification,
type NotificationContextPresenter, type NotificationContextPresenter,
@ -73,8 +74,8 @@ import {
type NotificationSetting, type NotificationSetting,
type NotificationStatus, type NotificationStatus,
type NotificationTemplate, type NotificationTemplate,
type PushSubscription,
type NotificationType, type NotificationType,
type PushSubscription,
type PushSubscriptionKeys type PushSubscriptionKeys
} from '@hcengineering/notification' } from '@hcengineering/notification'
import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform' import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform'
@ -91,6 +92,8 @@ export const DOMAIN_NOTIFICATION = 'notification' as Domain
@Model(notification.class.BrowserNotification, core.class.Doc, DOMAIN_NOTIFICATION) @Model(notification.class.BrowserNotification, core.class.Doc, DOMAIN_NOTIFICATION)
export class TBrowserNotification extends TDoc implements BrowserNotification { export class TBrowserNotification extends TDoc implements BrowserNotification {
senderId?: Ref<Account> | undefined
tag!: Ref<Doc<Space>>
title!: string title!: string
body!: string body!: string
onClickLocation?: Location | undefined onClickLocation?: Location | undefined
@ -365,8 +368,7 @@ export function createModel (builder: Builder): void {
core.space.Model, core.space.Model,
{ {
label: notification.string.Push, label: notification.string.Push,
depends: notification.providers.PlatformNotification, depends: notification.providers.PlatformNotification
onChange: notification.function.CheckPushPermission
}, },
notification.providers.BrowserNotification notification.providers.BrowserNotification
) )

View File

@ -85,14 +85,15 @@ export function addNotification (
title: string, title: string,
subTitle: string, subTitle: string,
component: AnyComponent | AnySvelteComponent, component: AnyComponent | AnySvelteComponent,
params?: Record<string, any> params?: Record<string, any>,
severity: NotificationSeverity = NotificationSeverity.Success
): void { ): void {
const closeTimeout = parseInt(localStorage.getItem('#platform.notification.timeout') ?? '10000') const closeTimeout = parseInt(localStorage.getItem('#platform.notification.timeout') ?? '10000')
const notification: Notification = { const notification: Notification = {
id: generateId(), id: generateId(),
title, title,
subTitle, subTitle,
severity: NotificationSeverity.Success, severity,
position: NotificationPosition.BottomRight, position: NotificationPosition.BottomRight,
component, component,
closeTimeout, closeTimeout,

View File

@ -46,6 +46,8 @@
"UnstarDocument": "Unstar document", "UnstarDocument": "Unstar document",
"Unsubscribe": "Unsubscribe", "Unsubscribe": "Unsubscribe",
"Push": "Push", "Push": "Push",
"Unreads": "Unreads" "Unreads": "Unreads",
"EnablePush": "Enable push notifications",
"NotificationBlockedInBrowser": "Notifications are blocked in your browser. Please enable notifications in your browser settings"
} }
} }

View File

@ -45,6 +45,8 @@
"ArchiveAllConfirmationMessage": "¿Estás seguro de que quieres archivar todas las notificaciones? Esta operación no se puede deshacer.", "ArchiveAllConfirmationMessage": "¿Estás seguro de que quieres archivar todas las notificaciones? Esta operación no se puede deshacer.",
"StarDocument": "Marcar documento", "StarDocument": "Marcar documento",
"UnstarDocument": "Desmarcar documento", "UnstarDocument": "Desmarcar documento",
"Push": "Push" "Push": "Push",
"EnablePush": "Habilitar notificaciones push",
"NotificationBlockedInBrowser": "Las notificaciones están bloqueadas en tu navegador. Por favor, habilita las notificaciones en la configuración de tu navegador."
} }
} }

View File

@ -45,6 +45,8 @@
"ArchiveAllConfirmationMessage": "Esta seguro que quer arquivar todas as notificações? Esta operação não se pode desfazer.", "ArchiveAllConfirmationMessage": "Esta seguro que quer arquivar todas as notificações? Esta operação não se pode desfazer.",
"StarDocument": "Marcar documento", "StarDocument": "Marcar documento",
"UnstarDocument": "Desmarcar documento", "UnstarDocument": "Desmarcar documento",
"Push": "Push" "Push": "Push",
"EnablePush": "Ativar notificações push",
"NotificationBlockedInBrowser": "Notificações bloqueadas no navegador. Por favor habilite las notificaciones en la configuración de su navegador."
} }
} }

View File

@ -46,6 +46,8 @@
"UnstarDocument": "Удалить из избранного", "UnstarDocument": "Удалить из избранного",
"Unsubscribe": "Отписаться", "Unsubscribe": "Отписаться",
"Push": "Push", "Push": "Push",
"Unreads": "Непрочитанные" "Unreads": "Непрочитанные",
"EnablePush": "Включить Push-уведомления",
"NotificationBlockedInBrowser": "Уведомления заблокированы в вашем браузере. Пожалуйста, включите уведомления в настройках браузера"
} }
} }

View File

@ -14,76 +14,50 @@
--> -->
<script lang="ts"> <script lang="ts">
import { getCurrentAccount } from '@hcengineering/core' import { getCurrentAccount } from '@hcengineering/core'
import notification from '@hcengineering/notification' import notification, { BrowserNotification, NotificationStatus } from '@hcengineering/notification'
import { getMetadata } from '@hcengineering/platform' import { createQuery, getClient } from '@hcengineering/presentation'
import { getClient } from '@hcengineering/presentation' import { checkPermission, pushAllowed, subscribePush } from '../utils'
import { getCurrentLocation, navigate, parseLocation } from '@hcengineering/ui' import { NotificationSeverity, addNotification } from '@hcengineering/ui'
import Notification from './Notification.svelte'
async function check (allowed: boolean) {
if (allowed) {
query.unsubscribe()
return
}
const res = await checkPermission(true)
if (res) {
query.unsubscribe()
return
}
const isSubscribed = await subscribePush()
if (isSubscribed) {
query.unsubscribe()
return
}
query.query(
notification.class.BrowserNotification,
{
user: getCurrentAccount()._id,
status: NotificationStatus.New,
createdOn: { $gt: Date.now() }
},
(res) => {
if (res.length > 0) {
notify(res[0])
}
}
)
}
const client = getClient() const client = getClient()
const publicKey = getMetadata(notification.metadata.PushPublicKey) async function notify (value: BrowserNotification): Promise<void> {
addNotification(value.title, value.body, Notification, { value }, NotificationSeverity.Info)
async function subscribe (): Promise<void> { await client.update(value, { status: NotificationStatus.Notified })
if ('serviceWorker' in navigator && 'PushManager' in window && publicKey !== undefined) {
try {
const loc = getCurrentLocation()
const registration = await navigator.serviceWorker.register('/serviceWorker.js', {
scope: `${loc.path[0]}/${loc.path[1]}`
})
const current = await registration.pushManager.getSubscription()
if (current == null) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey
})
await client.createDoc(notification.class.PushSubscription, notification.space.Notifications, {
user: getCurrentAccount()._id,
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth'))
}
})
} else {
const exists = await client.findOne(notification.class.PushSubscription, {
user: getCurrentAccount()._id,
endpoint: current.endpoint
})
if (exists === undefined) {
await client.createDoc(notification.class.PushSubscription, notification.space.Notifications, {
user: getCurrentAccount()._id,
endpoint: current.endpoint,
keys: {
p256dh: arrayBufferToBase64(current.getKey('p256dh')),
auth: arrayBufferToBase64(current.getKey('auth'))
}
})
}
}
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'notification-click') {
const { url } = event.data
if (url !== undefined) {
navigate(parseLocation(new URL(url)))
}
}
})
} catch (err) {
console.error('Service Worker registration failed:', err)
}
}
} }
function arrayBufferToBase64 (buffer: ArrayBuffer | null): string { const query = createQuery()
if (buffer) {
const bytes = new Uint8Array(buffer)
const array = Array.from(bytes)
const binary = String.fromCharCode.apply(null, array)
return btoa(binary)
} else {
return ''
}
}
subscribe() $: check($pushAllowed)
</script> </script>

View File

@ -0,0 +1,52 @@
<script lang="ts">
import { PersonAccount } from '@hcengineering/contact'
import { Avatar, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { BrowserNotification } from '@hcengineering/notification'
import { Button, Notification as PlatformNotification, NotificationToast, navigate } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { pushAvailable, subscribePush } from '../utils'
import plugin from '../plugin'
export let notification: PlatformNotification
export let onRemove: () => void
$: value = notification.params?.value as BrowserNotification
$: senderAccount =
value.senderId !== undefined ? $personAccountByIdStore.get(value.senderId as Ref<PersonAccount>) : undefined
$: sender = senderAccount !== undefined ? $personByIdStore.get(senderAccount.person) : undefined
</script>
<NotificationToast title={notification.title} severity={notification.severity} onClose={onRemove}>
<svelte:fragment slot="content">
<div class="flex-row-center flex-wrap gap-2">
{#if sender}
<Avatar avatar={sender.avatar} name={sender.name} size={'small'} />
{/if}
<span class="overflow-label">
{value.body}
</span>
</div>
</svelte:fragment>
<svelte:fragment slot="buttons">
{#if value.onClickLocation}
<Button
label={view.string.Open}
on:click={() => {
if (value.onClickLocation) {
onRemove()
navigate(value.onClickLocation)
}
}}
/>
{/if}
<Button
label={plugin.string.EnablePush}
disabled={!pushAvailable()}
showTooltip={!pushAvailable() ? { label: plugin.string.NotificationBlockedInBrowser } : undefined}
on:click={subscribePush}
/>
</svelte:fragment>
</NotificationToast>

View File

@ -35,6 +35,8 @@ export default mergeIds(notificationId, notification, {
People: '' as IntlString, People: '' as IntlString,
Read: '' as IntlString, Read: '' as IntlString,
Unread: '' as IntlString, Unread: '' as IntlString,
Unreads: '' as IntlString Unreads: '' as IntlString,
EnablePush: '' as IntlString,
NotificationBlockedInBrowser: '' as IntlString
} }
}) })

View File

@ -30,6 +30,7 @@ import {
type WithLookup type WithLookup
} from '@hcengineering/core' } from '@hcengineering/core'
import notification, { import notification, {
NotificationStatus,
decodeObjectURI, decodeObjectURI,
encodeObjectURI, encodeObjectURI,
notificationId, notificationId,
@ -45,14 +46,16 @@ import {
getCurrentLocation, getCurrentLocation,
getLocation, getLocation,
navigate, navigate,
parseLocation,
showPopup, showPopup,
type Location, type Location,
type ResolvedLocation type ResolvedLocation
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { get } from 'svelte/store' import { get, writable } from 'svelte/store'
import { InboxNotificationsClientImpl } from './inboxNotificationsClient' import { InboxNotificationsClientImpl } from './inboxNotificationsClient'
import { type InboxData, type InboxNotificationsFilter } from './types' import { type InboxData, type InboxNotificationsFilter } from './types'
import { getMetadata } from '@hcengineering/platform'
export async function hasDocNotifyContextPinAction (docNotifyContext: DocNotifyContext): Promise<boolean> { export async function hasDocNotifyContextPinAction (docNotifyContext: DocNotifyContext): Promise<boolean> {
if (docNotifyContext.hidden) { if (docNotifyContext.hidden) {
@ -540,6 +543,8 @@ export function openInboxDoc (
navigate(loc) navigate(loc)
} }
export const pushAllowed = writable<boolean>(false)
export async function checkPermission (value: boolean): Promise<boolean> { export async function checkPermission (value: boolean): Promise<boolean> {
if (!value) return true if (!value) return true
if ('serviceWorker' in navigator && 'PushManager' in window) { if ('serviceWorker' in navigator && 'PushManager' in window) {
@ -548,32 +553,121 @@ export async function checkPermission (value: boolean): Promise<boolean> {
const registration = await navigator.serviceWorker.getRegistration(`/${loc.path[0]}/${loc.path[1]}`) const registration = await navigator.serviceWorker.getRegistration(`/${loc.path[0]}/${loc.path[1]}`)
if (registration !== undefined) { if (registration !== undefined) {
const current = await registration.pushManager.getSubscription() const current = await registration.pushManager.getSubscription()
return current !== null const res = current !== null
pushAllowed.set(current !== null)
void registration.update()
addWorkerListener()
return res
} }
} catch { } catch {
pushAllowed.set(false)
return false return false
} }
} }
pushAllowed.set(false)
return false return false
} }
export async function askPermission (): Promise<void> { function addWorkerListener (): void {
if ('Notification' in window && Notification?.permission === 'default') { navigator.serviceWorker.addEventListener('message', (event) => {
await Notification?.requestPermission() if (event.data !== undefined && event.data.type === 'notification-click') {
const { url, _id } = event.data
if (url !== undefined) {
navigate(parseLocation(new URL(url)))
}
if (_id !== undefined) {
void cleanTag(_id)
}
}
})
}
export function pushAvailable (): boolean {
const publicKey = getMetadata(notification.metadata.PushPublicKey)
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
publicKey !== undefined &&
'Notification' in window &&
Notification.permission !== 'denied'
)
}
export async function subscribePush (): Promise<boolean> {
const client = getClient()
const publicKey = getMetadata(notification.metadata.PushPublicKey)
if ('serviceWorker' in navigator && 'PushManager' in window && publicKey !== undefined) {
try {
const loc = getCurrentLocation()
let registration = await navigator.serviceWorker.getRegistration(`/${loc.path[0]}/${loc.path[1]}`)
if (registration !== undefined) {
await registration.update()
} else {
registration = await navigator.serviceWorker.register('/serviceWorker.js', {
scope: `/${loc.path[0]}/${loc.path[1]}`
})
}
const current = await registration.pushManager.getSubscription()
if (current == null) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey
})
await client.createDoc(notification.class.PushSubscription, notification.space.Notifications, {
user: getCurrentAccount()._id,
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth'))
}
})
} else {
const exists = await client.findOne(notification.class.PushSubscription, {
user: getCurrentAccount()._id,
endpoint: current.endpoint
})
if (exists === undefined) {
await client.createDoc(notification.class.PushSubscription, notification.space.Notifications, {
user: getCurrentAccount()._id,
endpoint: current.endpoint,
keys: {
p256dh: arrayBufferToBase64(current.getKey('p256dh')),
auth: arrayBufferToBase64(current.getKey('auth'))
}
})
}
}
addWorkerListener()
pushAllowed.set(true)
return true
} catch (err) {
console.error('Service Worker registration failed:', err)
pushAllowed.set(false)
return false
}
}
pushAllowed.set(false)
return false
}
async function cleanTag (_id: Ref<Doc>): Promise<void> {
const client = getClient()
const notifications = await client.findAll(notification.class.BrowserNotification, {
tag: _id,
status: NotificationStatus.New
})
for (const notification of notifications) {
await client.update(notification, { status: NotificationStatus.Notified })
} }
} }
export function notify (title: string, body: string, _id?: string, onClick?: () => void): void { function arrayBufferToBase64 (buffer: ArrayBuffer | null): string {
if ('Notification' in window && Notification?.permission === 'granted') { if (buffer != null) {
const req: NotificationOptions = { const bytes = new Uint8Array(buffer)
body const array = Array.from(bytes)
} const binary = String.fromCharCode.apply(null, array)
if (_id !== undefined) { return btoa(binary)
req.tag = _id } else {
} return ''
const notification = new Notification(title, req)
if (onClick !== undefined) {
notification.onclick = onClick
}
} }
} }

View File

@ -49,6 +49,8 @@ export interface BrowserNotification extends Doc {
title: string title: string
body: string body: string
onClickLocation?: Location onClickLocation?: Location
senderId?: Ref<Account>
tag: Ref<Doc>
} }
export interface PushData { export interface PushData {

View File

@ -18,41 +18,56 @@ self.addEventListener('push', (event: PushEvent) => {
tag: payload.tag, tag: payload.tag,
data: { data: {
domain: payload.domain, domain: payload.domain,
url: payload.url url: payload.url,
notificationId: payload.tag
} }
}) })
}) })
// Listen for notification click event async function handleNotificationClick (event: any): Promise<void> {
self.addEventListener('notificationclick', (event: any) => { event.notification.close()
const clickedNotification = event.notification const clickedNotification = event.notification
const notificationData = clickedNotification.data const notificationData = clickedNotification.data
const notificationId = notificationData.notificationId
const notificationUrl = notificationData.url const notificationUrl = notificationData.url
const domain = notificationData.domain const domain = notificationData.domain
if (notificationUrl !== undefined && domain !== undefined) { if (notificationUrl !== undefined && domain !== undefined) {
// Check if any client with the same origin is already open const windowClients = (await self.clients.matchAll({
event.waitUntil( type: 'window',
// Check all active clients (browser windows or tabs) includeUncontrolled: true
self.clients })) as ReadonlyArray<any>
.matchAll({
type: 'window', const targetUrl = new URL(notificationUrl)
includeUncontrolled: true for (const client of windowClients) {
const clientUrl = new URL(client.url, self.location.href)
if (decodeURI(clientUrl.pathname) === targetUrl.pathname) {
client.postMessage({
type: 'notification-click',
url: notificationUrl,
_id: notificationId
}) })
.then((clientList: any) => { await client.focus()
// Loop through each client return
for (const client of clientList) { }
// If a client has the same URL origin, focus and navigate to it }
if ((client.url as string)?.startsWith(domain)) {
client.postMessage({ for (const client of windowClients) {
type: 'notification-click', if ((client.url as string)?.startsWith(domain)) {
url: notificationUrl client.postMessage({
}) type: 'notification-click',
return client.focus() url: notificationUrl,
} _id: notificationId
}
// If no client with the same URL origin is found, open a new window/tab
return self.clients.openWindow(notificationUrl)
}) })
) await client.focus()
return
}
}
console.log('No matching client found')
// If no client with the same URL origin is found, open a new window/tab
await self.clients.openWindow(notificationUrl)
} }
}) }
self.addEventListener('notificationclick', (e: any) => e.waitUntil(handleNotificationClick(e)))

View File

@ -61,6 +61,8 @@ import notification, {
InboxNotification, InboxNotification,
MentionInboxNotification, MentionInboxNotification,
notificationId, notificationId,
NotificationStatus,
PushSubscription,
NotificationType, NotificationType,
PushData PushData
} from '@hcengineering/notification' } from '@hcengineering/notification'
@ -424,7 +426,8 @@ export async function pushInboxNotifications (
const notificationTx = control.txFactory.createTxCreateDoc(_class, space, notificationData) const notificationTx = control.txFactory.createTxCreateDoc(_class, space, notificationData)
res.push(notificationTx) res.push(notificationTx)
if (shouldPush) { if (shouldPush) {
await createPushFromInbox( const now = Date.now()
const pushTx = await createPushFromInbox(
control, control,
targetUser, targetUser,
attachedTo, attachedTo,
@ -434,6 +437,10 @@ export async function pushInboxNotifications (
senderId, senderId,
notificationTx.objectId notificationTx.objectId
) )
console.log('Push takes', Date.now() - now, 'ms')
if (pushTx !== undefined) {
res.push(pushTx)
}
} }
} }
} }
@ -522,8 +529,8 @@ export async function createPushFromInbox (
data: Data<InboxNotification>, data: Data<InboxNotification>,
_class: Ref<Class<InboxNotification>>, _class: Ref<Class<InboxNotification>>,
senderId: Ref<PersonAccount>, senderId: Ref<PersonAccount>,
_id: string _id: Ref<Doc>
): Promise<void> { ): Promise<Tx | undefined> {
let title: string = '' let title: string = ''
let body: string = '' let body: string = ''
if (control.hierarchy.isDerived(_class, notification.class.ActivityInboxNotification)) { if (control.hierarchy.isDerived(_class, notification.class.ActivityInboxNotification)) {
@ -543,10 +550,24 @@ export async function createPushFromInbox (
if (sender !== undefined) { if (sender !== undefined) {
senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0] senderPerson = (await control.findAll(contact.class.Person, { _id: sender.person }))[0]
} }
await createPushNotification(control, targetUser, title, body, _id, senderPerson?.avatar, [ const path = [
workbenchId,
control.workspace.workspaceUrl,
notificationId, notificationId,
encodeObjectURI(attachedTo, attachedToClass) encodeObjectURI(attachedTo, attachedToClass)
]) ]
await createPushNotification(control, targetUser, title, body, _id, senderPerson?.avatar, path)
return control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, notification.space.Notifications, {
user: targetUser,
status: NotificationStatus.New,
title,
body,
senderId,
tag: _id,
onClickLocation: {
path
}
})
} }
export async function createPushNotification ( export async function createPushNotification (
@ -556,7 +577,7 @@ export async function createPushNotification (
body: string, body: string,
_id: string, _id: string,
senderAvatar?: string | null, senderAvatar?: string | null,
subPath?: string[] path?: string[]
): Promise<void> { ): Promise<void> {
const publicKey = getMetadata(notification.metadata.PushPublicKey) const publicKey = getMetadata(notification.metadata.PushPublicKey)
const privateKey = getMetadata(serverNotification.metadata.PushPrivateKey) const privateKey = getMetadata(serverNotification.metadata.PushPrivateKey)
@ -577,9 +598,8 @@ export async function createPushNotification (
const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}` const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}`
const domain = concatLink(front, domainPath) const domain = concatLink(front, domainPath)
data.domain = domain data.domain = domain
if (subPath !== undefined) { if (path !== undefined) {
const path = [domainPath, ...subPath].join('/') const url = concatLink(front, path.join('/'))
const url = concatLink(front, path)
data.url = url data.url = url
} }
if (senderAvatar != null) { if (senderAvatar != null) {
@ -598,14 +618,23 @@ export async function createPushNotification (
webpush.setVapidDetails(subject, publicKey, privateKey) webpush.setVapidDetails(subject, publicKey, privateKey)
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
try { void sendPushToSubscription(control, targetUser, subscription, data)
await webpush.sendNotification(subscription, JSON.stringify(data)) }
} catch (err) { }
console.log('Cannot send push notification to', targetUser, err)
if (err instanceof WebPushError && err.body.includes('expired')) { async function sendPushToSubscription (
const tx = control.txFactory.createTxRemoveDoc(subscription._class, subscription.space, subscription._id) control: TriggerControl,
await control.apply([tx], true) targetUser: Ref<Account>,
} subscription: PushSubscription,
data: PushData
): Promise<void> {
try {
await webpush.sendNotification(subscription, JSON.stringify(data))
} catch (err) {
console.log('Cannot send push notification to', targetUser, err)
if (err instanceof WebPushError && err.body.includes('expired')) {
const tx = control.txFactory.createTxRemoveDoc(subscription._class, subscription.space, subscription._id)
await control.apply([tx], true)
} }
} }
} }

View File

@ -97,8 +97,7 @@ export async function getIssueNotificationContent (
): Promise<NotificationContent> { ): Promise<NotificationContent> {
const issue = doc as Issue const issue = doc as Issue
const issueShortName = await issueTextPresenter(doc) const issueTitle = await issueTextPresenter(doc)
const issueTitle = `${issueShortName}: ${issue.title}`
const title = tracker.string.IssueNotificationTitle const title = tracker.string.IssueNotificationTitle
let body = tracker.string.IssueNotificationBody let body = tracker.string.IssueNotificationBody