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,