diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 152e87172..c583ecc4d 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -789,6 +789,7 @@ function Editor() { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence'); const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar'); return ( @@ -823,6 +824,13 @@ function Editor() { after={} /> + + } + /> + ); } diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts new file mode 100644 index 000000000..2883ffc95 --- /dev/null +++ b/src/app/hooks/usePresenceUpdater.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; +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 + +export function usePresenceUpdater() { + const mx = useMatrixClient(); + const [hidePresence] = useSetting(settingsAtom, 'hidePresence'); + + const idleTimerRef = useRef | undefined>(undefined); + const isIdleRef = useRef(false); + const lastActivityRef = useRef(0); + + useEffect(() => { + const setOnline = () => + mx.setPresence({ presence: 'online' }).catch(() => undefined); + const setUnavailable = () => + mx.setPresence({ presence: 'unavailable' }).catch(() => undefined); + + // When the user hides presence, broadcast offline and do nothing else. + if (hidePresence) { + mx.setPresence({ presence: 'offline' }).catch(() => undefined); + return undefined; + } + + const startIdleTimer = () => { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = window.setTimeout(() => { + isIdleRef.current = true; + setUnavailable(); + }, IDLE_TIMEOUT_MS); + }; + + const handleActivity = () => { + const now = Date.now(); + 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(); + } + startIdleTimer(); + }; + + const handleVisibilityChange = () => { + if (document.hidden) { + clearTimeout(idleTimerRef.current); + setUnavailable(); + } else { + isIdleRef.current = false; + lastActivityRef.current = Date.now(); + setOnline(); + startIdleTimer(); + } + }; + + // 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; + + fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ presence: 'offline' }), + keepalive: true, + }).catch(() => undefined); + }; + + // Announce online immediately when the client mounts. + setOnline(); + startIdleTimer(); + + const activityEvents = ['mousemove', 'keydown', 'touchstart', 'click', 'scroll'] as const; + activityEvents.forEach((e) => window.addEventListener(e, handleActivity, { passive: true })); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pagehide', handlePageHide); + + return () => { + clearTimeout(idleTimerRef.current); + activityEvents.forEach((e) => window.removeEventListener(e, handleActivity)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pagehide', handlePageHide); + }; + }, [mx, hidePresence]); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 077551bf1..0ba611006 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,6 +26,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { usePresenceUpdater } from '../../hooks/usePresenceUpdater'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -127,6 +128,11 @@ function InviteNotifications() { ); } +function PresenceUpdater() { + usePresenceUpdater(); + return null; +} + function MessageNotifications() { const audioRef = useRef(null); const notifRef = useRef(undefined); @@ -261,6 +267,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index e717e8d1f..479f285db 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -44,6 +44,7 @@ export interface Settings { twitterEmoji: boolean; pageZoom: number; hideActivity: boolean; + hidePresence: boolean; isPeopleDrawer: boolean; memberSortFilterIndex: number; @@ -87,6 +88,7 @@ const defaultSettings: Settings = { twitterEmoji: false, pageZoom: 100, hideActivity: false, + hidePresence: false, isPeopleDrawer: true, memberSortFilterIndex: 0,