Skip to content

feat(presence): add presence badges to the DM list and account switcher#608

Open
Just-Insane wants to merge 7 commits intoSableClient:devfrom
Just-Insane:feat/presence-sidebar-badges
Open

feat(presence): add presence badges to the DM list and account switcher#608
Just-Insane wants to merge 7 commits intoSableClient:devfrom
Just-Insane:feat/presence-sidebar-badges

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

@Just-Insane Just-Insane commented Mar 31, 2026

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

Note: #644 (merged) independently added presence badges to the expanded Direct Messages panel (RoomNavItem) and the Members panel (MemberTile). This PR covers the separate compact sidebar icon view — the two target different components and are not duplicates.

Also fixes presence under sliding sync. Sliding sync (MSC4186) does not deliver m.presence events, so User.presence stays at the SDK default and getLastActiveTs() stays 0 for all contacts. When no cached presence is available, the hook now falls back to GET /_matrix/client/v3/presence/{userId}/status and 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:

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
  • Do Not Disturb broadcasts as online so you still receive events; the status_msg: 'dnd' field signals the intent to clients that check it.
  • Invisible shows a "You will appear offline" description in the menu.
  • Selecting any option automatically re-enables presence broadcasting if the master toggle (sendPresence) was previously off.
  • The status label previously shown as "Away" is now "Idle" to match Matrix conventions.
  • The sidebar avatar badge reflects the active mode (including the solid red DND indicator).

useAppVisibility event listener fix

The visibilitychange and focus event 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: calls mx.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 by document.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

  • 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
  • My changes generate no new warnings

AI disclosure:

Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).

The REST presence fallback logic in useUserPresence and the badge rendering in DirectDMsList/AccountSwitcherTab were partially AI assisted. I reviewed the sliding sync limitation, verified server error handling, and confirmed the cancelled flag 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.

@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners March 31, 2026 18:43
@Just-Insane Just-Insane marked this pull request as draft March 31, 2026 20:13
…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
@Just-Insane Just-Insane force-pushed the feat/presence-sidebar-badges branch from ce61c9a to 4a6289e Compare April 6, 2026 16:57
@Just-Insane Just-Insane marked this pull request as ready for review April 8, 2026 21:54
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
@Just-Insane Just-Insane force-pushed the feat/presence-sidebar-badges branch from 02f512c to 8f69b61 Compare April 11, 2026 22:57
@Just-Insane Just-Insane deleted the feat/presence-sidebar-badges branch April 12, 2026 19:33
@Just-Insane Just-Insane restored the feat/presence-sidebar-badges branch April 12, 2026 19:41
@Just-Insane Just-Insane reopened this Apr 12, 2026
Copilot AI review requested due to automatic review settings April 12, 2026 21:51
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 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 useUserPresence to reset on user changes, listen at client level when needed, and fall back to GET /presence/{userId}/status when sliding sync doesn’t deliver presence.
  • Rebuild useAppVisibility listener/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';
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