From 445d973f5937f5579138d172d7ce4ef4e3d09b10 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:59:06 -0400 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20presence=20auto-idle=20=E2=80=94?= =?UTF-8?q?=20set=20unavailable=20after=205=20min=20inactivity=20or=20tab?= =?UTF-8?q?=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/settings/general/General.tsx | 17 ++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 59 +++++++++++++++++++ src/app/state/settings.ts | 2 + 3 files changed, 78 insertions(+) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..969637438 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,22 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + {sendPresence && ( + + + } + /> + + )} { // Classic sync: set_presence query param on every /sync poll. @@ -853,6 +854,64 @@ function PresenceFeature() { getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); + // Auto-idle: set presence to unavailable after 5 minutes of inactivity or + // when the tab is hidden, and restore online on activity. + useEffect(() => { + if (!sendPresence || !autoIdlePresence) return undefined; + + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + let idleTimer: ReturnType | undefined; + let isIdle = false; + + const goOnline = () => { + if (!isIdle) return; + isIdle = false; + mx.setPresence({ presence: 'online' }).catch(() => {}); + }; + + const goIdle = () => { + if (isIdle) return; + isIdle = true; + mx.setPresence({ presence: 'unavailable' }).catch(() => {}); + }; + + const resetTimer = () => { + goOnline(); + clearTimeout(idleTimer); + idleTimer = setTimeout(goIdle, IDLE_TIMEOUT_MS); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + clearTimeout(idleTimer); + goIdle(); + } else { + resetTimer(); + } + }; + + const ACTIVITY_EVENTS: (keyof DocumentEventMap)[] = [ + 'mousemove', + 'keydown', + 'pointerdown', + 'scroll', + ]; + + ACTIVITY_EVENTS.forEach((e) => document.addEventListener(e, resetTimer, { passive: true })); + document.addEventListener('visibilitychange', handleVisibilityChange); + resetTimer(); + + return () => { + clearTimeout(idleTimer); + ACTIVITY_EVENTS.forEach((e) => document.removeEventListener(e, resetTimer)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + // Restore online when feature is disabled + if (isIdle) { + mx.setPresence({ presence: 'online' }).catch(() => {}); + } + }; + }, [mx, sendPresence, autoIdlePresence]); + return null; } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b1b744c1f..fbe9e7ae2 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -131,6 +131,7 @@ export interface Settings { // Sable features! sendPresence: boolean; + autoIdlePresence: boolean; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -263,6 +264,7 @@ export const defaultSettings: Settings = { // Sable features! sendPresence: true, + autoIdlePresence: true, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, From 3f9700c31228266a84ff72861ebcb88ca768e99e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 19:30:16 -0400 Subject: [PATCH 02/13] fix(presence): show presence dot in account switcher, DM sidebar, and members drawer - AccountSwitcherTab: wrap SidebarAvatar in AvatarPresence with current user's dot - DirectDMsList: add AvatarPresence badge on 1:1 DM icons using the DM user's presence - MembersDrawer: replace lastActiveTs !== 0 guard with presence !== Offline so online users show a dot --- src/app/features/room/MembersDrawer.tsx | 4 +-- .../client/sidebar/AccountSwitcherTab.tsx | 36 ++++++++++++------- .../pages/client/sidebar/DirectDMsList.tsx | 21 +++++++++-- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 2641899d4..15d16a3cc 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({ > ) : undefined } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index a3ec48466..ed29252e5 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -45,6 +45,8 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -174,6 +176,7 @@ export function AccountSwitcherTab() { ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; const activeDisplayName = activeProfile.displayName; + const myPresence = useUserPresence(myUserId); const sessionProfiles = useSessionProfiles(sessions); @@ -269,19 +272,28 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} + + ) + } > - {nameInitials(label)}} - /> - + 1} + > + {nameInitials(label)}} + /> + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( 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) { {(triggerRef) => ( - - {renderAvatar()} - + + ) + } + > + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( From bf787e70896208aef4d17528abe17469890e0dd1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 20:05:02 -0400 Subject: [PATCH 03/13] fix(presence): publish online on enable, update auto-idle description --- src/app/features/settings/general/General.tsx | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 969637438..a06f61e9e 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -482,7 +482,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { {}); + } }, [mx, sendPresence]); // Auto-idle: set presence to unavailable after 5 minutes of inactivity or From ab8e7941d1f9d4d77520134022e64ddeb766f5ac Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 5 May 2026 10:31:34 -0400 Subject: [PATCH 04/13] feat(presence): configurable idle timeout setting --- src/app/features/settings/general/General.tsx | 58 ++++++++++++++++++- src/app/hooks/useUserPresence.ts | 22 +++++-- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++- src/app/state/settings.ts | 4 ++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index a06f61e9e..34ca0f992 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -482,7 +482,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { ) { /> )} + {sendPresence && autoIdlePresence && ( + + } + /> + + )} = (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 = (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 ( + + + + min + + + ); +} + function Calls() { const [alwaysShowCallButton, setAlwaysShowCallButton] = useSetting( settingsAtom, diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 0c90c79f9..edfa8e32f 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,6 +1,6 @@ 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 { @@ -31,7 +31,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 +62,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { user.removeListener(UserEvent.CurrentlyActive, updatePresence); user.removeListener(UserEvent.LastPresenceTs, updatePresence); }; - }, [user]); + }, [mx, user, userId]); return presence; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f8552e66c..74ae41118 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -845,6 +845,7 @@ function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); const [autoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); + const [presenceIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins'); useEffect(() => { // Classic sync: set_presence query param on every /sync poll. @@ -859,12 +860,12 @@ function PresenceFeature() { } }, [mx, sendPresence]); - // Auto-idle: set presence to unavailable after 5 minutes of inactivity or + // Auto-idle: set presence to unavailable after inactivity or // when the tab is hidden, and restore online on activity. useEffect(() => { if (!sendPresence || !autoIdlePresence) return undefined; - const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + const IDLE_TIMEOUT_MS = Math.max(1, presenceIdleTimeoutMins) * 60 * 1000; let idleTimer: ReturnType | undefined; let isIdle = false; @@ -915,7 +916,7 @@ function PresenceFeature() { mx.setPresence({ presence: 'online' }).catch(() => {}); } }; - }, [mx, sendPresence, autoIdlePresence]); + }, [mx, sendPresence, autoIdlePresence, presenceIdleTimeoutMins]); return null; } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index fbe9e7ae2..cf2d58f53 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; @@ -132,6 +133,7 @@ export interface Settings { // Sable features! sendPresence: boolean; autoIdlePresence: boolean; + presenceIdleTimeoutMins: number; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -211,6 +213,7 @@ export const defaultSettings: Settings = { isWidgetDrawer: false, memberSortFilterIndex: 0, enterForNewline: false, + isMarkdown: true, editorToolbar: false, composerToolbarOpen: false, messageLayout: 0, @@ -265,6 +268,7 @@ export const defaultSettings: Settings = { // Sable features! sendPresence: true, autoIdlePresence: true, + presenceIdleTimeoutMins: 5, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, From 0cac3c16af6c60b647fa3994707323aefb5c72d9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 18:54:33 -0400 Subject: [PATCH 05/13] feat(presence): restore Discord-style presence picker and presenceMode setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feat/presence-auto-idle branch (PR #672) was replaced by feat/presence (PR #689) without porting the picker, presenceMode setting, DND mode, or usePresenceAutoIdle hook. Restore from the 29e407699 tip still in the local object store. - appEvents: refactor to multi-subscriber Set pattern (supports multiple listeners on onVisibilityChange/onVisibilityHidden) - settings: add presenceMode ('online'|'unavailable'|'dnd'|'offline'), default 'online'; add ephemeral presenceAutoIdledAtom - usePresenceAutoIdle: new hook — inactivity timer, activity reset, appEvents visibility integration, multi-device sync via User.presence - usePresenceAutoIdle.test.tsx: 10 unit tests covering all branches - ClientNonUIFeatures: PresenceFeature now uses presenceMode + autoIdled to broadcast the correct presence state; DND sends status_msg='dnd' - AccountSwitcherTab: own badge driven from presenceMode settings state (not SDK User.presence which MSC4186 servers never echo back); adds Status section to account menu with Online/Idle/DND/Invisible picker --- src/app/hooks/useAppVisibility.ts | 10 +- src/app/hooks/usePresenceAutoIdle.test.tsx | 226 ++++++++++++++++++ src/app/hooks/usePresenceAutoIdle.ts | 109 +++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 100 +++----- .../client/sidebar/AccountSwitcherTab.tsx | 94 +++++++- src/app/state/settings.ts | 12 + src/app/utils/appEvents.ts | 28 ++- 7 files changed, 492 insertions(+), 87 deletions(-) create mode 100644 src/app/hooks/usePresenceAutoIdle.test.tsx create mode 100644 src/app/hooks/usePresenceAutoIdle.ts 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 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 | null = null; + +const makeMockMx = () => ({ + getUserId: vi.fn<() => string>(() => '@alice:test'), + getUser: vi.fn<() => ReturnType | null>(() => mockUser), +}); + +let mockMx: ReturnType; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +// Helper to read the atom value alongside the hook under test. +function useAutoIdledReader( + mx: ReturnType, + 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(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/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 74ae41118..199eb1971 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,79 +848,39 @@ 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 [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'; + // 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); - // Explicitly publish online so the server echoes back our presence event, - // which lets useUserPresence update the badge immediately. - if (sendPresence) { - mx.setPresence({ presence: 'online' }).catch(() => {}); - } - }, [mx, sendPresence]); - - // Auto-idle: set presence to unavailable after inactivity or - // when the tab is hidden, and restore online on activity. - useEffect(() => { - if (!sendPresence || !autoIdlePresence) return undefined; - - const IDLE_TIMEOUT_MS = Math.max(1, presenceIdleTimeoutMins) * 60 * 1000; - let idleTimer: ReturnType | undefined; - let isIdle = false; - - const goOnline = () => { - if (!isIdle) return; - isIdle = false; - mx.setPresence({ presence: 'online' }).catch(() => {}); - }; - - const goIdle = () => { - if (isIdle) return; - isIdle = true; - mx.setPresence({ presence: 'unavailable' }).catch(() => {}); - }; - - const resetTimer = () => { - goOnline(); - clearTimeout(idleTimer); - idleTimer = setTimeout(goIdle, IDLE_TIMEOUT_MS); - }; - - const handleVisibilityChange = () => { - if (document.visibilityState === 'hidden') { - clearTimeout(idleTimer); - goIdle(); - } else { - resetTimer(); - } - }; - - const ACTIVITY_EVENTS: (keyof DocumentEventMap)[] = [ - 'mousemove', - 'keydown', - 'pointerdown', - 'scroll', - ]; - - ACTIVITY_EVENTS.forEach((e) => document.addEventListener(e, resetTimer, { passive: true })); - document.addEventListener('visibilitychange', handleVisibilityChange); - resetTimer(); - - return () => { - clearTimeout(idleTimer); - ACTIVITY_EVENTS.forEach((e) => document.removeEventListener(e, resetTimer)); - document.removeEventListener('visibilitychange', handleVisibilityChange); - // Restore online when feature is disabled - if (isIdle) { - mx.setPresence({ presence: 'online' }).catch(() => {}); - } - }; - }, [mx, sendPresence, autoIdlePresence, presenceIdleTimeoutMins]); + // 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: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', + }).catch(() => { + // Server doesn't support presence — ignore. + }); + }, [mx, sendPresence, presenceMode, autoIdled]); return null; } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index ed29252e5..8a6fc98d5 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,8 +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 { Presence, useUserPresence } from '$hooks/useUserPresence'; +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'); @@ -176,7 +179,26 @@ export function AccountSwitcherTab() { ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; const activeDisplayName = activeProfile.displayName; - const myPresence = useUserPresence(myUserId); + + // 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 = + effectiveDisplayMode === 'dnd' ? ( + // DND: solid red badge (broadcasts as online with status_msg 'dnd') + + ) : ( + + ); + } const sessionProfiles = useSessionProfiles(sessions); @@ -272,14 +294,7 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - - ) - } - > + Add Account + + Status + + {( + [ + { 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' ? ( + + ) : ( + + ); + return ( + + ) : 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); + }} + > + + {statusLabel} + {desc && ( + + {desc} + + )} + + + ); + })} + { 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( (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(); +const visibilityHiddenHandlers = new Set(); + 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)); + }, }; From 6904a9ccd88ce4d2ce6d021fcee057db322b21f3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 09:28:47 -0400 Subject: [PATCH 06/13] fix(presence): show own presence/status with sliding sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSC4186 servers never echo back the client's own m.presence events, so mx.getUser(myUserId) was never updated in the SDK store. This left own presence badges in the member list stuck at the SDK default forever, and the status editor always showing a blank status. Three changes: - settings: add `presenceStatusMsg` to locally cache the user's custom status message so it survives mode changes and sliding-sync restarts and is no longer silently cleared to '' on every presence broadcast - slidingSync: add SlidingSyncManager.updateOwnPresence(presence, statusMsg) which synthesises a minimal m.presence event for own user and feeds it into the SDK User object — exactly what ExtensionPresence.onResponse does for remote users - ClientNonUIFeatures/PresenceFeature: read presenceStatusMsg from settings, preserve it in mx.setPresence() calls, then call updateOwnPresence() so the local SDK store stays accurate after each broadcast - Profile/handleSaveStatus: persist the new status to the settings atom, call updateOwnPresence() after mx.setPresence() so the member-list badge and status editor reflect the change immediately without needing a server echo --- src/app/features/settings/account/Profile.tsx | 25 +++++++++++------ src/app/pages/client/ClientNonUIFeatures.tsx | 19 +++++++++---- src/app/state/settings.ts | 3 +++ src/client/slidingSync.ts | 27 +++++++++++++++++++ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 19dc11609..dfcab8a31 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) { 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,14 @@ function ProfileExtended({ profile, userId }: Readonly) { const handleSaveStatus = useCallback( async (newStatus: string) => { - const currentState = presence?.presence || 'online'; - - await mx.setPresence({ - presence: currentState, - status_msg: newStatus, - }); + const currentState = presence?.presence ?? 'online'; + setPresenceStatusMsg(newStatus); + await mx.setPresence({ presence: currentState, status_msg: newStatus }); + // MSC4186 servers don't echo own presence back; synthesize the update locally so + // useUserPresence(myUserId) and the member-list badge stay accurate. + getSlidingSyncManager(mx)?.updateOwnPresence(currentState, newStatus); }, - [mx, presence] + [mx, presence, setPresenceStatusMsg] ); return ( diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 199eb1971..313057b7c 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -851,6 +851,7 @@ function PresenceFeature() { 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; @@ -863,6 +864,8 @@ function PresenceFeature() { 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. @@ -876,11 +879,17 @@ function PresenceFeature() { // their presence events because the extension is still enabled above. mx.setPresence({ presence: effectiveState, - status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', - }).catch(() => { - // Server doesn't support presence — ignore. - }); - }, [mx, sendPresence, presenceMode, autoIdled]); + 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/state/settings.ts b/src/app/state/settings.ts index ad5653c1d..8e101065c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -135,6 +135,8 @@ export interface Settings { 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; @@ -271,6 +273,7 @@ export const defaultSettings: Settings = { presenceMode: 'online', autoIdlePresence: true, presenceIdleTimeoutMins: 5, + presenceStatusMsg: '', mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, 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[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, From f01049e7c6d873755e728a5fe63cc23dff31cd79 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 09:31:07 -0400 Subject: [PATCH 07/13] fix(presence): show red DND dot in member list for all Sable clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix has no native DND state; Sable encodes it as presence='online' + status_msg='dnd'. Previously only the account switcher decoded this back to a red dot (via a hardcoded Badge); the member list and all other presence badges just saw 'online' and stayed green. - useUserPresence: add Presence.Dnd = 'dnd' to the enum, decode DND in getUserPresence (online + status_msg==='dnd' → Presence.Dnd), add 'Do Not Disturb' label - Presence.tsx: map Presence.Dnd → 'Critical' (red) in PresenceToColor; DND is not Offline so it naturally gets fill='Solid' - AccountSwitcherTab: remove the bespoke DND badge branch; PresenceBadge now handles Presence.Dnd correctly so the own-badge path is unified --- src/app/components/presence/Presence.tsx | 1 + src/app/hooks/useUserPresence.ts | 24 ++++++++++++++----- .../client/sidebar/AccountSwitcherTab.tsx | 8 +------ 3 files changed, 20 insertions(+), 13 deletions(-) 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.Online]: 'Success', [Presence.Unavailable]: 'Warning', [Presence.Offline]: 'Secondary', + [Presence.Dnd]: 'Critical', }; type PresenceBadgeProps = { diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index edfa8e32f..9a2cf6600 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -7,6 +7,8 @@ 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,21 @@ 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, + status: user.presenceStatusMsg, + active: user.currentlyActive, + lastActiveTs: user.getLastActiveTs(), + }; +}; export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); @@ -73,6 +84,7 @@ export const usePresenceLabel = (): Record => [Presence.Online]: 'Active', [Presence.Unavailable]: 'Busy', [Presence.Offline]: 'Away', + [Presence.Dnd]: 'Do Not Disturb', }), [] ); diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 8a6fc98d5..05277a2be 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -191,13 +191,7 @@ export function AccountSwitcherTab() { const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); let myOwnPresenceBadge: ReactNode; if (sendPresence) { - myOwnPresenceBadge = - effectiveDisplayMode === 'dnd' ? ( - // DND: solid red badge (broadcasts as online with status_msg 'dnd') - - ) : ( - - ); + myOwnPresenceBadge = ; } const sessionProfiles = useSessionProfiles(sessions); From eec0421738a353afef7de71533f070dc2bfd8689 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 09:40:22 -0400 Subject: [PATCH 08/13] fix(presence): fix status save and DND sentinel leaking as status text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs fixed: 1. handleSaveStatus passed presence?.presence to mx.setPresence(). After the Presence.Dnd addition, this could send presence='dnd', which is not a valid Matrix presence value — the server rejects it and the status is silently not saved. Fix: remove the direct mx.setPresence call; PresenceFeature's effect (already triggered by the presenceStatusMsg atom change) handles broadcasting with proper DND translation. 2. getUserPresence exposed user.presenceStatusMsg='dnd' as the status property. The member list and status editor would show the literal text "dnd" for DND users. Fix: filter the sentinel out (return undefined for status when presenceStatusMsg==='dnd'). 3. handleSaveStatus and PresenceFeature's effect both called mx.setPresence concurrently when the status changed. PresenceFeature's call correctly overwrites DND users' custom status with the 'dnd' sentinel, undoing the direct call. Fix: let PresenceFeature be the sole broadcaster; handleSaveStatus eagerly calls updateOwnPresence for instant member-list feedback without waiting for the network round-trip. --- src/app/features/room/MembersDrawer.tsx | 2 -- src/app/features/settings/account/Profile.tsx | 22 ++++++++++++++----- src/app/features/settings/general/General.tsx | 6 +---- src/app/hooks/useUserPresence.ts | 3 ++- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 15d16a3cc..cca9bbddd 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -296,8 +296,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { ); const handleMemberClick: MouseEventHandler = (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 dfcab8a31..c4aa8576b 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -522,14 +522,24 @@ function ProfileExtended({ profile, userId }: Readonly) { const handleSaveStatus = useCallback( async (newStatus: string) => { - const currentState = presence?.presence ?? 'online'; + // 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); - await mx.setPresence({ presence: currentState, status_msg: newStatus }); - // MSC4186 servers don't echo own presence back; synthesize the update locally so - // useUserPresence(myUserId) and the member-list badge stay accurate. - getSlidingSyncManager(mx)?.updateOwnPresence(currentState, 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, setPresenceStatusMsg] + [mx, setPresenceStatusMsg] ); return ( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 34ca0f992..14115dd13 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -484,11 +484,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { focusId="auto-idle-presence" description="Automatically appear unavailable after a period of inactivity or when the app isn't active." after={ - + } /> diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 9a2cf6600..6f78d734a 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -28,7 +28,8 @@ const getUserPresence = (user: User): UserPresence => { : rawPresence; return { presence, - status: user.presenceStatusMsg, + // 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(), }; From 780645ac85f01951b814a20b060f6176a61690e9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 20:10:24 -0400 Subject: [PATCH 09/13] fix(presence): update own presence badge optimistically, not in .then() Previously updateOwnPresence() was called inside the .then() callback of mx.setPresence(), so the own-presence badge in the member list and the DND red dot only appeared after the server acknowledged the request. If the server returned an error the update never happened at all. Move updateOwnPresence() before the API call so the local SDK User object is always kept in sync with the settings state immediately. The server PUT still happens in the background for broadcasting to others; we just no longer wait on it to update our own UI. --- src/app/pages/client/ClientNonUIFeatures.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 313057b7c..51cf5cc82 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -877,18 +877,16 @@ function PresenceFeature() { // - 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. + // Optimistically update own presence in the local SDK store so the member list + // badge and status editor reflect the change immediately. MSC4186 servers never + // echo own presence back, so we can't rely on the server round-trip for this. + getSlidingSyncManager(mx)?.updateOwnPresence(effectiveState, effectiveStatusMsg); 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. - }); + }).catch(() => { + // Server doesn't support presence — ignore. + }); }, [mx, sendPresence, presenceMode, presenceStatusMsg, autoIdled]); return null; From e4cd4544e3b49e7230bd3c483c60a3cb382ad81a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 10:37:53 -0400 Subject: [PATCH 10/13] fix(presence): update own presence after network call succeeds - Move updateOwnPresence() call to .then() after setPresence() succeeds - Prevents local SDK store from diverging if the network call fails - Improves consistency of own presence badge across UI - Network failures no longer leave stale optimistic state --- src/app/pages/client/ClientNonUIFeatures.tsx | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 51cf5cc82..b15c37200 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -873,20 +873,23 @@ function PresenceFeature() { // 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); - // 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. - // Optimistically update own presence in the local SDK store so the member list - // badge and status editor reflect the change immediately. MSC4186 servers never - // echo own presence back, so we can't rely on the server round-trip for this. - getSlidingSyncManager(mx)?.updateOwnPresence(effectiveState, effectiveStatusMsg); + + // Explicitly PUT /presence/{userId}/status so the server knows the exact state. + // Do the optimistic update AFTER the network call to avoid inconsistency if it fails. mx.setPresence({ presence: effectiveState, status_msg: effectiveStatusMsg, - }).catch(() => { - // Server doesn't support presence — ignore. - }); + }) + .then(() => { + // Optimistically update own presence in the local SDK store so the member list + // badge and status editor reflect the change immediately. MSC4186 servers never + // echo own presence back, so we rely on this local update for consistency. + getSlidingSyncManager(mx)?.updateOwnPresence(effectiveState, effectiveStatusMsg); + }) + .catch(() => { + // Server doesn't support presence or network error — the local SDK store + // won't be updated, but that's acceptable since the server state is canonical. + }); }, [mx, sendPresence, presenceMode, presenceStatusMsg, autoIdled]); return null; From a5866c415dca4985a077b5b102c0a12e4486a039 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 15 May 2026 11:36:13 -0400 Subject: [PATCH 11/13] fix(presence): ignore mousemove without OS focus so idle timer runs on desktop when user is in another app --- src/app/hooks/usePresenceAutoIdle.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index dc5af7e21..319446af2 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -4,6 +4,7 @@ import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/m import { presenceAutoIdledAtom } from '$state/settings'; import { appEvents } from '$utils/appEvents'; import { createDebugLogger } from '$utils/debugLogger'; +import { mobileOrTablet } from '$utils/user-agent'; const debugLog = createDebugLogger('PresenceAutoIdle'); const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const; @@ -55,7 +56,14 @@ export function usePresenceAutoIdle( setAutoIdled(true); }; - const handleActivity = () => { + const handleActivity = (event?: Event) => { + // On desktop, cursor movement over the window fires mousemove even when + // the window does not have OS focus (e.g. the user is working in another + // app). Treat those as non-events so the idle timer can run to completion + // without the user having to keep their hands off the mouse entirely. + if (!mobileOrTablet() && event?.type === 'mousemove' && !document.hasFocus()) { + return; + } clearTimer(); if (autoIdledRef.current) { debugLog.info('general', 'Activity detected — clearing auto-idle'); From 792214cb21a9e2ac27812123d930a56cf35f97f1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 21:51:31 -0400 Subject: [PATCH 12/13] fix(presence): wire presence badges in member list and media caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useUserPresence: fall back to content.user_id when event has no sender field (MSC4186 sliding sync presence events may omit sender) - MemberTile: show PresenceBadge for any non-offline presence state, not only when a status message is present; move badge to avatar overlay to match MembersDrawer style - sw.ts: add fetchMediaWithCache — checks sable-media-sw-v1 Cache API before the network; caches 2xx responses only so errors are never stored; replaces bare fetch() in all four auth-media code paths - AvatarImage: module-level svgBlobCache so SVG processing runs at most once per URL; subsequent remounts skip async work, eliminating flicker on scroll --- src/app/components/member-tile/MemberTile.tsx | 39 ++++++++------ .../components/room-avatar/AvatarImage.tsx | 25 ++++++--- src/app/hooks/useUserPresence.ts | 4 +- src/sw.ts | 53 +++++++++++++++---- 4 files changed, 88 insertions(+), 33 deletions(-) diff --git a/src/app/components/member-tile/MemberTile.tsx b/src/app/components/member-tile/MemberTile.tsx index c74433eb7..a754913e9 100644 --- a/src/app/components/member-tile/MemberTile.tsx +++ b/src/app/components/member-tile/MemberTile.tsx @@ -7,8 +7,8 @@ import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { useAtomValue } from 'jotai'; import { nicknamesAtom } from '$state/nicknames'; import { UserAvatar } from '$components/user-avatar'; -import { useUserPresence } from '$hooks/useUserPresence'; -import { PresenceBadge } from '$components/presence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import * as css from './style.css'; const getName = (room: Room, member: RoomMember, nicknames: Record) => @@ -39,25 +39,30 @@ export const MemberTile = as<'button', MemberTileProps>( return ( - - } - /> - + + ) : undefined + } + > + + } + /> + + {name} - {presence && presence.status && ( - - - - {presence.status} - - + {presence?.status && ( + + {presence.status} + )} {after} diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx index f322bce0e..6d0a17bc5 100644 --- a/src/app/components/room-avatar/AvatarImage.tsx +++ b/src/app/components/room-avatar/AvatarImage.tsx @@ -6,6 +6,12 @@ import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import * as css from './RoomAvatar.css'; +// Module-level cache: maps a Matrix media URL → processed blob URL so that +// SVG processing only runs once per unique image, even as virtual-list items +// unmount and remount. MXC URLs are content-addressed and never change, so +// the mapping is stable for the lifetime of the page. +const svgBlobCache = new Map(); + type AvatarImageProps = { src: string; alt?: string; @@ -23,9 +29,15 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp useEffect(() => { let isMounted = true; - let objectUrl: string | null = null; const processImage = async () => { + // Return the cached blob URL immediately — no network round-trip needed. + const cachedBlobUrl = svgBlobCache.get(src); + if (cachedBlobUrl) { + setProcessedSrc(cachedBlobUrl); + return; + } + try { const res = await fetch(src, { mode: 'cors' }); const contentType = res.headers.get('content-type'); @@ -46,8 +58,10 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp const newSvgString = serializer.serializeToString(doc); const blob = new Blob([newSvgString], { type: 'image/svg+xml' }); - objectUrl = URL.createObjectURL(blob); - if (isMounted) setProcessedSrc(objectUrl); + const blobUrl = URL.createObjectURL(blob); + // Store in module cache so future remounts skip processing. + svgBlobCache.set(src, blobUrl); + if (isMounted) setProcessedSrc(blobUrl); } else if (isMounted) setProcessedSrc(src); } catch { if (isMounted) setProcessedSrc(src); @@ -58,9 +72,8 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp return () => { isMounted = false; - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } + // Blob URLs are retained in svgBlobCache — do not revoke them here so + // that subsequent remounts can use the cached result without re-fetching. }; }, [src]); diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 6f78d734a..4964e078c 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -49,7 +49,9 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { // 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(); + // MSC4186 sliding sync presence events may carry the user ID in + // content.user_id rather than the sender field. + const sender = event.getSender() ?? (event.getContent().user_id as string | undefined); if (sender !== userId) return; const latestUser = mx.getUser(userId); if (latestUser) setPresence(getUserPresence(latestUser)); diff --git a/src/sw.ts b/src/sw.ts index 78255b701..21cbdadd4 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -639,15 +639,53 @@ function validMediaRequest(url: string, baseUrl: string): boolean { }); } +/** Cache for authenticated Matrix media responses — keyed by URL. */ +const SW_MEDIA_CACHE = 'sable-media-sw-v1'; + function fetchConfig(token: string): RequestInit { return { headers: { Authorization: `Bearer ${token}`, }, - cache: 'default', + // Use 'no-cache' to ensure we always check with the server on the first + // miss; successful responses are stored in SW_MEDIA_CACHE so avatars, + // stickers and other static media don't hit the network on every remount. + cache: 'no-cache', }; } +/** + * Fetch media with auth, returning a cached response if available. + * Successful (2xx) responses are written to SW_MEDIA_CACHE; errors are never + * cached so that a transient 401/404 doesn't permanently block a resource. + */ +async function fetchMediaWithCache( + url: string, + accessToken: string, + redirect: RequestRedirect +): Promise { + let cache: Cache | undefined; + try { + cache = await self.caches.open(SW_MEDIA_CACHE); + const cached = await cache.match(url); + if (cached) return cached; + } catch { + // caches may be unavailable (e.g. in private browsing on some browsers) + } + + const response = await fetch(url, { ...fetchConfig(accessToken), redirect }); + + if (cache && response.ok) { + // Store a clone — the original body is consumed by the browser. + // Failures are intentionally not cached. + cache.put(url, response.clone()).catch(() => { + // Ignore quota / write errors. + }); + } + + return response; +} + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -678,7 +716,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); + event.respondWith(fetchMediaWithCache(url, session.accessToken, redirect)); return; } @@ -698,7 +736,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { ? preloadedSession : undefined); if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithCache(url, byBaseUrl.accessToken, redirect)); return; } @@ -708,10 +746,7 @@ self.addEventListener('fetch', (event: FetchEvent) => { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { - ...fetchConfig(persisted.accessToken), - redirect, - }); + return fetchMediaWithCache(url, persisted.accessToken, redirect); } return fetch(event.request); }) @@ -723,13 +758,13 @@ self.addEventListener('fetch', (event: FetchEvent) => { requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithCache(url, s.accessToken, redirect); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithCache(url, persisted.accessToken, redirect); } console.warn( '[SW fetch] No valid session for media request', From 7dd384edd5b75761a0d87fbe527c3fccf1c98b5b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 22:21:14 -0400 Subject: [PATCH 13/13] fix(presence): fall back to REST API when MSC4186 has no presence extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synapse's MSC4186 simplified sliding sync does not implement the presence extension (its get_extensions_response only handles to_device, e2ee, account_data, receipts, typing, and thread_subscriptions), so the extensions.presence field is never included in sync responses. Add a fallback in useUserPresence: if user.lastPresenceTs === 0 (the SDK default — no presence data has ever been received), call mx.getPresence(userId) directly against the REST API to populate the User object. Errors are swallowed so servers with presence disabled silently keep the offline default. The ExtensionPresence class is kept in slidingSync.ts so clients automatically benefit if server support is added later; its comment is updated to document the current server limitation. --- src/app/hooks/useUserPresence.ts | 19 +++++++++++++++++++ src/client/slidingSync.ts | 7 +++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 4964e078c..4f5f68aca 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -71,6 +71,25 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { user.on(UserEvent.CurrentlyActive, updatePresence); user.on(UserEvent.LastPresenceTs, updatePresence); + // Synapse's MSC4186 simplified sliding sync has no presence extension, so + // presence events never arrive over the sync stream. If lastPresenceTs is + // still 0 we have never received presence data for this user — fall back to + // a direct REST call so badges appear on first render. + if (user.lastPresenceTs === 0) { + mx.getPresence(userId) + .then((status) => { + user.presence = status.presence; + user.presenceStatusMsg = status.status_msg; + user.currentlyActive = status.currently_active ?? false; + user.lastActiveAgo = status.last_active_ago ?? 0; + user.lastPresenceTs = Date.now(); + setPresence(getUserPresence(user)); + }) + .catch(() => { + // Presence unavailable or disabled on this server — stay offline. + }); + } + return () => { user.removeListener(UserEvent.Presence, updatePresence); user.removeListener(UserEvent.CurrentlyActive, updatePresence); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 95d03bc22..070aec664 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -200,8 +200,11 @@ const getListEndIndex = (list: MSC3575List | null): number => { }; // MSC4186 presence extension: requests `extensions.presence` in every sliding sync -// 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). +// poll. NOTE: Synapse's MSC4186 implementation does not currently support this +// extension (its get_extensions_response only handles to_device, e2ee, account_data, +// receipts, typing, and thread_subscriptions). The extension is kept here so that +// clients automatically benefit if/when server support is added; live presence for +// now is handled by the direct REST fallback in useUserPresence. class ExtensionPresence implements Extension<{ enabled: boolean }, { events?: object[] }> { private enabled = true;