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

View File

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

View File

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

View File

@ -14,76 +14,50 @@
-->
<script lang="ts">
import { getCurrentAccount } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { getMetadata } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { getCurrentLocation, navigate, parseLocation } from '@hcengineering/ui'
import notification, { BrowserNotification, NotificationStatus } from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import { checkPermission, pushAllowed, subscribePush } from '../utils'
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 publicKey = getMetadata(notification.metadata.PushPublicKey)
async function subscribe (): Promise<void> {
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)
}
}
async function notify (value: BrowserNotification): Promise<void> {
addNotification(value.title, value.body, Notification, { value }, NotificationSeverity.Info)
await client.update(value, { status: NotificationStatus.Notified })
}
function arrayBufferToBase64 (buffer: ArrayBuffer | null): string {
if (buffer) {
const bytes = new Uint8Array(buffer)
const array = Array.from(bytes)
const binary = String.fromCharCode.apply(null, array)
return btoa(binary)
} else {
return ''
}
}
const query = createQuery()
subscribe()
$: check($pushAllowed)
</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,
Read: '' 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
} from '@hcengineering/core'
import notification, {
NotificationStatus,
decodeObjectURI,
encodeObjectURI,
notificationId,
@ -45,14 +46,16 @@ import {
getCurrentLocation,
getLocation,
navigate,
parseLocation,
showPopup,
type Location,
type ResolvedLocation
} from '@hcengineering/ui'
import { get } from 'svelte/store'
import { get, writable } from 'svelte/store'
import { InboxNotificationsClientImpl } from './inboxNotificationsClient'
import { type InboxData, type InboxNotificationsFilter } from './types'
import { getMetadata } from '@hcengineering/platform'
export async function hasDocNotifyContextPinAction (docNotifyContext: DocNotifyContext): Promise<boolean> {
if (docNotifyContext.hidden) {
@ -540,6 +543,8 @@ export function openInboxDoc (
navigate(loc)
}
export const pushAllowed = writable<boolean>(false)
export async function checkPermission (value: boolean): Promise<boolean> {
if (!value) return true
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]}`)
if (registration !== undefined) {
const current = await registration.pushManager.getSubscription()
return current !== null
const res = current !== null
pushAllowed.set(current !== null)
void registration.update()
addWorkerListener()
return res
}
} catch {
pushAllowed.set(false)
return false
}
}
pushAllowed.set(false)
return false
}
export async function askPermission (): Promise<void> {
if ('Notification' in window && Notification?.permission === 'default') {
await Notification?.requestPermission()
function addWorkerListener (): void {
navigator.serviceWorker.addEventListener('message', (event) => {
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 {
if ('Notification' in window && Notification?.permission === 'granted') {
const req: NotificationOptions = {
body
}
if (_id !== undefined) {
req.tag = _id
}
const notification = new Notification(title, req)
if (onClick !== undefined) {
notification.onclick = onClick
}
function arrayBufferToBase64 (buffer: ArrayBuffer | null): string {
if (buffer != null) {
const bytes = new Uint8Array(buffer)
const array = Array.from(bytes)
const binary = String.fromCharCode.apply(null, array)
return btoa(binary)
} else {
return ''
}
}

View File

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

View File

@ -18,41 +18,56 @@ self.addEventListener('push', (event: PushEvent) => {
tag: payload.tag,
data: {
domain: payload.domain,
url: payload.url
url: payload.url,
notificationId: payload.tag
}
})
})
// Listen for notification click event
self.addEventListener('notificationclick', (event: any) => {
async function handleNotificationClick (event: any): Promise<void> {
event.notification.close()
const clickedNotification = event.notification
const notificationData = clickedNotification.data
const notificationId = notificationData.notificationId
const notificationUrl = notificationData.url
const domain = notificationData.domain
if (notificationUrl !== undefined && domain !== undefined) {
// Check if any client with the same origin is already open
event.waitUntil(
// Check all active clients (browser windows or tabs)
self.clients
.matchAll({
type: 'window',
includeUncontrolled: true
const windowClients = (await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
})) as ReadonlyArray<any>
const targetUrl = new URL(notificationUrl)
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) => {
// Loop through each client
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({
type: 'notification-click',
url: notificationUrl
})
return client.focus()
}
}
// If no client with the same URL origin is found, open a new window/tab
return self.clients.openWindow(notificationUrl)
await client.focus()
return
}
}
for (const client of windowClients) {
if ((client.url as string)?.startsWith(domain)) {
client.postMessage({
type: 'notification-click',
url: notificationUrl,
_id: notificationId
})
)
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,
MentionInboxNotification,
notificationId,
NotificationStatus,
PushSubscription,
NotificationType,
PushData
} from '@hcengineering/notification'
@ -424,7 +426,8 @@ export async function pushInboxNotifications (
const notificationTx = control.txFactory.createTxCreateDoc(_class, space, notificationData)
res.push(notificationTx)
if (shouldPush) {
await createPushFromInbox(
const now = Date.now()
const pushTx = await createPushFromInbox(
control,
targetUser,
attachedTo,
@ -434,6 +437,10 @@ export async function pushInboxNotifications (
senderId,
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>,
_class: Ref<Class<InboxNotification>>,
senderId: Ref<PersonAccount>,
_id: string
): Promise<void> {
_id: Ref<Doc>
): Promise<Tx | undefined> {
let title: string = ''
let body: string = ''
if (control.hierarchy.isDerived(_class, notification.class.ActivityInboxNotification)) {
@ -543,10 +550,24 @@ export async function createPushFromInbox (
if (sender !== undefined) {
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,
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 (
@ -556,7 +577,7 @@ export async function createPushNotification (
body: string,
_id: string,
senderAvatar?: string | null,
subPath?: string[]
path?: string[]
): Promise<void> {
const publicKey = getMetadata(notification.metadata.PushPublicKey)
const privateKey = getMetadata(serverNotification.metadata.PushPrivateKey)
@ -577,9 +598,8 @@ export async function createPushNotification (
const domainPath = `${workbenchId}/${control.workspace.workspaceUrl}`
const domain = concatLink(front, domainPath)
data.domain = domain
if (subPath !== undefined) {
const path = [domainPath, ...subPath].join('/')
const url = concatLink(front, path)
if (path !== undefined) {
const url = concatLink(front, path.join('/'))
data.url = url
}
if (senderAvatar != null) {
@ -598,14 +618,23 @@ export async function createPushNotification (
webpush.setVapidDetails(subject, publicKey, privateKey)
for (const subscription of subscriptions) {
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)
}
void sendPushToSubscription(control, targetUser, subscription, data)
}
}
async function sendPushToSubscription (
control: TriggerControl,
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> {
const issue = doc as Issue
const issueShortName = await issueTextPresenter(doc)
const issueTitle = `${issueShortName}: ${issue.title}`
const issueTitle = await issueTextPresenter(doc)
const title = tracker.string.IssueNotificationTitle
let body = tracker.string.IssueNotificationBody