feat(presence): presence badges, auto-idle, and Discord-style status picker#689
feat(presence): presence badges, auto-idle, and Discord-style status picker#689Just-Insane wants to merge 30 commits intoSableClient:devfrom
Conversation
…resence data - 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
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
…resence data - 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
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
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.
…esence hook tests
- 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
- 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
- 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)
- 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
# Conflicts: # src/app/hooks/useUserPresence.ts # src/app/pages/client/sidebar/DirectDMsList.tsx
# Conflicts: # src/app/hooks/useAppVisibility.ts # src/app/hooks/useClientConfig.ts # src/app/hooks/useUserPresence.ts # src/app/pages/client/ClientNonUIFeatures.tsx # src/app/pages/client/sidebar/AccountSwitcherTab.tsx # src/app/pages/client/sidebar/DirectDMsList.tsx # src/app/state/settings.ts
There was a problem hiding this comment.
Pull request overview
Adds end-to-end presence UX (badges + status picker + auto-idle) and improves app visibility event plumbing, including sliding-sync presence bootstrapping behavior.
Changes:
- Add presence badges to the compact DM rail and account switcher avatar, plus a Discord-style status picker.
- Implement auto-idle with configurable timeout and introduce a presence REST bootstrap for sliding-sync environments.
- Refactor
appEventsvisibility handling and expanduseAppVisibilitysession-sync/heartbeat logic.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/app/utils/appEvents.ts | Replace single visibility handlers with multi-subscriber emit/on API. |
| src/app/state/settings.ts | Add presenceMode setting + ephemeral presenceAutoIdledAtom (also introduces enableMessageBookmarks). |
| src/app/pages/client/sidebar/DirectDMsList.tsx | Render presence badges on compact DM avatars using useUserPresence. |
| src/app/pages/client/sidebar/AccountSwitcherTab.tsx | Add own-presence badge + status picker UI that writes presenceMode. |
| src/app/pages/client/ClientNonUIFeatures.tsx | Drive actual presence broadcasting + auto-idle integration and SW visibility handling. |
| src/app/hooks/useUserPresence.ts | Add sliding-sync REST bootstrap + client-level fallback listener; update labels. |
| src/app/hooks/useUserPresence.test.tsx | New unit tests for useUserPresence. |
| src/app/hooks/usePresenceAutoIdle.ts | New hook implementing inactivity auto-idle + visibility/activity listeners. |
| src/app/hooks/usePresenceAutoIdle.test.tsx | New unit tests for auto-idle behavior and cleanup. |
| src/app/hooks/useClientConfig.ts | Add experiment/sessionSync config types + variant selection helper + presence timeout config. |
| src/app/hooks/useAppVisibility.ts | Rebuild visibility/focus handlers + optional SW session-sync heartbeat behavior. |
| src/app/features/settings/developer-tools/DevelopTools.tsx | Add “Rotate Encryption Sessions” developer tool action. |
| config.json | Add presenceAutoIdleTimeoutMs default (5 min). |
| .changeset/presence-sidebar-badges.md | Changeset entry for presence badges. |
| .changeset/presence-auto-idle.md | Changeset entry for auto-idle + status picker. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| removeListener: vi.fn(), | ||
| }; | ||
|
|
||
| vi.mock('$hooks/useMatrixClient', () => ({ |
There was a problem hiding this comment.
The test mocks '$hooks/useMatrixClient', but useUserPresence imports useMatrixClient via a relative path ('./useMatrixClient'). This mock won’t be applied, so the hook will use the real matrix client and these tests will fail or become integration tests unintentionally. Mock the correct module path (or change useUserPresence to import from the aliased path consistently).
| vi.mock('$hooks/useMatrixClient', () => ({ | |
| vi.mock('./useMatrixClient', () => ({ |
| mx.setPresence({ | ||
| presence: effectiveState, | ||
| status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', |
There was a problem hiding this comment.
mx.setPresence is always sending status_msg: '' when not in DND. That will clear any existing status message set by this app (Profile status editor) or other clients, potentially on every mount/state change. Consider omitting status_msg entirely unless you’re explicitly setting DND, and/or persisting/restoring the previous status message when toggling DND so user-defined status text isn’t lost.
| mx.setPresence({ | |
| presence: effectiveState, | |
| status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', | |
| // Only send status_msg when explicitly broadcasting DND; otherwise omit it so we | |
| // don't clear any existing user-defined status text from this or other clients. | |
| mx.setPresence({ | |
| presence: effectiveState, | |
| ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), |
| (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online | ||
| ); | ||
|
|
||
| let presenceBadge: ReactNode; | ||
| if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { |
There was a problem hiding this comment.
lastActiveTs is optional in UserPresence (REST fallback sets it to undefined when last_active_ago is absent), but this logic treats undefined !== 0 as “known”. That can cause presence badges to render even when we don’t actually have a timestamp/bootstrapped presence. Consider explicitly checking lastActiveTs != null && lastActiveTs !== 0, or gating on a dedicated “hasPresenceData” flag from the hook.
| (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online | |
| ); | |
| let presenceBadge: ReactNode; | |
| if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { | |
| (p) => | |
| p && | |
| p.lastActiveTs != null && | |
| p.lastActiveTs !== 0 && | |
| p.presence === Presence.Online | |
| ); | |
| let presenceBadge: ReactNode; | |
| if ( | |
| !isGroupDM && | |
| singleDMPresence && | |
| singleDMPresence.lastActiveTs != null && | |
| singleDMPresence.lastActiveTs !== 0 | |
| ) { |
| // experimental | ||
| enableMessageBookmarks: boolean; | ||
|
|
There was a problem hiding this comment.
enableMessageBookmarks is introduced here (interface + defaults) but isn’t mentioned in the PR description and appears unrelated to presence. Consider removing it from this PR or splitting it into a dedicated changeset/PR so the scope stays focused and reviewable.
| // (including bridge bots). fire-and-forget per room. | ||
| encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); |
There was a problem hiding this comment.
crypto.prepareToEncrypt(room) is invoked fire-and-forget for each room. If it returns a Promise (or can throw), this can lead to unhandled rejections and makes failures invisible. Consider explicitly voiding and attaching a .catch(...) per call, or awaiting with controlled concurrency if you need to ensure key sharing is actually queued successfully.
| // (including bridge bots). fire-and-forget per room. | |
| encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); | |
| // (including bridge bots). fire-and-forget per room, but surface failures. | |
| encryptedRooms.forEach((room) => { | |
| void Promise.resolve() | |
| .then(() => crypto.prepareToEncrypt(room)) | |
| .catch((error) => { | |
| console.error('Failed to prepare room encryption', room.roomId, error); | |
| }); | |
| }); |
| // 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. | ||
| // 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, | ||
| }); |
There was a problem hiding this comment.
The REST bootstrap (mx.getPresence(userId)) will run for every hook instance where getLastActiveTs() === 0 (e.g. MSC4186 sliding-sync), and this hook is used in large lists (members list, room nav, etc.). That can easily become an N+1 flood of /presence/{userId}/status requests. Consider adding a shared in-memory cache + in-flight dedupe (and ideally a short TTL) so multiple components/users don’t trigger redundant requests.
| .catch(() => { | ||
| // Presence not available on this server (404 or not supported) — keep existing state. | ||
| }); |
There was a problem hiding this comment.
The REST fallback swallows all errors. The PR description calls out ignoring 404 when presence isn’t enabled, but this also hides transient network/server errors (and makes debugging harder). Consider only suppressing known “not supported/not found” cases (e.g. MatrixError with httpStatus===404 / M_NOT_FOUND) and logging others at debug/warn level.
|
|
||
| useEffect(() => { | ||
| if (!mx) return; | ||
| if (!phase2VisibleHeartbeat) return undefined; |
There was a problem hiding this comment.
The heartbeat effect runs whenever phase2VisibleHeartbeat is enabled, even if mx is currently undefined (ClientRoot calls useAppVisibility(mx) during initial load). That will schedule recurring timers and repeatedly call pushSessionNow('heartbeat'), which will always ‘skipped’ and can generate noisy logs / unnecessary work. Consider guarding the heartbeat loop on mx being defined (and/or only starting once session prerequisites exist).
| if (!phase2VisibleHeartbeat) return undefined; | |
| if (!phase2VisibleHeartbeat || !mx) return undefined; |
| 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]) |
There was a problem hiding this comment.
This PR adds a new Developer Tools action to rotate Megolm sessions across all encrypted rooms, but this functionality isn’t mentioned in the PR description (which is focused on presence). Consider splitting this into a separate PR/changeset to keep scope aligned and reduce risk for the presence feature rollout.
| // 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); |
There was a problem hiding this comment.
Selecting a status updates settings but does not close the popout menu (setMenuAnchor(undefined)), unlike other menu actions (e.g. Add Account / Settings). If the intended UX is to close after selection, consider closing the menu in this handler for consistency.
| if (!sendPresence) setSendPresence(true); | |
| if (!sendPresence) setSendPresence(true); | |
| setMenuAnchor(undefined); |
- 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 SableClient#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
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
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>
Description
Combined presence PR — replaces #608 and #672 with a single branch rebased onto
upstream/devto resolve merge conflicts cleanly.Presence sidebar badges (from #608)
DirectDMsList) and the account switcher avatar (AccountSwitcherTab).AvatarPresenceandPresenceBadgecomponents for consistent badge rendering.m.presenceevents, souseUserPresencenow falls back toGET /_matrix/client/v3/presence/{userId}/statusto bootstrap presence state. If the server returns 404, the error is silently ignored.ClientEvent.Eventlistener fallback for when theUserobject doesn't exist yet (sliding sync race condition).Auto-idle & status picker (from #672)
presenceModesetting — persists the user's chosen status across sessions.presenceAutoIdleTimeoutMsclient config option to customize or disable (0) the auto-idle timer.userIdpresence fetch to prevent 400 errors during initial load race conditions.useUserPresencehook including the auto-idle timer and status transitions.DND mode
presence: onlinepresence: unavailablepresence: online,status_msg: dndpresence: offlineSupersedes #608 and #672.
Type of change
Checklist