From c178b777b667df4de82d65848902233e0bbc5dee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:50:22 -0400 Subject: [PATCH 01/12] feat(presence): add presence badges to sidebar and fix sliding sync presence data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectDMsList: show PresenceBadge on DM avatar — actual presence for 1:1 DMs, green dot when any participant is online for group DMs - AccountSwitcherTab: show PresenceBadge on own account avatar in sidebar - Fix AvatarPresence placement: move wrapper outside SidebarAvatar (overflow:hidden was clipping the badge) - useUserPresence: reset presence state when userId changes; add REST fallback for sliding sync (Synapse MSC4186 has no presence extension so m.presence events are never delivered via sync — GET /presence/:userId/status bootstraps the initial state) - ClientNonUIFeatures: explicitly PUT /presence/:userId/status on visibility change so the server records online/offline state; setSyncPresence is a no-op on MSC4186 --- src/app/hooks/useUserPresence.ts | 50 +++++++++++++++++-- src/app/pages/client/ClientNonUIFeatures.tsx | 6 +++ .../client/sidebar/AccountSwitcherTab.tsx | 35 ++++++++----- .../pages/client/sidebar/DirectDMsList.tsx | 34 +++++++++++-- 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index f1b858422..a3b86ef08 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; +import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { @@ -29,20 +29,62 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); useEffect(() => { + setPresence(user ? getUserPresence(user) : undefined); + + let cancelled = false; + + // Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never + // delivered via sync. As a result, User.presence stays at the SDK default and + // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state. + if (!user || user.getLastActiveTs() === 0) { + mx.getPresence(userId) + .then((resp) => { + if (cancelled) return; + setPresence({ + presence: resp.presence as Presence, + status: resp.status_msg, + active: resp.currently_active ?? false, + lastActiveTs: + resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, + }); + }) + .catch(() => { + // Presence not available on this server (404 or not supported) — keep existing state. + }); + } + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { - if (u.userId === user?.userId) { - setPresence(getUserPresence(user)); + if (u.userId === userId) { + setPresence(getUserPresence(u)); } }; user?.on(UserEvent.Presence, updatePresence); user?.on(UserEvent.CurrentlyActive, updatePresence); user?.on(UserEvent.LastPresenceTs, updatePresence); + + // If the User object doesn't exist yet, subscribe at client level as a fallback. + // ExtensionPresence emits ClientEvent.Event after creating and updating the User object, + // so by the time this fires mx.getUser(userId) is guaranteed to be non-null. + let removeClientListener: (() => void) | undefined; + if (!user) { + const onClientEvent = (event: MatrixEvent) => { + if (event.getSender() !== userId || event.getType() !== 'm.presence') return; + const u = mx.getUser(userId); + if (!u) return; + setPresence(getUserPresence(u)); + }; + mx.on(ClientEvent.Event, onClientEvent); + removeClientListener = () => mx.removeListener(ClientEvent.Event, onClientEvent); + } + return () => { + cancelled = true; user?.removeListener(UserEvent.Presence, updatePresence); user?.removeListener(UserEvent.CurrentlyActive, updatePresence); user?.removeListener(UserEvent.LastPresenceTs, updatePresence); + removeClientListener?.(); }; - }, [user]); + }, [mx, userId, user]); return presence; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..311e31e5e 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -835,6 +835,12 @@ function PresenceFeature() { mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); // Sliding sync: enable/disable the presence extension on the next poll. getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); + // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no + // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's + // state — otherwise GET /presence returns stale offline and own presence badge is grey. + mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => { + // Server doesn't support presence — ignore. + }); }, [mx, sendPresence]); return null; diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 6e6ecc572..31d4b1a5f 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -40,10 +40,12 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; import { logoutClient, initClient, stopClient } from '$client/initMatrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useUserProfile } from '$hooks/useUserProfile'; +import { useUserPresence } from '$hooks/useUserPresence'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; import { useOpenSettings } from '$features/settings'; import { Modal500 } from '$components/Modal500'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; @@ -173,6 +175,7 @@ export function AccountSwitcherTab() { const myUserId = mx.getUserId() ?? ''; const activeProfile = useUserProfile(myUserId); + const myPresence = useUserPresence(myUserId); const activeAvatarUrl = activeProfile.avatarUrl ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; @@ -270,19 +273,27 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} + + ) : undefined + } > - {nameInitials(label)}} - /> - + 1} + > + {nameInitials(label)}} + /> + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 16e829ce5..34a108a60 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useEffect } from 'react'; +import { useMemo, useRef, useEffect, ReactNode } from 'react'; import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box } from 'folds'; @@ -15,6 +15,8 @@ import { } from '$components/sidebar'; import { RoomAvatar } from '$components/room-avatar'; import { UserAvatar } from '$components/user-avatar'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useUserPresence, Presence } from '$hooks/useUserPresence'; import { getDirectRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; @@ -48,6 +50,28 @@ function DMItem({ room, selected }: DMItemProps) { // Members are sorted by who last sent messages (most recent first) const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); + // Presence hooks — always called unconditionally (React rules of hooks). + // For single DMs: guessDMUserId() is synchronous; group slots use '' → undefined. + // For group DMs: singleDMUserId is '' → undefined; member slots use groupMembers. + const singleDMUserId = isGroupDM ? '' : room.guessDMUserId(); + const singleDMPresence = useUserPresence(singleDMUserId); + const member0Presence = useUserPresence(isGroupDM ? (groupMembers[0]?.userId ?? '') : ''); + const member1Presence = useUserPresence(isGroupDM ? (groupMembers[1]?.userId ?? '') : ''); + const member2Presence = useUserPresence(isGroupDM ? (groupMembers[2]?.userId ?? '') : ''); + + const groupDMOnline = + isGroupDM && + [member0Presence, member1Presence, member2Presence].some( + (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online + ); + + let presenceBadge: ReactNode; + if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { + presenceBadge = ; + } else if (isGroupDM && groupDMOnline) { + presenceBadge = ; + } + // Get unread info for badge const unread = roomToUnread.get(room.roomId); @@ -132,9 +156,11 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - - {renderAvatar()} - + + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( From ac7528459ad5ebbae9d703f5832fdd19c9700b53 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 12:17:18 -0400 Subject: [PATCH 02/12] chore: add changeset for presence-sidebar-badges --- .changeset/presence-sidebar-badges.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/presence-sidebar-badges.md diff --git a/.changeset/presence-sidebar-badges.md b/.changeset/presence-sidebar-badges.md new file mode 100644 index 000000000..9d0356c48 --- /dev/null +++ b/.changeset/presence-sidebar-badges.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add presence status badges to sidebar DM list and account switcher From c7d44d87e6dd786a55ebd26ba7c892c10d5f1ad8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 09:21:39 -0400 Subject: [PATCH 03/12] fix(presence): skip REST presence fetch when userId is empty string --- src/app/hooks/useUserPresence.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index a3b86ef08..52bb99467 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -36,7 +36,9 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { // Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never // delivered via sync. As a result, User.presence stays at the SDK default and // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state. - if (!user || user.getLastActiveTs() === 0) { + // Guard against empty userId — callers that render a fixed number of hooks (e.g. group DM + // slots) pass '' for absent members; firing getPresence('') would be a malformed request. + if (userId && (!user || user.getLastActiveTs() === 0)) { mx.getPresence(userId) .then((resp) => { if (cancelled) return; From ce458fb5f870527263f81a42e8009466b86f0be6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 12:43:27 -0400 Subject: [PATCH 04/12] test(presence): add useUserPresence unit tests --- src/app/hooks/useUserPresence.test.tsx | 205 +++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/app/hooks/useUserPresence.test.tsx diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx new file mode 100644 index 000000000..125629137 --- /dev/null +++ b/src/app/hooks/useUserPresence.test.tsx @@ -0,0 +1,205 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useUserPresence, Presence } from './useUserPresence'; + +// ------- mock setup ------- + +// Each test can override mockUser / mockGetPresence as needed. +let mockUser: ReturnType | null = null; +let mockGetPresence: ReturnType; + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + +// Listeners registered via user.on() – captured so tests can emit events. +const userListeners = new Map void)[]>(); + +const makeMockUser = (opts: { + presence?: string; + presenceStatusMsg?: string | undefined; + currentlyActive?: boolean; + lastActiveTs?: number; +} = {}) => ({ + userId: '@alice:test', + presence: opts.presence ?? 'online', + presenceStatusMsg: opts.presenceStatusMsg, + currentlyActive: opts.currentlyActive ?? true, + getLastActiveTs: vi.fn().mockReturnValue(opts.lastActiveTs ?? 1000), + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = userListeners.get(event) ?? []; + list.push(handler); + userListeners.set(event, list); + }), + removeListener: vi.fn(), +}); + +const mockMx = { + getUser: vi.fn((): ReturnType | null => mockUser), + getPresence: vi.fn( + (): Promise<{ + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; + }> => + mockGetPresence() + ), + on: vi.fn(), + removeListener: vi.fn(), +}; + +const USER_ID = '@alice:test'; + +beforeEach(() => { + vi.clearAllMocks(); + userListeners.clear(); + mockUser = null; + mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default + mockMx.getUser.mockImplementation(() => mockUser); + mockMx.getPresence.mockImplementation(() => mockGetPresence()); +}); + +// ------- tests ------- + +describe('useUserPresence', () => { + it('returns undefined when the user is not in the SDK and REST is pending', () => { + // mockUser is null; REST never resolves + const { result } = renderHook(() => useUserPresence(USER_ID)); + expect(result.current).toBeUndefined(); + }); + + it('initialises from SDK user when available with a non-zero lastActiveTs', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 5000 }); + // lastActiveTs > 0 — no REST fallback should be triggered + const { result } = renderHook(() => useUserPresence(USER_ID)); + + expect(result.current).toEqual({ + presence: Presence.Online, + status: undefined, + active: true, + lastActiveTs: 5000, + }); + expect(mockMx.getPresence).not.toHaveBeenCalled(); + }); + + it('fires the REST fallback when getLastActiveTs() is 0 (sliding-sync server)', async () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 0 }); + let resolvePresence!: (v: { + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number; + }) => void; + mockGetPresence = vi + .fn() + .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + await act(async () => { + resolvePresence({ + presence: 'unavailable', + status_msg: 'in a meeting', + currently_active: false, + last_active_ago: 60_000, + }); + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + expect(result.current?.status).toBe('in a meeting'); + expect(result.current?.active).toBe(false); + // lastActiveTs should be approximately Date.now() - 60_000 + expect(result.current?.lastActiveTs).toBeGreaterThan(0); + }); + + it('fires the REST fallback when user object does not exist yet', async () => { + // user is null — REST should still be requested + let resolvePresence!: (v: { presence: string }) => void; + mockGetPresence = vi + .fn() + .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + expect(mockMx.getPresence).toHaveBeenCalledWith(USER_ID); + + await act(async () => { + resolvePresence({ presence: 'online' }); + }); + + expect(result.current?.presence).toBe(Presence.Online); + }); + + it('does NOT fire REST when userId is an empty string', () => { + const { result } = renderHook(() => useUserPresence('')); + + expect(mockMx.getPresence).not.toHaveBeenCalled(); + expect(result.current).toBeUndefined(); + }); + + it('ignores the REST response after the component unmounts (cancelled flag)', async () => { + let resolvePresence!: (v: { presence: string }) => void; + mockGetPresence = vi + .fn() + .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + + const { result, unmount } = renderHook(() => useUserPresence(USER_ID)); + unmount(); + + // Resolve after unmount — cancelled = true, so state should NOT be updated + await act(async () => { + resolvePresence({ presence: 'online' }); + }); + + expect(result.current).toBeUndefined(); + }); + + it('updates presence when UserEvent.Presence fires on the user object', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + // Mutate mock user to simulate a presence change, then fire the registered listener + mockUser!.presence = 'unavailable'; + const handlers = userListeners.get('User.presence') ?? []; + + act(() => { + handlers.forEach((h) => h({}, mockUser)); + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + }); + + it('resets to undefined when userId changes to a user not in the SDK', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + + const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), { + initialProps: { uid: USER_ID }, + }); + + expect(result.current).not.toBeUndefined(); + + // Switch to unknown user + mockUser = null; + rerender({ uid: '@bob:test' }); + + expect(result.current).toBeUndefined(); + }); + + it('silently ignores a REST error (presence not supported on this server)', async () => { + mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found'))); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + // Wait for the rejection to be processed + await act(async () => { + await Promise.resolve(); + }); + + // Should still be undefined without throwing + expect(result.current).toBeUndefined(); + }); +}); From f7c7fee75eb78cf83e2bc55eaadac21f0518757a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:32:00 -0400 Subject: [PATCH 05/12] feat(presence): add presenceMode setting and Discord-style status picker Adds a new presenceMode setting ('online' | 'unavailable' | 'offline') that controls which Matrix presence state is broadcast when sendPresence is enabled. - Settings: new presenceMode field (default: 'online') - PresenceFeature: uses presenceMode; Invisible mode keeps sliding sync extension enabled so the user still receives others' presence events - AccountSwitcherTab: drive own badge from settings state (fixes stuck-offline badge on MSC4186 servers that never echo own presence); add Discord-style Online/Away/Invisible status picker in the account menu - usePresenceLabel: align label strings with Matrix state names - DevelopTools: add focusId to Rotate Encryption Sessions tile; fix import order --- .../settings/developer-tools/DevelopTools.tsx | 84 ++++++++++++++++++- src/app/hooks/useUserPresence.ts | 6 +- src/app/pages/client/ClientNonUIFeatures.tsx | 43 ++++++++-- .../client/sidebar/AccountSwitcherTab.tsx | 54 ++++++++++-- src/app/state/settings.ts | 9 ++ 5 files changed, 179 insertions(+), 17 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index c8ffeb12d..a499faf9c 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -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'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -9,9 +10,11 @@ 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'; +import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; @@ -25,6 +28,33 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); + 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. @@ -109,6 +139,58 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } + {developerTools && } + {developerTools && ( + + Encryption + + + ) + } + > + + {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'} + + + } + > + {rotateState.status === AsyncStatus.Success && ( + + 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. + + )} + {rotateState.status === AsyncStatus.Error && ( + + {rotateState.error.message} + + )} + + + + )} {developerTools && ( { export const usePresenceLabel = (): Record => useMemo( () => ({ - [Presence.Online]: 'Active', - [Presence.Unavailable]: 'Busy', - [Presence.Offline]: 'Away', + [Presence.Online]: 'Online', + [Presence.Unavailable]: 'Away', + [Presence.Offline]: 'Offline', }), [] ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 311e31e5e..5da90e4dd 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -56,6 +56,7 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; +import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -644,10 +645,23 @@ function SyncNotificationSettingsWithServiceWorker() { navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; + const postHidden = () => { + // pagehide fires more reliably than visibilitychange on iOS Safari PWA + // when the user locks the screen or backgrounds the app quickly, making + // it less likely that the SW is left with a stale appIsVisible=true. + const msg = { type: 'setAppVisible', visible: false }; + navigator.serviceWorker.controller?.postMessage(msg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); + }; + // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); - return () => document.removeEventListener('visibilitychange', postVisibility); + window.addEventListener('pagehide', postHidden); + return () => { + document.removeEventListener('visibilitychange', postVisibility); + window.removeEventListener('pagehide', postHidden); + }; }, []); useEffect(() => { @@ -828,20 +842,27 @@ function HandleDecryptPushEvent() { function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); useEffect(() => { + // Effective broadcast state: honour presenceMode when presence is on, otherwise offline. + const effectiveState = sendPresence ? (presenceMode ?? 'online') : 'offline'; + const broadcasting = effectiveState !== 'offline'; + // Classic sync: set_presence query param on every /sync poll. // Passing undefined restores the default (online); Offline suppresses broadcasting. - mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); - // Sliding sync: enable/disable the presence extension on the next poll. + mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline); + // Sliding sync: keep the extension enabled so we always receive others' presence. + // Only disable it when the master sendPresence toggle is off (full privacy mode). getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); - // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no - // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's - // state — otherwise GET /presence returns stale offline and own presence badge is grey. - mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => { + // Explicitly PUT /presence/{userId}/status so the server knows the exact state: + // - MSC4186 servers that have no presence extension see this immediately. + // - When 'offline' (Invisible mode), we appear offline to others but still receive + // their presence events because the extension is still enabled above. + mx.setPresence({ presence: effectiveState }).catch(() => { // Server doesn't support presence — ignore. }); - }, [mx, sendPresence]); + }, [mx, sendPresence, presenceMode]); return null; } @@ -851,11 +872,17 @@ function SettingsSyncFeature() { return null; } +function BookmarksFeature() { + useInitBookmarks(); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> + diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 31d4b1a5f..22ee02b34 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -40,7 +40,7 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; import { logoutClient, initClient, stopClient } from '$client/initMatrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useUserProfile } from '$hooks/useUserProfile'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence } from '$hooks/useUserPresence'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; import { useOpenSettings } from '$features/settings'; @@ -50,6 +50,8 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -175,7 +177,14 @@ export function AccountSwitcherTab() { const myUserId = mx.getUserId() ?? ''; const activeProfile = useUserProfile(myUserId); - const myPresence = useUserPresence(myUserId); + // Own presence badge is driven from settings state rather than the SDK's User object. + // The SDK won't echo your own presence back on MSC4186 sliding sync, so reading + // user.presence would leave the badge stuck at the SDK default forever. + const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode'); + const myOwnPresence: Presence | undefined = sendPresence + ? ((presenceMode ?? 'online') as Presence) + : undefined; const activeAvatarUrl = activeProfile.avatarUrl ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; @@ -275,9 +284,7 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - ) : undefined + myOwnPresence ? : undefined } > Add Account + + Status + + {( + [ + { statusLabel: 'Online', presence: Presence.Online }, + { statusLabel: 'Away', presence: Presence.Unavailable }, + { statusLabel: 'Invisible', presence: Presence.Offline }, + ] as const + ).map(({ statusLabel, presence }) => { + const isSelected = sendPresence && (presenceMode ?? 'online') === presence; + return ( + } + after={ + isSelected ? ( + + ) : undefined + } + onClick={() => { + setPresenceMode(presence); + // Re-enable presence broadcasting if the master toggle was off + if (!sendPresence) setSendPresence(true); + }} + > + {statusLabel} + + ); + })} + Date: Sat, 11 Apr 2026 18:50:55 -0400 Subject: [PATCH 06/12] feat(presence): Discord-style presence picker with Idle, DND, and Invisible options --- src/app/hooks/useAppVisibility.ts | 222 ++++++++++++++++-- src/app/hooks/useUserPresence.ts | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 9 +- .../client/sidebar/AccountSwitcherTab.tsx | 58 +++-- src/app/state/settings.ts | 2 +- 5 files changed, 251 insertions(+), 42 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 7fd5f2325..144f132a9 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -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); + + // 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 = () => { @@ -29,27 +109,129 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { appEvents.onVisibilityHidden?.(); + 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(); + + 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, + ]); } diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 7e0f0e78b..8c9b85959 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -95,7 +95,7 @@ export const usePresenceLabel = (): Record => useMemo( () => ({ [Presence.Online]: 'Online', - [Presence.Unavailable]: 'Away', + [Presence.Unavailable]: 'Idle', [Presence.Offline]: 'Offline', }), [] diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5da90e4dd..260f1dc28 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -846,7 +846,9 @@ function PresenceFeature() { useEffect(() => { // Effective broadcast state: honour presenceMode when presence is on, otherwise offline. - const effectiveState = sendPresence ? (presenceMode ?? 'online') : 'offline'; + // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg. + const activePresence = presenceMode === 'dnd' ? 'online' : (presenceMode ?? 'online'); + const effectiveState = sendPresence ? activePresence : 'offline'; const broadcasting = effectiveState !== 'offline'; // Classic sync: set_presence query param on every /sync poll. @@ -859,7 +861,10 @@ function PresenceFeature() { // - MSC4186 servers that have no presence extension see this immediately. // - When 'offline' (Invisible mode), we appear offline to others but still receive // their presence events because the extension is still enabled above. - mx.setPresence({ presence: effectiveState }).catch(() => { + mx.setPresence({ + presence: effectiveState, + status_msg: sendPresence && presenceMode === 'dnd' ? 'dnd' : '', + }).catch(() => { // Server doesn't support presence — ignore. }); }, [mx, sendPresence, presenceMode]); diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 22ee02b34..395edcfe7 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -1,5 +1,6 @@ -import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react'; +import { MouseEvent, MouseEventHandler, ReactNode, useCallback, useState } from 'react'; import { + Badge, Box, Button, Dialog, @@ -182,9 +183,16 @@ export function AccountSwitcherTab() { // user.presence would leave the badge stuck at the SDK default forever. const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode'); - const myOwnPresence: Presence | undefined = sendPresence - ? ((presenceMode ?? 'online') as Presence) - : undefined; + let myOwnPresenceBadge: ReactNode; + if (sendPresence) { + myOwnPresenceBadge = + presenceMode === 'dnd' ? ( + // DND: solid red badge (broadcasts as online with status_msg 'dnd') + + ) : ( + + ); + } const activeAvatarUrl = activeProfile.avatarUrl ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; @@ -282,11 +290,7 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - : undefined - } - > + {( [ - { statusLabel: 'Online', presence: Presence.Online }, - { statusLabel: 'Away', presence: Presence.Unavailable }, - { statusLabel: 'Invisible', presence: Presence.Offline }, + { label: 'Online', desc: undefined, mode: 'online' as const }, + { label: 'Idle', desc: undefined, mode: 'unavailable' as const }, + { label: 'Do Not Disturb', desc: undefined, mode: 'dnd' as const }, + { + label: 'Invisible', + desc: 'You will appear offline', + mode: 'offline' as const, + }, ] as const - ).map(({ statusLabel, presence }) => { - const isSelected = sendPresence && (presenceMode ?? 'online') === presence; + ).map(({ label: statusLabel, desc, mode }) => { + const isSelected = sendPresence && (presenceMode ?? 'online') === mode; + const badge = + mode === 'dnd' ? ( + + ) : ( + + ); return ( } + before={badge} after={ isSelected ? ( { - setPresenceMode(presence); + setPresenceMode(mode); // Re-enable presence broadcasting if the master toggle was off if (!sendPresence) setSendPresence(true); }} > - {statusLabel} + + {statusLabel} + {desc && ( + + {desc} + + )} + ); })} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 56b0fba52..4538ae287 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -94,7 +94,7 @@ export interface Settings { // Sable features! sendPresence: boolean; /** Which Matrix presence state to broadcast when sendPresence is true. */ - presenceMode: 'online' | 'unavailable' | 'offline'; + presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline'; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; From a71bdab9a4100c42acd42d6a9015ca38270550f0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 21:49:38 -0400 Subject: [PATCH 07/12] feat(presence): auto-idle after inactivity timeout Adds an optional inactivity-based presence auto-idle that downgrades the user's broadcast presence from online to unavailable after a configurable period without keyboard or pointer input. ## How it works - New config flag `presenceAutoIdleTimeoutMs` (default: 600 000 ms = 10 min, 0 = disabled). Operators can adjust or disable via config.json. - New hook `usePresenceAutoIdle` sets `presenceAutoIdledAtom` (ephemeral, not persisted) after the timeout, and clears it immediately on any mousemove / mousedown / keydown / touchstart / wheel event. - `PresenceFeature` reads `autoIdled` and derives the effective broadcast mode: when auto-idled the broadcast is forced to `unavailable` regardless of the user's configured presenceMode, then restored on activity. - `AccountSwitcherTab` badge and picker reflect the effective mode so the UI is consistent with what is actually broadcasted. ## Multi-device sync If another device sets the user back to `online` (e.g. the user becomes active there), the `User.presence` event handler in `usePresenceAutoIdle` clears the auto-idle flag on this device too. ## iOS caveat Background tab throttling on iOS Safari PWA may delay or prevent the inactivity timer from firing reliably. The feature degrades gracefully: presence will eventually update when the tab regains focus. --- config.json | 2 + src/app/hooks/useClientConfig.ts | 2 + src/app/hooks/usePresenceAutoIdle.ts | 101 ++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 28 ++--- .../client/sidebar/AccountSwitcherTab.tsx | 12 ++- src/app/state/settings.ts | 3 + 6 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 src/app/hooks/usePresenceAutoIdle.ts diff --git a/config.json b/config.json index f0c3c8b61..b930f457e 100644 --- a/config.json +++ b/config.json @@ -19,6 +19,8 @@ "enabled": true }, + "presenceAutoIdleTimeoutMs": 600000, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e523f15a7..0e7257532 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -43,6 +43,8 @@ export type ClientConfig = { matrixToBaseUrl?: string; settingsLinkBaseUrl?: string; + /** How long (ms) without input before auto-idling presence. 0 = disabled. */ + presenceAutoIdleTimeoutMs?: number; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts new file mode 100644 index 000000000..dd11e729b --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSetAtom } from 'jotai'; +import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk'; +import { presenceAutoIdledAtom } from '$state/settings'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('PresenceAutoIdle'); +const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const; + +/** + * Automatically transitions presence to idle after a configurable inactivity + * timeout, and clears the idle state when activity is detected. + * + * Also subscribes to the Matrix `User.presence` event so that if another device + * sets you back to `online`, the auto-idle state is cleared here too (multi-device + * sync). + * + * Note: On iOS Safari PWA, background tab throttling may delay or prevent the + * inactivity timer from firing reliably. The feature degrades gracefully — presence + * will eventually update when the tab regains focus. + */ +export function usePresenceAutoIdle( + mx: MatrixClient, + presenceMode: string, + sendPresence: boolean, + timeoutMs: number +): void { + const setAutoIdled = useSetAtom(presenceAutoIdledAtom); + const autoIdledRef = useRef(false); + const timerRef = useRef(undefined); + + const clearTimer = useCallback(() => { + if (timerRef.current !== undefined) { + window.clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }, []); + + // Inactivity timer: go idle after timeoutMs without user input. + useEffect(() => { + const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0; + if (!shouldAutoIdle) { + clearTimer(); + if (autoIdledRef.current) { + autoIdledRef.current = false; + setAutoIdled(false); + } + return undefined; + } + + const goIdle = () => { + debugLog.info('general', 'Inactivity timeout — auto-idling'); + autoIdledRef.current = true; + setAutoIdled(true); + }; + + const handleActivity = () => { + clearTimer(); + if (autoIdledRef.current) { + debugLog.info('general', 'Activity detected — clearing auto-idle'); + autoIdledRef.current = false; + setAutoIdled(false); + } + timerRef.current = window.setTimeout(goIdle, timeoutMs); + }; + + // Start the initial timer. + timerRef.current = window.setTimeout(goIdle, timeoutMs); + ACTIVITY_EVENTS.forEach((ev) => + document.addEventListener(ev, handleActivity, { passive: true }) + ); + + return () => { + ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); + clearTimer(); + }; + }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); + + // Multi-device sync: if another device sets us back to online, clear auto-idle. + useEffect(() => { + if (!sendPresence) return undefined; + const myUserId = mx.getUserId(); + if (!myUserId) return undefined; + const user = mx.getUser(myUserId); + if (!user) return undefined; + + const handlePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => { + if (u.userId !== myUserId) return; + if (u.presence === 'online' && autoIdledRef.current) { + debugLog.info('general', 'Remote device set Online — clearing auto-idle'); + autoIdledRef.current = false; + setAutoIdled(false); + } + }; + + user.on(UserEvent.Presence, handlePresence); + return () => { + user.removeListener(UserEvent.Presence, handlePresence); + }; + }, [mx, sendPresence, setAutoIdled]); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 260f1dc28..e4a8037ac 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom, useAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -21,7 +21,9 @@ import NotificationSound from '$public/sound/notification.ogg'; import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle'; import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; @@ -56,7 +58,6 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; -import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -843,11 +844,18 @@ function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const [autoIdled] = useAtom(presenceAutoIdledAtom); + const clientConfig = useClientConfig(); + const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0; + + usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { - // Effective broadcast state: honour presenceMode when presence is on, otherwise offline. + // When auto-idled, broadcast as unavailable regardless of the configured mode. + const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); + // Effective broadcast state: honour effectiveMode when presence is on, otherwise offline. // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg. - const activePresence = presenceMode === 'dnd' ? 'online' : (presenceMode ?? 'online'); + const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode; const effectiveState = sendPresence ? activePresence : 'offline'; const broadcasting = effectiveState !== 'offline'; @@ -863,11 +871,11 @@ function PresenceFeature() { // their presence events because the extension is still enabled above. mx.setPresence({ presence: effectiveState, - status_msg: sendPresence && presenceMode === 'dnd' ? 'dnd' : '', + status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', }).catch(() => { // Server doesn't support presence — ignore. }); - }, [mx, sendPresence, presenceMode]); + }, [mx, sendPresence, presenceMode, autoIdled]); return null; } @@ -877,17 +885,11 @@ function SettingsSyncFeature() { return null; } -function BookmarksFeature() { - useInitBookmarks(); - return null; -} - export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> - diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 395edcfe7..737bcf7c4 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -52,7 +52,7 @@ import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -183,14 +183,18 @@ export function AccountSwitcherTab() { // user.presence would leave the badge stuck at the SDK default forever. const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode'); + const autoIdled = useAtomValue(presenceAutoIdledAtom); + const setAutoIdled = useSetAtom(presenceAutoIdledAtom); + // The effective mode for badge display: if auto-idled, show unavailable regardless of selected mode. + const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); let myOwnPresenceBadge: ReactNode; if (sendPresence) { myOwnPresenceBadge = - presenceMode === 'dnd' ? ( + effectiveDisplayMode === 'dnd' ? ( // DND: solid red badge (broadcasts as online with status_msg 'dnd') ) : ( - + ); } const activeAvatarUrl = activeProfile.avatarUrl @@ -413,6 +417,8 @@ export function AccountSwitcherTab() { } onClick={() => { setPresenceMode(mode); + // Clear auto-idle so the badge updates immediately on manual selection. + setAutoIdled(false); // Re-enable presence broadcasting if the master toggle was off if (!sendPresence) setSendPresence(true); }} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 4538ae287..0d4c16bc8 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -265,3 +265,6 @@ export const settingsAtom = atom( setSettings(update); } ); + +/** Ephemeral (not persisted) — true when auto-idled due to inactivity. */ +export const presenceAutoIdledAtom = atom(false); From ca97c9bc83b661fc2c8513f14e2da4a37280fa53 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 12:10:29 -0400 Subject: [PATCH 08/12] chore: add changeset for presence-auto-idle --- .changeset/presence-auto-idle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/presence-auto-idle.md diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md new file mode 100644 index 000000000..0cdedfdac --- /dev/null +++ b/.changeset/presence-auto-idle.md @@ -0,0 +1,5 @@ +--- +'@sable/client': minor +--- + +feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker From 264e4ab9fab61345967318d64397a28ffe78aae6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:47:21 -0400 Subject: [PATCH 09/12] fix(presence): restore missing experiment config helpers and clean presence hook tests --- .../settings/developer-tools/DevelopTools.tsx | 2 - src/app/hooks/useClientConfig.ts | 94 +++++++++++++++++++ src/app/hooks/useUserPresence.test.tsx | 72 +++++++------- 3 files changed, 132 insertions(+), 36 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a499faf9c..4e38f7868 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -14,7 +14,6 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; -import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; @@ -139,7 +138,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } - {developerTools && } {developerTools && ( Encryption diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 0e7257532..3f5568e80 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,8 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -43,6 +70,7 @@ export type ClientConfig = { matrixToBaseUrl?: string; settingsLinkBaseUrl?: string; + sessionSync?: SessionSyncConfig; /** How long (ms) without input before auto-idling presence. 0 = disabled. */ presenceAutoIdleTimeoutMs?: number; }; @@ -57,6 +85,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/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx index 125629137..c311563b6 100644 --- a/src/app/hooks/useUserPresence.test.tsx +++ b/src/app/hooks/useUserPresence.test.tsx @@ -6,21 +6,25 @@ import { useUserPresence, Presence } from './useUserPresence'; // Each test can override mockUser / mockGetPresence as needed. let mockUser: ReturnType | null = null; -let mockGetPresence: ReturnType; - -vi.mock('$hooks/useMatrixClient', () => ({ - useMatrixClient: () => mockMx, -})); +type PresenceResponse = { + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; +}; +let mockGetPresence: () => Promise; // Listeners registered via user.on() – captured so tests can emit events. const userListeners = new Map void)[]>(); -const makeMockUser = (opts: { - presence?: string; - presenceStatusMsg?: string | undefined; - currentlyActive?: boolean; - lastActiveTs?: number; -} = {}) => ({ +const makeMockUser = ( + opts: { + presence?: string; + presenceStatusMsg?: string | undefined; + currentlyActive?: boolean; + lastActiveTs?: number; + } = {} +) => ({ userId: '@alice:test', presence: opts.presence ?? 'online', presenceStatusMsg: opts.presenceStatusMsg, @@ -36,26 +40,22 @@ const makeMockUser = (opts: { const mockMx = { getUser: vi.fn((): ReturnType | null => mockUser), - getPresence: vi.fn( - (): Promise<{ - presence: string; - status_msg?: string; - currently_active?: boolean; - last_active_ago?: number | null; - }> => - mockGetPresence() - ), + getPresence: vi.fn((): Promise => mockGetPresence()), on: vi.fn(), removeListener: vi.fn(), }; +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + const USER_ID = '@alice:test'; beforeEach(() => { vi.clearAllMocks(); userListeners.clear(); mockUser = null; - mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default + mockGetPresence = () => new Promise(() => {}); // pending by default mockMx.getUser.mockImplementation(() => mockUser); mockMx.getPresence.mockImplementation(() => mockGetPresence()); }); @@ -91,9 +91,10 @@ describe('useUserPresence', () => { currently_active?: boolean; last_active_ago?: number; }) => void; - mockGetPresence = vi - .fn() - .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + mockGetPresence = () => + new Promise((res) => { + resolvePresence = res; + }); const { result } = renderHook(() => useUserPresence(USER_ID)); @@ -116,9 +117,10 @@ describe('useUserPresence', () => { it('fires the REST fallback when user object does not exist yet', async () => { // user is null — REST should still be requested let resolvePresence!: (v: { presence: string }) => void; - mockGetPresence = vi - .fn() - .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + mockGetPresence = () => + new Promise((res) => { + resolvePresence = res; + }); const { result } = renderHook(() => useUserPresence(USER_ID)); @@ -140,9 +142,11 @@ describe('useUserPresence', () => { it('ignores the REST response after the component unmounts (cancelled flag)', async () => { let resolvePresence!: (v: { presence: string }) => void; - mockGetPresence = vi - .fn() - .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + mockGetPresence = vi.fn().mockReturnValue( + new Promise((res) => { + resolvePresence = res; + }) + ); const { result, unmount } = renderHook(() => useUserPresence(USER_ID)); unmount(); @@ -157,12 +161,12 @@ describe('useUserPresence', () => { it('updates presence when UserEvent.Presence fires on the user object', () => { mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); - mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + mockGetPresence = () => new Promise(() => {}); const { result } = renderHook(() => useUserPresence(USER_ID)); // Mutate mock user to simulate a presence change, then fire the registered listener - mockUser!.presence = 'unavailable'; + mockUser.presence = 'unavailable'; const handlers = userListeners.get('User.presence') ?? []; act(() => { @@ -174,7 +178,7 @@ describe('useUserPresence', () => { it('resets to undefined when userId changes to a user not in the SDK', () => { mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); - mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + mockGetPresence = () => new Promise(() => {}); const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), { initialProps: { uid: USER_ID }, @@ -190,7 +194,7 @@ describe('useUserPresence', () => { }); it('silently ignores a REST error (presence not supported on this server)', async () => { - mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found'))); + mockGetPresence = () => Promise.reject(new Error('404 Not Found')); const { result } = renderHook(() => useUserPresence(USER_ID)); From 3f0387686bf94583b6ea5c3b25219c2fd9d36b98 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:00:02 -0400 Subject: [PATCH 10/12] fix(presence): address review feedback for presence-auto-idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix changeset frontmatter: '@sable/client': minor → default: minor - Update presenceMode docstring to clarify dnd broadcasts as online+status_msg - Import KnownMembership from $types/matrix-sdk - Gate heartbeat effect on mx being defined to avoid no-op timers - Add mx to heartbeat effect dependency array --- .changeset/presence-auto-idle.md | 2 +- src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +- src/app/hooks/useAppVisibility.ts | 3 ++- src/app/state/settings.ts | 6 +++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md index 0cdedfdac..56889390c 100644 --- a/.changeset/presence-auto-idle.md +++ b/.changeset/presence-auto-idle.md @@ -1,5 +1,5 @@ --- -'@sable/client': minor +default: minor --- feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 4e38f7868..6bfd0f6cb 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds'; -import { KnownMembership } from 'matrix-js-sdk/lib/types'; +import { KnownMembership } from '$types/matrix-sdk'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 144f132a9..b1d25add0 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -171,7 +171,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S ]); useEffect(() => { - if (!phase2VisibleHeartbeat) return undefined; + if (!phase2VisibleHeartbeat || !mx) return undefined; // Reset adaptive backoff/suppression so a config or session change starts fresh. heartbeatFailuresRef.current = 0; @@ -230,6 +230,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S }, [ heartbeatIntervalMs, heartbeatMaxBackoffMs, + mx, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter, pushSessionNow, diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 0d4c16bc8..935b420ba 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -93,7 +93,11 @@ export interface Settings { // Sable features! sendPresence: boolean; - /** Which Matrix presence state to broadcast when sendPresence is true. */ + /** + * Which presence mode to use when sendPresence is true. + * Matrix presence states are sent as-is; the app-specific `dnd` mode is + * broadcast as `presence=online` with a `status_msg`. + */ presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline'; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; From 0231581f09bb41e7387cac52249da20100b861db Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:23:16 -0400 Subject: [PATCH 11/12] fix(presence): 5min default, wire visibility reset, add tests - Change presenceAutoIdleTimeoutMs from 600000 (10min) to 300000 (5min) - Wire appEvents.onVisibilityChange so returning to the app resets auto-idle - Add comprehensive usePresenceAutoIdle unit tests (10 tests) --- config.json | 2 +- src/app/hooks/usePresenceAutoIdle.test.tsx | 238 +++++++++++++++++++++ src/app/hooks/usePresenceAutoIdle.ts | 10 + 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/app/hooks/usePresenceAutoIdle.test.tsx diff --git a/config.json b/config.json index b930f457e..6410de4de 100644 --- a/config.json +++ b/config.json @@ -19,7 +19,7 @@ "enabled": true }, - "presenceAutoIdleTimeoutMs": 600000, + "presenceAutoIdleTimeoutMs": 300000, "featuredCommunities": { "openAsDefault": false, diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx new file mode 100644 index 000000000..0ebfd744a --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -0,0 +1,238 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider, useAtomValue } from 'jotai'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { usePresenceAutoIdle } from './usePresenceAutoIdle'; +import { presenceAutoIdledAtom } from '$state/settings'; +import { appEvents } from '$utils/appEvents'; +import type { ReactNode } from 'react'; + +// -------- mock setup -------- + +const userListeners = new Map void)[]>(); + +const makeMockUser = () => ({ + userId: '@alice:test', + presence: 'online', + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = userListeners.get(event) ?? []; + list.push(handler); + userListeners.set(event, list); + }), + removeListener: vi.fn(), +}); + +let mockUser: ReturnType | null = null; + +const makeMockMx = () => ({ + getUserId: vi.fn(() => '@alice:test'), + getUser: vi.fn(() => mockUser), +}); + +let mockMx: ReturnType; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +// Helper to read the atom value alongside the hook under test. +function useAutoIdledReader( + mx: ReturnType, + presenceMode: string, + sendPresence: boolean, + timeoutMs: number +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs); + return useAtomValue(presenceAutoIdledAtom); +} + +// -------- lifecycle -------- + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + userListeners.clear(); + mockUser = makeMockUser(); + mockMx = makeMockMx(); + appEvents.onVisibilityChange = null; +}); + +afterEach(() => { + vi.useRealTimers(); + appEvents.onVisibilityChange = null; +}); + +// -------- tests -------- + +describe('usePresenceAutoIdle', () => { + it('sets auto-idle after the timeout elapses', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + expect(result.current).toBe(false); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(result.current).toBe(true); + }); + + it('resets auto-idle when user activity is detected', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + // Go idle. + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate user activity. + act(() => { + document.dispatchEvent(new Event('mousemove')); + }); + expect(result.current).toBe(false); + }); + + it('resets auto-idle when app becomes visible via appEvents', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate app returning to foreground. + act(() => { + appEvents.onVisibilityChange?.(true); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when presenceMode is not online', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'dnd', true, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when sendPresence is false', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', false, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when timeoutMs is 0', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 0), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('restarts the idle timer on activity before timeout', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + // Advance partially, then trigger activity. + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current).toBe(false); + + act(() => { + document.dispatchEvent(new Event('keydown')); + }); + + // Original timeout would have fired at 5000ms, but we reset. + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current).toBe(false); + + // Now the full 5000ms from last activity should trigger idle. + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current).toBe(true); + }); + + it('clears auto-idle when presenceMode changes away from online', () => { + const { result, rerender } = renderHook( + ({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000), + { wrapper, initialProps: { mode: 'online' } } + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + rerender({ mode: 'dnd' }); + expect(result.current).toBe(false); + }); + + it('clears auto-idle when another device sets presence to online', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate User.presence event from another device. + const handlers = userListeners.get('User.presence') ?? []; + expect(handlers.length).toBeGreaterThan(0); + + act(() => { + handlers.forEach((h) => + h({}, { userId: '@alice:test', presence: 'online' }) + ); + }); + expect(result.current).toBe(false); + }); + + it('restores previous appEvents.onVisibilityChange on cleanup', () => { + const prev = vi.fn(); + appEvents.onVisibilityChange = prev; + + const { unmount } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + // Our handler should be installed. + expect(appEvents.onVisibilityChange).not.toBe(prev); + + unmount(); + + // Previous handler should be restored. + expect(appEvents.onVisibilityChange).toBe(prev); + }); +}); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index dd11e729b..abf25edba 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSetAtom } from 'jotai'; import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk'; import { presenceAutoIdledAtom } from '$state/settings'; +import { appEvents } from '$utils/appEvents'; import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('PresenceAutoIdle'); @@ -70,9 +71,18 @@ export function usePresenceAutoIdle( document.addEventListener(ev, handleActivity, { passive: true }) ); + // When the app returns to the foreground, treat it as activity so the user + // isn't shown as idle the moment they switch back to the tab/PWA. + const prevOnVisibilityChange = appEvents.onVisibilityChange; + appEvents.onVisibilityChange = (isVisible: boolean) => { + prevOnVisibilityChange?.(isVisible); + if (isVisible) handleActivity(); + }; + return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); clearTimer(); + appEvents.onVisibilityChange = prevOnVisibilityChange; }; }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); From 29e407699526d67322ac7bb8bb9321f383573872 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 00:23:43 -0400 Subject: [PATCH 12/12] refactor: align presence-auto-idle with sw-push-session-recovery - Remove activeSession param from useAppVisibility, use mx methods instead - Switch appEvents to multi-subscriber Set-based pattern - Update usePresenceAutoIdle to use subscription-based visibility handler - Update tests for new appEvents API --- src/app/hooks/useAppVisibility.ts | 20 +++++++--------- src/app/hooks/usePresenceAutoIdle.test.tsx | 25 +++++++++---------- src/app/hooks/usePresenceAutoIdle.ts | 8 +++---- src/app/utils/appEvents.ts | 28 ++++++++++++++++++++-- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index b1d25add0..e3000ecdf 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; -import { Session } from '$state/sessions'; import { appEvents } from '../utils/appEvents'; import { useClientConfig, useExperimentVariant } from './useClientConfig'; import { createDebugLogger } from '../utils/debugLogger'; @@ -13,11 +12,11 @@ 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) { +export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); + 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; @@ -55,9 +54,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S const pushSessionNow = useCallback( (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { - const baseUrl = activeSession?.baseUrl; - const accessToken = activeSession?.accessToken; - const userId = activeSession?.userId; + const baseUrl = mx?.getHomeserverUrl(); + const accessToken = mx?.getAccessToken(); + const userId = mx?.getUserId(); const canPush = !!mx && typeof baseUrl === 'string' && @@ -88,9 +87,6 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S return 'sent'; }, [ - activeSession?.accessToken, - activeSession?.baseUrl, - activeSession?.userId, mx, phase1ForegroundResync, phase2VisibleHeartbeat, @@ -106,9 +102,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); - appEvents.onVisibilityChange?.(isVisible); + appEvents.emitVisibilityChange(isVisible); if (!isVisible) { - appEvents.onVisibilityHidden?.(); + appEvents.emitVisibilityHidden(); return; } @@ -171,7 +167,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S ]); useEffect(() => { - if (!phase2VisibleHeartbeat || !mx) return undefined; + if (!phase2VisibleHeartbeat) return undefined; // Reset adaptive backoff/suppression so a config or session change starts fresh. heartbeatFailuresRef.current = 0; diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 0ebfd744a..043598c55 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -52,12 +52,10 @@ beforeEach(() => { userListeners.clear(); mockUser = makeMockUser(); mockMx = makeMockMx(); - appEvents.onVisibilityChange = null; }); afterEach(() => { vi.useRealTimers(); - appEvents.onVisibilityChange = null; }); // -------- tests -------- @@ -110,7 +108,7 @@ describe('usePresenceAutoIdle', () => { // Simulate app returning to foreground. act(() => { - appEvents.onVisibilityChange?.(true); + appEvents.emitVisibilityChange(true); }); expect(result.current).toBe(false); }); @@ -218,21 +216,24 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(false); }); - it('restores previous appEvents.onVisibilityChange on cleanup', () => { - const prev = vi.fn(); - appEvents.onVisibilityChange = prev; - - const { unmount } = renderHook( + it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => { + const { result, unmount } = renderHook( () => useAutoIdledReader(mockMx, 'online', true, 5000), { wrapper } ); - // Our handler should be installed. - expect(appEvents.onVisibilityChange).not.toBe(prev); + // Go idle. + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); unmount(); - // Previous handler should be restored. - expect(appEvents.onVisibilityChange).toBe(prev); + // After unmount, emitting visibility change should have no effect. + // (No error thrown means the handler was properly unsubscribed.) + act(() => { + appEvents.emitVisibilityChange(true); + }); }); }); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index abf25edba..dc5af7e21 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -73,16 +73,14 @@ export function usePresenceAutoIdle( // When the app returns to the foreground, treat it as activity so the user // isn't shown as idle the moment they switch back to the tab/PWA. - const prevOnVisibilityChange = appEvents.onVisibilityChange; - appEvents.onVisibilityChange = (isVisible: boolean) => { - prevOnVisibilityChange?.(isVisible); + const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => { if (isVisible) handleActivity(); - }; + }); return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); clearTimer(); - appEvents.onVisibilityChange = prevOnVisibilityChange; + unsubVisibility(); }; }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2834c5b6f..2430f5324 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,5 +1,29 @@ +export 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)); + }, };