Skip to content

feat(presence): auto-idle presence after inactivity with Discord-style status picker#672

Open
Just-Insane wants to merge 9 commits intoSableClient:devfrom
Just-Insane:feat/presence-auto-idle
Open

feat(presence): auto-idle presence after inactivity with Discord-style status picker#672
Just-Insane wants to merge 9 commits intoSableClient:devfrom
Just-Insane:feat/presence-auto-idle

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

Description

Builds on the presence sidebar badges introduced in #608 to add automatic idle detection and a Discord-style status picker.

Note: This branch includes the commits from #608 (feat/presence-sidebar-badges) as a dependency. If that PR is merged first, only the auto-idle commits will be new here.

What's in this PR:

From #608 — Presence sidebar badges

  • Adds live presence badges (online / idle / DND / offline) to the DM list avatars and the account switcher.
  • Fixes sliding-sync presence data so the correct status is shown immediately on load.

New in this PR — Auto-idle & status picker

  • Discord-style presence picker — replaces the plain online/offline toggle with a full status menu: Online, Idle, DND, Invisible, Offline. Status change is applied immediately via the m.presence API.
  • presenceMode setting — persists the user's chosen status across sessions.
  • Auto-idle — after a configurable inactivity timeout (default 5 min), the presence is automatically set to Idle. Activity (mouse move, keydown, touchstart) resets the timer and restores the previous status.
  • Skip empty userId fetch — prevents a REST presence GET with an empty string userId during initial load, which caused a 400 error in some race conditions.
  • Unit tests — covers useUserPresence hook including the auto-idle timer and status transitions.

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

The inactivity timer in useUserPresence and 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.

…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.
@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners April 12, 2026 16:13
@Just-Insane Just-Insane deleted the feat/presence-auto-idle branch April 12, 2026 19:33
@Just-Insane Just-Insane restored the feat/presence-auto-idle 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:47
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 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 ephemeral presenceAutoIdled flag, and wires a Discord-style status picker into the account switcher.
  • Adds auto-idle detection via a new usePresenceAutoIdle hook and updates presence broadcasting logic (sync + explicit /presence PUT).
  • 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
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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

Suggested change
'@sable/client': minor
default: minor

Copilot uses AI. Check for mistakes.

// Sable features!
sendPresence: boolean;
/** Which Matrix presence state to broadcast when sendPresence is true. */
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
/** 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`.
*/

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +126
// experimental
enableMessageBookmarks: boolean;

Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
"enabled": true
},

"presenceAutoIdleTimeoutMs": 600000,
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +20
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);
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines 109 to +112
appEvents.onVisibilityChange?.(isVisible);
if (!isVisible) {
appEvents.onVisibilityHidden?.();
return;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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.

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

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
import { KnownMembership } from 'matrix-js-sdk/lib/types';
import { KnownMembership } from '$types/matrix-sdk';

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

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
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