Skip to content

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

Closed
Just-Insane wants to merge 18 commits into
SableClient:devfrom
Just-Insane:feat/presence
Closed

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

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

@Just-Insane Just-Insane commented Apr 15, 2026

Description

Adds live presence support across the sidebar and account switcher with a Discord-style status picker and auto-idle behaviour.

  • Presence badges — online / idle / DND / offline badges on DM avatars in the compact sidebar rail and the account switcher avatar. Sliding sync does not deliver m.presence events, so useUserPresence bootstraps by falling back to GET /presence/{userId}/status (404 silently ignored).
  • Status picker — Online, Idle, Do Not Disturb, Invisible in the account switcher. Persisted as presenceMode setting across sessions.
  • Auto-idle — after a configurable inactivity window (default 5 min, presenceAutoIdleTimeoutMs in config.json) the client broadcasts Idle and restores the previous status on activity.
  • Fixes — optimistic badge update on status change, own-presence sync after network call succeeds, idle timer correctly ignores mousemove without OS focus (desktop idle fix), REST API fallback when the MSC4186 sliding sync extension has no presence data.

Fixes #

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

AI disclosure:

  • Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).
  • Fully AI generated (explain what all the generated code does in moderate detail).

The REST fallback calls GET /presence/{userId}/status and maps the presence field to the badge variant. The idle timer subscribes to mousemove and keydown on document, resets on activity, and only fires the Idle broadcast when document.hasFocus() to avoid treating background tabs as idle. The optimistic update sets local badge state immediately and rolls back if the PUT /presence call fails.

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
Comment thread src/app/pages/client/ClientNonUIFeatures.tsx Outdated
Comment thread src/app/pages/client/sidebar/DirectDMsList.tsx Outdated
Comment thread src/app/state/settings.ts Outdated
Comment thread src/app/features/settings/developer-tools/DevelopTools.tsx Outdated
Comment thread src/app/hooks/useUserPresence.ts Outdated
Comment thread src/app/hooks/useUserPresence.ts
Comment thread src/app/hooks/useAppVisibility.ts Outdated
Comment thread src/app/features/settings/developer-tools/DevelopTools.tsx Outdated
Comment thread src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@Just-Insane Just-Insane marked this pull request as draft April 17, 2026 11:55
Just-Insane added a commit to Just-Insane/Sable that referenced this pull request May 13, 2026
…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
Just-Insane added a commit to Just-Insane/Sable that referenced this pull request May 14, 2026
…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
… 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
…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
MSC4186 servers never echo back the client's own m.presence events, so
mx.getUser(myUserId) was never updated in the SDK store. This left own
presence badges in the member list stuck at the SDK default forever, and
the status editor always showing a blank status.

Three changes:
- settings: add `presenceStatusMsg` to locally cache the user's custom
  status message so it survives mode changes and sliding-sync restarts
  and is no longer silently cleared to '' on every presence broadcast
- slidingSync: add SlidingSyncManager.updateOwnPresence(presence, statusMsg)
  which synthesises a minimal m.presence event for own user and feeds it
  into the SDK User object — exactly what ExtensionPresence.onResponse
  does for remote users
- ClientNonUIFeatures/PresenceFeature: read presenceStatusMsg from settings,
  preserve it in mx.setPresence() calls, then call updateOwnPresence() so
  the local SDK store stays accurate after each broadcast
- Profile/handleSaveStatus: persist the new status to the settings atom,
  call updateOwnPresence() after mx.setPresence() so the member-list badge
  and status editor reflect the change immediately without needing a
  server echo
Matrix has no native DND state; Sable encodes it as presence='online'
+ status_msg='dnd'. Previously only the account switcher decoded this
back to a red dot (via a hardcoded Badge); the member list and all other
presence badges just saw 'online' and stayed green.

- useUserPresence: add Presence.Dnd = 'dnd' to the enum, decode DND in
  getUserPresence (online + status_msg==='dnd' → Presence.Dnd), add
  'Do Not Disturb' label
- Presence.tsx: map Presence.Dnd → 'Critical' (red) in PresenceToColor;
  DND is not Offline so it naturally gets fill='Solid'
