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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user