-
Notifications
You must be signed in to change notification settings - Fork 42
feat(presence): auto-idle presence after inactivity with Discord-style status picker #672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
c178b77
ac75284
c7d44d8
ce458fb
f7c7fee
b86b5de
a71bdab
ca97c9b
264e4ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@sable/client': minor | ||
| --- | ||
|
|
||
| feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| default: patch | ||
| --- | ||
|
|
||
| Add presence status badges to sidebar DM list and account switcher |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,8 @@ | |
| "enabled": true | ||
| }, | ||
|
|
||
| "presenceAutoIdleTimeoutMs": 600000, | ||
|
||
|
|
||
| "featuredCommunities": { | ||
| "openAsDefault": false, | ||
| "spaces": [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||
| 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'; | ||||||
|
||||||
| import { KnownMembership } from 'matrix-js-sdk/lib/types'; | |
| import { KnownMembership } from '$types/matrix-sdk'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,102 @@ | ||
| import { useEffect } from 'react'; | ||
| import { useCallback, useEffect, useRef } from 'react'; | ||
| import { MatrixClient } from '$types/matrix-sdk'; | ||
| import { useAtom } from 'jotai'; | ||
| import { togglePusher } from '../features/settings/notifications/PushNotifications'; | ||
| import { Session } from '$state/sessions'; | ||
| import { appEvents } from '../utils/appEvents'; | ||
| import { useClientConfig } from './useClientConfig'; | ||
| import { useSetting } from '../state/hooks/settings'; | ||
| import { settingsAtom } from '../state/settings'; | ||
| import { pushSubscriptionAtom } from '../state/pushSubscription'; | ||
| import { mobileOrTablet } from '../utils/user-agent'; | ||
| import { useClientConfig, useExperimentVariant } from './useClientConfig'; | ||
| import { createDebugLogger } from '../utils/debugLogger'; | ||
| import { pushSessionToSW } from '../../sw-session'; | ||
|
|
||
| const debugLog = createDebugLogger('AppVisibility'); | ||
|
|
||
| export function useAppVisibility(mx: MatrixClient | undefined) { | ||
| const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; | ||
| const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; | ||
| const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; | ||
| const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; | ||
|
|
||
| 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
+16
to
+20
|
||
|
|
||
| // Derive phase flags from experiment variant; fall back to direct config when not in experiment. | ||
| const inSessionSync = sessionSyncVariant.inExperiment; | ||
| const syncVariant = sessionSyncVariant.variant; | ||
| const phase1ForegroundResync = inSessionSync | ||
| ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' | ||
| : sessionSyncConfig?.phase1ForegroundResync === true; | ||
| const phase2VisibleHeartbeat = inSessionSync | ||
| ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' | ||
| : sessionSyncConfig?.phase2VisibleHeartbeat === true; | ||
| const phase3AdaptiveBackoffJitter = inSessionSync | ||
| ? syncVariant === 'session-sync-adaptive' | ||
| : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; | ||
|
|
||
| const foregroundDebounceMs = Math.max( | ||
| 0, | ||
| sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS | ||
| ); | ||
| const heartbeatIntervalMs = Math.max( | ||
| 1000, | ||
| sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS | ||
| ); | ||
| const resumeHeartbeatSuppressMs = Math.max( | ||
| 0, | ||
| sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS | ||
| ); | ||
| const heartbeatMaxBackoffMs = Math.max( | ||
| heartbeatIntervalMs, | ||
| sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS | ||
| ); | ||
|
|
||
| const lastForegroundPushAtRef = useRef(0); | ||
| const suppressHeartbeatUntilRef = useRef(0); | ||
| const heartbeatFailuresRef = useRef(0); | ||
|
|
||
| const pushSessionNow = useCallback( | ||
| (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { | ||
| const baseUrl = activeSession?.baseUrl; | ||
| const accessToken = activeSession?.accessToken; | ||
| const userId = activeSession?.userId; | ||
| const canPush = | ||
| !!mx && | ||
| typeof baseUrl === 'string' && | ||
| typeof accessToken === 'string' && | ||
| typeof userId === 'string' && | ||
| 'serviceWorker' in navigator && | ||
| !!navigator.serviceWorker.controller; | ||
|
|
||
| if (!canPush) { | ||
| debugLog.warn('network', 'Skipped SW session sync', { | ||
| reason, | ||
| hasClient: !!mx, | ||
| hasBaseUrl: !!baseUrl, | ||
| hasAccessToken: !!accessToken, | ||
| hasUserId: !!userId, | ||
| hasSwController: !!navigator.serviceWorker?.controller, | ||
| }); | ||
| return 'skipped'; | ||
| } | ||
|
|
||
| pushSessionToSW(baseUrl, accessToken, userId); | ||
| debugLog.info('network', 'Pushed session to SW', { | ||
| reason, | ||
| phase1ForegroundResync, | ||
| phase2VisibleHeartbeat, | ||
| phase3AdaptiveBackoffJitter, | ||
| }); | ||
| return 'sent'; | ||
| }, | ||
| [ | ||
| activeSession?.accessToken, | ||
| activeSession?.baseUrl, | ||
| activeSession?.userId, | ||
| mx, | ||
| phase1ForegroundResync, | ||
| phase2VisibleHeartbeat, | ||
| phase3AdaptiveBackoffJitter, | ||
| ] | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| const handleVisibilityChange = () => { | ||
|
|
@@ -29,27 +109,129 @@ export function useAppVisibility(mx: MatrixClient | undefined) { | |
| appEvents.onVisibilityChange?.(isVisible); | ||
| if (!isVisible) { | ||
| appEvents.onVisibilityHidden?.(); | ||
| return; | ||
|
Comment on lines
109
to
+112
|
||
| } | ||
|
|
||
| // Always kick the sync loop on foreground regardless of phase flags — | ||
| // the SDK may be sitting in exponential backoff after iOS froze the tab. | ||
| mx?.retryImmediately(); | ||
|
|
||
| if (!phase1ForegroundResync) return; | ||
|
|
||
| const now = Date.now(); | ||
| if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; | ||
| lastForegroundPushAtRef.current = now; | ||
|
|
||
| if (pushSessionNow('foreground') === 'sent') { | ||
| // A successful push proves the SW controller is up — reset adaptive backoff | ||
| // so the heartbeat returns to its normal interval immediately rather than | ||
| // staying on an inflated delay left over from a prior SW absence period. | ||
| if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; | ||
| if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { | ||
| suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const handleFocus = () => { | ||
| if (document.visibilityState !== 'visible') return; | ||
|
|
||
| // Always kick the sync loop on focus for the same reason as above. | ||
| mx?.retryImmediately(); | ||
|
|
||
| if (!phase1ForegroundResync) return; | ||
|
|
||
| const now = Date.now(); | ||
| if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; | ||
| lastForegroundPushAtRef.current = now; | ||
|
|
||
| if (pushSessionNow('focus') === 'sent') { | ||
| if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; | ||
| if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { | ||
| suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| document.addEventListener('visibilitychange', handleVisibilityChange); | ||
| window.addEventListener('focus', handleFocus); | ||
|
|
||
| return () => { | ||
| document.removeEventListener('visibilitychange', handleVisibilityChange); | ||
| window.removeEventListener('focus', handleFocus); | ||
| }; | ||
| }, []); | ||
| }, [ | ||
| foregroundDebounceMs, | ||
| mx, | ||
| phase1ForegroundResync, | ||
| phase2VisibleHeartbeat, | ||
| phase3AdaptiveBackoffJitter, | ||
| pushSessionNow, | ||
| resumeHeartbeatSuppressMs, | ||
| ]); | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { | ||
| timeoutId = window.setTimeout(tick, getDelayMs()); | ||
| return; | ||
| } | ||
|
|
||
| const result = pushSessionNow('heartbeat'); | ||
| if (phase3AdaptiveBackoffJitter) { | ||
| if (result === 'sent') { | ||
| heartbeatFailuresRef.current = 0; | ||
| } else { | ||
| // 'skipped' means prerequisites (SW controller, session) aren't ready. | ||
| // Treat as a transient failure so backoff grows until the SW is ready. | ||
| heartbeatFailuresRef.current += 1; | ||
| } | ||
| } | ||
|
|
||
| timeoutId = window.setTimeout(tick, getDelayMs()); | ||
| }; | ||
|
|
||
| appEvents.onVisibilityChange = handleVisibilityForNotifications; | ||
| // eslint-disable-next-line consistent-return | ||
| timeoutId = window.setTimeout(tick, getDelayMs()); | ||
|
|
||
| return () => { | ||
| appEvents.onVisibilityChange = null; | ||
| if (timeoutId !== undefined) window.clearTimeout(timeoutId); | ||
| }; | ||
| }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); | ||
| }, [ | ||
| heartbeatIntervalMs, | ||
| heartbeatMaxBackoffMs, | ||
| phase2VisibleHeartbeat, | ||
| phase3AdaptiveBackoffJitter, | ||
| pushSessionNow, | ||
| ]); | ||
| } | ||
There was a problem hiding this comment.
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".