diff --git a/.changeset/feat-presence-toggle.md b/.changeset/feat-presence-toggle.md
new file mode 100644
index 000000000..8dd0a1f19
--- /dev/null
+++ b/.changeset/feat-presence-toggle.md
@@ -0,0 +1,10 @@
+---
+sable: minor
+---
+
+Adds a **Presence Status** toggle under Settings → General.
+
+- New `sendPresence` setting (boolean, default `true`) persisted in localStorage
+- When disabled, the MSC4186 presence extension sends `{ enabled: false }` so the server stops delivering presence events
+- Also disables presence for classic sync via `client.setSyncPresence('offline')`
+- Takes effect at runtime — no reconnect needed
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index f4932972d..a329ec8ef 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -410,6 +410,7 @@ function Editor({ isMobile }: { isMobile: boolean }) {
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads');
+ const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
return (
@@ -453,6 +454,13 @@ function Editor({ isMobile }: { isMobile: boolean }) {
after={}
/>
+
+ }
+ />
+
);
}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 580135e64..9bf2162d1 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,7 +1,7 @@
import { useAtomValue, useSetAtom } from 'jotai';
import { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
-import { PushProcessor, RoomEvent, RoomEventHandlerMap } from '$types/matrix-sdk';
+import { PushProcessor, RoomEvent, RoomEventHandlerMap, SetPresence } from '$types/matrix-sdk';
import parse from 'html-react-parser';
import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser';
import { sanitizeCustomHtml } from '$utils/sanitize';
@@ -38,6 +38,7 @@ import {
} from '$utils/notificationStyle';
import { mobileOrTablet } from '$utils/user-agent';
import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom';
+import { getSlidingSyncManager } from '$client/initMatrix';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -575,6 +576,21 @@ function SlidingSyncActiveRoomSubscriber() {
return null;
}
+function PresenceFeature() {
+ const mx = useMatrixClient();
+ const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
+
+ useEffect(() => {
+ // 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.
+ getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
+ }, [mx, sendPresence]);
+
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@@ -587,6 +603,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
{children}
>
);
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 7174b9818..29bea6897 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -78,6 +78,7 @@ export interface Settings {
captionPosition: CaptionPosition;
// Sable features!
+ sendPresence: boolean;
mobileGestures: boolean;
rightSwipeAction: RightSwipeAction;
hideMembershipInReadOnly: boolean;
@@ -153,6 +154,7 @@ const defaultSettings: Settings = {
captionPosition: CaptionPosition.Below,
// Sable features!
+ sendPresence: true,
mobileGestures: true,
rightSwipeAction: RightSwipeAction.Reply,
hideMembershipInReadOnly: true,
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 0029a63c9..f2c9257dc 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -242,8 +242,14 @@ const getListEndIndex = (list: MSC3575List | null): number => {
// poll and feeds received `m.presence` events into the SDK's User objects so that
// components using `useUserPresence` see live updates (same path as regular /sync).
class ExtensionPresence implements Extension<{ enabled: boolean }, { events?: object[] }> {
+ private enabled = true;
+
public constructor(private readonly mx: MatrixClient) {}
+ public setEnabled(value: boolean): void {
+ this.enabled = value;
+ }
+
// eslint-disable-next-line class-methods-use-this
public name(): string {
return 'presence';
@@ -255,9 +261,8 @@ class ExtensionPresence implements Extension<{ enabled: boolean }, { events?: ob
return ExtensionState.PostProcess;
}
- // eslint-disable-next-line class-methods-use-this
public async onRequest(): Promise<{ enabled: boolean }> {
- return { enabled: true };
+ return { enabled: this.enabled };
}
public async onResponse(data: { events?: object[] }): Promise {
@@ -301,6 +306,8 @@ export class SlidingSyncManager {
private readonly onLifecycle: (state: SlidingSyncState, resp: unknown, err?: Error) => void;
+ private presenceExtension!: ExtensionPresence;
+
public readonly slidingSync: SlidingSync;
public readonly probeTimeoutMs: number;
@@ -333,7 +340,8 @@ export class SlidingSyncManager {
// Register the presence extension so m.presence events from the server are fed
// into the SDK's User objects, keeping useUserPresence accurate during sliding sync.
- this.slidingSync.registerExtension(new ExtensionPresence(mx));
+ this.presenceExtension = new ExtensionPresence(mx);
+ this.slidingSync.registerExtension(this.presenceExtension);
// Register a custom subscription for unencrypted active rooms; encrypted rooms use
// the default subscription (which already has [*,*]).
@@ -410,6 +418,10 @@ export class SlidingSyncManager {
);
}
+ public setPresenceEnabled(enabled: boolean): void {
+ this.presenceExtension.setEnabled(enabled);
+ }
+
public getDiagnostics(): SlidingSyncDiagnostics {
return {
proxyBaseUrl: this.proxyBaseUrl,