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 5 commits into
Conversation
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); |
1d06a65 to
2de2315
Compare
… members drawer - AccountSwitcherTab: wrap SidebarAvatar in AvatarPresence with current user's dot - DirectDMsList: add AvatarPresence badge on 1:1 DM icons using the DM user's presence - MembersDrawer: replace lastActiveTs !== 0 guard with presence !== Offline so online users show a dot
b161a41 to
74ca3b4
Compare
…e setting The feat/presence-auto-idle branch (PR SableClient#672) was replaced by feat/presence (PR SableClient#689) without porting the picker, presenceMode setting, DND mode, or usePresenceAutoIdle hook. Restore from the 29e4076 tip still in the local object store. - appEvents: refactor to multi-subscriber Set pattern (supports multiple listeners on onVisibilityChange/onVisibilityHidden) - settings: add presenceMode ('online'|'unavailable'|'dnd'|'offline'), default 'online'; add ephemeral presenceAutoIdledAtom - usePresenceAutoIdle: new hook — inactivity timer, activity reset, appEvents visibility integration, multi-device sync via User.presence - usePresenceAutoIdle.test.tsx: 10 unit tests covering all branches - ClientNonUIFeatures: PresenceFeature now uses presenceMode + autoIdled to broadcast the correct presence state; DND sends status_msg='dnd' - AccountSwitcherTab: own badge driven from presenceMode settings state (not SDK User.presence which MSC4186 servers never echo back); adds Status section to account menu with Online/Idle/DND/Invisible picker
…e setting The feat/presence-auto-idle branch (PR SableClient#672) was replaced by feat/presence (PR SableClient#689) without porting the picker, presenceMode setting, DND mode, or usePresenceAutoIdle hook. Restore from the 29e4076 tip still in the local object store. - appEvents: refactor to multi-subscriber Set pattern (supports multiple listeners on onVisibilityChange/onVisibilityHidden) - settings: add presenceMode ('online'|'unavailable'|'dnd'|'offline'), default 'online'; add ephemeral presenceAutoIdledAtom - usePresenceAutoIdle: new hook — inactivity timer, activity reset, appEvents visibility integration, multi-device sync via User.presence - usePresenceAutoIdle.test.tsx: 10 unit tests covering all branches - ClientNonUIFeatures: PresenceFeature now uses presenceMode + autoIdled to broadcast the correct presence state; DND sends status_msg='dnd' - AccountSwitcherTab: own badge driven from presenceMode settings state (not SDK User.presence which MSC4186 servers never echo back); adds Status section to account menu with Online/Idle/DND/Invisible picker
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