feat(presence): auto-idle presence after inactivity with Discord-style status picker#672
feat(presence): auto-idle presence after inactivity with Discord-style status picker#672Just-Insane wants to merge 9 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
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
There was a problem hiding this comment.
Pull request overview
Adds richer presence UX and behavior on top of the presence sidebar badges work, including a status picker, persistence, auto-idle after inactivity, and improved presence bootstrapping under sliding sync.
Changes:
- Introduces
presenceMode(persisted) plus an ephemeralpresenceAutoIdledflag, and wires a Discord-style status picker into the account switcher. - Adds auto-idle detection via a new
usePresenceAutoIdlehook and updates presence broadcasting logic (sync + explicit/presencePUT). - Improves presence correctness under sliding sync by adding a REST fallback + event-based updates in
useUserPresence, with accompanying unit tests.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/app/state/settings.ts |
Adds presenceMode setting + presenceAutoIdledAtom (and a new experimental setting). |
src/app/pages/client/sidebar/DirectDMsList.tsx |
Displays presence badges on DM avatars using useUserPresence. |
src/app/pages/client/sidebar/AccountSwitcherTab.tsx |
Adds status picker UI and shows own presence badge driven by settings/auto-idle state. |
src/app/pages/client/ClientNonUIFeatures.tsx |
Hooks up auto-idle + explicit presence broadcasting; improves SW visibility signaling. |
src/app/hooks/useUserPresence.ts |
REST fallback + client event fallback for presence under sliding sync; label updates. |
src/app/hooks/useUserPresence.test.tsx |
Adds tests for useUserPresence REST fallback, events, and cancellation. |
src/app/hooks/usePresenceAutoIdle.ts |
New hook implementing inactivity-based auto-idle with activity listeners + multi-device clearing. |
src/app/hooks/useClientConfig.ts |
Extends client config types and adds experiment selection helpers. |
src/app/hooks/useAppVisibility.ts |
Reworks visibility/focus handling and adds SW session sync heartbeat/foreground logic. |
src/app/features/settings/developer-tools/DevelopTools.tsx |
Adds a developer tool to rotate encryption sessions across encrypted rooms. |
config.json |
Adds presenceAutoIdleTimeoutMs default. |
.changeset/presence-sidebar-badges.md |
Documents sidebar presence badges change. |
.changeset/presence-auto-idle.md |
Documents auto-idle + status picker change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| '@sable/client': minor | |||
There was a problem hiding this comment.
The changeset frontmatter uses "'@sable/client': minor", but this repo’s documented changeset format uses the single key "default: ". As-is, release tooling that parses changesets is likely to ignore or error on this file; please change the frontmatter to "default: minor".
| '@sable/client': minor | |
| default: minor |
|
|
||
| // Sable features! | ||
| sendPresence: boolean; | ||
| /** Which Matrix presence state to broadcast when sendPresence is true. */ |
There was a problem hiding this comment.
The presenceMode docstring says it is a Matrix presence state, but the allowed values include the app-specific "dnd" mode (which is represented via presence=online + status_msg). Please update the comment (or the type) so the meaning matches the actual values and broadcast behavior.
| /** Which Matrix presence state to broadcast when sendPresence is true. */ | |
| /** | |
| * Which presence mode to use when sendPresence is true. | |
| * Matrix presence states are sent as-is; the app-specific `dnd` mode is | |
| * broadcast as `presence=online` with a `status_msg`. | |
| */ |
| // experimental | ||
| enableMessageBookmarks: boolean; | ||
|
|
There was a problem hiding this comment.
This PR introduces the new setting "enableMessageBookmarks", but it isn’t referenced anywhere else in the repo (only declared here). If it’s not part of this PR’s scope, consider dropping it to avoid carrying an unused persisted setting, or add the corresponding feature flag usage/UI in this PR.
| "enabled": true | ||
| }, | ||
|
|
||
| "presenceAutoIdleTimeoutMs": 600000, |
There was a problem hiding this comment.
PR description says the inactivity timeout defaults to 5 minutes, but config.json sets presenceAutoIdleTimeoutMs to 600000ms (10 minutes). Please align the default value with the documented behavior (or update the PR description).
| 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); |
There was a problem hiding this comment.
useAppVisibility’s new SW session-sync logic depends on activeSession (baseUrl/accessToken/userId), but activeSession is optional and current call sites don’t appear to provide it. This makes pushSessionNow() always return 'skipped', so the foreground/heartbeat session sync never actually runs. Consider making activeSession required, or sourcing the active session inside the hook so the feature works in production.
| appEvents.onVisibilityChange?.(isVisible); | ||
| if (!isVisible) { | ||
| appEvents.onVisibilityHidden?.(); | ||
| return; |
There was a problem hiding this comment.
appEvents.onVisibilityChange/onVisibilityHidden are invoked here, but there are no remaining assignments to these callbacks anywhere in the codebase. As a result these calls are always no-ops; either remove them (and possibly appEvents entirely) or reintroduce the intended registration so visibility changes actually fan out to consumers.
| 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'; |
There was a problem hiding this comment.
Please avoid importing Matrix SDK symbols directly from "matrix-js-sdk/...". This repo has an app-facing SDK boundary module ($types/matrix-sdk) specifically to centralize SDK imports; import KnownMembership from there instead.
| import { KnownMembership } from 'matrix-js-sdk/lib/types'; | |
| import { KnownMembership } from '$types/matrix-sdk'; |
| // 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]); |
There was a problem hiding this comment.
usePresenceAutoIdle introduces new timing- and event-driven behavior (inactivity timeout, activity resets, multi-device presence clearing) but there are no unit tests for it yet. Given the edge cases around timers and cleanup, please add a vitest hook test (with fake timers) that asserts the idle transition and that listeners/timers are cleaned up on unmount.
Description
Builds on the presence sidebar badges introduced in #608 to add automatic idle detection and a Discord-style status picker.
What's in this PR:
From #608 — Presence sidebar badges
New in this PR — Auto-idle & status picker
m.presenceAPI.presenceModesetting — persists the user's chosen status across sessions.userIdfetch — prevents a REST presence GET with an empty stringuserIdduring initial load, which caused a 400 error in some race conditions.useUserPresencehook including the auto-idle timer and status transitions.Fixes #
Type of change
Checklist:
AI disclosure:
The inactivity timer in
useUserPresenceand the event-listener cleanup pattern were drafted with AI assistance and reviewed against React hook best-practices. The presence-mode persistence, the status-picker UI layout, and the sliding-sync fix were written manually.