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,