diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx
index 88543b7f6..085020ec2 100644
--- a/src/app/components/presence/Presence.tsx
+++ b/src/app/components/presence/Presence.tsx
@@ -9,6 +9,7 @@ const PresenceToColor: Record<Presence, MainColor> = {
   [Presence.Online]: 'Success',
   [Presence.Unavailable]: 'Warning',
   [Presence.Offline]: 'Secondary',
+  [Presence.Dnd]: 'Critical',
 };
 
 type PresenceBadgeProps = {
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index 2641899d4..cca9bbddd 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -26,7 +26,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
 import classNames from 'classnames';
 
 import { AvatarPresence, PresenceBadge } from '$components/presence';
-import { useUserPresence } from '$hooks/useUserPresence';
+import { Presence, useUserPresence } from '$hooks/useUserPresence';
 import { useMatrixClient } from '$hooks/useMatrixClient';
 import { UseStateProvider } from '$components/UseStateProvider';
 import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch';
@@ -150,7 +150,7 @@ function MemberItem({
     >
       <AvatarPresence
         badge={
-          presence && presence.lastActiveTs !== 0 ? (
+          presence && presence.presence !== Presence.Offline ? (
             <PresenceBadge presence={presence.presence} size="200" />
           ) : undefined
         }
@@ -296,8 +296,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
   );
 
   const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
-    // oxlint-disable-next-line no-console
-    console.log(evt);
     const btn = evt.currentTarget as HTMLButtonElement;
     const userId = btn.getAttribute('data-user-id');
     if (!userId) return;
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
index 19dc11609..c4aa8576b 100644
--- a/src/app/features/settings/account/Profile.tsx
+++ b/src/app/features/settings/account/Profile.tsx
@@ -43,6 +43,9 @@ import { useCapabilities } from '$hooks/useCapabilities';
 import { profilesCacheAtom } from '$state/userRoomProfile';
 import { SequenceCardStyle } from '$features/settings/styles.css';
 import { useUserPresence } from '$hooks/useUserPresence';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { getSlidingSyncManager } from '$client/initMatrix';
 import type { MSC1767Text } from '$types/matrix/common';
 import { TimezoneEditor } from './TimezoneEditor';
 import { PronounEditor } from './PronounEditor';
@@ -485,7 +488,13 @@ function ProfileExtended({ profile, userId }: Readonly<ProfileProps>) {
 
   const pronouns = (profile.pronouns as PronounSet[]) || [];
   const presence = useUserPresence(userId);
-  const currentStatus = presence?.status || '';
+  // presenceStatusMsg is the locally-cached status. On sliding sync, own presence is
+  // never echoed back by the server, so presence?.status would always be stale/empty.
+  // The settings atom is the authoritative local source; fall back to the SDK value for
+  // other clients (e.g. when viewing another user's profile page — but this component
+  // is only rendered for the own user, so the atom always wins in practice).
+  const [presenceStatusMsg, setPresenceStatusMsg] = useSetting(settingsAtom, 'presenceStatusMsg');
+  const currentStatus = presenceStatusMsg || presence?.status || '';
 
   // Keys we don't render here nor handle seperately but still need to exclude
   const EXCLUDED_KEYS = new Set([
@@ -513,14 +522,24 @@ function ProfileExtended({ profile, userId }: Readonly<ProfileProps>) {
 
   const handleSaveStatus = useCallback(
     async (newStatus: string) => {
-      const currentState = presence?.presence || 'online';
-
-      await mx.setPresence({
-        presence: currentState,
-        status_msg: newStatus,
-      });
+      // Only update the local atom. PresenceFeature's effect will broadcast the new
+      // status_msg to the server on its next run (triggered by this atom change).
+      // We don't call mx.setPresence here to avoid passing our internal Presence.Dnd
+      // value ('dnd'), which is not a valid Matrix presence state.
+      setPresenceStatusMsg(newStatus);
+
+      // Eagerly mirror the change in the SDK store so the member list updates without
+      // waiting for the PresenceFeature effect to resolve the network call.
+      const myUser = mx.getUser(mx.getUserId() ?? '');
+      const isDnd = myUser?.presence === 'online' && myUser?.presenceStatusMsg === 'dnd';
+      if (!isDnd) {
+        // Not in DND: update local presence to reflect the new status immediately.
+        getSlidingSyncManager(mx)?.updateOwnPresence(myUser?.presence ?? 'online', newStatus);
+      }
+      // In DND mode the sentinel ('dnd') stays as status_msg on the wire; the user's
+      // custom status is preserved in the atom and used once they leave DND.
     },
-    [mx, presence]
+    [mx, setPresenceStatusMsg]
   );
 
   return (
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 29361bdd6..14115dd13 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) {
   const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
   const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads');
   const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
+  const [autoIdlePresence, setAutoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence');
   const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies');
 
   return (
@@ -476,6 +477,28 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) {
           after={<Switch variant="Primary" value={sendPresence} onChange={setSendPresence} />}
         />
       </SequenceCard>
+      {sendPresence && (
+        <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
+          <SettingTile
+            title="Auto-Idle"
+            focusId="auto-idle-presence"
+            description="Automatically appear unavailable after a period of inactivity or when the app isn't active."
+            after={
+              <Switch variant="Primary" value={autoIdlePresence} onChange={setAutoIdlePresence} />
+            }
+          />
+        </SequenceCard>
+      )}
+      {sendPresence && autoIdlePresence && (
+        <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
+          <SettingTile
+            title="Idle Timeout"
+            focusId="presence-idle-timeout"
+            description="Minutes of inactivity before appearing unavailable."
+            after={<PresenceIdleTimeoutInput />}
+          />
+        </SequenceCard>
+      )}
       <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
         <SettingTile
           title="Send notifications for replies"
@@ -840,6 +863,52 @@ function EmojiSelectorThresholdInput() {
   );
 }
 
+function PresenceIdleTimeoutInput() {
+  const [idleTimeoutMins, setIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins');
+  const [inputValue, setInputValue] = useState(idleTimeoutMins.toString());
+
+  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
+    const val = evt.target.value;
+    setInputValue(val);
+    const parsed = Number.parseInt(val, 10);
+    if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) {
+      setIdleTimeoutMins(parsed);
+    }
+  };
+
+  const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
+    if (isKeyHotkey('escape', evt)) {
+      evt.stopPropagation();
+      setInputValue(idleTimeoutMins.toString());
+      (evt.target as HTMLInputElement).blur();
+    }
+    if (isKeyHotkey('enter', evt)) {
+      (evt.target as HTMLInputElement).blur();
+    }
+  };
+
+  return (
+    <Box alignItems="Center" gap="200">
+      <Input
+        style={{ width: toRem(80) }}
+        variant={Number.parseInt(inputValue, 10) === idleTimeoutMins ? 'Secondary' : 'Success'}
+        size="300"
+        radii="300"
+        type="number"
+        min="1"
+        max="60"
+        value={inputValue}
+        onChange={handleChange}
+        onKeyDown={handleKeyDown}
+        outlined
+      />
+      <Text size="T200" priority="300">
+        min
+      </Text>
+    </Box>
+  );
+}
+
 function Calls() {
   const [alwaysShowCallButton, setAlwaysShowCallButton] = useSetting(
     settingsAtom,
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index b56f564ca..0f659c8d2 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -26,9 +26,9 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
         `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`,
         { visibilityState: document.visibilityState }
       );
-      appEvents.onVisibilityChange?.(isVisible);
+      appEvents.emitVisibilityChange(isVisible);
       if (!isVisible) {
-        appEvents.onVisibilityHidden?.();
+        appEvents.emitVisibilityHidden();
       }
     };
 
@@ -46,9 +46,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
       togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
     };
 
-    appEvents.onVisibilityChange = handleVisibilityForNotifications;
-    return () => {
-      appEvents.onVisibilityChange = null;
-    };
+    const unsub = appEvents.onVisibilityChange(handleVisibilityForNotifications);
+    return unsub;
   }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
 }
diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx
new file mode 100644
index 000000000..523a03a61
--- /dev/null
+++ b/src/app/hooks/usePresenceAutoIdle.test.tsx
@@ -0,0 +1,226 @@
+import { act, renderHook } from '@testing-library/react';
+import { Provider, useAtomValue } from 'jotai';
+import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
+import { usePresenceAutoIdle } from './usePresenceAutoIdle';
+import { presenceAutoIdledAtom } from '$state/settings';
+import { appEvents } from '$utils/appEvents';
+import type { ReactNode } from 'react';
+
+// -------- mock setup --------
+
+const userListeners = new Map<string, ((...args: unknown[]) => void)[]>();
+
+const makeMockUser = () => ({
+  userId: '@alice:test',
+  presence: 'online',
+  on: vi
+    .fn<(event: string, handler: (...args: unknown[]) => void) => void>()
+    .mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
+      const list = userListeners.get(event) ?? [];
+      list.push(handler);
+      userListeners.set(event, list);
+    }),
+  removeListener: vi.fn<() => void>(),
+});
+
+let mockUser: ReturnType<typeof makeMockUser> | null = null;
+
+const makeMockMx = () => ({
+  getUserId: vi.fn<() => string>(() => '@alice:test'),
+  getUser: vi.fn<() => ReturnType<typeof makeMockUser> | null>(() => mockUser),
+});
+
+let mockMx: ReturnType<typeof makeMockMx>;
+
+const wrapper = ({ children }: { children: ReactNode }) => <Provider>{children}</Provider>;
+
+// Helper to read the atom value alongside the hook under test.
+function useAutoIdledReader(
+  mx: ReturnType<typeof makeMockMx>,
+  presenceMode: string,
+  sendPresence: boolean,
+  timeoutMs: number
+) {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs);
+  return useAtomValue(presenceAutoIdledAtom);
+}
+
+// -------- lifecycle --------
+
+beforeEach(() => {
+  vi.useFakeTimers();
+  vi.clearAllMocks();
+  userListeners.clear();
+  mockUser = makeMockUser();
+  mockMx = makeMockMx();
+});
+
+afterEach(() => {
+  vi.useRealTimers();
+});
+
+// -------- tests --------
+
+describe('usePresenceAutoIdle', () => {
+  it('sets auto-idle after the timeout elapses', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+      wrapper,
+    });
+
+    expect(result.current).toBe(false);
+
+    act(() => {
+      vi.advanceTimersByTime(5000);
+    });
+
+    expect(result.current).toBe(true);
+  });
+
+  it('resets auto-idle when user activity is detected', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+      wrapper,
+    });
+
+    // Go idle.
+    act(() => {
+      vi.advanceTimersByTime(5000);
+    });
+    expect(result.current).toBe(true);
+
+    // Simulate user activity.
+    act(() => {
+      document.dispatchEvent(new Event('mousemove'));
+    });
+    expect(result.current).toBe(false);
+  });
+
+  it('resets auto-idle when app becomes visible via appEvents', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+      wrapper,
+    });
+
+    act(() => {
+      vi.advanceTimersByTime(5000);
+    });
+    expect(result.current).toBe(true);
+
+    // Simulate app returning to foreground.
+    act(() => {
+      appEvents.emitVisibilityChange(true);
+    });
+    expect(result.current).toBe(false);
+  });
+
+  it('does not go idle when presenceMode is not online', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'dnd', true, 5000), { wrapper });
+
+    act(() => {
+      vi.advanceTimersByTime(10000);
+    });
+    expect(result.current).toBe(false);
+  });
+
+  it('does not go idle when sendPresence is false', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', false, 5000), {
+      wrapper,
+    });
+
+    act(() => {
+      vi.advanceTimersByTime(10000);
+    });
+    expect(result.current).toBe(false);
+  });
+
+  it('does not go idle when timeoutMs is 0', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 0), { wrapper });
+
+    act(() => {
+      vi.advanceTimersByTime(10000);
+    });
+    expect(result.current).toBe(false);
+  });
+
+  it('restarts the idle timer on activity before timeout', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+      wrapper,
+    });
+
+    // Advance partially, then trigger activity.
+    act(() => {
+      vi.advanceTimersByTime(3000);
+    });
+    expect(result.current).toBe(false);
+
+    act(() => {
+      document.dispatchEvent(new Event('keydown'));
+    });
+
+    // Original timeout would have fired at 5000ms, but we reset.
+    act(() => {
+      vi.advanceTimersByTime(3000);
+    });
+    expect(result.current).toBe(false);
+
+    // Now the full 5000ms from last activity should trigger idle.
+    act(() => {
+      vi.advanceTimersByTime(2000);
+    });
+    expect(result.current).toBe(true);
+  });
+
+  it('clears auto-idle when presenceMode changes away from online', () => {
+    const { result, rerender } = renderHook(
+      ({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000),
+      { wrapper, initialProps: { mode: 'online' } }
+    );
+
+    act(() => {
+      vi.advanceTimersByTime(5000);
+    });
+    expect(result.current).toBe(true);
+
+    rerender({ mode: 'dnd' });
+    expect(result.current).toBe(false);
+  });
+
+  it('clears auto-idle when another device sets presence to online', () => {
+    const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+      wrapper,
+    });
+
+    act(() => {
+      vi.advanceTimersByTime(5000);
+    });
+    expect(result.current).toBe(true);
+
+    // Simulate User.presence event from another device.
+    const handlers = userListeners.get('User.presence') ?? [];
+    expect(handlers.length).toBeGreaterThan(0);
+
+    act(() => {
+      handlers.forEach((h) => h({}, { userId: '@alice:test', presence: 'online' }));
+    });
+    expect(result.current).toBe(false);
+  });
+
+  it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => {
+    const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+      wrapper,
+    });
+
+    // Go idle.
+    act(() => {
+      vi.advanceTimersByTime(5000);
+    });
+    expect(result.current).toBe(true);
+
+    unmount();
+
+    // After unmount, emitting visibility change should have no effect.
+    // (No error thrown means the handler was properly unsubscribed.)
+    act(() => {
+      appEvents.emitVisibilityChange(true);
+    });
+  });
+});
diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts
new file mode 100644
index 000000000..dc5af7e21
--- /dev/null
+++ b/src/app/hooks/usePresenceAutoIdle.ts
@@ -0,0 +1,109 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useSetAtom } from 'jotai';
+import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk';
+import { presenceAutoIdledAtom } from '$state/settings';
+import { appEvents } from '$utils/appEvents';
+import { createDebugLogger } from '$utils/debugLogger';
+
+const debugLog = createDebugLogger('PresenceAutoIdle');
+const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const;
+
+/**
+ * Automatically transitions presence to idle after a configurable inactivity
+ * timeout, and clears the idle state when activity is detected.
+ *
+ * Also subscribes to the Matrix `User.presence` event so that if another device
+ * sets you back to `online`, the auto-idle state is cleared here too (multi-device
+ * sync).
+ *
+ * Note: On iOS Safari PWA, background tab throttling may delay or prevent the
+ * inactivity timer from firing reliably. The feature degrades gracefully — presence
+ * will eventually update when the tab regains focus.
+ */
+export function usePresenceAutoIdle(
+  mx: MatrixClient,
+  presenceMode: string,
+  sendPresence: boolean,
+  timeoutMs: number
+): void {
+  const setAutoIdled = useSetAtom(presenceAutoIdledAtom);
+  const autoIdledRef = useRef(false);
+  const timerRef = useRef<number | undefined>(undefined);
+
+  const clearTimer = useCallback(() => {
+    if (timerRef.current !== undefined) {
+      window.clearTimeout(timerRef.current);
+      timerRef.current = undefined;
+    }
+  }, []);
+
+  // Inactivity timer: go idle after timeoutMs without user input.
+  useEffect(() => {
+    const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0;
+    if (!shouldAutoIdle) {
+      clearTimer();
+      if (autoIdledRef.current) {
+        autoIdledRef.current = false;
+        setAutoIdled(false);
+      }
+      return undefined;
+    }
+
+    const goIdle = () => {
+      debugLog.info('general', 'Inactivity timeout — auto-idling');
+      autoIdledRef.current = true;
+      setAutoIdled(true);
+    };
+
+    const handleActivity = () => {
+      clearTimer();
+      if (autoIdledRef.current) {
+        debugLog.info('general', 'Activity detected — clearing auto-idle');
+        autoIdledRef.current = false;
+        setAutoIdled(false);
+      }
+      timerRef.current = window.setTimeout(goIdle, timeoutMs);
+    };
+
+    // Start the initial timer.
+    timerRef.current = window.setTimeout(goIdle, timeoutMs);
+    ACTIVITY_EVENTS.forEach((ev) =>
+      document.addEventListener(ev, handleActivity, { passive: true })
+    );
+
+    // When the app returns to the foreground, treat it as activity so the user
+    // isn't shown as idle the moment they switch back to the tab/PWA.
+    const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => {
+      if (isVisible) handleActivity();
+    });
+
+    return () => {
+      ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity));
+      clearTimer();
+      unsubVisibility();
+    };
+  }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
+
+  // Multi-device sync: if another device sets us back to online, clear auto-idle.
+  useEffect(() => {
+    if (!sendPresence) return undefined;
+    const myUserId = mx.getUserId();
+    if (!myUserId) return undefined;
+    const user = mx.getUser(myUserId);
+    if (!user) return undefined;
+
+    const handlePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
+      if (u.userId !== myUserId) return;
+      if (u.presence === 'online' && autoIdledRef.current) {
+        debugLog.info('general', 'Remote device set Online — clearing auto-idle');
+        autoIdledRef.current = false;
+        setAutoIdled(false);
+      }
+    };
+
+    user.on(UserEvent.Presence, handlePresence);
+    return () => {
+      user.removeListener(UserEvent.Presence, handlePresence);
+    };
+  }, [mx, sendPresence, setAutoIdled]);
+}
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index 0c90c79f9..6f78d734a 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -1,12 +1,14 @@
 import { useEffect, useMemo, useState } from 'react';
