Skip to content

feat(presence): presence badges, auto-idle, and Discord-style status picker#689

Draft
Just-Insane wants to merge 30 commits intoSableClient:devfrom
Just-Insane:feat/presence
Draft

feat(presence): presence badges, auto-idle, and Discord-style status picker#689
Just-Insane wants to merge 30 commits intoSableClient:devfrom
Just-Insane:feat/presence

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

Description

Combined presence PR — replaces #608 and #672 with a single branch rebased onto upstream/dev to resolve merge conflicts cleanly.

Presence sidebar badges (from #608)

  • Adds live presence badges (online / idle / DND / offline) to the compact sidebar DM icon rail (DirectDMsList) and the account switcher avatar (AccountSwitcherTab).
  • AvatarPresence and PresenceBadge components for consistent badge rendering.
  • Fixes sliding-sync presence: MSC4186 doesn't deliver m.presence events, so useUserPresence now falls back to GET /_matrix/client/v3/presence/{userId}/status to bootstrap presence state. If the server returns 404, the error is silently ignored.
  • ClientEvent.Event listener fallback for when the User object doesn't exist yet (sliding sync race condition).

Auto-idle & status picker (from #672)

  • Discord-style presence picker in the account switcher — Online, Idle, Do Not Disturb, Invisible.
  • presenceMode setting — persists the user's chosen status across sessions.
  • Auto-idle — after a configurable inactivity timeout (default 5 min), presence is automatically set to Idle. Activity (mouse move, keydown, touchstart) resets the timer and restores the previous status.
  • presenceAutoIdleTimeoutMs client config option to customize or disable (0) the auto-idle timer.
  • Skip empty userId presence fetch to prevent 400 errors during initial load race conditions.
  • Unit tests covering useUserPresence hook including the auto-idle timer and status transitions.

DND mode

Option Badge Matrix broadcast
Online 🟢 green dot presence: online
Idle 🟡 yellow dot presence: unavailable
Do Not Disturb 🔴 red dot presence: online, status_msg: dnd
Invisible (none) presence: offline

Supersedes #608 and #672.

Type of change

  • New feature (non-breaking change which adds functionality)

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • New and existing unit tests pass locally with my changes

…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.
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 appEvents visibility handling and expand useAppVisibility session-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.

Comment thread src/app/hooks/useUserPresence.test.tsx Outdated
removeListener: vi.fn(),
};

vi.mock('$hooks/useMatrixClient', () => ({
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
vi.mock('$hooks/useMatrixClient', () => ({
vi.mock('./useMatrixClient', () => ({

Copilot uses AI. Check for mistakes.
Comment on lines +872 to +874
mx.setPresence({
presence: effectiveState,
status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '',
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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' } : {}),

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +69
(p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online
);

let presenceBadge: ReactNode;
if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) {
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
(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
) {

Copilot uses AI. Check for mistakes.
Comment thread src/app/state/settings.ts Outdated
Comment on lines +128 to +130
// experimental
enableMessageBookmarks: boolean;

Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +51
// (including bridge bots). fire-and-forget per room.
encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room));
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// (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);
});
});

Copilot uses AI. Check for mistakes.
Comment thread src/app/hooks/useUserPresence.ts Outdated
Comment on lines +35 to +50
// 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,
});
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/app/hooks/useUserPresence.ts Outdated
Comment on lines +52 to +54
.catch(() => {
// Presence not available on this server (404 or not supported) — keep existing state.
});
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/app/hooks/useAppVisibility.ts Outdated

useEffect(() => {
if (!mx) return;
if (!phase2VisibleHeartbeat) return undefined;
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
if (!phase2VisibleHeartbeat) return undefined;
if (!phase2VisibleHeartbeat || !mx) return undefined;

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +54
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])
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// 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);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (!sendPresence) setSendPresence(true);
if (!sendPresence) setSendPresence(true);
setMenuAnchor(undefined);

Copilot uses AI. Check for mistakes.
Just-Insane and others added 3 commits April 15, 2026 11:58
- 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>
@Just-Insane Just-Insane marked this pull request as draft April 17, 2026 11:55
Just-Insane and others added 4 commits April 18, 2026 20:26
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants