diff --git a/.changeset/reaction-notification-context.md b/.changeset/reaction-notification-context.md new file mode 100644 index 000000000..18e22446b --- /dev/null +++ b/.changeset/reaction-notification-context.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix reaction notifications not being delivered by passing room and user context to the notification event filter diff --git a/.changeset/sw-push-session-recovery.md b/.changeset/sw-push-session-recovery.md new file mode 100644 index 000000000..646fefbdf --- /dev/null +++ b/.changeset/sw-push-session-recovery.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +fix(sw): improve push session recovery by increasing TTL, adding timeout fallback, and resetting heartbeat backoff on foreground sync diff --git a/config.json b/config.json index f0c3c8b61..3659a35d2 100644 --- a/config.json +++ b/config.json @@ -19,6 +19,11 @@ "enabled": true }, + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 7fd5f2325..224dd3137 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,55 +1,269 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; import { useAtom } from 'jotai'; +import { getSlidingSyncManager } from '$client/initMatrix'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig } from './useClientConfig'; +import { useClientConfig, useExperimentVariant } from './useClientConfig'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; +import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); +const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; +const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; +const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; + export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = mobileOrTablet(); + const sessionSyncConfig = clientConfig.sessionSync; + const sessionSyncVariant = useExperimentVariant( + 'sessionSyncStrategy', + mx?.getUserId() ?? undefined + ); + + // Derive phase flags from experiment variant; fall back to direct config when not in experiment. + const inSessionSync = sessionSyncVariant.inExperiment; + const syncVariant = sessionSyncVariant.variant; + const phase1ForegroundResync = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase1ForegroundResync === true; + const phase2VisibleHeartbeat = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase2VisibleHeartbeat === true; + const phase3AdaptiveBackoffJitter = inSessionSync + ? syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; + + const foregroundDebounceMs = Math.max( + 0, + sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS + ); + const heartbeatIntervalMs = Math.max( + 1000, + sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS + ); + const resumeHeartbeatSuppressMs = Math.max( + 0, + sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS + ); + const heartbeatMaxBackoffMs = Math.max( + heartbeatIntervalMs, + sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS + ); + + const lastForegroundPushAtRef = useRef(0); + const suppressHeartbeatUntilRef = useRef(0); + const heartbeatFailuresRef = useRef(0); + const lastEmittedVisibilityRef = useRef(undefined); + + const pushSessionNow = useCallback( + (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { + const baseUrl = mx?.getHomeserverUrl(); + const accessToken = mx?.getAccessToken(); + const userId = mx?.getUserId(); + const canPush = + !!mx && + typeof baseUrl === 'string' && + typeof accessToken === 'string' && + typeof userId === 'string' && + 'serviceWorker' in navigator; + + if (!canPush) { + debugLog.warn('network', 'Skipped SW session sync', { + reason, + hasClient: !!mx, + hasBaseUrl: !!baseUrl, + hasAccessToken: !!accessToken, + hasUserId: !!userId, + hasSwController: !!navigator.serviceWorker?.controller, + }); + return 'skipped'; + } + + pushSessionToSW(baseUrl, accessToken, userId); + debugLog.info('network', 'Pushed session to SW', { + reason, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + hasSwController: !!navigator.serviceWorker?.controller, + }); + return 'sent'; + }, + [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] + ); + useEffect(() => { - const handleVisibilityChange = () => { - const isVisible = document.visibilityState === 'visible'; + const handleVisibilityState = (isVisible: boolean, source: 'visibilitychange' | 'pagehide') => { + if (lastEmittedVisibilityRef.current === isVisible) return; + lastEmittedVisibilityRef.current = isVisible; + debugLog.info( 'general', `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, - { visibilityState: document.visibilityState } + { visibilityState: document.visibilityState, source } ); - appEvents.onVisibilityChange?.(isVisible); + appEvents.emitVisibilityChange(isVisible); if (!isVisible) { - appEvents.onVisibilityHidden?.(); + appEvents.emitVisibilityHidden(); + return; + } + + // Always kick the sync loop on foreground regardless of phase flags — + // the SDK may be sitting in exponential backoff after iOS froze the tab. + mx?.retryImmediately(); + // retryImmediately() is a no-op on SlidingSyncSdk — call resend() on the + // SlidingSync instance directly to abort a stale long-poll and start fresh. + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('foreground') === 'sent') { + // A successful push proves the SW controller is up — reset adaptive backoff + // so the heartbeat returns to its normal interval immediately rather than + // staying on an inflated delay left over from a prior SW absence period. + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } + } + }; + + const handleVisibilityChange = () => { + handleVisibilityState(document.visibilityState === 'visible', 'visibilitychange'); + }; + + const handlePageHide = () => { + handleVisibilityState(false, 'pagehide'); + }; + + const handleFocus = () => { + if (document.visibilityState !== 'visible') return; + + // Always kick the sync loop on focus for the same reason as above. + mx?.retryImmediately(); + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('focus') === 'sent') { + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } } }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pagehide', handlePageHide); + window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pagehide', handlePageHide); + window.removeEventListener('focus', handleFocus); }; - }, []); + }, [ + foregroundDebounceMs, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + resumeHeartbeatSuppressMs, + ]); useEffect(() => { - if (!mx) return; + if (!mx) return undefined; const handleVisibilityForNotifications = (isVisible: boolean) => { togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); }; - appEvents.onVisibilityChange = handleVisibilityForNotifications; - // eslint-disable-next-line consistent-return + const unsubscribe = appEvents.onVisibilityChange(handleVisibilityForNotifications); + return unsubscribe; + }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + + useEffect(() => { + if (!phase2VisibleHeartbeat) return undefined; + + // Reset adaptive backoff/suppression so a config or session change starts fresh. + heartbeatFailuresRef.current = 0; + suppressHeartbeatUntilRef.current = 0; + + let timeoutId: number | undefined; + + const getDelayMs = (): number => { + let delay = heartbeatIntervalMs; + + if (phase3AdaptiveBackoffJitter) { + const failures = heartbeatFailuresRef.current; + const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); + delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); + + // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. + const jitter = 0.8 + Math.random() * 0.4; + delay = Math.max(1000, Math.round(delay * jitter)); + } + + return delay; + }; + + const tick = () => { + const now = Date.now(); + + if (document.visibilityState !== 'visible' || !navigator.onLine) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + const result = pushSessionNow('heartbeat'); + if (phase3AdaptiveBackoffJitter) { + if (result === 'sent') { + heartbeatFailuresRef.current = 0; + } else { + // 'skipped' means prerequisites (SW controller, session) aren't ready. + // Treat as a transient failure so backoff grows until the SW is ready. + heartbeatFailuresRef.current += 1; + } + } + + timeoutId = window.setTimeout(tick, getDelayMs()); + }; + + timeoutId = window.setTimeout(tick, getDelayMs()); + return () => { - appEvents.onVisibilityChange = null; + if (timeoutId !== undefined) window.clearTimeout(timeoutId); }; - }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + }, [ + heartbeatIntervalMs, + heartbeatMaxBackoffMs, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + ]); } diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e523f15a7..9538ca0bf 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -5,6 +5,31 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + +export type SessionSyncConfig = { + phase1ForegroundResync?: boolean; + phase2VisibleHeartbeat?: boolean; + phase3AdaptiveBackoffJitter?: boolean; + foregroundDebounceMs?: number; + heartbeatIntervalMs?: number; + resumeHeartbeatSuppressMs?: number; + heartbeatMaxBackoffMs?: number; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -14,6 +39,10 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + + sessionSync?: SessionSyncConfig; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -55,6 +84,72 @@ export function useClientConfig(): ClientConfig { return config; } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + const enabled = experiment?.enabled === true; + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex], + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx new file mode 100644 index 000000000..55bde4b48 --- /dev/null +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -0,0 +1,118 @@ +import { ReactNode } from 'react'; +import { act, render } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { SyncState } from '$types/matrix-sdk'; +import { getHomeRoomPath } from '$pages/pathUtils'; +import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; +import { mDirectAtom } from '$state/mDirectList'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { NotificationJumper } from './useNotificationJumper'; + +const navigateMock = vi.fn(); + +const roomTimelineEvents: { getId: () => string }[] = []; +const roomMock = { + roomId: '!room:test', + getMyMembership: vi.fn(() => 'join'), + getCanonicalAlias: vi.fn(() => undefined), + getLiveTimeline: vi.fn(() => ({ + getState: vi.fn(() => ({ + getStateEvents: vi.fn(() => undefined), + })), + })), + getUnfilteredTimelineSet: vi.fn(() => ({ + getLiveTimeline: () => ({ + getEvents: () => roomTimelineEvents, + }), + })), +}; + +const mxMock = { + getUserId: vi.fn(() => '@alice:test'), + getSyncState: vi.fn(() => SyncState.Syncing), + getRoom: vi.fn(() => roomMock), + getRooms: vi.fn(() => []), + on: vi.fn(), + removeListener: vi.fn(), +}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + +vi.mock('./useMatrixClient', () => ({ + useMatrixClient: () => mxMock, +})); + +vi.mock('./useSyncState', () => ({ + useSyncState: vi.fn(), +})); + +vi.mock('../utils/debug', () => ({ + createLogger: () => ({ + log: vi.fn(), + }), +})); + +type WrapperProps = { + children: ReactNode; +}; + +function HydratedWrapper({ children }: WrapperProps) { + const store = createStore(); + store.set(activeSessionIdAtom, '@alice:test'); + store.set(pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set() }); + store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: new Map() }); + + return ( + + {children} + + ); +} + +describe('NotificationJumper', () => { + beforeEach(() => { + vi.useFakeTimers(); + navigateMock.mockReset(); + roomTimelineEvents.length = 0; + roomMock.getMyMembership.mockReturnValue('join'); + mxMock.getUserId.mockReturnValue('@alice:test'); + mxMock.getSyncState.mockReturnValue(SyncState.Syncing); + mxMock.getRoom.mockReturnValue(roomMock); + mxMock.getRooms.mockReturnValue([]); + mxMock.on.mockClear(); + mxMock.removeListener.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('navigates immediately when the target event is already in the live timeline', () => { + roomTimelineEvents.push({ getId: () => '$event:test' }); + + render(, { wrapper: HydratedWrapper }); + + expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test')); + }); + + it('falls back after the timeout even if no further room events arrive', () => { + render(, { wrapper: HydratedWrapper }); + + expect(navigateMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(30_000); + }); + + expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test')); + }); +}); diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 43c358317..e04ad818d 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; -import { SyncState, ClientEvent } from '$types/matrix-sdk'; +import { SyncState, ClientEvent, RoomEvent, Room, MatrixEvent } from '$types/matrix-sdk'; import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions'; import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; @@ -12,6 +12,10 @@ import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; +// How long to wait for the notification event to appear in the live timeline +// before navigating with the eventId anyway (triggers historical context load). +const JUMP_TIMEOUT_MS = 30_000; + export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -27,6 +31,18 @@ export function NotificationJumper() { // churn re-calls performJump (from the ClientEvent.Room listener or effect // re-runs) before React has committed the null, causing repeated navigation. const jumpingRef = useRef(false); + // Tracks when we first started waiting for the target event to appear in the + // live timeline. Reset whenever `pending` changes. + const jumpStartTimeRef = useRef(null); + const jumpTimeoutRef = useRef | undefined>(undefined); + const performJumpRef = useRef<() => void>(() => undefined); + + const clearJumpTimeout = useCallback(() => { + if (jumpTimeoutRef.current !== undefined) { + clearTimeout(jumpTimeoutRef.current); + jumpTimeoutRef.current = undefined; + } + }, []); const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; @@ -52,13 +68,55 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - log.log('jumping to:', pending.roomId, pending.eventId); + const liveEvents = + room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.() ?? []; + const eventInLive = pending.eventId + ? liveEvents.some((event) => event.getId() === pending.eventId) + : false; + + // Defer while the target event hasn't arrived in the live timeline yet. + // Navigating with an eventId not in the live timeline triggers a sparse + // historical context load — the room appears empty or shows only one message. + // Retry on each RoomEvent.Timeline until the event appears, then navigate + // with the eventId so the room scrolls to and highlights it in full context. + // After JUMP_TIMEOUT_MS fall back to opening the room at the live bottom. + if (pending.eventId && !eventInLive) { + if (jumpStartTimeRef.current === null) { + jumpStartTimeRef.current = Date.now(); + } + const elapsedMs = Date.now() - jumpStartTimeRef.current; + if (elapsedMs < JUMP_TIMEOUT_MS) { + if (jumpTimeoutRef.current === undefined) { + jumpTimeoutRef.current = setTimeout(() => { + jumpTimeoutRef.current = undefined; + performJumpRef.current(); + }, JUMP_TIMEOUT_MS - elapsedMs); + } + log.log('event not yet in live timeline, deferring jump...', { + roomId: pending.roomId, + eventId: pending.eventId, + }); + return; + } + log.log('timed out waiting for event in live; falling back to live bottom', { + roomId: pending.roomId, + eventId: pending.eventId, + }); + } + + // Pass eventId when confirmed in the live timeline (best case — scrolls to + // and highlights the event in full room context), OR when the timeout fires + // (triggers a historical context load so the user at least sees the message + // they tapped). Only omit eventId when we never had one in the first place. + const targetEventId = pending.eventId ?? undefined; + log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; + clearJumpTimeout(); // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectRoomPath(roomIdOrAlias, targetEventId)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -74,11 +132,11 @@ export function NotificationJumper() { getSpaceRoomPath( getCanonicalAliasOrRoomId(mx, parentSpace), roomIdOrAlias, - pending.eventId + targetEventId ) ); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); } } setPending(null); @@ -90,19 +148,30 @@ export function NotificationJumper() { membership: room?.getMyMembership(), }); } - }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); + }, [ + pending, + activeSessionId, + mx, + mDirects, + roomToParents, + navigate, + setPending, + log, + clearJumpTimeout, + ]); - // Reset the guard only when pending is replaced (new notification or cleared). + // Reset guards only when pending is replaced (new notification or cleared). useEffect(() => { + clearJumpTimeout(); jumpingRef.current = false; - }, [pending]); + jumpStartTimeRef.current = null; + }, [pending, clearJumpTimeout]); // Keep a stable ref to the latest performJump so that the listeners below // always invoke the current version without adding performJump to their dep // arrays. Adding performJump as a dep causes the effect to re-run (and call // performJump again) on every atom change during an account switch — that is // the second source of repeated navigation. - const performJumpRef = useRef(performJump); performJumpRef.current = performJump; useSyncState( @@ -117,13 +186,25 @@ export function NotificationJumper() { if (!pending) return undefined; const onRoom = () => performJumpRef.current(); + const onTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => { + if (eventRoom?.roomId === pending.roomId) performJumpRef.current(); + }; mx.on(ClientEvent.Room, onRoom); + mx.on(RoomEvent.Timeline, onTimeline); performJumpRef.current(); return () => { mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(RoomEvent.Timeline, onTimeline); }; }, [pending, mx]); // performJump intentionally omitted — use ref above + useEffect( + () => () => { + clearJumpTimeout(); + }, + [clearJumpTimeout] + ); + return null; } diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 395718223..aad805035 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -26,6 +26,7 @@ import { getMemberDisplayName, getNotificationType, getStateEvent, + getRoomDisplayName, isNotificationEvent, getMDirects, isDMRoom, @@ -35,6 +36,7 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import LogoSVG from '$public/res/svg/cinny-logo.svg'; import { nicknamesAtom } from '$state/nicknames'; +import { activeRoomIdAtom } from '$state/room/activeRoomId'; import { buildRoomMessageNotification, resolveNotificationPreviewText, @@ -110,8 +112,11 @@ export function BackgroundNotifications() { ); const shouldRunBackgroundNotifications = showNotifications || usePushNotifications; const nicknames = useAtomValue(nicknamesAtom); + const activeRoomId = useAtomValue(activeRoomIdAtom); const nicknamesRef = useRef(nicknames); nicknamesRef.current = nicknames; + const activeRoomIdRef = useRef(activeRoomId); + activeRoomIdRef.current = activeRoomId; // Refs so handleTimeline callbacks always read current settings without stale closures const showNotificationsRef = useRef(showNotifications); showNotificationsRef.current = showNotifications; @@ -171,6 +176,7 @@ export function BackgroundNotifications() { const { current } = clientsRef; const activeIds = new Set(inactiveSessions.map((s) => s.userId)); + const pendingRetryTimers = new Set>(); async function sendNotification(opts: NotifyOptions): Promise { // Prefer ServiceWorkerRegistration.showNotification so that taps are handled @@ -323,7 +329,7 @@ export function BackgroundNotifications() { return; } - if (!isNotificationEvent(mEvent)) { + if (!isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { return; } @@ -414,6 +420,10 @@ export function BackgroundNotifications() { const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption); + // After decryption, getType() still returns the wire type (m.room.encrypted). + // Use the effective event type to get the decrypted type when available. + const effectiveEventType = mEvent.getEffectiveEvent()?.type ?? mEvent.getType(); + notifiedEventsRef.current.add(dedupeId); // Cap the set so it doesn't grow unbounded if (notifiedEventsRef.current.size > 200) { @@ -422,13 +432,13 @@ export function BackgroundNotifications() { } const notificationPayload = buildRoomMessageNotification({ - roomName: room.name ?? room.getCanonicalAlias() ?? room.roomId, + roomName: getRoomDisplayName(room), roomAvatar, username: senderName, recipientId: session.userId, previewText: resolveNotificationPreviewText({ content: mEvent.getContent(), - eventType: mEvent.getType(), + eventType: effectiveEventType, isEncryptedRoom, showMessageContent: showMessageContentRef.current, showEncryptedMessageContent: showEncryptedMessageContentRef.current, @@ -451,6 +461,17 @@ export function BackgroundNotifications() { setPending({ roomId: room.roomId, eventId, targetSessionId: session.userId }); }; + // Skip notifications entirely when the active session is viewing + // this exact room and the window has focus — the user is already + // looking at the messages. + if (room.roomId === activeRoomIdRef.current && document.hasFocus()) { + debugLog.debug('notification', 'Skipping notification — room is active', { + roomId: room.roomId, + eventId, + }); + return; + } + // Show in-app banner when app is visible, mobile, and in-app notifications enabled const canShowInAppBanner = document.visibilityState === 'visible' && @@ -467,7 +488,7 @@ export function BackgroundNotifications() { setInAppBannerRef.current({ id: dedupeId, title: notificationPayload.title, - roomName: room.name ?? room.getCanonicalAlias() ?? undefined, + roomName: getRoomDisplayName(room), senderName, body: notificationPayload.options.body, icon: notificationPayload.options.icon, @@ -522,7 +543,8 @@ export function BackgroundNotifications() { // Retry with exponential backoff, up to 5 attempts (5s, 10s, 20s, 40s, 60s cap). if (attempt < 5) { const retryDelay = Math.min(5_000 * 2 ** attempt, 60_000); - setTimeout(() => { + const timerId = setTimeout(() => { + pendingRetryTimers.delete(timerId); const latestSession = inactiveSessionsRef.current.find( (s) => s.userId === session.userId ); @@ -530,6 +552,7 @@ export function BackgroundNotifications() { startSession(latestSession, attempt + 1); } }, retryDelay); + pendingRetryTimers.add(timerId); } }); }; @@ -539,6 +562,8 @@ export function BackgroundNotifications() { }); return () => { + pendingRetryTimers.forEach((id) => clearTimeout(id)); + pendingRetryTimers.clear(); // Reading ref.current in cleanup is intentional - we want cleanup functions // that were registered during async startBackgroundClient operations // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..f0c6aa5dd 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -31,6 +31,7 @@ import { getMemberDisplayName, getNotificationType, getStateEvent, + getRoomDisplayName, isDMRoom, isNotificationEvent, } from '$utils/room'; @@ -134,7 +135,9 @@ function FaviconUpdater() { // for an OS-level app badge. if (highlightTotal > 0) { navigator.setAppBadge(highlightTotal); - } else { + } else if (document.visibilityState === 'visible') { + // Only clear when foregrounded — the SW sets the badge from push + // payloads while backgrounded, and local state may be stale. navigator.clearAppBadge(); } if (usePushNotifications) { @@ -336,7 +339,12 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { + if ( + !room || + isHistoricalEvent || + room.isSpaceRoom() || + !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined) + ) { return; } @@ -409,7 +417,7 @@ function MessageNotifications() { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const osPayload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', + roomName: getRoomDisplayName(room), roomAvatar: avatarMxc ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined, @@ -485,7 +493,7 @@ function MessageNotifications() { } const payload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', + roomName: getRoomDisplayName(room), roomAvatar, username: resolvedSenderName, previewText, @@ -500,7 +508,7 @@ function MessageNotifications() { setInAppBanner({ id: eventId, title: payload.title, - roomName: room.name ?? undefined, + roomName: getRoomDisplayName(room), serverName, senderName: resolvedSenderName, body: previewText, @@ -513,8 +521,12 @@ function MessageNotifications() { }); } - // In-app audio: play when notification sounds are enabled AND this notification is loud. - if (notificationSound && isLoud) { + // In-app audio: play when the app is in the foreground (has focus) and + // notification sounds are enabled for this notification type. + // Gating on hasFocus() rather than just visibilityState prevents a race + // where the page is still 'visible' for a brief window after the user + // backgrounds the app on mobile — hasFocus() flips false first. + if (notificationSound && isLoud && document.hasFocus()) { playSound(); } }; @@ -765,7 +777,9 @@ function HandleDecryptPushEvent() { const handleMessage = async (ev: MessageEvent) => { const { data } = ev; - if (!data || data.type !== 'decryptPushEvent') return; + if (!data) return; + + if (data.type !== 'decryptPushEvent') return; const { rawEvent } = data as { rawEvent: Record }; const eventId = rawEvent.event_id as string; diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 1780fa728..a7a5eac0d 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -4,6 +4,7 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; +import { useActiveRoomIdSync } from '$state/room/activeRoomId'; import { useDirectRooms } from './useDirectRooms'; export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { @@ -16,6 +17,8 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); + useActiveRoomIdSync(roomId); + if (!room || !rooms.includes(room.roomId)) { return ; } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index b6f7ba7e3..c86ed8219 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -5,6 +5,7 @@ import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; +import { useActiveRoomIdSync } from '$state/room/activeRoomId'; import { useHomeRooms } from './useHomeRooms'; export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { @@ -18,6 +19,8 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); + useActiveRoomIdSync(roomId); + if (!room || !rooms.includes(room.roomId)) { return ( (undefined); + +/** Keep {@link activeRoomIdAtom} in sync with the current route's room. */ +export function useActiveRoomIdSync(roomId: string | undefined): void { + const setActiveRoomId = useSetAtom(activeRoomIdAtom); + useEffect(() => { + setActiveRoomId(roomId); + return () => setActiveRoomId(undefined); + }, [roomId, setActiveRoomId]); +} diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2834c5b6f..f96c016cb 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,5 +1,29 @@ +type VisibilityChangeHandler = (isVisible: boolean) => void; +type VisibilityHiddenHandler = () => void; + +const visibilityChangeHandlers = new Set(); +const visibilityHiddenHandlers = new Set(); + export const appEvents = { - onVisibilityHidden: null as (() => void) | null, + onVisibilityHidden(handler: VisibilityHiddenHandler): () => void { + visibilityHiddenHandlers.add(handler); + return () => { + visibilityHiddenHandlers.delete(handler); + }; + }, + + emitVisibilityHidden(): void { + visibilityHiddenHandlers.forEach((h) => h()); + }, + + onVisibilityChange(handler: VisibilityChangeHandler): () => void { + visibilityChangeHandlers.add(handler); + return () => { + visibilityChangeHandlers.delete(handler); + }; + }, - onVisibilityChange: null as ((isVisible: boolean) => void) | null, + emitVisibilityChange(isVisible: boolean): void { + visibilityChangeHandlers.forEach((h) => h(isVisible)); + }, }; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 9391dbc90..fa6114466 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -536,6 +536,16 @@ export const getMemberDisplayName = ( return name; }; +/** + * Returns the room's display name, normalising the case where the SDK computes + * a raw Matrix user ID for unnamed DMs. In that case, use the localpart so + * notifications and banners do not show the full MXID. + */ +export const getRoomDisplayName = (room: Room): string => { + const { name } = room; + return name.match(/^@([^:]+):/)?.[1] ?? name; +}; + export const getMemberSearchStr = ( member: RoomMember, query: string, diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index f6f428a10..316157340 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -614,6 +614,14 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { stopClient(mx); clearNavToActivePathStore(mx.getSafeUserId()); await mx.store.deleteAllData(); + + // Unregister all service workers so the next load starts fresh. + // Especially important on iOS/mobile where stale SWs can persist. + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + window.location.reload(); }; diff --git a/src/index.tsx b/src/index.tsx index 4f2e57245..c342852b5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -93,6 +93,14 @@ if ('serviceWorker' in navigator) { log.warn('SW ready failed:', err); }); + // When the SW updates (skipWaiting + clients.claim), the old SW is killed and + // the new one has an empty sessions Map. Re-push the session immediately so + // push notifications and authenticated media fetches keep working. + navigator.serviceWorker.addEventListener('controllerchange', () => { + log.log('SW controller changed — re-sending session'); + sendSessionToSW(); + }); + navigator.serviceWorker.addEventListener('message', (ev) => { const { data } = ev; if (!data || typeof data !== 'object') return; diff --git a/src/sw-session.ts b/src/sw-session.ts index e4d2672da..63d285d99 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,11 +1,45 @@ -export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { - if (!('serviceWorker' in navigator)) return; - if (!navigator.serviceWorker.controller) return; +type ServiceWorkerSessionPayload = { + type: 'setSession'; + accessToken?: string; + baseUrl?: string; + userId?: string; +}; - navigator.serviceWorker.controller.postMessage({ +function postSessionPayload( + target: ServiceWorker | null | undefined, + payload: ServiceWorkerSessionPayload, + seenTargets: WeakSet +) { + if (!target || seenTargets.has(target)) return false; + seenTargets.add(target); + target.postMessage(payload); + return true; +} + +export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string): boolean { + if (!('serviceWorker' in navigator)) return false; + + const payload: ServiceWorkerSessionPayload = { type: 'setSession', accessToken, baseUrl, userId, - }); + }; + const seenTargets = new WeakSet(); + postSessionPayload(navigator.serviceWorker.controller, payload, seenTargets); + + // Backgrounded/mobile browsers can drop the current controller reference even + // though the registration is still active. Post to any reachable worker from + // navigator.serviceWorker.ready so the session is restored without a reload. + void navigator.serviceWorker.ready + .then((registration) => { + postSessionPayload(registration.active, payload, seenTargets); + postSessionPayload(registration.waiting, payload, seenTargets); + postSessionPayload(registration.installing, payload, seenTargets); + }) + .catch(() => undefined); + + // Treat a queued ready() delivery as a successful attempt so foreground/heartbeat + // recovery keeps running even if controller is temporarily absent. + return true; } diff --git a/src/sw.ts b/src/sw.ts index d843963bd..1075330e5 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -8,13 +8,14 @@ export type {}; declare const self: ServiceWorkerGlobalScope; let notificationSoundEnabled = true; -// Tracks whether a page client has reported itself as visible. -// The clients.matchAll() visibilityState is unreliable on iOS Safari PWA, -// so we use this explicit flag as a fallback. -let appIsVisible = false; let showMessageContent = false; let showEncryptedMessageContent = false; let clearNotificationsOnRead = false; + +// Tracks whether a page client has reported itself as visible. +// Combines with clients.matchAll() in the push handler because iOS Safari PWA +// often returns empty or stale results from matchAll(). +let appIsVisible = false; const { handlePushNotificationPushData } = createPushNotifications(self, () => ({ showMessageContent, showEncryptedMessageContent, @@ -69,9 +70,12 @@ async function loadPersistedSettings() { async function persistSession(session: SessionInfo): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); + const sessionWithTimestamp = { ...session, persistedAt: Date.now() }; await cache.put( SW_SESSION_URL, - new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify(sessionWithTimestamp), { + headers: { 'Content-Type': 'application/json' }, + }) ); } catch { // Ignore — caches may be unavailable in some environments. @@ -91,13 +95,34 @@ async function loadPersistedSession(): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); const response = await cache.match(SW_SESSION_URL); - if (!response) return undefined; - const s = await response.json(); - if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + if (response) { + const s = await response.json(); + + // Reject persisted sessions older than 24 hours. Matrix access tokens are + // long-lived and are only invalidated on explicit logout or device revocation — + // not by the passage of time. A short TTL (e.g. 60 s) was too aggressive: it + // caused the SW to show generic "New Message" notifications whenever the app + // was backgrounded for more than a minute, because the cached session was + // rejected and requestSession had no live window client to reach. + // If the token truly is revoked the fetches in handleMinimalPushPayload will + // receive a 401 and gracefully fall back to a generic notification anyway. + if (typeof s.accessToken !== 'string' || typeof s.baseUrl !== 'string') { + console.debug('[SW] loadPersistedSession: invalid cached session (missing fields)'); + return undefined; + } + + const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; + const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + if (age > MAX_SESSION_AGE_MS) { + console.debug('[SW] loadPersistedSession: session expired', { age }); + return undefined; + } + return { accessToken: s.accessToken, baseUrl: s.baseUrl, userId: typeof s.userId === 'string' ? s.userId : undefined, + persistedAt: s.persistedAt, }; } return undefined; @@ -111,6 +136,8 @@ type SessionInfo = { baseUrl: string; /** Matrix user ID of the account, used to identify which account a push belongs to. */ userId?: string; + /** Timestamp when this session was persisted to cache, used to expire stale tokens. */ + persistedAt?: number; }; /** @@ -368,37 +395,43 @@ async function requestDecryptionFromClient( rawEvent: Record ): Promise { const eventId = rawEvent.event_id as string; + if (windowClients.length === 0) return undefined; + + // Broadcast to all clients, but resolve once from the first successful reply. + // The prior Promise.any implementation accidentally overwrote the pending + // resolver on each iteration, leaving only the last client able to satisfy + // the request for a given eventId. + const resultPromise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, (result) => { + decryptionPendingMap.delete(eventId); + resolve(result); + }); + }); - // Chain clients sequentially using reduce to avoid await-in-loop and for-of. - return Array.from(windowClients).reduce( - async (prevPromise, client) => { - const prev = await prevPromise; - if (prev?.success) return prev; - - const promise = new Promise((resolve) => { - decryptionPendingMap.set(eventId, resolve); - }); + let postedToClient = false; + Array.from(windowClients).forEach((client) => { + try { + (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + postedToClient = true; + } catch (err) { + console.warn('[SW decryptRelay] postMessage error', err); + } + }); - const timeout = new Promise((resolve) => { - setTimeout(() => { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] timed out waiting for client', client.id); - resolve(undefined); - }, 5000); - }); + if (!postedToClient) { + decryptionPendingMap.delete(eventId); + return undefined; + } - try { - (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); - } catch (err) { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] postMessage error', err); - return undefined; - } + const timeout = new Promise((resolve) => { + setTimeout(() => { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] timed out waiting for client response'); + resolve(undefined); + }, 5000); + }); - return Promise.race([promise, timeout]); - }, - Promise.resolve(undefined) as Promise - ); + return Promise.race([resultPromise, timeout]); } /** @@ -413,8 +446,18 @@ async function handleMinimalPushPayload( ): Promise { // On iOS the SW is killed and restarted for every push, clearing the in-memory sessions // Map. Fall back to the Cache Storage copy that was written when the user last opened - // the app (same pattern as settings persistence). - const session = getAnyStoredSession() ?? (await loadPersistedSession()); + // the app (same pattern as settings persistence). If onPushNotification already loaded + // the persisted session into preloadedSession, reuse it to avoid a second cache read. + // Last resort: if neither the in-memory map nor the cache has a session, ask any live + // window client for a fresh token (the app may be backgrounded but still alive in memory). + let session = getAnyStoredSession() ?? preloadedSession ?? (await loadPersistedSession()); + if (!session && windowClients.length > 0) { + console.debug('[SW push] no cached session, requesting from window clients'); + const results = await Promise.all( + Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) + ); + session = results.find((r) => r != null) ?? undefined; + } if (!session) { // No session anywhere — app was never opened since install, or the user logged out. @@ -479,8 +522,10 @@ async function handleMinimalPushPayload( ? await requestDecryptionFromClient(windowClients, rawEvent) : undefined; - // If the relay responded and the app is currently visible, the in-app UI is already - // displaying the message — skip the OS notification entirely. + // If the relay responded and indicates the app is currently visible, the + // in-app UI is already displaying the message — skip the OS notification. + // result.visibilityState comes from a live postMessage round-trip, so it + // reflects the page's actual current state (not stale in-memory flags). if (result?.visibilityState === 'visible') return; if (result?.success) { @@ -495,6 +540,7 @@ async function handleMinimalPushPayload( // Prefer relay's room name (has m.direct / computed SDK name); fall back to state fetch. room_name: result.room_name || resolvedRoomName, room_avatar_url: notificationAvatarUrl, + isEncryptedRoom: true, }); } else { // App is frozen or fully closed — show "Encrypted message" fallback. @@ -555,6 +601,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setSession') { setSession(client.id, accessToken, baseUrl, userId); + // Keep the SW alive until the cache write completes. persistSession is + // called fire-and-forget inside setSession; without waitUntil the browser + // can kill the SW before caches.put resolves, leaving the persisted session + // stale on the next restart and causing intermittent 401s on media fetches. + const persisted = sessions.get(client.id); + event.waitUntil( + (persisted ? persistSession(persisted) : clearPersistedSession()).catch(() => undefined) + ); event.waitUntil(cleanupDeadClients()); } if (type === 'pushDecryptResult') { @@ -604,12 +658,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const MEDIA_PATHS = [ '/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail', + '/_matrix/client/v1/media/preview_url', + '/_matrix/client/v3/media/download', + '/_matrix/client/v3/media/thumbnail', + '/_matrix/client/v3/media/preview_url', + '/_matrix/client/r0/media/download', + '/_matrix/client/r0/media/thumbnail', + '/_matrix/client/r0/media/preview_url', + '/_matrix/client/unstable/org.matrix.msc3916/media/download', + '/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail', + '/_matrix/client/unstable/org.matrix.msc3916/media/preview_url', // Legacy unauthenticated endpoints — servers that require auth return 404/403 // for these when no token is present, so intercept and add auth here too. '/_matrix/media/v3/download', '/_matrix/media/v3/thumbnail', + '/_matrix/media/v3/preview_url', '/_matrix/media/r0/download', '/_matrix/media/r0/thumbnail', + '/_matrix/media/r0/preview_url', ]; function mediaPath(url: string): boolean { @@ -628,6 +694,39 @@ function validMediaRequest(url: string, baseUrl: string): boolean { }); } +function getMatchingSessions(url: string): SessionInfo[] { + return [...sessions.values()].filter((s) => validMediaRequest(url, s.baseUrl)); +} + +function isAuthFailureStatus(status: number): boolean { + return status === 401 || status === 403; +} + +async function getLiveWindowSessions(url: string, clientId: string): Promise { + const collected: SessionInfo[] = []; + const seen = new Set(); + const add = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seen.has(key)) return; + seen.add(key); + collected.push(session); + }; + + if (clientId) { + add(await requestSessionWithTimeout(clientId, 1500)); + return collected; + } + + const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const liveSessions = await Promise.all( + windowClients.map((client) => requestSessionWithTimeout(client.id, 750)) + ); + liveSessions.forEach((session) => add(session)); + + return collected; +} + function fetchConfig(token: string): RequestInit { return { headers: { @@ -637,6 +736,100 @@ function fetchConfig(token: string): RequestInit { }; } +/** + * Fetch a media URL, retrying once with the most-current in-memory session on 401. + * + * There is a timing window between when the SDK refreshes its access token + * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW() + * postMessage is processed by the SW. Media requests that land in this window + * are sent with the stale token and receive 401. By the time the retry runs, + * the setSession message will normally have been processed and sessions will + * hold the new token. + * + * A second timing window exists at startup: preloadedSession may hold a stale + * token but the live setSession from the page hasn't arrived yet. In that case + * the in-memory check yields no fresher token, so we ask the live client tab + * directly (requestSessionWithTimeout) before giving up. + */ +async function fetchMediaWithRetry( + url: string, + token: string, + redirect: RequestRedirect, + clientId: string +): Promise { + let response = await fetch(url, { ...fetchConfig(token), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + + console.warn('[SW media] Initial authenticated fetch failed', { + url, + status: response.status, + clientId, + hasClientBoundSession: !!(clientId && sessions.get(clientId)), + hasPreloadedSession: !!preloadedSession, + }); + + const attemptedTokens = new Set([token]); + const retrySessions: Array<{ session: SessionInfo; source: string }> = []; + const seenSessions = new Set(); + + const addRetrySession = (session: SessionInfo | undefined, source: string) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seenSessions.has(key)) return; + seenSessions.add(key); + retrySessions.push({ session, source }); + }; + + if (clientId) addRetrySession(sessions.get(clientId), 'client_session'); + getMatchingSessions(url).forEach((session, index) => + addRetrySession(session, `matching_session_${index}`) + ); + addRetrySession(preloadedSession, 'preloaded_session'); + addRetrySession(await loadPersistedSession(), 'persisted_session'); + (await getLiveWindowSessions(url, clientId)).forEach((session, index) => + addRetrySession(session, `live_window_${index}`) + ); + + console.debug('[SW media] Retry candidates collected', { + url, + candidateSources: retrySessions.map(({ source }) => source), + candidateCount: retrySessions.length, + }); + + // Try each plausible token once. This handles token-refresh races and ambiguous + // multi-account sessions on the same homeserver, including no-clientId requests. + // Sequential await is intentional: we want to try one token at a time until one succeeds. + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < retrySessions.length; i += 1) { + const candidate = retrySessions[i]; + if (!candidate || attemptedTokens.has(candidate.session.accessToken)) { + // skip this candidate + } else { + attemptedTokens.add(candidate.session.accessToken); + console.debug('[SW media] Retrying with alternate session', { + url, + source: candidate.source, + attempt: i + 1, + }); + response = await fetch(url, { ...fetchConfig(candidate.session.accessToken), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + console.warn('[SW media] Alternate session also failed auth', { + url, + source: candidate.source, + status: response.status, + }); + } + } + /* eslint-enable no-await-in-loop */ + + console.warn('[SW media] Exhausted authenticated retry candidates', { + url, + finalStatus: response.status, + attemptedTokenCount: attemptedTokens.size, + }); + return response; +} + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -667,37 +860,24 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); - return; - } - - // Since widgets like element call have their own client ids, - // we need this logic. We just go through the sessions list and get a session - // with the right base url. Media requests to a homeserver simply are fine with any account - // on the homeserver authenticating it, so this is fine. But it can be technically wrong. - // If you have two tabs for different users on the same homeserver, it might authenticate - // as the wrong one. - // Thus any logic in the future which cares about which user is authenticating the request - // might break this. Also, again, it is technically wrong. - // Also checks preloadedSession — populated from cache at SW activate — for the window - // between SW restart and the first live setSession arriving from the page. - const byBaseUrl = - [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ?? - (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl) - ? preloadedSession - : undefined); - if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithRetry(url, session.accessToken, redirect, clientId)); return; } // No clientId: the fetch came from a context not associated with a specific - // window (e.g. a prerender). Fall back to the persisted session directly. + // window (e.g. a prerender). Fall back to persisted/unique-by-baseUrl sessions. if (!clientId) { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, ''); + } + const matching = getMatchingSessions(url); + if (matching.length === 1) { + return fetchMediaWithRetry(url, matching[0].accessToken, redirect, ''); + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, ''); } return fetch(event.request); }) @@ -705,17 +885,30 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } + // Synchronous fast-path: check in-memory sessions by baseUrl and the + // preloaded session before paying the 3-second requestSessionWithTimeout + // cost. This restores the old byBaseUrl behaviour while keeping retry logic. + const syncByBaseUrl = getMatchingSessions(url); + if (syncByBaseUrl.length === 1) { + event.respondWith(fetchMediaWithRetry(url, syncByBaseUrl[0].accessToken, redirect, clientId)); + return; + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + event.respondWith(fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId)); + return; + } + event.respondWith( requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithRetry(url, s.accessToken, redirect, clientId); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, clientId); } console.warn( '[SW fetch] No valid session for media request', @@ -741,14 +934,19 @@ const onPushNotification = async (event: PushEvent) => { // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. - const [, , clients] = await Promise.all([ + // Capture the persisted session result into preloadedSession so that + // media fetch handlers can use it as a fallback without a second cache read. + const [, persistedSession, clients] = await Promise.all([ loadPersistedSettings(), loadPersistedSession(), self.clients.matchAll({ type: 'window', includeUncontrolled: true }), ]); + if (persistedSession && !preloadedSession) { + preloadedSession = persistedSession; + } // If the app is open and visible, skip the OS push notification — the in-app - // pill notification handles the alert instead. + // notification handles the alert instead. // Combine clients.matchAll() visibility with the explicit appIsVisible flag // because iOS Safari PWA often returns empty or stale results from matchAll(). const hasVisibleClient = @@ -794,11 +992,40 @@ const onPushNotification = async (event: PushEvent) => { // to relay decryption to an open app tab. if (isMinimalPushPayload(pushData)) { console.debug('[SW push] minimal payload detected — fetching event', pushData.event_id); - await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + try { + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + } catch (err) { + console.error('[SW push] handleMinimalPushPayload failed:', err); + // Show a generic fallback so the user still sees something on iOS. + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: `room-${pushData.room_id}`, + renotify: true, + data: { room_id: pushData.room_id, event_id: pushData.event_id }, + } as NotificationOptions); + } return; } - await handlePushNotificationPushData(pushData); + try { + await handlePushNotificationPushData(pushData); + } catch (err) { + console.error('[SW push] handlePushNotificationPushData failed:', err); + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: pushData.room_id ? `room-${pushData.room_id}` : (pushData.event_id ?? 'Cinny'), + renotify: true, + data: { + room_id: pushData.room_id, + event_id: pushData.event_id, + user_id: pushData.user_id, + }, + } as NotificationOptions); + } }; // --------------------------------------------------------------------------- @@ -902,5 +1129,5 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { ); }); -precacheAndRoute(self.__WB_MANIFEST); +precacheAndRoute(self.__WB_MANIFEST ?? []); cleanupOutdatedCaches(); diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index 1152d3d44..328e999db 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -45,7 +45,17 @@ export const createPushNotifications = ( silent, data, }; + const existingNotifications = await self.registration.getNotifications(); + const replacedCount = existingNotifications.filter( + (notification) => notification.tag === tag + ).length; console.debug('[SW showNotification] title:', title, '| data:', JSON.stringify(data, null, 2)); + console.debug('[SW showNotification] tag diagnostics:', { + tag, + roomId, + renotify, + replacedCount, + }); await self.registration.showNotification(title, notifOptions as NotificationOptions); }; @@ -88,7 +98,7 @@ export const createPushNotifications = ( previewText: resolveNotificationPreviewText({ content: pushData?.content, eventType: pushData?.type, - isEncryptedRoom: false, + isEncryptedRoom: pushData?.isEncryptedRoom === true, showMessageContent: getNotificationSettings().showMessageContent, showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent, }),