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 LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
||||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||||
import InviteSound from '../../../../public/sound/invite.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 { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -141,17 +141,21 @@ function InviteNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noti = new window.Notification('Invitation', {
|
const invitesPath = getInboxInvitesPath();
|
||||||
icon: LogoSVG,
|
showOsNotification(
|
||||||
badge: LogoSVG,
|
'Invitation',
|
||||||
body: `You have ${count} new invitation request.`,
|
{
|
||||||
silent: true,
|
icon: LogoSVG,
|
||||||
});
|
badge: LogoSVG,
|
||||||
|
body: `You have ${count} new invitation request.`,
|
||||||
noti.onclick = () => {
|
silent: true,
|
||||||
if (!window.closed) navigate(getInboxInvitesPath());
|
tag: 'lotus-invites',
|
||||||
noti.close();
|
data: { path: invitesPath },
|
||||||
};
|
},
|
||||||
|
() => {
|
||||||
|
if (!window.closed) navigate(invitesPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[navigate, setToast],
|
[navigate, setToast],
|
||||||
);
|
);
|
||||||
@@ -202,7 +206,6 @@ function PresenceUpdater() {
|
|||||||
|
|
||||||
function MessageNotifications() {
|
function MessageNotifications() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const notifRef = useRef<Notification | undefined>(undefined);
|
|
||||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -276,26 +279,42 @@ function MessageNotifications() {
|
|||||||
// persists in the notification center / lock screen / is readable by other
|
// persists in the notification center / lock screen / is readable by other
|
||||||
// apps). For encrypted rooms show only the sender; the in-page toast above
|
// 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.
|
// still shows the preview while the user is actively looking at the screen.
|
||||||
const noti = new window.Notification(roomName, {
|
showOsNotification(
|
||||||
icon: LogoSVG,
|
roomName,
|
||||||
badge: LogoSVG,
|
{
|
||||||
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
|
icon: LogoSVG,
|
||||||
silent: true,
|
badge: LogoSVG,
|
||||||
});
|
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
|
||||||
|
silent: true,
|
||||||
noti.onclick = () => {
|
// Coalesce repeated notifications for the same room (replaces the old
|
||||||
window.focus();
|
// manual notifRef.close() dedup, which a SW notification can't hold).
|
||||||
navigate(roomPath);
|
tag: roomId,
|
||||||
noti.close();
|
data: { path: roomPath },
|
||||||
notifRef.current = undefined;
|
},
|
||||||
};
|
() => {
|
||||||
|
window.focus();
|
||||||
notifRef.current?.close();
|
navigate(roomPath);
|
||||||
notifRef.current = noti;
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[navigate, setToast, mDirects],
|
[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 playSound = useCallback(() => {
|
||||||
const audioElement = audioRef.current;
|
const audioElement = audioRef.current;
|
||||||
audioElement?.play();
|
audioElement?.play();
|
||||||
|
|||||||
@@ -254,6 +254,42 @@ export const notificationPermission = (permission: NotificationPermission) => {
|
|||||||
return true;
|
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) => ({
|
export const getMouseEventCords = (event: MouseEvent) => ({
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
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'];
|
const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail'];
|
||||||
|
|
||||||
function mediaPath(url: string): boolean {
|
function mediaPath(url: string): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user