-import type { User, UserEventHandlerMap } from '$types/matrix-sdk';
-import { UserEvent } from '$types/matrix-sdk';
+import type { MatrixEvent, User, UserEventHandlerMap } from '$types/matrix-sdk';
+import { ClientEvent, UserEvent } from '$types/matrix-sdk';
 import { useMatrixClient } from './useMatrixClient';
 
 export enum Presence {
   Online = 'online',
   Unavailable = 'unavailable',
   Offline = 'offline',
+  // DND is not a native Matrix state; Sable encodes it as online + status_msg='dnd'.
+  Dnd = 'dnd',
 }
 
 export type UserPresence = {
@@ -16,12 +18,22 @@ export type UserPresence = {
   lastActiveTs?: number;
 };
 
-const getUserPresence = (user: User): UserPresence => ({
-  presence: user.presence as Presence,
-  status: user.presenceStatusMsg,
-  active: user.currentlyActive,
-  lastActiveTs: user.getLastActiveTs(),
-});
+const getUserPresence = (user: User): UserPresence => {
+  const rawPresence = user.presence as Presence;
+  // DND is encoded as online + status_msg 'dnd'. Decode it back so the badge
+  // renders red for any Sable client, not just the sender's own account switcher.
+  const presence =
+    rawPresence === Presence.Online && user.presenceStatusMsg === 'dnd'
+      ? Presence.Dnd
+      : rawPresence;
+  return {
+    presence,
+    // Don't leak the internal DND sentinel as a visible status message.
+    status: user.presenceStatusMsg !== 'dnd' ? user.presenceStatusMsg : undefined,
+    active: user.currentlyActive,
+    lastActiveTs: user.getLastActiveTs(),
+  };
+};
 
 export const useUserPresence = (userId: string): UserPresence | undefined => {
   const mx = useMatrixClient();
@@ -31,7 +43,21 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
   useEffect(() => {
     if (!user) {
       setPresence(undefined);
-      return undefined;
+
+      // When the user isn't in the SDK store yet (e.g., presence arrived before
+      // any membership event), listen on the client for incoming events so we
+      // can re-evaluate once a presence event for this user is stored.
+      const handleEvent = (event: MatrixEvent) => {
+        if (event.getType() !== 'm.presence') return;
+        const sender = event.getSender();
+        if (sender !== userId) return;
+        const latestUser = mx.getUser(userId);
+        if (latestUser) setPresence(getUserPresence(latestUser));
+      };
+      mx.on(ClientEvent.Event, handleEvent);
+      return () => {
+        mx.removeListener(ClientEvent.Event, handleEvent);
+      };
     }
     setPresence(getUserPresence(user));
     const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (e, u) => {
@@ -48,7 +74,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
       user.removeListener(UserEvent.CurrentlyActive, updatePresence);
       user.removeListener(UserEvent.LastPresenceTs, updatePresence);
     };
-  }, [user]);
+  }, [mx, user, userId]);
 
   return presence;
 };
