diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 88543b7f6..085020ec2 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -9,6 +9,7 @@ const PresenceToColor: Record = { [Presence.Online]: 'Success', [Presence.Unavailable]: 'Warning', [Presence.Offline]: 'Secondary', + [Presence.Dnd]: 'Critical', }; type PresenceBadgeProps = { diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 2641899d4..cca9bbddd 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -26,7 +26,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; import { AvatarPresence, PresenceBadge } from '$components/presence'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { UseStateProvider } from '$components/UseStateProvider'; import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; @@ -150,7 +150,7 @@ function MemberItem({ > ) : undefined } @@ -296,8 +296,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { ); const handleMemberClick: MouseEventHandler = (evt) => { - // oxlint-disable-next-line no-console - console.log(evt); const btn = evt.currentTarget as HTMLButtonElement; const userId = btn.getAttribute('data-user-id'); if (!userId) return; diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 19dc11609..c4aa8576b 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -43,6 +43,9 @@ import { useCapabilities } from '$hooks/useCapabilities'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { useUserPresence } from '$hooks/useUserPresence'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { getSlidingSyncManager } from '$client/initMatrix'; import type { MSC1767Text } from '$types/matrix/common'; import { TimezoneEditor } from './TimezoneEditor'; import { PronounEditor } from './PronounEditor'; @@ -485,7 +488,13 @@ function ProfileExtended({ profile, userId }: Readonly) { const pronouns = (profile.pronouns as PronounSet[]) || []; const presence = useUserPresence(userId); - const currentStatus = presence?.status || ''; + // presenceStatusMsg is the locally-cached status. On sliding sync, own presence is + // never echoed back by the server, so presence?.status would always be stale/empty. + // The settings atom is the authoritative local source; fall back to the SDK value for + // other clients (e.g. when viewing another user's profile page — but this component + // is only rendered for the own user, so the atom always wins in practice). + const [presenceStatusMsg, setPresenceStatusMsg] = useSetting(settingsAtom, 'presenceStatusMsg'); + const currentStatus = presenceStatusMsg || presence?.status || ''; // Keys we don't render here nor handle seperately but still need to exclude const EXCLUDED_KEYS = new Set([ @@ -513,14 +522,24 @@ function ProfileExtended({ profile, userId }: Readonly) { const handleSaveStatus = useCallback( async (newStatus: string) => { - const currentState = presence?.presence || 'online'; - - await mx.setPresence({ - presence: currentState, - status_msg: newStatus, - }); + // Only update the local atom. PresenceFeature's effect will broadcast the new + // status_msg to the server on its next run (triggered by this atom change). + // We don't call mx.setPresence here to avoid passing our internal Presence.Dnd + // value ('dnd'), which is not a valid Matrix presence state. + setPresenceStatusMsg(newStatus); + + // Eagerly mirror the change in the SDK store so the member list updates without + // waiting for the PresenceFeature effect to resolve the network call. + const myUser = mx.getUser(mx.getUserId() ?? ''); + const isDnd = myUser?.presence === 'online' && myUser?.presenceStatusMsg === 'dnd'; + if (!isDnd) { + // Not in DND: update local presence to reflect the new status immediately. + getSlidingSyncManager(mx)?.updateOwnPresence(myUser?.presence ?? 'online', newStatus); + } + // In DND mode the sentinel ('dnd') stays as status_msg on the wire; the user's + // custom status is preserved in the atom and used once they leave DND. }, - [mx, presence] + [mx, setPresenceStatusMsg] ); return ( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..14115dd13 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [autoIdlePresence, setAutoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -476,6 +477,28 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + {sendPresence && ( + + + } + /> + + )} + {sendPresence && autoIdlePresence && ( + + } + /> + + )} = (evt) => { + const val = evt.target.value; + setInputValue(val); + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) { + setIdleTimeoutMins(parsed); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setInputValue(idleTimeoutMins.toString()); + (evt.target as HTMLInputElement).blur(); + } + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; + + return ( + + + + min + + + ); +} + function Calls() { const [alwaysShowCallButton, setAlwaysShowCallButton] = useSetting( settingsAtom, diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index b56f564ca..0f659c8d2 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -26,9 +26,9 @@ export function useAppVisibility(mx: MatrixClient | undefined) { `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); - appEvents.onVisibilityChange?.(isVisible); + appEvents.emitVisibilityChange(isVisible); if (!isVisible) { - appEvents.onVisibilityHidden?.(); + appEvents.emitVisibilityHidden(); } }; @@ -46,9 +46,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); }; - appEvents.onVisibilityChange = handleVisibilityForNotifications; - return () => { - appEvents.onVisibilityChange = null; - }; + const unsub = appEvents.onVisibilityChange(handleVisibilityForNotifications); + return unsub; }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); } diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx new file mode 100644 index 000000000..523a03a61 --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -0,0 +1,226 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider, useAtomValue } from 'jotai'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { usePresenceAutoIdle } from './usePresenceAutoIdle'; +import { presenceAutoIdledAtom } from '$state/settings'; +import { appEvents } from '$utils/appEvents'; +import type { ReactNode } from 'react'; + +// -------- mock setup -------- + +const userListeners = new Map void)[]>(); + +const makeMockUser = () => ({ + userId: '@alice:test', + presence: 'online', + on: vi + .fn<(event: string, handler: (...args: unknown[]) => void) => void>() + .mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = userListeners.get(event) ?? []; + list.push(handler); + userListeners.set(event, list); + }), + removeListener: vi.fn<() => void>(), +}); + +let mockUser: ReturnType | null = null; + +const makeMockMx = () => ({ + getUserId: vi.fn<() => string>(() => '@alice:test'), + getUser: vi.fn<() => ReturnType | null>(() => 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 +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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 app becomes visible via appEvents', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate app returning to foreground. + act(() => { + appEvents.emitVisibilityChange(true); + }); + expect(result.current).toBe(false); + }); + + 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('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('unsubscribes from appEvents.onVisibilityChange on cleanup', () => { + const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + // Go idle. + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + unmount(); + + // After unmount, emitting visibility change should have no effect. + // (No error thrown means the handler was properly unsubscribed.) + act(() => { + appEvents.emitVisibilityChange(true); + }); + }); +}); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts new file mode 100644 index 000000000..dc5af7e21 --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -0,0 +1,109 @@ +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 { appEvents } from '$utils/appEvents'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('PresenceAutoIdle'); +const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const; + +/** + * 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 clearTimer = useCallback(() => { + if (timerRef.current !== undefined) { + window.clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }, []); + + // 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 }) + ); + + // When the app returns to the foreground, treat it as activity so the user + // isn't shown as idle the moment they switch back to the tab/PWA. + const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => { + if (isVisible) handleActivity(); + }); + + return () => { + ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); + clearTimer(); + unsubVisibility(); + }; + }, [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.ts b/src/app/hooks/useUserPresence.ts index 0c90c79f9..6f78d734a 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,12 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; -import type { User, UserEventHandlerMap } from '$types/matrix-sdk'; -import { UserEvent } from '$types/matrix-sdk'; +import type { MatrixEvent, User, UserEventHandlerMap } from '$types/matrix-sdk'; +import { ClientEvent, UserEvent } from '$types/matrix-sdk'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { Online = 'online', Unavailable = 'unavailable', Offline = 'offline', + // DND is not a native Matrix state; Sable encodes it as online + status_msg='dnd'. + Dnd = 'dnd', } export type UserPresence = { @@ -16,12 +18,22 @@ export type UserPresence = { lastActiveTs?: number; }; -const getUserPresence = (user: User): UserPresence => ({ - presence: user.presence as Presence, - status: user.presenceStatusMsg, - active: user.currentlyActive, - lastActiveTs: user.getLastActiveTs(), -}); +const getUserPresence = (user: User): UserPresence => { + const rawPresence = user.presence as Presence; + // DND is encoded as online + status_msg 'dnd'. Decode it back so the badge + // renders red for any Sable client, not just the sender's own account switcher. + const presence = + rawPresence === Presence.Online && user.presenceStatusMsg === 'dnd' + ? Presence.Dnd + : rawPresence; + return { + presence, + // Don't leak the internal DND sentinel as a visible status message. + status: user.presenceStatusMsg !== 'dnd' ? user.presenceStatusMsg : undefined, + active: user.currentlyActive, + lastActiveTs: user.getLastActiveTs(), + }; +}; export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); @@ -31,7 +43,21 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { useEffect(() => { if (!user) { setPresence(undefined); - return undefined; + + // When the user isn't in the SDK store yet (e.g., presence arrived before + // any membership event), listen on the client for incoming events so we + // can re-evaluate once a presence event for this user is stored. + const handleEvent = (event: MatrixEvent) => { + if (event.getType() !== 'm.presence') return; + const sender = event.getSender(); + if (sender !== userId) return; + const latestUser = mx.getUser(userId); + if (latestUser) setPresence(getUserPresence(latestUser)); + }; + mx.on(ClientEvent.Event, handleEvent); + return () => { + mx.removeListener(ClientEvent.Event, handleEvent); + }; } setPresence(getUserPresence(user)); const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (e, u) => { @@ -48,7 +74,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { user.removeListener(UserEvent.CurrentlyActive, updatePresence); user.removeListener(UserEvent.LastPresenceTs, updatePresence); }; - }, [user]); + }, [mx, user, userId]); return presence; }; @@ -59,6 +85,7 @@ export const usePresenceLabel = (): Record => [Presence.Online]: 'Active', [Presence.Unavailable]: 'Busy', [Presence.Offline]: 'Away', + [Presence.Dnd]: 'Do Not Disturb', }), [] ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f847e0856..313057b7c 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; @@ -24,7 +24,7 @@ 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 { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; @@ -60,6 +60,10 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; +import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle'; +import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; +import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom'; +import { useReminderSync } from '$features/bookmarks/useReminderSync'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -844,14 +848,48 @@ function HandleDecryptPushEvent() { function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const [autoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); + const [presenceIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins'); + const [presenceStatusMsg] = useSetting(settingsAtom, 'presenceStatusMsg'); + const [autoIdled] = useAtom(presenceAutoIdledAtom); + + const timeoutMs = autoIdlePresence ? Math.max(1, presenceIdleTimeoutMins) * 60 * 1000 : 0; + usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { + // When auto-idled, broadcast as unavailable regardless of the configured mode. + const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); + // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg. + const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode; + const effectiveState = sendPresence ? activePresence : 'offline'; + const broadcasting = effectiveState !== 'offline'; + // DND overrides the user's custom status message with the 'dnd' sentinel. + const effectiveStatusMsg = sendPresence && effectiveMode === 'dnd' ? 'dnd' : presenceStatusMsg; + // Classic sync: set_presence query param on every /sync poll. // Passing undefined restores the default (online); Offline suppresses broadcasting. - mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); - // Sliding sync: enable/disable the presence extension on the next poll. + mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline); + // Sliding sync: keep the extension enabled so we always receive others' presence. + // Only disable it when the master sendPresence toggle is off (full privacy mode). getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); - }, [mx, sendPresence]); + // Explicitly PUT /presence/{userId}/status so the server knows the exact state: + // - MSC4186 servers that have no presence extension see this immediately. + // - When 'offline' (Invisible mode), we appear offline to others but still receive + // their presence events because the extension is still enabled above. + mx.setPresence({ + presence: effectiveState, + status_msg: effectiveStatusMsg, + }) + .then(() => { + // MSC4186 servers don't echo own presence back; synthesize the update locally so + // useUserPresence(myUserId) stays accurate (e.g. own badge in member list). + getSlidingSyncManager(mx)?.updateOwnPresence(effectiveState, effectiveStatusMsg); + }) + .catch(() => { + // Server doesn't support presence — ignore. + }); + }, [mx, sendPresence, presenceMode, presenceStatusMsg, autoIdled]); return null; } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index a3ec48466..05277a2be 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -1,7 +1,8 @@ -import type { MouseEvent, MouseEventHandler } from 'react'; +import type { MouseEvent, MouseEventHandler, ReactNode } from 'react'; import { useCallback, useState } from 'react'; import type { RectCords } from 'folds'; import { + Badge, Box, Button, Dialog, @@ -45,6 +46,10 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import type { Presence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -175,6 +180,20 @@ export function AccountSwitcherTab() { : undefined; const activeDisplayName = activeProfile.displayName; + // 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 sessionProfiles = useSessionProfiles(sessions); const { disableAccountSwitcher } = useClientConfig(); @@ -269,19 +288,21 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} - > - {nameInitials(label)}} - /> - + + 1} + > + {nameInitials(label)}} + /> + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( @@ -351,6 +372,63 @@ 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 = + mode === 'dnd' ? ( + + ) : ( + + ); + 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); + }} + > + + {statusLabel} + {desc && ( + + {desc} + + )} + + + ); + })} + 2; + const dmUserId = !isGroupDM ? room.getAvatarFallbackMember()?.userId : undefined; + const dmPresence = useUserPresence(dmUserId ?? ''); + // Get member info for group DMs using m.direct and profile API (doesn't require full room state) // Members are sorted by who last sent messages (most recent first) const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); @@ -135,9 +140,19 @@ 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 b1b744c1f..8e101065c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -79,6 +79,7 @@ export interface Settings { isWidgetDrawer: boolean; memberSortFilterIndex: number; enterForNewline: boolean; + isMarkdown: boolean; editorToolbar: boolean; composerToolbarOpen: boolean; messageLayout: MessageLayout; @@ -131,6 +132,11 @@ export interface Settings { // Sable features! sendPresence: boolean; + presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline'; + autoIdlePresence: boolean; + presenceIdleTimeoutMins: number; + /** User-set status message, cached locally so it survives mode changes and sliding-sync restarts. */ + presenceStatusMsg: string; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -210,6 +216,7 @@ export const defaultSettings: Settings = { isWidgetDrawer: false, memberSortFilterIndex: 0, enterForNewline: false, + isMarkdown: true, editorToolbar: false, composerToolbarOpen: false, messageLayout: 0, @@ -263,6 +270,10 @@ export const defaultSettings: Settings = { // Sable features! sendPresence: true, + presenceMode: 'online', + autoIdlePresence: true, + presenceIdleTimeoutMins: 5, + presenceStatusMsg: '', mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, @@ -469,6 +480,10 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { val === CaptionPosition.Below ? val : undefined; + case 'presenceMode': + return val === 'online' || val === 'unavailable' || val === 'dnd' || val === 'offline' + ? val + : undefined; case 'rightSwipeAction': return val === RightSwipeAction.Members || val === RightSwipeAction.Reply ? val : undefined; case 'renderUserCards': @@ -561,6 +576,12 @@ export const setSettings = (settings: Settings) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); }; +/** + * Ephemeral atom — true when the auto-idle hook has transitioned the user to idle. + * Not persisted to localStorage; resets to false on every page load. + */ +export const presenceAutoIdledAtom = atom(false); + export const settingsAtom = atom( (get) => get(baseSettings), (_get, set, update) => { diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2834c5b6f..2430f5324 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,5 +1,29 @@ +export type VisibilityChangeHandler = (isVisible: boolean) => void; +type VisibilityHiddenHandler = () => void; + +const visibilityChangeHandlers = new Set(); +const visibilityHiddenHandlers = new Set(); + export const appEvents = { - onVisibilityHidden: null as (() => void) | null, + onVisibilityHidden(handler: VisibilityHiddenHandler): () => void { + visibilityHiddenHandlers.add(handler); + return () => { + visibilityHiddenHandlers.delete(handler); + }; + }, + + emitVisibilityHidden(): void { + visibilityHiddenHandlers.forEach((h) => h()); + }, + + onVisibilityChange(handler: VisibilityChangeHandler): () => void { + visibilityChangeHandlers.add(handler); + return () => { + visibilityChangeHandlers.delete(handler); + }; + }, - onVisibilityChange: null as ((isVisible: boolean) => void) | null, + emitVisibilityChange(isVisible: boolean): void { + visibilityChangeHandlers.forEach((h) => h(isVisible)); + }, }; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index cea5b1f78..95d03bc22 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -578,6 +578,33 @@ export class SlidingSyncManager { this.presenceExtension.setEnabled(enabled); } + /** + * Synthesizes an own-presence update into the SDK store. + * MSC4186 servers never echo back the client's own m.presence events, so after + * calling mx.setPresence() we manually build a synthetic event and feed it into + * the SDK's User object — exactly what ExtensionPresence.onResponse does for others. + */ + public updateOwnPresence(presence: string, statusMsg: string): void { + const userId = this.mx.getUserId(); + if (!userId) return; + const mapper = this.mx.getEventMapper(); + const rawEvent = { + type: 'm.presence', + sender: userId, + content: { presence, status_msg: statusMsg, currently_active: presence === 'online' }, + }; + const event = mapper(rawEvent as Parameters[0]); + let user = this.mx.store.getUser(userId); + if (user) { + user.setPresenceEvent(event); + } else { + user = User.createUser(userId, this.mx); + user.setPresenceEvent(event); + this.mx.store.storeUser(user); + } + this.mx.emit(ClientEvent.Event, event); + } + public getDiagnostics(): SlidingSyncDiagnostics { return { proxyBaseUrl: this.proxyBaseUrl,