feat(presence): add presence badges to the DM list and account switcher#608
Open
Just-Insane wants to merge 7 commits intoSableClient:devfrom
Open
feat(presence): add presence badges to the DM list and account switcher#608Just-Insane wants to merge 7 commits intoSableClient:devfrom
Just-Insane wants to merge 7 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
ce61c9a to
4a6289e
Compare
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
02f512c to
8f69b61
Compare
10 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
Adds presence UI and presence state fixes across the compact sidebar DM list and account switcher, including a REST fallback for sliding-sync servers and updated visibility/session-sync behaviors.
Changes:
- Add presence badges to the compact DM icon rail (
DirectDMsList) and the account switcher avatar, plus a status picker in the switcher menu. - Enhance
useUserPresenceto reset on user changes, listen at client level when needed, and fall back toGET /presence/{userId}/statuswhen sliding sync doesn’t deliver presence. - Rebuild
useAppVisibilitylistener/heartbeat logic and extend client config types to support experiment-based session sync strategies.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/app/state/settings.ts | Adds new persisted settings, including presenceMode default and other new flags. |
| src/app/pages/client/sidebar/DirectDMsList.tsx | Wraps DM avatars with AvatarPresence and computes presence badges for 1:1 and group DMs. |
| src/app/pages/client/sidebar/AccountSwitcherTab.tsx | Shows own presence badge from settings + adds status picker menu items. |
| src/app/pages/client/ClientNonUIFeatures.tsx | Updates presence broadcasting logic (including DND semantics) and adds SW visibility handling via pagehide. |
| src/app/hooks/useUserPresence.ts | Adds REST bootstrap + client-level event fallback and updates presence labels. |
| src/app/hooks/useUserPresence.test.tsx | Adds unit tests for REST fallback, reset-on-user-change, and cancellation behavior. |
| src/app/hooks/useClientConfig.ts | Adds experiment + session sync config types and variant selection utilities. |
| src/app/hooks/useAppVisibility.ts | Reintroduces/fixes event listeners and adds session-to-SW sync heartbeat/backoff behavior. |
| src/app/features/settings/developer-tools/DevelopTools.tsx | Adds a developer tool to rotate/discard Megolm sessions for encrypted rooms. |
| .changeset/presence-sidebar-badges.md | Adds a changeset entry for the sidebar presence badge feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+67
to
+80
| // If the User object doesn't exist yet, subscribe at client level as a fallback. | ||
| // ExtensionPresence emits ClientEvent.Event after creating and updating the User object, | ||
| // so by the time this fires mx.getUser(userId) is guaranteed to be non-null. | ||
| let removeClientListener: (() => void) | undefined; | ||
| if (!user) { | ||
| const onClientEvent = (event: MatrixEvent) => { | ||
| if (event.getSender() !== userId || event.getType() !== 'm.presence') return; | ||
| const u = mx.getUser(userId); | ||
| if (!u) return; | ||
| setPresence(getUserPresence(u)); | ||
| }; | ||
| mx.on(ClientEvent.Event, onClientEvent); | ||
| removeClientListener = () => mx.removeListener(ClientEvent.Event, onClientEvent); | ||
| } |
Comment on lines
+16
to
+21
| 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); | ||
|
|
Comment on lines
173
to
+205
| useEffect(() => { | ||
| if (!mx) return; | ||
| if (!phase2VisibleHeartbeat) return undefined; | ||
|
|
||
| // Reset adaptive backoff/suppression so a config or session change starts fresh. | ||
| heartbeatFailuresRef.current = 0; | ||
| suppressHeartbeatUntilRef.current = 0; | ||
|
|
||
| let timeoutId: number | undefined; | ||
|
|
||
| const getDelayMs = (): number => { | ||
| let delay = heartbeatIntervalMs; | ||
|
|
||
| if (phase3AdaptiveBackoffJitter) { | ||
| const failures = heartbeatFailuresRef.current; | ||
| const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); | ||
| delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); | ||
|
|
||
| // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. | ||
| const jitter = 0.8 + Math.random() * 0.4; | ||
| delay = Math.max(1000, Math.round(delay * jitter)); | ||
| } | ||
|
|
||
| return delay; | ||
| }; | ||
|
|
||
| const tick = () => { | ||
| const now = Date.now(); | ||
|
|
||
| const handleVisibilityForNotifications = (isVisible: boolean) => { | ||
| togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); | ||
| if (document.visibilityState !== 'visible' || !navigator.onLine) { | ||
| timeoutId = window.setTimeout(tick, getDelayMs()); | ||
| return; | ||
| } | ||
|
|
Comment on lines
+186
to
+195
| let myOwnPresenceBadge: ReactNode; | ||
| if (sendPresence) { | ||
| myOwnPresenceBadge = | ||
| presenceMode === 'dnd' ? ( | ||
| // DND: solid red badge (broadcasts as online with status_msg 'dnd') | ||
| <Badge size="200" variant="Critical" fill="Solid" radii="Pill" /> | ||
| ) : ( | ||
| <PresenceBadge presence={(presenceMode ?? 'online') as Presence} size="200" /> | ||
| ); | ||
| } |
Comment on lines
+381
to
+398
| [ | ||
| { label: 'Online', desc: undefined, mode: 'online' as const }, | ||
| { label: 'Idle', desc: undefined, mode: 'unavailable' as const }, | ||
| { label: 'Do Not Disturb', desc: undefined, mode: 'dnd' as const }, | ||
| { | ||
| label: 'Invisible', | ||
| desc: 'You will appear offline', | ||
| mode: 'offline' as const, | ||
| }, | ||
| ] as const | ||
| ).map(({ label: statusLabel, desc, mode }) => { | ||
| const isSelected = sendPresence && (presenceMode ?? 'online') === mode; | ||
| const badge = | ||
| mode === 'dnd' ? ( | ||
| <Badge size="300" variant="Critical" fill="Solid" radii="Pill" /> | ||
| ) : ( | ||
| <PresenceBadge presence={mode as Presence} size="300" /> | ||
| ); |
Comment on lines
55
to
57
| mediaAutoLoad: boolean; | ||
| bundledPreview: boolean; | ||
| urlPreview: boolean; |
| 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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related to closed PR #598 (
fix/perf-rerender-reduction), which was split into smaller focused PRs.Description
Adds visual presence indicators (online / busy / offline dot) to the compact sidebar DM icon rail (
DirectDMsList) and the account switcher avatar (AccountSwitcherTab).Also fixes presence under sliding sync. Sliding sync (MSC4186) does not deliver
m.presenceevents, soUser.presencestays at the SDK default andgetLastActiveTs()stays 0 for all contacts. When no cached presence is available, the hook now falls back toGET /_matrix/client/v3/presence/{userId}/statusand updates the badge once the response arrives. If the server returns 404 (presence not enabled), the error is silently ignored and existing state is kept.Presence picker
The account switcher now includes a Discord-style status picker with four options:
presence: onlinepresence: unavailablepresence: online,status_msg: dndpresence: offlineonlineso you still receive events; thestatus_msg: 'dnd'field signals the intent to clients that check it.sendPresence) was previously off.useAppVisibilityevent listener fixThe
visibilitychangeandfocusevent listeners were never registered due to a regression introduced during a cherry-pick conflict resolution. Both handlers were rebuilt against the correct full implementation:handleVisibilityChange: callsmx.retryImmediately()unconditionally, then (if phase 1 is enabled) pushes the session to the SW with foreground-debounce guard and resets adaptive backoff on success.handleFocus: same logic guarded bydocument.visibilityState === 'visible'.Also removed dead imports and variables (
useAtom,togglePusher,useSetting,settingsAtom,pushSubscriptionAtom,mobileOrTablet) that became unused when the pusher feature was replaced.Fixes #
Type of change
Checklist:
AI disclosure:
Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).
The REST presence fallback logic in
useUserPresenceand the badge rendering inDirectDMsList/AccountSwitcherTabwere partially AI assisted. I reviewed the sliding sync limitation, verified server error handling, and confirmed thecancelledflag guards the async fetch correctly on unmount. The presence picker structure and DND broadcast logic were also partially AI assisted; I verified the Matrix spec intent and tested the status_msg approach against the account switcher UI.