- AccountSwitcherTab: remove the bespoke DND badge branch; PresenceBadge
  now handles Presence.Dnd correctly so the own-badge path is unified
Three bugs fixed:

1. handleSaveStatus passed presence?.presence to mx.setPresence(). After
   the Presence.Dnd addition, this could send presence='dnd', which is
   not a valid Matrix presence value — the server rejects it and the
   status is silently not saved. Fix: remove the direct mx.setPresence
   call; PresenceFeature's effect (already triggered by the presenceStatusMsg
   atom change) handles broadcasting with proper DND translation.

2. getUserPresence exposed user.presenceStatusMsg='dnd' as the status
   property. The member list and status editor would show the literal
   text "dnd" for DND users. Fix: filter the sentinel out (return
   undefined for status when presenceStatusMsg==='dnd').

3. handleSaveStatus and PresenceFeature's effect both called mx.setPresence
   concurrently when the status changed. PresenceFeature's call correctly
   overwrites DND users' custom status with the 'dnd' sentinel, undoing
   the direct call. Fix: let PresenceFeature be the sole broadcaster;
   handleSaveStatus eagerly calls updateOwnPresence for instant member-list
   feedback without waiting for the network round-trip.
Previously updateOwnPresence() was called inside the .then() callback of
mx.setPresence(), so the own-presence badge in the member list and the DND
red dot only appeared after the server acknowledged the request. If the
server returned an error the update never happened at all.

Move updateOwnPresence() before the API call so the local SDK User object
is always kept in sync with the settings state immediately. The server PUT
still happens in the background for broadcasting to others; we just no longer
wait on it to update our own UI.
- Move updateOwnPresence() call to .then() after setPresence() succeeds
- Prevents local SDK store from diverging if the network call fails
- Improves consistency of own presence badge across UI
- Network failures no longer leave stale optimistic state
- useUserPresence: fall back to content.user_id when event has no
  sender field (MSC4186 sliding sync presence events may omit sender)
- MemberTile: show PresenceBadge for any non-offline presence state,
  not only when a status message is present; move badge to avatar
  overlay to match MembersDrawer style
- sw.ts: add fetchMediaWithCache — checks sable-media-sw-v1 Cache API
  before the network; caches 2xx responses only so errors are never
  stored; replaces bare fetch() in all four auth-media code paths
- AvatarImage: module-level svgBlobCache so SVG processing runs at
  most once per URL; subsequent remounts skip async work, eliminating
  flicker on scroll
…ension

Synapse's MSC4186 simplified sliding sync does not implement the presence
extension (its get_extensions_response only handles to_device, e2ee,
account_data, receipts, typing, and thread_subscriptions), so the
extensions.presence field is never included in sync responses.

Add a fallback in useUserPresence: if user.lastPresenceTs === 0 (the
SDK default — no presence data has ever been received), call
mx.getPresence(userId) directly against the REST API to populate the
User object.  Errors are swallowed so servers with presence disabled
silently keep the offline default.

The ExtensionPresence class is kept in slidingSync.ts so clients
automatically benefit if server support is added later; its comment is
updated to document the current server limitation.
…ard in test

The bookmarks imports (useInitBookmarks, bookmarksPanelAtom, useReminderSync)
leaked from the integration branch and are not used by the presence feature.

The usePresenceAutoIdle test dispatched mousemove to simulate activity, but
the implementation intentionally ignores mousemove when document.hasFocus()
is false (desktop focus guard). jsdom never grants document focus, so the
event was silently dropped. Use keydown instead, which has no focus guard.
@Just-Insane Just-Insane marked this pull request as ready for review May 19, 2026 23:38
…1000 desktop)

The SW media cache had no size limit — it would grow indefinitely, exhausting
iOS Safari's per-origin Cache API quota and triggering PWA storage eviction
(causing a full reload on next open). Add FIFO eviction keyed on a
platform-aware entry count: 200 on mobile, 1000 on desktop.
SW_MEDIA_CACHE and fetchMediaWithCache belong with the other media caching
work; this branch retains only the presence-badge changes.

Restores mediaFetchConfig (cache: default) for media auth fetches.
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