Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/presence-auto-idle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sable/client': minor
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changeset frontmatter uses "'@sable/client': minor", but this repo’s documented changeset format uses the single key "default: ". As-is, release tooling that parses changesets is likely to ignore or error on this file; please change the frontmatter to "default: minor".

Suggested change
'@sable/client': minor
default: minor

Copilot uses AI. Check for mistakes.
---

feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker
5 changes: 5 additions & 0 deletions .changeset/presence-sidebar-badges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Add presence status badges to sidebar DM list and account switcher
2 changes: 2 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"enabled": true
},

"presenceAutoIdleTimeoutMs": 600000,
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the inactivity timeout defaults to 5 minutes, but config.json sets presenceAutoIdleTimeoutMs to 600000ms (10 minutes). Please align the default value with the documented behavior (or update the PR description).

Copilot uses AI. Check for mistakes.

"featuredCommunities": {
"openAsDefault": false,
"spaces": [
Expand Down
82 changes: 81 additions & 1 deletion src/app/features/settings/developer-tools/DevelopTools.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { Box, Text, Scroll, Switch, Button } from 'folds';
import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
import { KnownMembership } from 'matrix-js-sdk/lib/types';
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid importing Matrix SDK symbols directly from "matrix-js-sdk/...". This repo has an app-facing SDK boundary module ($types/matrix-sdk) specifically to centralize SDK imports; import KnownMembership from there instead.

Suggested change
import { KnownMembership } from 'matrix-js-sdk/lib/types';
import { KnownMembership } from '$types/matrix-sdk';

Copilot uses AI. Check for mistakes.
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
Expand All @@ -9,6 +10,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor';
import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
Expand All @@ -25,6 +27,33 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();

const [rotateState, rotateAllSessions] = useAsyncCallback<
{ rotated: number; total: number },
Error,
[]
>(
useCallback(async () => {
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Crypto module not available');

const encryptedRooms = mx
.getRooms()
.filter(
(room) =>
room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId)
);

await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)));
const rotated = encryptedRooms.length;

// Proactively start session creation + key sharing with all devices
// (including bridge bots). fire-and-forget per room.
encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room));

return { rotated, total: encryptedRooms.length };
}, [mx])
);

const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// TODO: remove cast once account data typing is unified.
Expand Down Expand Up @@ -109,6 +138,57 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
</Box>
{developerTools && <SyncDiagnostics />}
{developerTools && (
<Box direction="Column" gap="100">
<Text size="L400">Encryption</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Rotate Encryption Sessions"
focusId="rotate-encryption-sessions"
description="Discard current Megolm sessions and begin sharing new keys with all room members. Key delivery happens in the background — send a message in each affected room to confirm the bridge has received the new keys."
after={
<Button
onClick={rotateAllSessions}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
outlined
disabled={rotateState.status === AsyncStatus.Loading}
before={
rotateState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Secondary" />
)
}
>
<Text size="B300">
{rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'}
</Text>
</Button>
}
>
{rotateState.status === AsyncStatus.Success && (
<Text size="T200" style={{ color: color.Success.Main }}>
Sessions discarded for {rotateState.data.rotated} of{' '}
{rotateState.data.total} encrypted rooms. Key sharing is starting in the
background — send a message in an affected room to confirm delivery to
bridges.
</Text>
)}
{rotateState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{rotateState.error.message}
</Text>
)}
</SettingTile>
</SequenceCard>
</Box>
)}
{developerTools && (
<AccountData
expand={expand}
Expand Down
222 changes: 202 additions & 20 deletions src/app/hooks/useAppVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,102 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { MatrixClient } from '$types/matrix-sdk';
import { useAtom } from 'jotai';
import { togglePusher } from '../features/settings/notifications/PushNotifications';
import { Session } from '$state/sessions';
import { appEvents } from '../utils/appEvents';
import { useClientConfig } 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 { useClientConfig, useExperimentVariant } from './useClientConfig';
import { createDebugLogger } from '../utils/debugLogger';
import { pushSessionToSW } from '../../sw-session';

const debugLog = createDebugLogger('AppVisibility');

export function useAppVisibility(mx: MatrixClient | undefined) {
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, activeSession?: Session) {
const clientConfig = useClientConfig();
const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
const pushSubAtom = useAtom(pushSubscriptionAtom);
const isMobile = mobileOrTablet();

const sessionSyncConfig = clientConfig.sessionSync;
const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId);
Comment on lines +16 to +20
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useAppVisibility’s new SW session-sync logic depends on activeSession (baseUrl/accessToken/userId), but activeSession is optional and current call sites don’t appear to provide it. This makes pushSessionNow() always return 'skipped', so the foreground/heartbeat session sync never actually runs. Consider making activeSession required, or sourcing the active session inside the hook so the feature works in production.

Copilot uses AI. Check for mistakes.

// 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 pushSessionNow = useCallback(
(reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => {
const baseUrl = activeSession?.baseUrl;
const accessToken = activeSession?.accessToken;
const userId = activeSession?.userId;
const canPush =
!!mx &&
typeof baseUrl === 'string' &&
typeof accessToken === 'string' &&
typeof userId === 'string' &&
'serviceWorker' in navigator &&
!!navigator.serviceWorker.controller;

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,
});
return 'sent';
},
[
activeSession?.accessToken,
activeSession?.baseUrl,
activeSession?.userId,
mx,
phase1ForegroundResync,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
]
);

useEffect(() => {
const handleVisibilityChange = () => {
Expand All @@ -29,27 +109,129 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
appEvents.onVisibilityChange?.(isVisible);
if (!isVisible) {
appEvents.onVisibilityHidden?.();
return;
Comment on lines 109 to +112
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appEvents.onVisibilityChange/onVisibilityHidden are invoked here, but there are no remaining assignments to these callbacks anywhere in the codebase. As a result these calls are always no-ops; either remove them (and possibly appEvents entirely) or reintroduce the intended registration so visibility changes actually fan out to consumers.

Copilot uses AI. Check for mistakes.
}

// 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();

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 handleFocus = () => {
if (document.visibilityState !== 'visible') return;

// Always kick the sync loop on focus for the same reason as above.
mx?.retryImmediately();

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('focus', handleFocus);

return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
};
}, []);
}, [
foregroundDebounceMs,
mx,
phase1ForegroundResync,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
pushSessionNow,
resumeHeartbeatSuppressMs,
]);

useEffect(() => {
if (!mx) return;
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();

const handleVisibilityForNotifications = (isVisible: boolean) => {
togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
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());
};

appEvents.onVisibilityChange = handleVisibilityForNotifications;
// eslint-disable-next-line consistent-return
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