@@ -59,6 +85,7 @@ export const usePresenceLabel = (): Record<Presence, string> =>
       [Presence.Online]: 'Active',
       [Presence.Unavailable]: 'Busy',
       [Presence.Offline]: 'Away',
+      [Presence.Dnd]: 'Do Not Disturb',
     }),
     []
   );
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index f847e0856..313057b7c 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,4 +1,4 @@
-import { useAtomValue, useSetAtom } from 'jotai';
+import { useAtom, useAtomValue, useSetAtom } from 'jotai';
 import * as Sentry from '@sentry/react';
 import type { ReactNode } from 'react';
 import { useCallback, useEffect, useRef } from 'react';
@@ -24,7 +24,7 @@ import NotificationSound from '$public/sound/notification.ogg';
 import InviteSound from '$public/sound/invite.ogg';
 import { notificationPermission, setFavicon } from '$utils/dom';
 import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
+import { settingsAtom, presenceAutoIdledAtom } from '$state/settings';
 import { nicknamesAtom } from '$state/nicknames';
 import { mDirectAtom } from '$state/mDirectList';
 import { allInvitesAtom } from '$state/room-list/inviteList';
@@ -60,6 +60,10 @@ import { useCallSignaling } from '$hooks/useCallSignaling';
 import { getBlobCacheStats } from '$hooks/useBlobCache';
 import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
 import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
