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
+36
View File
@@ -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<void> => {
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,