fix(app): improve service worker notification handling (#6)

This commit is contained in:
hampus-fluxer 2026-01-03 08:21:02 +01:00 committed by GitHub
parent 4cd1caaa80
commit 5d5815963c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 174 additions and 42 deletions

View File

@ -48,6 +48,7 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
const [hasHandledNotificationNav, setHasHandledNotificationNav] = React.useState(false);
const [previousMobileLayoutState, setPreviousMobileLayoutState] = React.useState(mobileLayoutState.enabled);
const lastMobileHistoryBuildRef = React.useRef<{ts: number; path: string} | null>(null);
const lastNotificationNavRef = React.useRef<{ts: number; key: string} | null>(null);
const isLocationStoreHydrated = LocationStore.isHydrated;
const canNavigateToProtectedRoutes = InitializationStore.canNavigateToProtectedRoutes;
const pendingRedirectRef = React.useRef<string | null>(null);
@ -78,6 +79,18 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
const shouldBypassGateway = isAuthRoute && pathname !== Routes.PENDING_VERIFICATION;
const authToken = AuthenticationStore.authToken;
const normalizeInternalUrl = React.useCallback((rawUrl: string): string => {
try {
const u = new URL(rawUrl, window.location.origin);
if (u.origin === window.location.origin) {
return u.pathname + u.search + u.hash;
}
return rawUrl;
} catch {
return rawUrl;
}
}, []);
React.useEffect(() => {
if (!SessionManager.isInitialized) return;
if (AccountManager.isSwitching) return;
@ -242,16 +255,24 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
);
React.useEffect(() => {
if (!isAuthenticated || !mobileLayoutState.enabled) return;
if (!isAuthenticated) return;
const handleNotificationNavigate = (event: MessageEvent) => {
if (event.data?.type === 'NOTIFICATION_CLICK_NAVIGATE') {
if (hasHandledNotificationNav) {
const rawUrl = typeof event.data.url === 'string' ? event.data.url : null;
if (!rawUrl) return;
const targetUserId =
typeof event.data.targetUserId === 'string' ? (event.data.targetUserId as string) : undefined;
const normalizedUrl = normalizeInternalUrl(rawUrl);
const key = `${targetUserId ?? ''}:${normalizedUrl}`;
const now = Date.now();
const last = lastNotificationNavRef.current;
if (last && last.key === key && now - last.ts < 1500) {
return;
}
const url = event.data.url;
const targetUserId = event.data.targetUserId as string | undefined;
lastNotificationNavRef.current = {ts: now, key};
void (async () => {
if (targetUserId && targetUserId !== AccountManager.currentUserId && AccountManager.canSwitchAccounts) {
@ -262,7 +283,12 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
}
}
navigateWithHistoryStack(url);
if (mobileLayoutState.enabled) {
navigateWithHistoryStack(normalizedUrl);
} else {
RouterUtils.transitionTo(normalizedUrl);
}
setHasHandledNotificationNav(true);
})();
@ -283,7 +309,12 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
newParams.delete('fromNotification');
const cleanPath = location.pathname + (newParams.toString() ? `?${newParams.toString()}` : '');
navigateWithHistoryStack(cleanPath);
if (mobileLayoutState.enabled) {
navigateWithHistoryStack(cleanPath);
} else {
RouterUtils.transitionTo(cleanPath);
}
setHasHandledNotificationNav(true);
}
}
@ -293,7 +324,14 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
return () => {
navigator.serviceWorker?.removeEventListener('message', handleNotificationNavigate);
};
}, [isAuthenticated, mobileLayoutState.enabled, hasHandledNotificationNav, location, navigateWithHistoryStack]);
}, [
isAuthenticated,
mobileLayoutState.enabled,
hasHandledNotificationNav,
location,
navigateWithHistoryStack,
normalizeInternalUrl,
]);
React.useEffect(() => {
if (currentUser?.pendingManualVerification) {

View File

@ -130,12 +130,25 @@ const focusOrOpenClient = async (targetUrl: string, targetUserId?: string): Prom
if (targetUserId) {
message.targetUserId = targetUserId;
}
const clientList = await postMessageToClients(message);
for (const client of clientList) {
if (client.url === targetUrl) {
await client.focus();
return;
const exact = clientList.find((c) => c.url === targetUrl);
if (exact) {
await exact.focus();
return;
}
const sameOrigin = clientList.find((c) => {
try {
return new URL(c.url).origin === self.location.origin;
} catch {
return false;
}
});
if (sameOrigin) {
await sameOrigin.focus();
return;
}
if (self.clients.openWindow) {

View File

@ -123,6 +123,81 @@ export interface NotificationResult {
nativeNotificationId: string | null;
}
const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
if (typeof navigator === 'undefined' || typeof navigator.serviceWorker === 'undefined') {
return null;
}
try {
return (await navigator.serviceWorker.getRegistration()) ?? null;
} catch {
return null;
}
};
const tryShowNotificationViaServiceWorker = async ({
title,
body,
url,
icon,
targetUserId,
}: {
title: string;
body: string;
url?: string;
icon?: string;
targetUserId?: string;
}): Promise<{shown: boolean; result: NotificationResult}> => {
const registration = await getServiceWorkerRegistration();
if (!registration) {
return {shown: false, result: {browserNotification: null, nativeNotificationId: null}};
}
const options: NotificationOptions = {body};
if (icon) {
options.icon = icon;
}
if (url || targetUserId) {
const data: Record<string, unknown> = {};
if (url) data.url = url;
if (targetUserId) data.target_user_id = targetUserId;
options.data = data;
}
try {
await registration.showNotification(title, options);
return {shown: true, result: {browserNotification: null, nativeNotificationId: null}};
} catch {
return {shown: false, result: {browserNotification: null, nativeNotificationId: null}};
}
};
const tryShowNotificationViaWindowNotification = ({
title,
body,
url,
icon,
}: {
title: string;
body: string;
url?: string;
icon?: string;
}): NotificationResult => {
const notificationOptions: NotificationOptions = icon ? {body, icon} : {body};
const notification = new Notification(title, notificationOptions);
notification.addEventListener('click', (event) => {
event.preventDefault();
window.focus();
if (url) {
RouterUtils.transitionTo(url);
}
notification.close();
});
return {browserNotification: notification, nativeNotificationId: null};
};
export const showNotification = async ({
title,
body,
@ -136,40 +211,46 @@ export const showNotification = async ({
icon?: string;
playSound?: boolean;
}): Promise<NotificationResult> => {
if (playSound) {
playNotificationSoundIfEnabled();
}
const electronApi = getElectronAPI();
if (electronApi) {
try {
const result = await electronApi.showNotification({
title,
body,
icon: icon ?? '',
url,
});
return {browserNotification: null, nativeNotificationId: result.id};
} catch {
return {browserNotification: null, nativeNotificationId: null};
try {
if (playSound) {
playNotificationSoundIfEnabled();
}
}
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
const notificationOptions: NotificationOptions = icon ? {body, icon} : {body};
const notification = new Notification(title, notificationOptions);
notification.addEventListener('click', (event) => {
event.preventDefault();
window.focus();
if (url) {
RouterUtils.transitionTo(url);
const electronApi = getElectronAPI();
if (electronApi) {
try {
const result = await electronApi.showNotification({
title,
body,
icon: icon ?? '',
url,
});
return {browserNotification: null, nativeNotificationId: result.id};
} catch {
return {browserNotification: null, nativeNotificationId: null};
}
notification.close();
});
return {browserNotification: notification, nativeNotificationId: null};
}
}
return {browserNotification: null, nativeNotificationId: null};
const targetUserId = AuthenticationStore.currentUserId ?? undefined;
const swAttempt = await tryShowNotificationViaServiceWorker({title, body, url, icon, targetUserId});
if (swAttempt.shown) {
return swAttempt.result;
}
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
try {
return tryShowNotificationViaWindowNotification({title, body, url, icon});
} catch {
const swFallback = await tryShowNotificationViaServiceWorker({title, body, url, icon, targetUserId});
return swFallback.result;
}
}
return swAttempt.result;
} catch {
return {browserNotification: null, nativeNotificationId: null};
}
};
export const closeNativeNotification = (id: string): void => {