diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts index 266e039cf..788e3b168 100644 --- a/src/app/hooks/usePresenceUpdater.ts +++ b/src/app/hooks/usePresenceUpdater.ts @@ -3,12 +3,13 @@ import { useMatrixClient } from './useMatrixClient'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; -const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // go unavailable after 10 min of no input -const ACTIVITY_THROTTLE_MS = 1000; // process activity events at most once per second +const IDLE_TIMEOUT_MS = 10 * 60 * 1000; +const ACTIVITY_THROTTLE_MS = 1000; export function usePresenceUpdater() { const mx = useMatrixClient(); const [hidePresence] = useSetting(settingsAtom, 'hidePresence'); + const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus'); const idleTimerRef = useRef | undefined>(undefined); const isIdleRef = useRef(false); @@ -16,14 +17,29 @@ export function usePresenceUpdater() { useEffect(() => { const setOnline = () => mx.setPresence({ presence: 'online' }).catch(() => undefined); - const setUnavailable = () => mx.setPresence({ presence: 'unavailable' }).catch(() => undefined); + const setUnavailable = (statusMsg?: string) => + mx.setPresence({ presence: 'unavailable', status_msg: statusMsg }).catch(() => undefined); + const setOffline = () => mx.setPresence({ presence: 'offline' }).catch(() => undefined); - // When the user hides presence, broadcast offline and do nothing else. - if (hidePresence) { - mx.setPresence({ presence: 'offline' }).catch(() => undefined); + // Manual presence overrides — no activity tracking needed. + if (hidePresence || presenceStatus === 'invisible') { + setOffline(); + return undefined; + } + if (presenceStatus === 'online') { + setOnline(); + return undefined; + } + if (presenceStatus === 'idle') { + setUnavailable(); + return undefined; + } + if (presenceStatus === 'dnd') { + setUnavailable('dnd'); return undefined; } + // presenceStatus === 'auto' — original activity-tracking behavior. const startIdleTimer = () => { clearTimeout(idleTimerRef.current); idleTimerRef.current = window.setTimeout(() => { @@ -37,7 +53,6 @@ export function usePresenceUpdater() { if (now - lastActivityRef.current < ACTIVITY_THROTTLE_MS) return; lastActivityRef.current = now; - // Coming back from idle while the tab is visible — go back online. if (isIdleRef.current && !document.hidden) { isIdleRef.current = false; setOnline(); @@ -57,12 +72,9 @@ export function usePresenceUpdater() { } }; - // Best-effort offline on page close. fetch+keepalive survives the page unload - // unlike a regular async call, and avoids the bfcache penalty of beforeunload. const handlePageHide = () => { const userId = mx.getUserId(); const token = mx.getAccessToken(); - // MatrixClient exposes baseUrl as a public property const baseUrl = (mx as unknown as { baseUrl: string }).baseUrl; if (!userId || !token || !baseUrl) return; @@ -77,7 +89,6 @@ export function usePresenceUpdater() { }).catch(() => undefined); }; - // Announce online immediately when the client mounts. setOnline(); startIdleTimer(); @@ -92,5 +103,5 @@ export function usePresenceUpdater() { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('pagehide', handlePageHide); }; - }, [mx, hidePresence]); + }, [mx, hidePresence, presenceStatus]); } diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 15d6c284d..50050f205 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -1,5 +1,19 @@ import React, { useState } from 'react'; -import { Text } from 'folds'; +import { + Badge, + Box, + Icon, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, + color, + config, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { UserAvatar } from '../../../components/user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -9,39 +23,172 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { Settings } from '../../../features/settings'; import { useUserProfile } from '../../../hooks/useUserProfile'; import { Modal500 } from '../../../components/Modal500'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; + +type PresenceOption = { + id: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible'; + label: string; + dotColor: string; + soft?: boolean; +}; + +const PRESENCE_OPTIONS: PresenceOption[] = [ + { id: 'online', label: 'Online', dotColor: color.Success.Main }, + { id: 'idle', label: 'Idle', dotColor: color.Warning.Main }, + { id: 'dnd', label: 'Do Not Disturb', dotColor: color.Critical.Main }, + { id: 'invisible', label: 'Invisible', dotColor: color.Secondary.Main, soft: true }, + { id: 'auto', label: 'Auto (activity-based)', dotColor: color.Primary.Main }, +]; + +function PresenceDot({ option, size = 10 }: { option: PresenceOption; size?: number }) { + return ( +
+ ); +} + +function presenceVariant(status: string): 'Success' | 'Warning' | 'Critical' | 'Secondary' { + if (status === 'online') return 'Success'; + if (status === 'idle') return 'Warning'; + if (status === 'dnd') return 'Critical'; + return 'Secondary'; +} export function SettingsTab() { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const userId = mx.getUserId()!; const profile = useUserProfile(userId); + const [presenceStatus, setPresenceStatus] = useSetting(settingsAtom, 'presenceStatus'); - const [settings, setSettings] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(); + const [settingsOpen, setSettingsOpen] = useState(false); const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const avatarUrl = profile.avatarUrl ? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; - const openSettings = () => setSettings(true); - const closeSettings = () => setSettings(false); + const currentOption = + PRESENCE_OPTIONS.find((o) => o.id === presenceStatus) ?? PRESENCE_OPTIONS[4]; + + const closeMenu = () => setMenuAnchor(undefined); return ( - - - {(triggerRef) => ( - - {nameInitials(displayName)}} - /> - - )} - - {settings && ( - - + + + + + + + Set Status + + + {PRESENCE_OPTIONS.map((option) => ( + } + after={ + option.id === presenceStatus ? ( + + ) : undefined + } + onClick={() => { + setPresenceStatus(option.id); + closeMenu(); + }} + > + {option.label} + + ))} +
+ } + onClick={() => { + closeMenu(); + setSettingsOpen(true); + }} + > + User Settings + + +
+ + } + > + + {(triggerRef) => ( + ) => + setMenuAnchor(e.currentTarget.getBoundingClientRect()) + } + > +
+ {nameInitials(displayName)}} + /> +
+ +
+
+
+ )} +
+
+ {settingsOpen && ( + setSettingsOpen(false)}> + setSettingsOpen(false)} /> )}
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 479f285db..32d309f30 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -45,6 +45,7 @@ export interface Settings { pageZoom: number; hideActivity: boolean; hidePresence: boolean; + presenceStatus: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible'; isPeopleDrawer: boolean; memberSortFilterIndex: number; @@ -89,6 +90,7 @@ const defaultSettings: Settings = { pageZoom: 100, hideActivity: false, hidePresence: false, + presenceStatus: 'auto', isPeopleDrawer: true, memberSortFilterIndex: 0,