+import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle';
+import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
+import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom';
+import { useReminderSync } from '$features/bookmarks/useReminderSync';
 import { getInboxInvitesPath } from '../pathUtils';
 import { BackgroundNotifications } from './BackgroundNotifications';
 
@@ -844,14 +848,48 @@ function HandleDecryptPushEvent() {
 function PresenceFeature() {
   const mx = useMatrixClient();
   const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
+  const [presenceMode] = useSetting(settingsAtom, 'presenceMode');
+  const [autoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence');
+  const [presenceIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins');
+  const [presenceStatusMsg] = useSetting(settingsAtom, 'presenceStatusMsg');
+  const [autoIdled] = useAtom(presenceAutoIdledAtom);
+
+  const timeoutMs = autoIdlePresence ? Math.max(1, presenceIdleTimeoutMins) * 60 * 1000 : 0;
+  usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs);
 
   useEffect(() => {
+    // When auto-idled, broadcast as unavailable regardless of the configured mode.
+    const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online');
+    // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg.
+    const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode;
+    const effectiveState = sendPresence ? activePresence : 'offline';
+    const broadcasting = effectiveState !== 'offline';
+    // DND overrides the user's custom status message with the 'dnd' sentinel.
+    const effectiveStatusMsg = sendPresence && effectiveMode === 'dnd' ? 'dnd' : presenceStatusMsg;
+
     // 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.
+    mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline);
+    // Sliding sync: keep the extension enabled so we always receive others' presence.
+    // Only disable it when the master sendPresence toggle is off (full privacy mode).
     getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
-  }, [mx, sendPresence]);
+    // Explicitly PUT /presence/{userId}/status so the server knows the exact state:
+    // - MSC4186 servers that have no presence extension see this immediately.
+    // - When 'offline' (Invisible mode), we appear offline to others but still receive
+    //   their presence events because the extension is still enabled above.
+    mx.setPresence({
+      presence: effectiveState,
+      status_msg: effectiveStatusMsg,
+    })
+      .then(() => {
+        // MSC4186 servers don't echo own presence back; synthesize the update locally so
+        // useUserPresence(myUserId) stays accurate (e.g. own badge in member list).
+        getSlidingSyncManager(mx)?.updateOwnPresence(effectiveState, effectiveStatusMsg);
+      })
+      .catch(() => {
+        // Server doesn't support presence — ignore.
+      });
+  }, [mx, sendPresence, presenceMode, presenceStatusMsg, autoIdled]);
 
   return null;
 }
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index a3ec48466..05277a2be 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -1,7 +1,8 @@
-import type { MouseEvent, MouseEventHandler } from 'react';
+import type { MouseEvent, MouseEventHandler, ReactNode } from 'react';
 import { useCallback, useState } from 'react';
 import type { RectCords } from 'folds';
 import {
+  Badge,
   Box,
   Button,
   Dialog,
@@ -45,6 +46,10 @@ import { createLogger } from '$utils/debug';
 import { createDebugLogger } from '$utils/debugLogger';
 import { useClientConfig } from '$hooks/useClientConfig';
 import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
+import type { Presence } from '$hooks/useUserPresence';
+import { AvatarPresence, PresenceBadge } from '$components/presence';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom, presenceAutoIdledAtom } from '$state/settings';
 
 const log = createLogger('AccountSwitcherTab');
 const debugLog = createDebugLogger('AccountSwitcherTab');
