From c178b777b667df4de82d65848902233e0bbc5dee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:50:22 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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));