From cee0c591e2d03d7c40d4d44c5bb5c218ffe46e56 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 09:09:57 -0400 Subject: [PATCH] =?UTF-8?q?fix(pwa):=20N105=20=E2=80=94=20notification=20c?= =?UTF-8?q?licks=20work=20after=20the=20tab=20is=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OS notifications were shown via page-level `new Notification()` whose onclick only works while the originating tab is alive — clicking a notification after closing the tab did nothing. - New `showOsNotification()` (utils/dom) prefers `registration.showNotification()` so the notification is service-worker-owned and persists; falls back to `new Notification()` (with the previous onclick) when no SW is available, so worst case is unchanged behaviour. - sw.ts gains a `notificationclick` handler: focuses an existing app window and forwards the target path, or opens the app if none is open. - ClientNonUIFeatures forwards the SW `notificationClick` message to react-router `navigate()` (works for both hash and browser router configs), and uses a per-room `tag` to coalesce notifications (replacing the old notifRef.close() dedup a SW notification can't hold). Co-Authored-By: Claude Opus 4.8 --- src/app/pages/client/ClientNonUIFeatures.tsx | 77 ++++++++++++-------- src/app/utils/dom.ts | 36 +++++++++ src/sw.ts | 33 +++++++++ 3 files changed, 117 insertions(+), 29 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index dd4674785..b4fc6c4c2 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -8,7 +8,7 @@ import LogoUnreadSVG from '../../../../public/res/lotus-unread.png'; import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; -import { notificationPermission, setFavicon } from '../../utils/dom'; +import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom'; import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; @@ -141,17 +141,21 @@ function InviteNotifications() { return; } - const noti = new window.Notification('Invitation', { - icon: LogoSVG, - badge: LogoSVG, - body: `You have ${count} new invitation request.`, - silent: true, - }); - - noti.onclick = () => { - if (!window.closed) navigate(getInboxInvitesPath()); - noti.close(); - }; + const invitesPath = getInboxInvitesPath(); + showOsNotification( + 'Invitation', + { + icon: LogoSVG, + badge: LogoSVG, + body: `You have ${count} new invitation request.`, + silent: true, + tag: 'lotus-invites', + data: { path: invitesPath }, + }, + () => { + if (!window.closed) navigate(invitesPath); + }, + ); }, [navigate, setToast], ); @@ -202,7 +206,6 @@ function PresenceUpdater() { function MessageNotifications() { const audioRef = useRef(null); - const notifRef = useRef(undefined); const unreadCacheRef = useRef>(new Map()); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -276,26 +279,42 @@ function MessageNotifications() { // persists in the notification center / lock screen / is readable by other // apps). For encrypted rooms show only the sender; the in-page toast above // still shows the preview while the user is actively looking at the screen. - const noti = new window.Notification(roomName, { - icon: LogoSVG, - badge: LogoSVG, - body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username, - silent: true, - }); - - noti.onclick = () => { - window.focus(); - navigate(roomPath); - noti.close(); - notifRef.current = undefined; - }; - - notifRef.current?.close(); - notifRef.current = noti; + showOsNotification( + roomName, + { + icon: LogoSVG, + badge: LogoSVG, + body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username, + silent: true, + // Coalesce repeated notifications for the same room (replaces the old + // manual notifRef.close() dedup, which a SW notification can't hold). + tag: roomId, + data: { path: roomPath }, + }, + () => { + window.focus(); + navigate(roomPath); + }, + ); }, [navigate, setToast, mDirects], ); + // N105: when a service-worker-owned notification is clicked, the SW focuses + // this tab and forwards the target path here so we can route to it (works even + // when the click happened while the tab was in the background / reopened). + useEffect(() => { + if (!('serviceWorker' in navigator)) return undefined; + const onMessage = (event: MessageEvent) => { + const data = event.data ?? {}; + if (data.type === 'notificationClick' && typeof data.path === 'string') { + navigate(data.path); + } + }; + navigator.serviceWorker.addEventListener('message', onMessage); + return () => navigator.serviceWorker.removeEventListener('message', onMessage); + }, [navigate]); + const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 81f4a8087..40cfc3fcc 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -254,6 +254,42 @@ export const notificationPermission = (permission: NotificationPermission) => { return true; }; +/** + * Show an OS notification. + * + * Prefers a service-worker-owned notification (`registration.showNotification`) + * so the notification persists and its click is handled by the SW + * `notificationclick` listener even after the originating tab is closed — the + * data.path is forwarded to the SW. Falls back to a page-level `Notification` + * (with the provided `onClick`) when no service worker is available, preserving + * the previous behaviour. + */ +export const showOsNotification = async ( + title: string, + options: NotificationOptions & { data?: { path?: string } }, + onClick?: () => void, +): Promise => { + try { + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.ready; + if (registration && typeof registration.showNotification === 'function') { + await registration.showNotification(title, options); + return; + } + } + } catch { + // fall through to the page-level Notification below + } + + const noti = new Notification(title, options); + if (onClick) { + noti.onclick = () => { + onClick(); + noti.close(); + }; + } +}; + export const getMouseEventCords = (event: MouseEvent) => ({ x: event.clientX, y: event.clientY, diff --git a/src/sw.ts b/src/sw.ts index 9339e6c7a..b098fd915 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -104,6 +104,39 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } }); +/** + * N105: handle clicks on service-worker-owned notifications. This fires even + * when the originating tab was closed (a page-level `Notification.onclick` + * would not). Focus an existing app window and forward the target path so it + * can route there; if no window is open, open the app. + */ +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close(); + const data = event.notification.data ?? {}; + const path = typeof data.path === 'string' ? data.path : undefined; + + event.waitUntil( + (async () => { + const windowClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + const client = windowClients.find((c): c is WindowClient => 'focus' in c); + if (client) { + await client.focus(); + if (path) client.postMessage({ type: 'notificationClick', path }); + return; + } + // No app window open — open one at the app root (the client routes from + // there). Router config (hash vs browser) is page-owned, so we don't try + // to deep-link the URL here. + if (self.clients.openWindow) { + await self.clients.openWindow(self.registration.scope); + } + })(), + ); +}); + const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail']; function mediaPath(url: string): boolean {