@@ -175,6 +180,20 @@ export function AccountSwitcherTab() {
     : undefined;
   const activeDisplayName = activeProfile.displayName;
 
+  // Own presence badge is driven from settings state rather than the SDK's User object.
+  // The SDK won't echo your own presence back on MSC4186 sliding sync, so reading
+  // user.presence would leave the badge stuck at the SDK default forever.
+  const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
+  const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode');
+  const autoIdled = useAtomValue(presenceAutoIdledAtom);
+  const setAutoIdled = useSetAtom(presenceAutoIdledAtom);
+  // The effective mode for badge display: if auto-idled, show unavailable regardless of selected mode.
+  const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online');
+  let myOwnPresenceBadge: ReactNode;
+  if (sendPresence) {
+    myOwnPresenceBadge = <PresenceBadge presence={effectiveDisplayMode as Presence} size="200" />;
+  }
+
   const sessionProfiles = useSessionProfiles(sessions);
 
   const { disableAccountSwitcher } = useClientConfig();
@@ -269,19 +288,21 @@ export function AccountSwitcherTab() {
     <SidebarItem active={!!menuAnchor}>
       <SidebarItemTooltip tooltip={label}>
         {(triggerRef) => (
-          <SidebarAvatar
-            as="button"
-            ref={triggerRef}
-            onClick={handleToggle}
-            outlined={sessions.length > 1}
-          >
-            <UserAvatar
-              userId={activeSession.userId}
-              src={activeAvatarUrl}
-              alt={label}
-              renderFallback={() => <Text size="H4">{nameInitials(label)}</Text>}
-            />
-          </SidebarAvatar>
+          <AvatarPresence badge={myOwnPresenceBadge}>
+            <SidebarAvatar
+              as="button"
+              ref={triggerRef}
+              onClick={handleToggle}
+              outlined={sessions.length > 1}
+            >
+              <UserAvatar
+                userId={activeSession.userId}
+                src={activeAvatarUrl}
+                alt={label}
+                renderFallback={() => <Text size="H4">{nameInitials(label)}</Text>}
+              />
+            </SidebarAvatar>
+          </AvatarPresence>
         )}
       </SidebarItemTooltip>
       {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && (
@@ -351,6 +372,63 @@ export function AccountSwitcherTab() {
                   <Text size="T300">Add Account</Text>
                 </MenuItem>
                 <Line variant="Surface" size="300" style={{ margin: `${config.space.S100} 0` }} />
+                <Text size="L400" style={{ padding: `${config.space.S100} ${config.space.S200}` }}>
+                  Status
+                </Text>
+                {(
+                  [
+                    { label: 'Online', desc: undefined, mode: 'online' as const },
+                    { label: 'Idle', desc: undefined, mode: 'unavailable' as const },
+                    { label: 'Do Not Disturb', desc: undefined, mode: 'dnd' as const },
+                    {
+                      label: 'Invisible',
+                      desc: 'You will appear offline',
+                      mode: 'offline' as const,
+                    },
+                  ] as const
+                ).map(({ label: statusLabel, desc, mode }) => {
+                  const isSelected = sendPresence && (presenceMode ?? 'online') === mode;
+                  const badge =
+                    mode === 'dnd' ? (
+                      <Badge size="300" variant="Critical" fill="Solid" radii="Pill" />
+                    ) : (
+                      <PresenceBadge presence={mode as Presence} size="300" />
+                    );
+                  return (
+                    <MenuItem
+                      key={mode}
+                      size="300"
+                      radii="300"
+                      before={badge}
+                      after={
+                        isSelected ? (
+                          <Icon
+                            size="200"
+                            src={Icons.Check}
+                            style={{ color: 'var(--mx-c-success)' }}
+                          />
+                        ) : undefined
+                      }
+                      onClick={() => {
+                        setPresenceMode(mode);
+                        // Clear auto-idle so the badge updates immediately on manual selection.
+                        setAutoIdled(false);
+                        // Re-enable presence broadcasting if the master toggle was off.
+                        if (!sendPresence) setSendPresence(true);
+                      }}
+                    >
+                      <Box direction="Column">
+                        <Text size="T300">{statusLabel}</Text>
+                        {desc && (
+                          <Text size="T200" priority="300">
+                            {desc}
+                          </Text>
+                        )}
+                      </Box>
+                    </MenuItem>
+                  );
+                })}
+                <Line variant="Surface" size="300" style={{ margin: `${config.space.S100} 0` }} />
                 <MenuItem
                   size="300"
                   radii="300"
diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx
index 8c3313335..31d17d68a 100644
--- a/src/app/pages/client/sidebar/DirectDMsList.tsx
+++ b/src/app/pages/client/sidebar/DirectDMsList.tsx
@@ -22,6 +22,8 @@ import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix';
 import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
 import { useGroupDMMembers } from '$hooks/useGroupDMMembers';
 import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds';
+import { Presence, useUserPresence } from '$hooks/useUserPresence';
+import { AvatarPresence, PresenceBadge } from '$components/presence';
 import * as css from './DirectDMsList.css';
 
 const MAX_GROUP_MEMBERS = 3;
@@ -44,6 +46,9 @@ function DMItem({ room, selected }: DMItemProps) {
   // Check if this is a group DM (more than 2 members)
   const isGroupDM = room.getJoinedMemberCount() > 2;
 
+  const dmUserId = !isGroupDM ? room.getAvatarFallbackMember()?.userId : undefined;
+  const dmPresence = useUserPresence(dmUserId ?? '');
+
   // Get member info for group DMs using m.direct and profile API (doesn't require full room state)
   // Members are sorted by who last sent messages (most recent first)
   const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS);
@@ -135,9 +140,19 @@ function DMItem({ room, selected }: DMItemProps) {
     <SidebarItem active={selected}>
       <SidebarItemTooltip tooltip={room.name}>
         {(triggerRef) => (
-          <SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleClick} size="400">
-            {renderAvatar()}
-          </SidebarAvatar>
+          <AvatarPresence
+            badge={
+              !isGroupDM &&
+              dmPresence &&
+              dmPresence.presence !== Presence.Offline && (
+                <PresenceBadge presence={dmPresence.presence} size="200" />
+              )
+            }
+          >
+            <SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleClick} size="400">
+              {renderAvatar()}
+            </SidebarAvatar>
+          </AvatarPresence>
         )}
       </SidebarItemTooltip>
       {unread && (unread.total > 0 || unread.highlight > 0) && (
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index b1b744c1f..8e101065c 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -79,6 +79,7 @@ export interface Settings {
   isWidgetDrawer: boolean;
   memberSortFilterIndex: number;
   enterForNewline: boolean;
+  isMarkdown: boolean;
   editorToolbar: boolean;
   composerToolbarOpen: boolean;
   messageLayout: MessageLayout;
@@ -131,6 +132,11 @@ export interface Settings {
 
   // Sable features!
   sendPresence: boolean;
+  presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline';
+  autoIdlePresence: boolean;
+  presenceIdleTimeoutMins: number;
+  /** User-set status message, cached locally so it survives mode changes and sliding-sync restarts. */
+  presenceStatusMsg: string;
   mobileGestures: boolean;
   rightSwipeAction: RightSwipeAction;
   hideMembershipInReadOnly: boolean;
@@ -210,6 +216,7 @@ export const defaultSettings: Settings = {
   isWidgetDrawer: false,
   memberSortFilterIndex: 0,
   enterForNewline: false,
+  isMarkdown: true,
   editorToolbar: false,
   composerToolbarOpen: false,
   messageLayout: 0,
@@ -263,6 +270,10 @@ export const defaultSettings: Settings = {
 
   // Sable features!
   sendPresence: true,
+  presenceMode: 'online',
+  autoIdlePresence: true,
+  presenceIdleTimeoutMins: 5,
+  presenceStatusMsg: '',
   mobileGestures: true,
   rightSwipeAction: RightSwipeAction.Reply,
   hideMembershipInReadOnly: true,
@@ -469,6 +480,10 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown {
         val === CaptionPosition.Below
         ? val
         : undefined;
+    case 'presenceMode':
+      return val === 'online' || val === 'unavailable' || val === 'dnd' || val === 'offline'
+        ? val
+        : undefined;
     case 'rightSwipeAction':
       return val === RightSwipeAction.Members || val === RightSwipeAction.Reply ? val : undefined;
     case 'renderUserCards':
@@ -561,6 +576,12 @@ export const setSettings = (settings: Settings) => {
   localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
 };
 
+/**
+ * Ephemeral atom — true when the auto-idle hook has transitioned the user to idle.
+ * Not persisted to localStorage; resets to false on every page load.
+ */
+export const presenceAutoIdledAtom = atom(false);
+
 export const settingsAtom = atom<Settings, [Settings], undefined>(
   (get) => get(baseSettings),
   (_get, set, update) => {
diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts
index 2834c5b6f..2430f5324 100644
--- a/src/app/utils/appEvents.ts
+++ b/src/app/utils/appEvents.ts
@@ -1,5 +1,29 @@
+export type VisibilityChangeHandler = (isVisible: boolean) => void;
+type VisibilityHiddenHandler = () => void;
+
+const visibilityChangeHandlers = new Set<VisibilityChangeHandler>();
+const visibilityHiddenHandlers = new Set<VisibilityHiddenHandler>();
+
 export const appEvents = {
-  onVisibilityHidden: null as (() => void) | null,
+  onVisibilityHidden(handler: VisibilityHiddenHandler): () => void {
+    visibilityHiddenHandlers.add(handler);
+    return () => {
+      visibilityHiddenHandlers.delete(handler);
+    };
+  },
+
+  emitVisibilityHidden(): void {
+    visibilityHiddenHandlers.forEach((h) => h());
+  },
+
+  onVisibilityChange(handler: VisibilityChangeHandler): () => void {
+    visibilityChangeHandlers.add(handler);
+    return () => {
+      visibilityChangeHandlers.delete(handler);
+    };
+  },
 
-  onVisibilityChange: null as ((isVisible: boolean) => void) | null,
+  emitVisibilityChange(isVisible: boolean): void {
+    visibilityChangeHandlers.forEach((h) => h(isVisible));
+  },
 };
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index cea5b1f78..95d03bc22 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -578,6 +578,33 @@ export class SlidingSyncManager {
     this.presenceExtension.setEnabled(enabled);
   }
 
+  /**
+   * Synthesizes an own-presence update into the SDK store.
+   * MSC4186 servers never echo back the client's own m.presence events, so after
+   * calling mx.setPresence() we manually build a synthetic event and feed it into
+   * the SDK's User object — exactly what ExtensionPresence.onResponse does for others.
+   */
+  public updateOwnPresence(presence: string, statusMsg: string): void {
+    const userId = this.mx.getUserId();
+    if (!userId) return;
+    const mapper = this.mx.getEventMapper();
+    const rawEvent = {
+      type: 'm.presence',
+      sender: userId,
+      content: { presence, status_msg: statusMsg, currently_active: presence === 'online' },
+    };
+    const event = mapper(rawEvent as Parameters<typeof mapper>[0]);
+    let user = this.mx.store.getUser(userId);
+    if (user) {
+      user.setPresenceEvent(event);
+    } else {
+      user = User.createUser(userId, this.mx);
+      user.setPresenceEvent(event);
+      this.mx.store.storeUser(user);
+    }
+    this.mx.emit(ClientEvent.Event, event);
+  }
+
   public getDiagnostics(): SlidingSyncDiagnostics {
     return {
       proxyBaseUrl: this.proxyBaseUrl,
