diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md new file mode 100644 index 000000000..56889390c --- /dev/null +++ b/.changeset/presence-auto-idle.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker diff --git a/.changeset/presence-sidebar-badges.md b/.changeset/presence-sidebar-badges.md new file mode 100644 index 000000000..9d0356c48 --- /dev/null +++ b/.changeset/presence-sidebar-badges.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add presence status badges to sidebar DM list and account switcher diff --git a/config.json b/config.json index f0c3c8b61..6410de4de 100644 --- a/config.json +++ b/config.json @@ -19,6 +19,8 @@ "enabled": true }, + "presenceAutoIdleTimeoutMs": 300000, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index e6ac463bb..ea9ed73d5 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -18,6 +18,7 @@ const PresenceToColor: Record = { [Presence.Online]: 'Success', [Presence.Unavailable]: 'Warning', [Presence.Offline]: 'Secondary', + [Presence.Dnd]: 'Critical', }; type PresenceBadgeProps = { diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index da2d140f6..2595f7e1c 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -46,7 +46,7 @@ import { CompactUploadCardRenderer } from '$components/upload-card'; import { useCapabilities } from '$hooks/useCapabilities'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { MSC1767Text } from '$types/matrix/common'; import { TimezoneEditor } from './TimezoneEditor'; import { PronounEditor } from './PronounEditor'; @@ -511,7 +511,10 @@ function ProfileExtended({ profile, userId }: Readonly) { const handleSaveStatus = useCallback( async (newStatus: string) => { - const currentState = presence?.presence || 'online'; + const currentState = + presence?.presence === Presence.Dnd + ? Presence.Online + : (presence?.presence ?? Presence.Online); await mx.setPresence({ presence: currentState, diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e523f15a7..0e7257532 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -43,6 +43,8 @@ export type ClientConfig = { matrixToBaseUrl?: string; settingsLinkBaseUrl?: string; + /** How long (ms) without input before auto-idling presence. 0 = disabled. */ + presenceAutoIdleTimeoutMs?: number; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx new file mode 100644 index 000000000..407e7f69c --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -0,0 +1,240 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider, useAtomValue } from 'jotai'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { presenceAutoIdledAtom } from '$state/settings'; +import type { ReactNode } from 'react'; +import { usePresenceAutoIdle } from './usePresenceAutoIdle'; + +// -------- mock setup -------- + +const userListeners = new Map void)[]>(); + +const makeMockUser = () => ({ + userId: '@alice:test', + presence: 'online', + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = userListeners.get(event) ?? []; + list.push(handler); + userListeners.set(event, list); + }), + removeListener: vi.fn(), +}); + +let mockUser: ReturnType | null = null; + +const makeMockMx = () => ({ + getUserId: vi.fn(() => '@alice:test'), + getUser: vi.fn(() => mockUser), +}); + +let mockMx: ReturnType; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +// Helper to read the atom value alongside the hook under test. +function useAutoIdledReader( + mx: ReturnType, + presenceMode: string, + sendPresence: boolean, + timeoutMs: number +) { + usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs); + return useAtomValue(presenceAutoIdledAtom); +} + +// -------- lifecycle -------- + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + userListeners.clear(); + mockUser = makeMockUser(); + mockMx = makeMockMx(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// -------- tests -------- + +describe('usePresenceAutoIdle', () => { + it('sets auto-idle after the timeout elapses', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + expect(result.current).toBe(false); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(result.current).toBe(true); + }); + + it('resets auto-idle when user activity is detected', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + // Go idle. + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate user activity. + act(() => { + document.dispatchEvent(new Event('mousemove')); + }); + expect(result.current).toBe(false); + }); + + it('resets auto-idle when the document becomes visible again', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + const visibilityStateSpy = vi + .spyOn(document, 'visibilityState', 'get') + .mockReturnValue('visible'); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(result.current).toBe(false); + + visibilityStateSpy.mockRestore(); + }); + + it('does not go idle when presenceMode is not online', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'dnd', true, 5000), { wrapper }); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when sendPresence is false', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', false, 5000), { + wrapper, + }); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when timeoutMs is 0', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 0), { wrapper }); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('restarts the idle timer on activity before timeout', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + // Advance partially, then trigger activity. + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current).toBe(false); + + act(() => { + document.dispatchEvent(new Event('keydown')); + }); + + // Original timeout would have fired at 5000ms, but we reset. + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current).toBe(false); + + // Now the full 5000ms from last activity should trigger idle. + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current).toBe(true); + }); + + it('still goes idle after the window loses focus', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + act(() => { + window.dispatchEvent(new Event('blur')); + vi.advanceTimersByTime(5000); + }); + + expect(result.current).toBe(true); + }); + + it('clears auto-idle when presenceMode changes away from online', () => { + const { result, rerender } = renderHook( + ({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000), + { wrapper, initialProps: { mode: 'online' } } + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + rerender({ mode: 'dnd' }); + expect(result.current).toBe(false); + }); + + it('clears auto-idle when another device sets presence to online', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate User.presence event from another device. + const handlers = userListeners.get('User.presence') ?? []; + expect(handlers.length).toBeGreaterThan(0); + + act(() => { + handlers.forEach((h) => h({}, { userId: '@alice:test', presence: 'online' })); + }); + expect(result.current).toBe(false); + }); + + it('stops responding to focus events after cleanup', () => { + const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + // Go idle. + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + unmount(); + + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts new file mode 100644 index 000000000..6dfad4968 --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSetAtom } from 'jotai'; +import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk'; +import { presenceAutoIdledAtom } from '$state/settings'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('PresenceAutoIdle'); +const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const; +const IDLE_CHECK_INTERVAL_MS = 30_000; + +/** + * Automatically transitions presence to idle after a configurable inactivity + * timeout, and clears the idle state when activity is detected. + * + * Also subscribes to the Matrix `User.presence` event so that if another device + * sets you back to `online`, the auto-idle state is cleared here too (multi-device + * sync). + * + * Note: On iOS Safari PWA, background tab throttling may delay or prevent the + * inactivity timer from firing reliably. The feature degrades gracefully — presence + * will eventually update when the tab regains focus. + */ +export function usePresenceAutoIdle( + mx: MatrixClient, + presenceMode: string, + sendPresence: boolean, + timeoutMs: number +): void { + const setAutoIdled = useSetAtom(presenceAutoIdledAtom); + const autoIdledRef = useRef(false); + const timerRef = useRef(undefined); + const intervalRef = useRef(undefined); + const lastActivityAtRef = useRef(Date.now()); + + const clearTimer = useCallback(() => { + if (timerRef.current !== undefined) { + window.clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }, []); + + const clearIntervalTimer = useCallback(() => { + if (intervalRef.current !== undefined) { + window.clearInterval(intervalRef.current); + intervalRef.current = undefined; + } + }, []); + + // Inactivity timer: go idle after timeoutMs without user input. + useEffect(() => { + const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0; + if (!shouldAutoIdle) { + clearTimer(); + clearIntervalTimer(); + if (autoIdledRef.current) { + autoIdledRef.current = false; + setAutoIdled(false); + } + return undefined; + } + + const goIdle = () => { + if (autoIdledRef.current) return; + debugLog.info('general', 'Inactivity timeout — auto-idling'); + autoIdledRef.current = true; + setAutoIdled(true); + }; + + const checkIdleDeadline = () => { + const elapsedMs = Date.now() - lastActivityAtRef.current; + if (elapsedMs >= timeoutMs) { + goIdle(); + return; + } + clearTimer(); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs - elapsedMs); + }; + + const handleActivity = () => { + lastActivityAtRef.current = Date.now(); + clearTimer(); + if (autoIdledRef.current) { + debugLog.info('general', 'Activity detected — clearing auto-idle'); + autoIdledRef.current = false; + setAutoIdled(false); + } + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs); + }; + + const handleBlur = () => { + debugLog.info('general', 'Window blurred — keeping idle deadline active'); + checkIdleDeadline(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') handleActivity(); + }; + + // Start the initial timer. + lastActivityAtRef.current = Date.now(); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs); + intervalRef.current = window.setInterval( + checkIdleDeadline, + Math.min(timeoutMs, IDLE_CHECK_INTERVAL_MS) + ); + ACTIVITY_EVENTS.forEach((ev) => + document.addEventListener(ev, handleActivity, { passive: true }) + ); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleActivity); + window.addEventListener('blur', handleBlur); + + return () => { + ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleActivity); + window.removeEventListener('blur', handleBlur); + clearTimer(); + clearIntervalTimer(); + }; + }, [clearIntervalTimer, clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); + + // Multi-device sync: if another device sets us back to online, clear auto-idle. + useEffect(() => { + if (!sendPresence) return undefined; + const myUserId = mx.getUserId(); + if (!myUserId) return undefined; + const user = mx.getUser(myUserId); + if (!user) return undefined; + + const handlePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => { + if (u.userId !== myUserId) return; + if (u.presence === 'online' && autoIdledRef.current) { + debugLog.info('general', 'Remote device set Online — clearing auto-idle'); + autoIdledRef.current = false; + setAutoIdled(false); + } + }; + + user.on(UserEvent.Presence, handlePresence); + return () => { + user.removeListener(UserEvent.Presence, handlePresence); + }; + }, [mx, sendPresence, setAutoIdled]); +} diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx new file mode 100644 index 000000000..70ca6b5d2 --- /dev/null +++ b/src/app/hooks/useUserPresence.test.tsx @@ -0,0 +1,291 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { presenceAutoIdledAtom, settingsAtom } from '$state/settings'; +import { useUserPresence, Presence, clearPresenceCache } from './useUserPresence'; + +// ------- mock setup ------- + +// Each test can override mockUser / mockGetPresence as needed. +let mockUser: ReturnType | null = null; +type PresenceResponse = { + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; +}; +let mockGetPresence: () => Promise; + +// Listeners registered via user.on() – captured so tests can emit events. +const userListeners = new Map void)[]>(); + +const makeMockUser = ( + opts: { + presence?: string; + presenceStatusMsg?: string | undefined; + currentlyActive?: boolean; + lastActiveTs?: number; + } = {} +) => ({ + userId: '@alice:test', + presence: opts.presence ?? 'online', + presenceStatusMsg: opts.presenceStatusMsg, + currentlyActive: opts.currentlyActive ?? true, + getLastActiveTs: vi.fn().mockReturnValue(opts.lastActiveTs ?? 1000), + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = userListeners.get(event) ?? []; + list.push(handler); + userListeners.set(event, list); + }), + removeListener: vi.fn(), +}); + +const mockMx = { + getUser: vi.fn((): ReturnType | null => mockUser), + getPresence: vi.fn((): Promise => mockGetPresence()), + getUserId: vi.fn<() => string | undefined>(() => undefined), + on: vi.fn(), + removeListener: vi.fn(), +}; + +vi.mock('./useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + +const USER_ID = '@alice:test'; + +type HookWrapperProps = { + children: ReactNode; + sendPresence?: boolean; + presenceMode?: 'online' | 'unavailable' | 'dnd' | 'offline'; + autoIdled?: boolean; +}; + +const localStorageSettings = () => { + const rawSettings = localStorage.getItem('settings'); + return rawSettings ? JSON.parse(rawSettings) : {}; +}; + +const HydratePresenceSettings = ({ + children, + sendPresence = true, + presenceMode = 'online', + autoIdled = false, +}: HookWrapperProps) => { + useHydrateAtoms([ + [settingsAtom, { ...localStorageSettings(), sendPresence, presenceMode }], + [presenceAutoIdledAtom, autoIdled], + ]); + return children; +}; + +const createWrapper = (options?: Omit) => { + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + return Wrapper; +}; + +beforeEach(() => { + vi.clearAllMocks(); + userListeners.clear(); + clearPresenceCache(); + localStorage.clear(); + mockUser = null; + mockGetPresence = () => new Promise(() => {}); // pending by default + mockMx.getUser.mockImplementation(() => mockUser); + mockMx.getPresence.mockImplementation(() => mockGetPresence()); + mockMx.getUserId.mockReturnValue(undefined); +}); + +// ------- tests ------- + +describe('useUserPresence', () => { + it('returns undefined when the user is not in the SDK and REST is pending', () => { + // mockUser is null; REST never resolves + const { result } = renderHook(() => useUserPresence(USER_ID)); + expect(result.current).toBeUndefined(); + }); + + it('initialises from SDK user when available with a non-zero lastActiveTs', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 5000 }); + // lastActiveTs > 0 — no REST fallback should be triggered + const { result } = renderHook(() => useUserPresence(USER_ID)); + + expect(result.current).toEqual({ + presence: Presence.Online, + status: undefined, + active: true, + lastActiveTs: 5000, + }); + expect(mockMx.getPresence).not.toHaveBeenCalled(); + }); + + it('fires the REST fallback when getLastActiveTs() is 0 (sliding-sync server)', async () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 0 }); + let resolvePresence!: (v: { + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number; + }) => void; + mockGetPresence = () => + new Promise((res) => { + resolvePresence = res; + }); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + await act(async () => { + resolvePresence({ + presence: 'unavailable', + status_msg: 'in a meeting', + currently_active: false, + last_active_ago: 60_000, + }); + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + expect(result.current?.status).toBe('in a meeting'); + expect(result.current?.active).toBe(false); + // lastActiveTs should be approximately Date.now() - 60_000 + expect(result.current?.lastActiveTs).toBeGreaterThan(0); + }); + + it('fires the REST fallback when user object does not exist yet', async () => { + // user is null — REST should still be requested + let resolvePresence!: (v: { presence: string }) => void; + mockGetPresence = () => + new Promise((res) => { + resolvePresence = res; + }); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + expect(mockMx.getPresence).toHaveBeenCalledWith(USER_ID); + + await act(async () => { + resolvePresence({ presence: 'online' }); + }); + + expect(result.current?.presence).toBe(Presence.Online); + }); + + it('does NOT fire REST when userId is an empty string', () => { + const { result } = renderHook(() => useUserPresence('')); + + expect(mockMx.getPresence).not.toHaveBeenCalled(); + expect(result.current).toBeUndefined(); + }); + + it('ignores the REST response after the component unmounts (cancelled flag)', async () => { + let resolvePresence!: (v: { presence: string }) => void; + mockGetPresence = vi.fn().mockReturnValue( + new Promise((res) => { + resolvePresence = res; + }) + ); + + const { result, unmount } = renderHook(() => useUserPresence(USER_ID)); + unmount(); + + // Resolve after unmount — cancelled = true, so state should NOT be updated + await act(async () => { + resolvePresence({ presence: 'online' }); + }); + + expect(result.current).toBeUndefined(); + }); + + it('updates presence when UserEvent.Presence fires on the user object', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockGetPresence = () => new Promise(() => {}); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + // Mutate mock user to simulate a presence change, then fire the registered listener + mockUser.presence = 'unavailable'; + const handlers = userListeners.get('User.presence') ?? []; + + act(() => { + handlers.forEach((h) => h({}, mockUser)); + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + }); + + it('resets to undefined when userId changes to a user not in the SDK', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockGetPresence = () => new Promise(() => {}); + + const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), { + initialProps: { uid: USER_ID }, + }); + + expect(result.current).not.toBeUndefined(); + + // Switch to unknown user + mockUser = null; + rerender({ uid: '@bob:test' }); + + expect(result.current).toBeUndefined(); + }); + + it('silently ignores a REST error (presence not supported on this server)', async () => { + mockGetPresence = () => Promise.reject(new Error('404 Not Found')); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + // Wait for the rejection to be processed + await act(async () => { + await Promise.resolve(); + }); + + // Should still be undefined without throwing + expect(result.current).toBeUndefined(); + }); + + it('normalizes synthetic dnd presence from the SDK user object', () => { + mockUser = makeMockUser({ presence: 'online', presenceStatusMsg: 'dnd', lastActiveTs: 1000 }); + + const { result } = renderHook(() => useUserPresence('@bob:test')); + + expect(result.current).toEqual({ + presence: Presence.Dnd, + status: undefined, + active: true, + lastActiveTs: 1000, + }); + }); + + it('overrides own presence from settings so member lists update immediately', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockMx.getUserId.mockReturnValue(USER_ID); + + const { result } = renderHook(() => useUserPresence(USER_ID), { + wrapper: createWrapper({ presenceMode: 'dnd' }), + }); + + expect(result.current?.presence).toBe(Presence.Dnd); + expect(result.current?.status).toBeUndefined(); + }); + + it('marks own presence idle when auto-idle is active', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockMx.getUserId.mockReturnValue(USER_ID); + + const { result } = renderHook(() => useUserPresence(USER_ID), { + wrapper: createWrapper({ autoIdled: true }), + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + expect(result.current?.active).toBe(false); + }); +}); diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 2c040b989..63e995df9 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,11 +1,15 @@ import { useEffect, useMemo, useState } from 'react'; -import { User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; +import { useAtomValue } from 'jotai'; +import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; +import { presenceAutoIdledAtom, settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { Online = 'online', Unavailable = 'unavailable', Offline = 'offline', + Dnd = 'dnd', } export type UserPresence = { @@ -15,49 +19,197 @@ export type UserPresence = { lastActiveTs?: number; }; +const isSyntheticDndStatus = (status?: string): boolean => status === 'dnd'; + +const normalizePresence = (presence: string | undefined, status?: string): Presence => { + if (presence === Presence.Online && isSyntheticDndStatus(status)) return Presence.Dnd; + if (presence === Presence.Unavailable) return Presence.Unavailable; + if (presence === Presence.Offline) return Presence.Offline; + return Presence.Online; +}; + +const sanitizeStatus = (status?: string): string | undefined => + isSyntheticDndStatus(status) ? undefined : status; + const getUserPresence = (user: User): UserPresence => ({ - presence: user.presence as Presence, - status: user.presenceStatusMsg, + presence: normalizePresence(user.presence, user.presenceStatusMsg), + status: sanitizeStatus(user.presenceStatusMsg), active: user.currentlyActive, lastActiveTs: user.getLastActiveTs(), }); +const getOwnEffectivePresence = ( + sendPresence: boolean, + presenceMode: string | undefined, + autoIdled: boolean +): Presence => { + if (!sendPresence) return Presence.Offline; + if (autoIdled) return Presence.Unavailable; + if (presenceMode === Presence.Unavailable) return Presence.Unavailable; + if (presenceMode === Presence.Offline) return Presence.Offline; + if (presenceMode === Presence.Dnd) return Presence.Dnd; + return Presence.Online; +}; + +const applyOwnPresenceOverride = ( + rawPresence: UserPresence | undefined, + sendPresence: boolean, + presenceMode: string | undefined, + autoIdled: boolean +): UserPresence | undefined => { + const effectivePresence = getOwnEffectivePresence(sendPresence, presenceMode, autoIdled); + const sanitizedStatus = sanitizeStatus(rawPresence?.status); + + if (!rawPresence) { + return { + presence: effectivePresence, + status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus, + active: effectivePresence === Presence.Online || effectivePresence === Presence.Dnd, + }; + } + + return { + ...rawPresence, + presence: effectivePresence, + status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus, + active: + effectivePresence === Presence.Online || effectivePresence === Presence.Dnd + ? rawPresence.active + : false, + }; +}; + +// In-memory presence REST cache to avoid N+1 /presence/{userId}/status floods. +// Multiple hook instances for the same user share a single in-flight request. +const PRESENCE_CACHE_TTL_MS = 60_000; +const presenceCache = new Map(); +const presenceInflight = new Map>(); + +/** Visible for testing — clears the in-memory REST presence cache. */ +export function clearPresenceCache(): void { + presenceCache.clear(); + presenceInflight.clear(); +} + +function fetchPresenceOnce( + mx: { + getPresence: (userId: string) => Promise<{ + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; + }>; + }, + userId: string +): Promise { + const cached = presenceCache.get(userId); + if (cached && Date.now() - cached.fetchedAt < PRESENCE_CACHE_TTL_MS) { + return Promise.resolve(cached.data); + } + + const existing = presenceInflight.get(userId); + if (existing) return existing; + + const promise = mx + .getPresence(userId) + .then((resp) => { + const data: UserPresence = { + presence: normalizePresence(resp.presence, resp.status_msg), + status: sanitizeStatus(resp.status_msg), + active: resp.currently_active ?? false, + lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, + }; + presenceCache.set(userId, { data, fetchedAt: Date.now() }); + return data; + }) + .catch((err: unknown) => { + // Suppress expected failures (404/403 = presence not supported, network errors). + // Only log unexpected server errors (5xx) for debugging. + const status = (err as { httpStatus?: number })?.httpStatus; + if (status && status >= 500) { + console.warn('[useUserPresence] REST fetch failed for', userId, err); + } + return undefined; + }) + .finally(() => { + presenceInflight.delete(userId); + }); + + presenceInflight.set(userId, promise); + return promise; +} + export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); + const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const autoIdled = useAtomValue(presenceAutoIdledAtom); const user = mx.getUser(userId); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); useEffect(() => { - if (!user) { - setPresence(undefined); - return undefined; + setPresence(user ? getUserPresence(user) : undefined); + + let cancelled = false; + + // Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never + // delivered via sync. As a result, User.presence stays at the SDK default and + // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state. + // Guard against empty userId — callers that render a fixed number of hooks (e.g. group DM + // slots) pass '' for absent members; firing getPresence('') would be a malformed request. + if (userId && (!user || user.getLastActiveTs() === 0)) { + fetchPresenceOnce(mx, userId).then((data) => { + if (cancelled || !data) return; + setPresence(data); + }); } - setPresence(getUserPresence(user)); - const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (e, u) => { - if (u.userId === user.userId) { - setPresence(getUserPresence(user)); + + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { + if (u.userId === userId) { + setPresence(getUserPresence(u)); } }; - user.on(UserEvent.Presence, updatePresence); - user.on(UserEvent.CurrentlyActive, updatePresence); - user.on(UserEvent.LastPresenceTs, updatePresence); + user?.on(UserEvent.Presence, updatePresence); + user?.on(UserEvent.CurrentlyActive, updatePresence); + user?.on(UserEvent.LastPresenceTs, updatePresence); + + // 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 && userId) { + 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); + } return () => { - user.removeListener(UserEvent.Presence, updatePresence); - user.removeListener(UserEvent.CurrentlyActive, updatePresence); - user.removeListener(UserEvent.LastPresenceTs, updatePresence); + cancelled = true; + user?.removeListener(UserEvent.Presence, updatePresence); + user?.removeListener(UserEvent.CurrentlyActive, updatePresence); + user?.removeListener(UserEvent.LastPresenceTs, updatePresence); + removeClientListener?.(); }; - }, [user]); + }, [mx, userId, user]); - return presence; + return useMemo(() => { + if (userId !== mx.getUserId()) return presence; + return applyOwnPresenceOverride(presence, sendPresence, presenceMode, autoIdled); + }, [autoIdled, mx, presence, presenceMode, sendPresence, userId]); }; export const usePresenceLabel = (): Record => useMemo( () => ({ - [Presence.Online]: 'Active', - [Presence.Unavailable]: 'Busy', - [Presence.Offline]: 'Away', + [Presence.Online]: 'Online', + [Presence.Unavailable]: 'Idle', + [Presence.Offline]: 'Offline', + [Presence.Dnd]: 'Do Not Disturb', }), [] ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 26ac2f431..8d69332f4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -21,7 +21,9 @@ import NotificationSound from '$public/sound/notification.ogg'; import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle'; import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; @@ -825,17 +827,74 @@ function HandleDecryptPushEvent() { return null; } +// How often an active device re-asserts its online state to the server. +// Matrix presence is per-user (not per-device): if another device sets you to +// idle/unavailable, this heartbeat wins the server state back within one interval. +// Must be shorter than the shortest expected idle timeout (default 5 min). +const PRESENCE_HEARTBEAT_INTERVAL_MS = 2 * 60_000; // 2 minutes + function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const autoIdled = useAtomValue(presenceAutoIdledAtom); + const clientConfig = useClientConfig(); + const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0; + + usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { - // Classic sync: set_presence query param on every /sync poll. - // Passing undefined restores the default (online); Offline suppresses broadcasting. + const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); + const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode; + const effectiveState = sendPresence ? activePresence : 'offline'; + const ownUser = mx.getUser(mx.getUserId() ?? ''); + const shouldClearSyntheticDndStatus = + ownUser?.presenceStatusMsg === 'dnd' && (!sendPresence || effectiveMode !== 'dnd'); + let statusPayload: { status_msg: string } | undefined; + + if (sendPresence && effectiveMode === 'dnd') { + statusPayload = { status_msg: 'dnd' }; + } else if (shouldClearSyntheticDndStatus) { + statusPayload = { status_msg: '' }; + } + mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); - // Sliding sync: enable/disable the presence extension on the next poll. getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); - }, [mx, sendPresence]); + const presencePayload = { + presence: effectiveState, + ...statusPayload, + }; + let retryTimer: ReturnType | undefined; + const trySetPresence = (attempt = 0) => { + mx.setPresence(presencePayload).catch(() => { + if (attempt < 3) { + retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1)); + } + }); + }; + trySetPresence(); + return () => { + if (retryTimer !== undefined) clearTimeout(retryTimer); + }; + }, [autoIdled, mx, presenceMode, sendPresence]); + + // Presence heartbeat: periodically re-assert online state while this device + // is active. Fixes a multi-device race where a different idle device sets the + // shared server presence to unavailable while the user is active here. + useEffect(() => { + const isActiveOnline = sendPresence && !autoIdled && presenceMode === 'online'; + if (!isActiveOnline) return undefined; + + const heartbeatId = window.setInterval(() => { + mx.setPresence({ presence: 'online' }).catch(() => { + // Silently ignore — the main effect will retry on next state change. + }); + }, PRESENCE_HEARTBEAT_INTERVAL_MS); + + return () => { + window.clearInterval(heartbeatId); + }; + }, [autoIdled, mx, presenceMode, sendPresence]); return null; } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 6e6ecc572..4c3838007 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react'; +import { MouseEvent, MouseEventHandler, ReactNode, useCallback, useState } from 'react'; import { Box, Button, @@ -40,14 +40,18 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; import { logoutClient, initClient, stopClient } from '$client/initMatrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useUserProfile } from '$hooks/useUserProfile'; +import { Presence } from '$hooks/useUserPresence'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; import { useOpenSettings } from '$features/settings'; import { Modal500 } from '$components/Modal500'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -173,6 +177,19 @@ export function AccountSwitcherTab() { const myUserId = mx.getUserId() ?? ''; const activeProfile = useUserProfile(myUserId); + // Own presence badge is driven from settings state rather than the SDK's User object. + // The SDK won't echo your own presence back on MSC4186 sliding sync, so reading + // user.presence would leave the badge stuck at the SDK default forever. + const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode'); + const autoIdled = useAtomValue(presenceAutoIdledAtom); + const setAutoIdled = useSetAtom(presenceAutoIdledAtom); + // The effective mode for badge display: if auto-idled, show unavailable regardless of selected mode. + const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); + let myOwnPresenceBadge: ReactNode; + if (sendPresence) { + myOwnPresenceBadge = ; + } const activeAvatarUrl = activeProfile.avatarUrl ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; @@ -270,19 +287,21 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} - > - {nameInitials(label)}} - /> - + + 1} + > + {nameInitials(label)}} + /> + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( @@ -352,6 +371,59 @@ export function AccountSwitcherTab() { Add Account + + Status + + {( + [ + { 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 = ; + return ( + + ) : undefined + } + onClick={() => { + setPresenceMode(mode); + // Clear auto-idle so the badge updates immediately on manual selection. + setAutoIdled(false); + // Re-enable presence broadcasting if the master toggle was off + if (!sendPresence) setSendPresence(true); + setMenuAnchor(undefined); + }} + > + + {statusLabel} + {desc && ( + + {desc} + + )} + + + ); + })} + ( + (acc, current) => { + if (!current || current.lastActiveTs == null || current.lastActiveTs === 0) return acc; + if (current.presence === Presence.Dnd) return Presence.Dnd; + if (!acc && current.presence === Presence.Online) return Presence.Online; + return acc; + }, + undefined + ) + : undefined; + + let presenceBadge: ReactNode; + if ( + !isGroupDM && + singleDMPresence && + singleDMPresence.lastActiveTs != null && + singleDMPresence.lastActiveTs !== 0 + ) { + presenceBadge = ; + } else if (groupDMPresence) { + presenceBadge = ; + } + // Get unread info for badge const unread = roomToUnread.get(room.roomId); @@ -135,9 +170,11 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - - {renderAvatar()} - + + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 05bb8e0fb..adcf90c71 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -93,6 +93,12 @@ export interface Settings { // Sable features! sendPresence: boolean; + /** + * 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`. + */ + presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline'; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -194,6 +200,7 @@ const defaultSettings: Settings = { // Sable features! sendPresence: true, + presenceMode: 'online', mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, @@ -256,3 +263,6 @@ export const settingsAtom = atom( setSettings(update); } ); + +/** Ephemeral (not persisted) — true when auto-idled due to inactivity. */ +export const presenceAutoIdledAtom = atom(false);