fix(pwa): N105 — notification clicks work after the tab is closed

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 09:09:57 -04:00
parent 68b6ffffd7
commit cee0c591e2
3 changed files with 117 additions and 29 deletions
+33
View File
@@ -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 {