diff --git a/fluxer_app/src/router/components/RootComponent.tsx b/fluxer_app/src/router/components/RootComponent.tsx index 5a78acff..2bfc57bc 100644 --- a/fluxer_app/src/router/components/RootComponent.tsx +++ b/fluxer_app/src/router/components/RootComponent.tsx @@ -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(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) { diff --git a/fluxer_app/src/sw/worker.ts b/fluxer_app/src/sw/worker.ts index 28100e9e..ca22fdd4 100644 --- a/fluxer_app/src/sw/worker.ts +++ b/fluxer_app/src/sw/worker.ts @@ -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) { diff --git a/fluxer_app/src/utils/NotificationUtils.tsx b/fluxer_app/src/utils/NotificationUtils.tsx index 965d382a..3fc3d386 100644 --- a/fluxer_app/src/utils/NotificationUtils.tsx +++ b/fluxer_app/src/utils/NotificationUtils.tsx @@ -123,6 +123,81 @@ export interface NotificationResult { nativeNotificationId: string | null; } +const getServiceWorkerRegistration = async (): Promise => { + 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 = {}; + 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 => { - 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 => {