Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
fafea7b
fix(sw): increase session TTL to 24h and add requestSessionWithTimeou…
Just-Insane Apr 11, 2026
00e9095
fix(sw): reset heartbeat backoff on foreground sync; warm preloadedSe…
Just-Insane Apr 11, 2026
8672ff9
chore: add changeset for sw-push-session-recovery
Just-Insane Apr 12, 2026
d18e0df
fix(notifications): replace stale visibility flags with live client ping
Just-Insane Apr 12, 2026
15f5707
fix(notifications): use matchAll visibilityState instead of live ping
Just-Insane Apr 12, 2026
6b585d6
fix(notifications): pass room and userId context to reaction notifica…
Just-Insane Apr 12, 2026
5a4a324
fix: wrap long if condition for prettier compliance
Just-Insane Apr 12, 2026
3c5d087
feat(types): add experiment config, sessionSync types and useExperime…
Just-Insane Apr 12, 2026
90f8716
fix(sw): require both visibility signals before suppressing push
Just-Insane Apr 13, 2026
90e2e1e
fix(notifications): open joined rooms at live timeline on notificatio…
Just-Insane Apr 13, 2026
11fe16c
fix(notifications): prefer live timeline before event-scoped jump
Just-Insane Apr 13, 2026
4f1bed3
fix(notifications): defer event-scoped jump until event appears in li…
Just-Insane Apr 13, 2026
5b3a0fb
fix(sw): expire appIsVisible after 45 s; use hasFocus + heartbeat to …
Just-Insane Apr 13, 2026
f79b75e
revert(sw): remove appIsVisible signaling; rely solely on clients.mat…
Just-Insane Apr 13, 2026
b8d56ac
fix(notifications): restore appIsVisible flag and setAppVisible SW ha…
Just-Insane Apr 13, 2026
a0a6140
fix: convert appEvents to multi-subscriber pattern and cancel retry t…
Just-Insane Apr 13, 2026
350a54a
fix: address PR #671 review comments + add controllerchange handler
Just-Insane Apr 13, 2026
a78e989
fix(sw): replace stale visibility flags with live client ping
Just-Insane Apr 13, 2026
9c6baad
fix: remove presence-auto-idle and bookmarks imports that don't exist…
Just-Insane Apr 14, 2026
b80b708
revert(sw): replace live visibility ping with upstream appIsVisible+m…
Just-Insane Apr 14, 2026
808a654
fix(sw): address review feedback for push session recovery
Just-Insane Apr 15, 2026
5f963cd
chore: remove accidentally committed test.txt
Just-Insane Apr 15, 2026
a5f036e
refactor: use mx.getUserId() instead of activeSession param in useApp…
Just-Insane Apr 15, 2026
bb8b35d
fix: kick sliding sync on foreground return
Just-Insane Apr 15, 2026
14c9d4b
fix(sw): improve push notification reliability and encrypted room han…
Just-Insane Apr 15, 2026
40d971b
chore: fix lint and format issues
Just-Insane Apr 15, 2026
ac62f80
fix(config): enable SW session sync phases for reliable mobile notifi…
Just-Insane Apr 15, 2026
4e28db5
fix(sw): reuse preloaded session in handleMinimalPushPayload
Just-Insane Apr 15, 2026
da209be
fix(badge): only clear app badge when foregrounded
Just-Insane Apr 16, 2026
17ed816
feat(cache): unregister service workers on Clear Cache
Just-Insane Apr 16, 2026
15a216f
Merge branch 'fix/clear-cache-sw-unregister' into fix/push-notifications
Just-Insane Apr 17, 2026
f84e1d1
Merge branch 'fix/reaction-notification-context' into fix/push-notifi…
Just-Insane Apr 17, 2026
5cffb9b
Merge fix/sw-push-session-recovery into fix/push-notifications
Just-Insane Apr 17, 2026
66a4c00
fix(notifications): skip in-app notification for active room
Just-Insane Apr 17, 2026
a054294
fix(notifications): improve notification jump reliability
Just-Insane Apr 18, 2026
7a6c949
fix(notifications): restore background visibility sync
Just-Insane Apr 18, 2026
e98bcb8
fix(notifications): normalize DM room names
Just-Insane Apr 18, 2026
36a7e04
fix(notifications): guarantee jump timeout fallback
Just-Insane Apr 19, 2026
5994810
test(notifications): align jumper room mock with room utils
Just-Insane Apr 19, 2026
cea8c2a
test(notifications): use current jotai hydrate api
Just-Insane Apr 19, 2026
c8e2894
test(notifications): initialize jumper atoms via jotai store
Just-Insane Apr 19, 2026
fd50276
fix(notifications): avoid jumper ref lint error
Just-Insane Apr 19, 2026
00cfccb
fix(sw): recover session sync without controller
Just-Insane Apr 19, 2026
9739838
fix(sw): add media and notification diagnostics
Just-Insane Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reaction-notification-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix reaction notifications not being delivered by passing room and user context to the notification event filter
5 changes: 5 additions & 0 deletions .changeset/sw-push-session-recovery.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"enabled": true
},

"sessionSync": {
"phase1ForegroundResync": true,
"phase2VisibleHeartbeat": true
},

"featuredCommunities": {
"openAsDefault": false,
"spaces": [
Expand Down
240 changes: 227 additions & 13 deletions src/app/hooks/useAppVisibility.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined>(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,
]);
}
Loading
Loading