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
+48 -29
View File
@@ -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<HTMLAudioElement>(null);
const notifRef = useRef<Notification | undefined>(undefined);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(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();