From 73beb8c0627f134205e521311f9757260a598057 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:50:22 -0400 Subject: [PATCH 01/27] 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 4a6289e2b48e9ffc7d2c0f2c30bdcbf06209450a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 12:17:18 -0400 Subject: [PATCH 02/27] 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 ac4e5b44199391c331b0eb30457613c1e42ecdaa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 09:21:39 -0400 Subject: [PATCH 03/27] 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 35acb82117bd3bdf0ad0d0529560c2982cec774b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 12:43:27 -0400 Subject: [PATCH 04/27] 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 4404e849f75313a8fc9b3ffbd8d89ab342a482f7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:32:00 -0400 Subject: [PATCH 05/27] 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 | 13 +++ 5 files changed, 183 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/27] 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 c178b777b667df4de82d65848902233e0bbc5dee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:50:22 -0400 Subject: [PATCH 07/27] 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 08/27] 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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] 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 15/27] 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 878f2fcd2221ac0357f3281145b6473434d0334b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:51:05 -0400 Subject: [PATCH 16/27] fix(presence): resolve missing deps and stabilize presence hook tests --- .../settings/developer-tools/DevelopTools.tsx | 2 - src/app/hooks/useClientConfig.ts | 94 +++++++++++++++++++ src/app/hooks/useUserPresence.test.tsx | 72 +++++++------- src/app/pages/client/ClientNonUIFeatures.tsx | 7 -- 4 files changed, 132 insertions(+), 43 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 e523f15a7..b6eb0dea3 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; }; const ClientConfigContext = createContext(null); @@ -55,6 +83,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)); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 260f1dc28..65aadac4f 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -56,7 +56,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'; @@ -877,17 +876,11 @@ function SettingsSyncFeature() { return null; } -function BookmarksFeature() { - useInitBookmarks(); - return null; -} - export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> - From e5bdd7cf17cfb0b2830a22b35ae9ebd9e00e7994 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 22:58:14 -0400 Subject: [PATCH 17/27] fix(presence): address review feedback for presence sidebar badges - Guard useUserPresence client-level listener for empty userId - Hide badge when Invisible mode is active (presenceMode === 'offline') - Hide badge in Invisible menu row - Import KnownMembership from $types/matrix-sdk --- src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +- src/app/hooks/useUserPresence.ts | 2 +- src/app/pages/client/sidebar/AccountSwitcherTab.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) 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/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 8c9b85959..4643a6c02 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -68,7 +68,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { // 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) { + if (!user && userId) { const onClientEvent = (event: MatrixEvent) => { if (event.getSender() !== userId || event.getType() !== 'm.presence') return; const u = mx.getUser(userId); diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 395edcfe7..073293175 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -184,7 +184,7 @@ export function AccountSwitcherTab() { const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode'); let myOwnPresenceBadge: ReactNode; - if (sendPresence) { + if (sendPresence && presenceMode !== 'offline') { myOwnPresenceBadge = presenceMode === 'dnd' ? ( // DND: solid red badge (broadcasts as online with status_msg 'dnd') @@ -393,7 +393,7 @@ export function AccountSwitcherTab() { const badge = mode === 'dnd' ? ( - ) : ( + ) : mode === 'offline' ? undefined : ( ); return ( From 3f0387686bf94583b6ea5c3b25219c2fd9d36b98 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:00:02 -0400 Subject: [PATCH 18/27] 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 19/27] 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 20/27] 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)); + }, }; From 69179c15648c1e1d46b4bf7f80eea9f7b83103a7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 10:58:40 -0400 Subject: [PATCH 21/27] style: fix lint errors from merge --- src/app/hooks/useAppVisibility.ts | 12 ++-- src/app/hooks/usePresenceAutoIdle.test.tsx | 66 ++++++++-------------- 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index e3000ecdf..af2bd2e69 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -16,7 +16,10 @@ export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', mx?.getUserId() ?? undefined); + 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; @@ -86,12 +89,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { }); return 'sent'; }, - [ - mx, - phase1ForegroundResync, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - ] + [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] ); useEffect(() => { diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 043598c55..8e2f6d138 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -1,10 +1,10 @@ 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'; +import { usePresenceAutoIdle } from './usePresenceAutoIdle'; // -------- mock setup -------- @@ -39,7 +39,6 @@ function useAutoIdledReader( sendPresence: boolean, timeoutMs: number ) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs); return useAtomValue(presenceAutoIdledAtom); } @@ -62,10 +61,9 @@ afterEach(() => { describe('usePresenceAutoIdle', () => { it('sets auto-idle after the timeout elapses', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); expect(result.current).toBe(false); @@ -77,10 +75,9 @@ describe('usePresenceAutoIdle', () => { }); it('resets auto-idle when user activity is detected', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); // Go idle. act(() => { @@ -96,10 +93,9 @@ describe('usePresenceAutoIdle', () => { }); it('resets auto-idle when app becomes visible via appEvents', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); act(() => { vi.advanceTimersByTime(5000); @@ -114,10 +110,7 @@ describe('usePresenceAutoIdle', () => { }); it('does not go idle when presenceMode is not online', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'dnd', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'dnd', true, 5000), { wrapper }); act(() => { vi.advanceTimersByTime(10000); @@ -126,10 +119,9 @@ describe('usePresenceAutoIdle', () => { }); it('does not go idle when sendPresence is false', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', false, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', false, 5000), { + wrapper, + }); act(() => { vi.advanceTimersByTime(10000); @@ -138,10 +130,7 @@ describe('usePresenceAutoIdle', () => { }); it('does not go idle when timeoutMs is 0', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 0), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 0), { wrapper }); act(() => { vi.advanceTimersByTime(10000); @@ -150,10 +139,9 @@ describe('usePresenceAutoIdle', () => { }); it('restarts the idle timer on activity before timeout', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); // Advance partially, then trigger activity. act(() => { @@ -194,10 +182,9 @@ describe('usePresenceAutoIdle', () => { }); it('clears auto-idle when another device sets presence to online', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); act(() => { vi.advanceTimersByTime(5000); @@ -209,18 +196,15 @@ describe('usePresenceAutoIdle', () => { expect(handlers.length).toBeGreaterThan(0); act(() => { - handlers.forEach((h) => - h({}, { userId: '@alice:test', presence: 'online' }) - ); + handlers.forEach((h) => h({}, { userId: '@alice:test', presence: 'online' })); }); expect(result.current).toBe(false); }); it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => { - const { result, unmount } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); // Go idle. act(() => { From d7fd6406de89d693fd74168a1a7787b1f3a2159d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 11:58:45 -0400 Subject: [PATCH 22/27] fix(presence): address review feedback - Fix test mock path to match relative import - Only send status_msg when explicitly setting DND (avoid clearing user status) - Guard lastActiveTs null check to prevent false presence badges - Remove unrelated enableMessageBookmarks leak from PR scope - Revert DevelopTools rotate-sessions changes (belongs in PR #670) - Add in-memory presence REST cache + in-flight dedupe to prevent N+1 floods - Only log 5xx server errors in presence fetch (suppress 404/network) - Close status picker menu after selection for UX consistency - Guard heartbeat effect on mx being defined --- .../settings/developer-tools/DevelopTools.tsx | 82 +------------------ src/app/hooks/useAppVisibility.ts | 2 +- src/app/hooks/useUserPresence.test.tsx | 5 +- src/app/hooks/useUserPresence.ts | 72 ++++++++++++---- src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- .../client/sidebar/AccountSwitcherTab.tsx | 1 + .../pages/client/sidebar/DirectDMsList.tsx | 13 ++- src/app/state/settings.ts | 6 -- 8 files changed, 76 insertions(+), 107 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 6bfd0f6cb..c8ffeb12d 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,6 +1,5 @@ import { useCallback, useState } from 'react'; -import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds'; -import { KnownMembership } from '$types/matrix-sdk'; +import { Box, Text, Scroll, Switch, Button } from 'folds'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -10,7 +9,6 @@ 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'; @@ -27,33 +25,6 @@ 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. @@ -138,57 +109,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {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 && ( { - if (!phase2VisibleHeartbeat) return undefined; + if (!phase2VisibleHeartbeat || !mx) return undefined; // Reset adaptive backoff/suppression so a config or session change starts fresh. heartbeatFailuresRef.current = 0; diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx index c311563b6..78f334d71 100644 --- a/src/app/hooks/useUserPresence.test.tsx +++ b/src/app/hooks/useUserPresence.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useUserPresence, Presence } from './useUserPresence'; +import { useUserPresence, Presence, clearPresenceCache } from './useUserPresence'; // ------- mock setup ------- @@ -45,7 +45,7 @@ const mockMx = { removeListener: vi.fn(), }; -vi.mock('$hooks/useMatrixClient', () => ({ +vi.mock('./useMatrixClient', () => ({ useMatrixClient: () => mockMx, })); @@ -54,6 +54,7 @@ const USER_ID = '@alice:test'; beforeEach(() => { vi.clearAllMocks(); userListeners.clear(); + clearPresenceCache(); mockUser = null; mockGetPresence = () => new Promise(() => {}); // pending by default mockMx.getUser.mockImplementation(() => mockUser); diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 71a3d5b43..6cabf4546 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -22,6 +22,60 @@ const getUserPresence = (user: User): UserPresence => ({ lastActiveTs: user.getLastActiveTs(), }); +// In-memory presence REST cache to avoid N+1 /presence/{userId}/status floods. +// Multiple hook instances for the same user share a single in-flight request. +const PRESENCE_CACHE_TTL_MS = 60_000; +const presenceCache = new Map(); +const presenceInflight = new Map>(); + +/** Visible for testing — clears the in-memory REST presence cache. */ +export function clearPresenceCache(): void { + presenceCache.clear(); + presenceInflight.clear(); +} + +function fetchPresenceOnce( + mx: { getPresence: (userId: string) => Promise<{ presence: string; status_msg?: string; currently_active?: boolean; last_active_ago?: number | null }> }, + userId: string +): Promise { + const cached = presenceCache.get(userId); + if (cached && Date.now() - cached.fetchedAt < PRESENCE_CACHE_TTL_MS) { + return Promise.resolve(cached.data); + } + + const existing = presenceInflight.get(userId); + if (existing) return existing; + + const promise = mx + .getPresence(userId) + .then((resp) => { + const data: UserPresence = { + 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, + }; + presenceCache.set(userId, { data, fetchedAt: Date.now() }); + return data; + }) + .catch((err: unknown) => { + // Suppress expected failures (404/403 = presence not supported, network errors). + // Only log unexpected server errors (5xx) for debugging. + const status = (err as { httpStatus?: number })?.httpStatus; + if (status && status >= 500) { + console.warn('[useUserPresence] REST fetch failed for', userId, err); + } + return undefined; + }) + .finally(() => { + presenceInflight.delete(userId); + }); + + presenceInflight.set(userId, promise); + return promise; +} + export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); const user = mx.getUser(userId); @@ -38,20 +92,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { // 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; - 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. - }); + fetchPresenceOnce(mx, userId).then((data) => { + if (cancelled || !data) return; + setPresence(data); + }); } const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index e4a8037ac..7c71db23a 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -871,7 +871,7 @@ function PresenceFeature() { // their presence events because the extension is still enabled above. mx.setPresence({ presence: effectiveState, - status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', + ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), }).catch(() => { // Server doesn't support presence — ignore. }); diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 737bcf7c4..191a585dd 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -421,6 +421,7 @@ export function AccountSwitcherTab() { setAutoIdled(false); // Re-enable presence broadcasting if the master toggle was off if (!sendPresence) setSendPresence(true); + setMenuAnchor(undefined); }} > diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 60063d0a3..4d142985d 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -62,11 +62,20 @@ function DMItem({ room, selected }: DMItemProps) { const groupDMOnline = isGroupDM && [member0Presence, member1Presence, member2Presence].some( - (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online + (p) => + p && + p.lastActiveTs != null && + p.lastActiveTs !== 0 && + p.presence === Presence.Online ); let presenceBadge: ReactNode; - if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { + if ( + !isGroupDM && + singleDMPresence && + singleDMPresence.lastActiveTs != null && + singleDMPresence.lastActiveTs !== 0 + ) { presenceBadge = ; } else if (isGroupDM && groupDMOnline) { presenceBadge = ; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 935b420ba..adcf90c71 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -125,9 +125,6 @@ export interface Settings { showPersonaSetting: boolean; closeFoldersByDefault: boolean; - // experimental - enableMessageBookmarks: boolean; - // furry stuff renderAnimals: boolean; } @@ -230,9 +227,6 @@ const defaultSettings: Settings = { showPersonaSetting: false, closeFoldersByDefault: false, - // experimental - enableMessageBookmarks: false, - // furry stuff renderAnimals: true, }; From cb243020b391fa96f84c4a592a918b3d079d2e52 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:17:56 -0400 Subject: [PATCH 23/27] chore: fix lint and format issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useUserPresence.ts | 12 +++++++++--- src/app/pages/client/sidebar/DirectDMsList.tsx | 6 +----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 6cabf4546..c8e2a480f 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -35,7 +35,14 @@ export function clearPresenceCache(): void { } function fetchPresenceOnce( - mx: { getPresence: (userId: string) => Promise<{ presence: string; status_msg?: string; currently_active?: boolean; last_active_ago?: number | null }> }, + mx: { + getPresence: (userId: string) => Promise<{ + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; + }>; + }, userId: string ): Promise { const cached = presenceCache.get(userId); @@ -53,8 +60,7 @@ function fetchPresenceOnce( 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, + lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, }; presenceCache.set(userId, { data, fetchedAt: Date.now() }); return data; diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 4d142985d..72d10775a 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -62,11 +62,7 @@ function DMItem({ room, selected }: DMItemProps) { const groupDMOnline = isGroupDM && [member0Presence, member1Presence, member2Presence].some( - (p) => - p && - p.lastActiveTs != null && - p.lastActiveTs !== 0 && - p.presence === Presence.Online + (p) => p && p.lastActiveTs != null && p.lastActiveTs !== 0 && p.presence === Presence.Online ); let presenceBadge: ReactNode; From 8c3c0e7585bc538caf8ddbe99cc0d2a5bdb82d9f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 08:58:45 -0400 Subject: [PATCH 24/27] fix(presence): retry setPresence on failure for app resume reliability When the app resumes from background, the HTTP client may not have reconnected yet, causing setPresence to fail silently. Retry up to 3 times with back-off (2s, 4s, 6s) so presence recovers from idle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 7c71db23a..c50f411a4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -869,12 +869,24 @@ 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({ + const presencePayload = { presence: effectiveState, ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), - }).catch(() => { - // Server doesn't support presence — ignore. - }); + }; + let retryTimer: ReturnType | undefined; + const trySetPresence = (attempt = 0) => { + mx.setPresence(presencePayload).catch(() => { + // Retry up to 3 times with back-off: the HTTP client may not have + // reconnected yet after the app resumes from background. + if (attempt < 3) { + retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1)); + } + }); + }; + trySetPresence(); + return () => { + if (retryTimer !== undefined) clearTimeout(retryTimer); + }; }, [mx, sendPresence, presenceMode, autoIdled]); return null; From 5cc0da583f974d698971ce4b1517a27aee0d9e9d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:45:43 -0400 Subject: [PATCH 25/27] fix(presence): normalize dnd state handling --- src/app/components/presence/Presence.tsx | 1 + src/app/features/settings/account/Profile.tsx | 7 +- src/app/hooks/useAppVisibility.ts | 219 ++---------------- src/app/hooks/useClientConfig.ts | 94 -------- src/app/hooks/usePresenceAutoIdle.test.tsx | 20 +- src/app/hooks/usePresenceAutoIdle.ts | 16 +- src/app/hooks/useUserPresence.test.tsx | 81 +++++++ src/app/hooks/useUserPresence.ts | 74 +++++- src/app/pages/client/ClientNonUIFeatures.tsx | 49 ++-- .../client/sidebar/AccountSwitcherTab.tsx | 16 +- .../pages/client/sidebar/DirectDMsList.tsx | 20 +- src/app/utils/appEvents.ts | 28 +-- 12 files changed, 230 insertions(+), 395 deletions(-) diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index e6ac463bb..ea9ed73d5 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -18,6 +18,7 @@ const PresenceToColor: Record = { [Presence.Online]: 'Success', [Presence.Unavailable]: 'Warning', [Presence.Offline]: 'Secondary', + [Presence.Dnd]: 'Critical', }; type PresenceBadgeProps = { diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index da2d140f6..2595f7e1c 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -46,7 +46,7 @@ import { CompactUploadCardRenderer } from '$components/upload-card'; import { useCapabilities } from '$hooks/useCapabilities'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { MSC1767Text } from '$types/matrix/common'; import { TimezoneEditor } from './TimezoneEditor'; import { PronounEditor } from './PronounEditor'; @@ -511,7 +511,10 @@ function ProfileExtended({ profile, userId }: Readonly) { const handleSaveStatus = useCallback( async (newStatus: string) => { - const currentState = presence?.presence || 'online'; + const currentState = + presence?.presence === Presence.Dnd + ? Presence.Online + : (presence?.presence ?? Presence.Online); await mx.setPresence({ presence: currentState, diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 4e7b5b131..7fd5f2325 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,96 +1,22 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; +import { useAtom } from 'jotai'; +import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig, useExperimentVariant } from './useClientConfig'; +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 { createDebugLogger } from '../utils/debugLogger'; -import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; -const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; -const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; -const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; - export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); - - const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant( - 'sessionSyncStrategy', - mx?.getUserId() ?? undefined - ); - - // Derive phase flags from experiment variant; fall back to direct config when not in experiment. - const inSessionSync = sessionSyncVariant.inExperiment; - const syncVariant = sessionSyncVariant.variant; - const phase1ForegroundResync = inSessionSync - ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' - : sessionSyncConfig?.phase1ForegroundResync === true; - const phase2VisibleHeartbeat = inSessionSync - ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' - : sessionSyncConfig?.phase2VisibleHeartbeat === true; - const phase3AdaptiveBackoffJitter = inSessionSync - ? syncVariant === 'session-sync-adaptive' - : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; - - const foregroundDebounceMs = Math.max( - 0, - sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS - ); - const heartbeatIntervalMs = Math.max( - 1000, - sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS - ); - const resumeHeartbeatSuppressMs = Math.max( - 0, - sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS - ); - const heartbeatMaxBackoffMs = Math.max( - heartbeatIntervalMs, - sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS - ); - - const lastForegroundPushAtRef = useRef(0); - const suppressHeartbeatUntilRef = useRef(0); - const heartbeatFailuresRef = useRef(0); - - const pushSessionNow = useCallback( - (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { - const baseUrl = mx?.getHomeserverUrl(); - const accessToken = mx?.getAccessToken(); - const userId = mx?.getUserId(); - const canPush = - !!mx && - typeof baseUrl === 'string' && - typeof accessToken === 'string' && - typeof userId === 'string' && - 'serviceWorker' in navigator && - !!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'; - }, - [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] - ); + const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); + const pushSubAtom = useAtom(pushSubscriptionAtom); + const isMobile = mobileOrTablet(); useEffect(() => { const handleVisibilityChange = () => { @@ -100,133 +26,30 @@ export function useAppVisibility(mx: MatrixClient | undefined) { `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); - appEvents.emitVisibilityChange(isVisible); + appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { - appEvents.emitVisibilityHidden(); - return; - } - - // Always kick the sync loop on foreground regardless of phase flags — - // the SDK may be sitting in exponential backoff after iOS froze the tab. - mx?.retryImmediately(); - - 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; - } + appEvents.onVisibilityHidden?.(); } }; 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 (!phase2VisibleHeartbeat || !mx) return undefined; - - // Reset adaptive backoff/suppression so a config or session change starts fresh. - heartbeatFailuresRef.current = 0; - suppressHeartbeatUntilRef.current = 0; - - let timeoutId: number | undefined; - - const getDelayMs = (): number => { - let delay = heartbeatIntervalMs; - - if (phase3AdaptiveBackoffJitter) { - const failures = heartbeatFailuresRef.current; - const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); - delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); - - // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. - const jitter = 0.8 + Math.random() * 0.4; - delay = Math.max(1000, Math.round(delay * jitter)); - } - - return delay; - }; - - const tick = () => { - const now = Date.now(); + if (!mx) return; - 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()); + const handleVisibilityForNotifications = (isVisible: boolean) => { + togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); }; - timeoutId = window.setTimeout(tick, getDelayMs()); - + appEvents.onVisibilityChange = handleVisibilityForNotifications; + // eslint-disable-next-line consistent-return return () => { - if (timeoutId !== undefined) window.clearTimeout(timeoutId); + appEvents.onVisibilityChange = null; }; - }, [ - heartbeatIntervalMs, - heartbeatMaxBackoffMs, - mx, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - pushSessionNow, - ]); + }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); } diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 3f5568e80..0e7257532 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -5,31 +5,6 @@ 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[]; @@ -39,8 +14,6 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; - experiments?: Record; - pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -70,7 +43,6 @@ export type ClientConfig = { matrixToBaseUrl?: string; settingsLinkBaseUrl?: string; - sessionSync?: SessionSyncConfig; /** How long (ms) without input before auto-idling presence. 0 = disabled. */ presenceAutoIdleTimeoutMs?: number; }; @@ -85,72 +57,6 @@ 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/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 8e2f6d138..2fea1eddd 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -2,7 +2,6 @@ import { act, renderHook } from '@testing-library/react'; import { Provider, useAtomValue } from 'jotai'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { presenceAutoIdledAtom } from '$state/settings'; -import { appEvents } from '$utils/appEvents'; import type { ReactNode } from 'react'; import { usePresenceAutoIdle } from './usePresenceAutoIdle'; @@ -92,7 +91,7 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(false); }); - it('resets auto-idle when app becomes visible via appEvents', () => { + it('resets auto-idle when the document becomes visible again', () => { const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { wrapper, }); @@ -102,11 +101,16 @@ describe('usePresenceAutoIdle', () => { }); expect(result.current).toBe(true); - // Simulate app returning to foreground. + const visibilityStateSpy = vi + .spyOn(document, 'visibilityState', 'get') + .mockReturnValue('visible'); + act(() => { - appEvents.emitVisibilityChange(true); + document.dispatchEvent(new Event('visibilitychange')); }); expect(result.current).toBe(false); + + visibilityStateSpy.mockRestore(); }); it('does not go idle when presenceMode is not online', () => { @@ -201,7 +205,7 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(false); }); - it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => { + it('stops responding to focus events after cleanup', () => { const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { wrapper, }); @@ -214,10 +218,10 @@ describe('usePresenceAutoIdle', () => { unmount(); - // After unmount, emitting visibility change should have no effect. - // (No error thrown means the handler was properly unsubscribed.) act(() => { - appEvents.emitVisibilityChange(true); + window.dispatchEvent(new Event('focus')); }); + + expect(result.current).toBe(true); }); }); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index dc5af7e21..c4f14a008 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -2,7 +2,6 @@ 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'); @@ -65,22 +64,23 @@ export function usePresenceAutoIdle( timerRef.current = window.setTimeout(goIdle, timeoutMs); }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') handleActivity(); + }; + // Start the initial timer. timerRef.current = window.setTimeout(goIdle, timeoutMs); ACTIVITY_EVENTS.forEach((ev) => 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 unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => { - if (isVisible) handleActivity(); - }); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleActivity); return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleActivity); clearTimer(); - unsubVisibility(); }; }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx index 78f334d71..70ca6b5d2 100644 --- a/src/app/hooks/useUserPresence.test.tsx +++ b/src/app/hooks/useUserPresence.test.tsx @@ -1,5 +1,9 @@ import { act, renderHook } from '@testing-library/react'; +import { Provider } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { presenceAutoIdledAtom, settingsAtom } from '$state/settings'; import { useUserPresence, Presence, clearPresenceCache } from './useUserPresence'; // ------- mock setup ------- @@ -41,6 +45,7 @@ const makeMockUser = ( const mockMx = { getUser: vi.fn((): ReturnType | null => mockUser), getPresence: vi.fn((): Promise => mockGetPresence()), + getUserId: vi.fn<() => string | undefined>(() => undefined), on: vi.fn(), removeListener: vi.fn(), }; @@ -51,14 +56,53 @@ vi.mock('./useMatrixClient', () => ({ const USER_ID = '@alice:test'; +type HookWrapperProps = { + children: ReactNode; + sendPresence?: boolean; + presenceMode?: 'online' | 'unavailable' | 'dnd' | 'offline'; + autoIdled?: boolean; +}; + +const localStorageSettings = () => { + const rawSettings = localStorage.getItem('settings'); + return rawSettings ? JSON.parse(rawSettings) : {}; +}; + +const HydratePresenceSettings = ({ + children, + sendPresence = true, + presenceMode = 'online', + autoIdled = false, +}: HookWrapperProps) => { + useHydrateAtoms([ + [settingsAtom, { ...localStorageSettings(), sendPresence, presenceMode }], + [presenceAutoIdledAtom, autoIdled], + ]); + return children; +}; + +const createWrapper = (options?: Omit) => { + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + return Wrapper; +}; + beforeEach(() => { vi.clearAllMocks(); userListeners.clear(); clearPresenceCache(); + localStorage.clear(); mockUser = null; mockGetPresence = () => new Promise(() => {}); // pending by default mockMx.getUser.mockImplementation(() => mockUser); mockMx.getPresence.mockImplementation(() => mockGetPresence()); + mockMx.getUserId.mockReturnValue(undefined); }); // ------- tests ------- @@ -207,4 +251,41 @@ describe('useUserPresence', () => { // Should still be undefined without throwing expect(result.current).toBeUndefined(); }); + + it('normalizes synthetic dnd presence from the SDK user object', () => { + mockUser = makeMockUser({ presence: 'online', presenceStatusMsg: 'dnd', lastActiveTs: 1000 }); + + const { result } = renderHook(() => useUserPresence('@bob:test')); + + expect(result.current).toEqual({ + presence: Presence.Dnd, + status: undefined, + active: true, + lastActiveTs: 1000, + }); + }); + + it('overrides own presence from settings so member lists update immediately', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockMx.getUserId.mockReturnValue(USER_ID); + + const { result } = renderHook(() => useUserPresence(USER_ID), { + wrapper: createWrapper({ presenceMode: 'dnd' }), + }); + + expect(result.current?.presence).toBe(Presence.Dnd); + expect(result.current?.status).toBeUndefined(); + }); + + it('marks own presence idle when auto-idle is active', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockMx.getUserId.mockReturnValue(USER_ID); + + const { result } = renderHook(() => useUserPresence(USER_ID), { + wrapper: createWrapper({ autoIdled: true }), + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + expect(result.current?.active).toBe(false); + }); }); diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index c8e2a480f..63e995df9 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,11 +1,15 @@ import { useEffect, useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; +import { presenceAutoIdledAtom, settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { Online = 'online', Unavailable = 'unavailable', Offline = 'offline', + Dnd = 'dnd', } export type UserPresence = { @@ -15,13 +19,66 @@ export type UserPresence = { lastActiveTs?: number; }; +const isSyntheticDndStatus = (status?: string): boolean => status === 'dnd'; + +const normalizePresence = (presence: string | undefined, status?: string): Presence => { + if (presence === Presence.Online && isSyntheticDndStatus(status)) return Presence.Dnd; + if (presence === Presence.Unavailable) return Presence.Unavailable; + if (presence === Presence.Offline) return Presence.Offline; + return Presence.Online; +}; + +const sanitizeStatus = (status?: string): string | undefined => + isSyntheticDndStatus(status) ? undefined : status; + const getUserPresence = (user: User): UserPresence => ({ - presence: user.presence as Presence, - status: user.presenceStatusMsg, + presence: normalizePresence(user.presence, user.presenceStatusMsg), + status: sanitizeStatus(user.presenceStatusMsg), active: user.currentlyActive, lastActiveTs: user.getLastActiveTs(), }); +const getOwnEffectivePresence = ( + sendPresence: boolean, + presenceMode: string | undefined, + autoIdled: boolean +): Presence => { + if (!sendPresence) return Presence.Offline; + if (autoIdled) return Presence.Unavailable; + if (presenceMode === Presence.Unavailable) return Presence.Unavailable; + if (presenceMode === Presence.Offline) return Presence.Offline; + if (presenceMode === Presence.Dnd) return Presence.Dnd; + return Presence.Online; +}; + +const applyOwnPresenceOverride = ( + rawPresence: UserPresence | undefined, + sendPresence: boolean, + presenceMode: string | undefined, + autoIdled: boolean +): UserPresence | undefined => { + const effectivePresence = getOwnEffectivePresence(sendPresence, presenceMode, autoIdled); + const sanitizedStatus = sanitizeStatus(rawPresence?.status); + + if (!rawPresence) { + return { + presence: effectivePresence, + status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus, + active: effectivePresence === Presence.Online || effectivePresence === Presence.Dnd, + }; + } + + return { + ...rawPresence, + presence: effectivePresence, + status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus, + active: + effectivePresence === Presence.Online || effectivePresence === Presence.Dnd + ? rawPresence.active + : false, + }; +}; + // In-memory presence REST cache to avoid N+1 /presence/{userId}/status floods. // Multiple hook instances for the same user share a single in-flight request. const PRESENCE_CACHE_TTL_MS = 60_000; @@ -57,8 +114,8 @@ function fetchPresenceOnce( .getPresence(userId) .then((resp) => { const data: UserPresence = { - presence: resp.presence as Presence, - status: resp.status_msg, + presence: normalizePresence(resp.presence, resp.status_msg), + status: sanitizeStatus(resp.status_msg), active: resp.currently_active ?? false, lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, }; @@ -84,6 +141,9 @@ function fetchPresenceOnce( export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); + const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const autoIdled = useAtomValue(presenceAutoIdledAtom); const user = mx.getUser(userId); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); @@ -137,7 +197,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { }; }, [mx, userId, user]); - return presence; + return useMemo(() => { + if (userId !== mx.getUserId()) return presence; + return applyOwnPresenceOverride(presence, sendPresence, presenceMode, autoIdled); + }, [autoIdled, mx, presence, presenceMode, sendPresence, userId]); }; export const usePresenceLabel = (): Record => @@ -146,6 +209,7 @@ export const usePresenceLabel = (): Record => [Presence.Online]: 'Online', [Presence.Unavailable]: 'Idle', [Presence.Offline]: 'Offline', + [Presence.Dnd]: 'Do Not Disturb', }), [] ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c50f411a4..9c5c76306 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom, useAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -646,23 +646,10 @@ 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); - window.addEventListener('pagehide', postHidden); - return () => { - document.removeEventListener('visibilitychange', postVisibility); - window.removeEventListener('pagehide', postHidden); - }; + return () => document.removeEventListener('visibilitychange', postVisibility); }, []); useEffect(() => { @@ -844,40 +831,36 @@ function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); - const [autoIdled] = useAtom(presenceAutoIdledAtom); + const autoIdled = useAtomValue(presenceAutoIdledAtom); const clientConfig = useClientConfig(); const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0; usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { - // 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 = effectiveMode === 'dnd' ? 'online' : effectiveMode; const effectiveState = sendPresence ? activePresence : 'offline'; - const broadcasting = effectiveState !== 'offline'; + const ownUser = mx.getUser(mx.getUserId() ?? ''); + const shouldClearSyntheticDndStatus = + ownUser?.presenceStatusMsg === 'dnd' && (!sendPresence || effectiveMode !== 'dnd'); + let statusPayload: { status_msg: string } | undefined; + + if (sendPresence && effectiveMode === 'dnd') { + statusPayload = { status_msg: 'dnd' }; + } else if (shouldClearSyntheticDndStatus) { + statusPayload = { status_msg: '' }; + } - // Classic sync: set_presence query param on every /sync poll. - // Passing undefined restores the default (online); Offline suppresses broadcasting. - 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). + mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); - // 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. const presencePayload = { presence: effectiveState, - ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), + ...statusPayload, }; let retryTimer: ReturnType | undefined; const trySetPresence = (attempt = 0) => { mx.setPresence(presencePayload).catch(() => { - // Retry up to 3 times with back-off: the HTTP client may not have - // reconnected yet after the app resumes from background. if (attempt < 3) { retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1)); } @@ -887,7 +870,7 @@ function PresenceFeature() { return () => { if (retryTimer !== undefined) clearTimeout(retryTimer); }; - }, [mx, sendPresence, presenceMode, autoIdled]); + }, [autoIdled, mx, presenceMode, sendPresence]); return null; } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 191a585dd..4c3838007 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -1,6 +1,5 @@ import { MouseEvent, MouseEventHandler, ReactNode, useCallback, useState } from 'react'; import { - Badge, Box, Button, Dialog, @@ -189,13 +188,7 @@ export function AccountSwitcherTab() { const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); let myOwnPresenceBadge: ReactNode; if (sendPresence) { - myOwnPresenceBadge = - effectiveDisplayMode === 'dnd' ? ( - // DND: solid red badge (broadcasts as online with status_msg 'dnd') - - ) : ( - - ); + myOwnPresenceBadge = ; } const activeAvatarUrl = activeProfile.avatarUrl ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) @@ -394,12 +387,7 @@ export function AccountSwitcherTab() { ] as const ).map(({ label: statusLabel, desc, mode }) => { const isSelected = sendPresence && (presenceMode ?? 'online') === mode; - const badge = - mode === 'dnd' ? ( - - ) : ( - - ); + const badge = ; return ( p && p.lastActiveTs != null && p.lastActiveTs !== 0 && p.presence === Presence.Online - ); + const groupDMPresence = isGroupDM + ? [member0Presence, member1Presence, member2Presence].reduce( + (acc, current) => { + if (!current || current.lastActiveTs == null || current.lastActiveTs === 0) return acc; + if (current.presence === Presence.Dnd) return Presence.Dnd; + if (!acc && current.presence === Presence.Online) return Presence.Online; + return acc; + }, + undefined + ) + : undefined; let presenceBadge: ReactNode; if ( @@ -73,8 +79,8 @@ function DMItem({ room, selected }: DMItemProps) { singleDMPresence.lastActiveTs !== 0 ) { presenceBadge = ; - } else if (isGroupDM && groupDMOnline) { - presenceBadge = ; + } else if (groupDMPresence) { + presenceBadge = ; } // Get unread info for badge diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2430f5324..2834c5b6f 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,29 +1,5 @@ -export type VisibilityChangeHandler = (isVisible: boolean) => void; -type VisibilityHiddenHandler = () => void; - -const visibilityChangeHandlers = new Set(); -const visibilityHiddenHandlers = new Set(); - export const appEvents = { - 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); - }; - }, + onVisibilityHidden: null as (() => void) | null, - emitVisibilityChange(isVisible: boolean): void { - visibilityChangeHandlers.forEach((h) => h(isVisible)); - }, + onVisibilityChange: null as ((isVisible: boolean) => void) | null, }; From ef017653e0b2a93bb35e4d6c8c854f9326fd0d2d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 13:38:15 -0400 Subject: [PATCH 26/27] fix(presence): harden desktop auto-idle detection --- src/app/hooks/usePresenceAutoIdle.test.tsx | 13 +++++++ src/app/hooks/usePresenceAutoIdle.ts | 42 ++++++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 2fea1eddd..407e7f69c 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -170,6 +170,19 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(true); }); + it('still goes idle after the window loses focus', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + act(() => { + window.dispatchEvent(new Event('blur')); + vi.advanceTimersByTime(5000); + }); + + 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), diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index c4f14a008..6dfad4968 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -6,6 +6,7 @@ import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('PresenceAutoIdle'); const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const; +const IDLE_CHECK_INTERVAL_MS = 30_000; /** * Automatically transitions presence to idle after a configurable inactivity @@ -28,6 +29,8 @@ export function usePresenceAutoIdle( const setAutoIdled = useSetAtom(presenceAutoIdledAtom); const autoIdledRef = useRef(false); const timerRef = useRef(undefined); + const intervalRef = useRef(undefined); + const lastActivityAtRef = useRef(Date.now()); const clearTimer = useCallback(() => { if (timerRef.current !== undefined) { @@ -36,11 +39,19 @@ export function usePresenceAutoIdle( } }, []); + const clearIntervalTimer = useCallback(() => { + if (intervalRef.current !== undefined) { + window.clearInterval(intervalRef.current); + intervalRef.current = undefined; + } + }, []); + // Inactivity timer: go idle after timeoutMs without user input. useEffect(() => { const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0; if (!shouldAutoIdle) { clearTimer(); + clearIntervalTimer(); if (autoIdledRef.current) { autoIdledRef.current = false; setAutoIdled(false); @@ -49,19 +60,36 @@ export function usePresenceAutoIdle( } const goIdle = () => { + if (autoIdledRef.current) return; debugLog.info('general', 'Inactivity timeout — auto-idling'); autoIdledRef.current = true; setAutoIdled(true); }; + const checkIdleDeadline = () => { + const elapsedMs = Date.now() - lastActivityAtRef.current; + if (elapsedMs >= timeoutMs) { + goIdle(); + return; + } + clearTimer(); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs - elapsedMs); + }; + const handleActivity = () => { + lastActivityAtRef.current = Date.now(); clearTimer(); if (autoIdledRef.current) { debugLog.info('general', 'Activity detected — clearing auto-idle'); autoIdledRef.current = false; setAutoIdled(false); } - timerRef.current = window.setTimeout(goIdle, timeoutMs); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs); + }; + + const handleBlur = () => { + debugLog.info('general', 'Window blurred — keeping idle deadline active'); + checkIdleDeadline(); }; const handleVisibilityChange = () => { @@ -69,20 +97,28 @@ export function usePresenceAutoIdle( }; // Start the initial timer. - timerRef.current = window.setTimeout(goIdle, timeoutMs); + lastActivityAtRef.current = Date.now(); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs); + intervalRef.current = window.setInterval( + checkIdleDeadline, + Math.min(timeoutMs, IDLE_CHECK_INTERVAL_MS) + ); ACTIVITY_EVENTS.forEach((ev) => document.addEventListener(ev, handleActivity, { passive: true }) ); document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('focus', handleActivity); + window.addEventListener('blur', handleBlur); return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleActivity); + window.removeEventListener('blur', handleBlur); clearTimer(); + clearIntervalTimer(); }; - }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); + }, [clearIntervalTimer, clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); // Multi-device sync: if another device sets us back to online, clear auto-idle. useEffect(() => { From a8b17b8ab3f9f84f448b1527d0c209e0f508c1f0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 22 Apr 2026 09:59:51 -0400 Subject: [PATCH 27/27] fix(presence): add heartbeat to win back online state on multi-device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix presence is per-user on the server, not per-device. When an idle device sends `setPresence({ presence: 'unavailable' })`, the shared server state changes for all clients. The active device's PresenceFeature only re-sends its state when `autoIdled`, `presenceMode`, or `sendPresence` changes — none of which fire on the active device, so the idle device permanently 'wins' until the user switches tabs or interacts. Fix: add a 2-minute heartbeat that re-asserts `{ presence: 'online' }` Within one heartbeat cycle the active device wins back the server state. The heartbeat is idle-free (stops when autoIdled or mode changes), so it doesn't fight against intentional DND/offline/idle changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 9c5c76306..8d69332f4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -827,6 +827,12 @@ function HandleDecryptPushEvent() { return null; } +// How often an active device re-asserts its online state to the server. +// Matrix presence is per-user (not per-device): if another device sets you to +// idle/unavailable, this heartbeat wins the server state back within one interval. +// Must be shorter than the shortest expected idle timeout (default 5 min). +const PRESENCE_HEARTBEAT_INTERVAL_MS = 2 * 60_000; // 2 minutes + function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -872,6 +878,24 @@ function PresenceFeature() { }; }, [autoIdled, mx, presenceMode, sendPresence]); + // Presence heartbeat: periodically re-assert online state while this device + // is active. Fixes a multi-device race where a different idle device sets the + // shared server presence to unavailable while the user is active here. + useEffect(() => { + const isActiveOnline = sendPresence && !autoIdled && presenceMode === 'online'; + if (!isActiveOnline) return undefined; + + const heartbeatId = window.setInterval(() => { + mx.setPresence({ presence: 'online' }).catch(() => { + // Silently ignore — the main effect will retry on next state change. + }); + }, PRESENCE_HEARTBEAT_INTERVAL_MS); + + return () => { + window.clearInterval(heartbeatId); + }; + }, [autoIdled, mx, presenceMode, sendPresence]); + return null; }