Skip to content

Commit 339056d

Browse files
committed
feat(presence): restore Discord-style presence picker and presenceMode setting
The feat/presence-auto-idle branch (PR SableClient#672) was replaced by feat/presence (PR SableClient#689) without porting the picker, presenceMode setting, DND mode, or usePresenceAutoIdle hook. Restore from the 29e4076 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
1 parent 47b155e commit 339056d

7 files changed

Lines changed: 489 additions & 87 deletions

File tree

src/app/hooks/useAppVisibility.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
2424
`App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`,
2525
{ visibilityState: document.visibilityState }
2626
);
27-
appEvents.onVisibilityChange?.(isVisible);
27+
appEvents.emitVisibilityChange(isVisible);
2828
if (!isVisible) {
29-
appEvents.onVisibilityHidden?.();
29+
appEvents.emitVisibilityHidden();
3030
}
3131
};
3232

@@ -49,9 +49,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
4949
togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, true);
5050
};
5151

52-
appEvents.onVisibilityChange = handleVisibilityForNotifications;
53-
return () => {
54-
appEvents.onVisibilityChange = null;
55-
};
52+
const unsub = appEvents.onVisibilityChange(handleVisibilityForNotifications);
53+
return unsub;
5654
}, [mx, clientConfig, usePushNotifications, pushSubAtom]);
5755
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { Provider, useAtomValue } from 'jotai';
3+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
4+
import { usePresenceAutoIdle } from './usePresenceAutoIdle';
5+
import { presenceAutoIdledAtom } from '$state/settings';
6+
import { appEvents } from '$utils/appEvents';
7+
import type { ReactNode } from 'react';
8+
9+
// -------- mock setup --------
10+
11+
const userListeners = new Map<string, ((...args: unknown[]) => void)[]>();
12+
13+
const makeMockUser = () => ({
14+
userId: '@alice:test',
15+
presence: 'online',
16+
on: vi
17+
.fn<(event: string, handler: (...args: unknown[]) => void) => void>()
18+
.mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
19+
const list = userListeners.get(event) ?? [];
20+
list.push(handler);
21+
userListeners.set(event, list);
22+
}),
23+
removeListener: vi.fn<() => void>(),
24+
});
25+
26+
let mockUser: ReturnType<typeof makeMockUser> | null = null;
27+
28+
const makeMockMx = () => ({
29+
getUserId: vi.fn<() => string>(() => '@alice:test'),
30+
getUser: vi.fn<() => ReturnType<typeof makeMockUser> | null>(() => mockUser),
31+
});
32+
33+
let mockMx: ReturnType<typeof makeMockMx>;
34+
35+
const wrapper = ({ children }: { children: ReactNode }) => <Provider>{children}</Provider>;
36+
37+
// Helper to read the atom value alongside the hook under test.
38+
function useAutoIdledReader(
39+
mx: ReturnType<typeof makeMockMx>,
40+
presenceMode: string,
41+
sendPresence: boolean,
42+
timeoutMs: number
43+
) {
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs);
46+
return useAtomValue(presenceAutoIdledAtom);
47+
}
48+
49+
// -------- lifecycle --------
50+
51+
beforeEach(() => {
52+
vi.useFakeTimers();
53+
vi.clearAllMocks();
54+
userListeners.clear();
55+
mockUser = makeMockUser();
56+
mockMx = makeMockMx();
57+
});
58+
59+
afterEach(() => {
60+
vi.useRealTimers();
61+
});
62+
63+
// -------- tests --------
64+
65+
describe('usePresenceAutoIdle', () => {
66+
it('sets auto-idle after the timeout elapses', () => {
67+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
68+
wrapper,
69+
});
70+
71+
expect(result.current).toBe(false);
72+
73+
act(() => {
74+
vi.advanceTimersByTime(5000);
75+
});
76+
77+
expect(result.current).toBe(true);
78+
});
79+
80+
it('resets auto-idle when user activity is detected', () => {
81+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
82+
wrapper,
83+
});
84+
85+
// Go idle.
86+
act(() => {
87+
vi.advanceTimersByTime(5000);
88+
});
89+
expect(result.current).toBe(true);
90+
91+
// Simulate user activity.
92+
act(() => {
93+
document.dispatchEvent(new Event('mousemove'));
94+
});
95+
expect(result.current).toBe(false);
96+
});
97+
98+
it('resets auto-idle when app becomes visible via appEvents', () => {
99+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
100+
wrapper,
101+
});
102+
103+
act(() => {
104+
vi.advanceTimersByTime(5000);
105+
});
106+
expect(result.current).toBe(true);
107+
108+
// Simulate app returning to foreground.
109+
act(() => {
110+
appEvents.emitVisibilityChange(true);
111+
});
112+
expect(result.current).toBe(false);
113+
});
114+
115+
it('does not go idle when presenceMode is not online', () => {
116+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'dnd', true, 5000), { wrapper });
117+
118+
act(() => {
119+
vi.advanceTimersByTime(10000);
120+
});
121+
expect(result.current).toBe(false);
122+
});
123+
124+
it('does not go idle when sendPresence is false', () => {
125+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', false, 5000), {
126+
wrapper,
127+
});
128+
129+
act(() => {
130+
vi.advanceTimersByTime(10000);
131+
});
132+
expect(result.current).toBe(false);
133+
});
134+
135+
it('does not go idle when timeoutMs is 0', () => {
136+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 0), { wrapper });
137+
138+
act(() => {
139+
vi.advanceTimersByTime(10000);
140+
});
141+
expect(result.current).toBe(false);
142+
});
143+
144+
it('restarts the idle timer on activity before timeout', () => {
145+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
146+
wrapper,
147+
});
148+
149+
// Advance partially, then trigger activity.
150+
act(() => {
151+
vi.advanceTimersByTime(3000);
152+
});
153+
expect(result.current).toBe(false);
154+
155+
act(() => {
156+
document.dispatchEvent(new Event('keydown'));
157+
});
158+
159+
// Original timeout would have fired at 5000ms, but we reset.
160+
act(() => {
161+
vi.advanceTimersByTime(3000);
162+
});
163+
expect(result.current).toBe(false);
164+
165+
// Now the full 5000ms from last activity should trigger idle.
166+
act(() => {
167+
vi.advanceTimersByTime(2000);
168+
});
169+
expect(result.current).toBe(true);
170+
});
171+
172+
it('clears auto-idle when presenceMode changes away from online', () => {
173+
const { result, rerender } = renderHook(
174+
({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000),
175+
{ wrapper, initialProps: { mode: 'online' } }
176+
);
177+
178+
act(() => {
179+
vi.advanceTimersByTime(5000);
180+
});
181+
expect(result.current).toBe(true);
182+
183+
rerender({ mode: 'dnd' });
184+
expect(result.current).toBe(false);
185+
});
186+
187+
it('clears auto-idle when another device sets presence to online', () => {
188+
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
189+
wrapper,
190+
});
191+
192+
act(() => {
193+
vi.advanceTimersByTime(5000);
194+
});
195+
expect(result.current).toBe(true);
196+
197+
// Simulate User.presence event from another device.
198+
const handlers = userListeners.get('User.presence') ?? [];
199+
expect(handlers.length).toBeGreaterThan(0);
200+
201+
act(() => {
202+
handlers.forEach((h) => h({}, { userId: '@alice:test', presence: 'online' }));
203+
});
204+
expect(result.current).toBe(false);
205+
});
206+
207+
it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => {
208+
const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
209+
wrapper,
210+
});
211+
212+
// Go idle.
213+
act(() => {
214+
vi.advanceTimersByTime(5000);
215+
});
216+
expect(result.current).toBe(true);
217+
218+
unmount();
219+
220+
// After unmount, emitting visibility change should have no effect.
221+
// (No error thrown means the handler was properly unsubscribed.)
222+
act(() => {
223+
appEvents.emitVisibilityChange(true);
224+
});
225+
});
226+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
import { useSetAtom } from 'jotai';
3+
import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk';
4+
import { presenceAutoIdledAtom } from '$state/settings';
5+
import { appEvents } from '$utils/appEvents';
6+
import { createDebugLogger } from '$utils/debugLogger';
7+
8+
const debugLog = createDebugLogger('PresenceAutoIdle');
9+
const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const;
10+
11+
/**
12+
* Automatically transitions presence to idle after a configurable inactivity
13+
* timeout, and clears the idle state when activity is detected.
14+
*
15+
* Also subscribes to the Matrix `User.presence` event so that if another device
16+
* sets you back to `online`, the auto-idle state is cleared here too (multi-device
17+
* sync).
18+
*
19+
* Note: On iOS Safari PWA, background tab throttling may delay or prevent the
20+
* inactivity timer from firing reliably. The feature degrades gracefully — presence
21+
* will eventually update when the tab regains focus.
22+
*/
23+
export function usePresenceAutoIdle(
24+
mx: MatrixClient,
25+
presenceMode: string,
26+
sendPresence: boolean,
27+
timeoutMs: number
28+
): void {
29+
const setAutoIdled = useSetAtom(presenceAutoIdledAtom);
30+
const autoIdledRef = useRef(false);
31+
const timerRef = useRef<number | undefined>(undefined);
32+
33+
const clearTimer = useCallback(() => {
34+
if (timerRef.current !== undefined) {
35+
window.clearTimeout(timerRef.current);
36+
timerRef.current = undefined;
37+
}
38+
}, []);
39+
40+
// Inactivity timer: go idle after timeoutMs without user input.
41+
useEffect(() => {
42+
const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0;
43+
if (!shouldAutoIdle) {
44+
clearTimer();
45+
if (autoIdledRef.current) {
46+
autoIdledRef.current = false;
47+
setAutoIdled(false);
48+
}
49+
return undefined;
50+
}
51+
52+
const goIdle = () => {
53+
debugLog.info('general', 'Inactivity timeout — auto-idling');
54+
autoIdledRef.current = true;
55+
setAutoIdled(true);
56+
};
57+
58+
const handleActivity = () => {
59+
clearTimer();
60+
if (autoIdledRef.current) {
61+
debugLog.info('general', 'Activity detected — clearing auto-idle');
62+
autoIdledRef.current = false;
63+
setAutoIdled(false);
64+
}
65+
timerRef.current = window.setTimeout(goIdle, timeoutMs);
66+
};
67+
68+
// Start the initial timer.
69+
timerRef.current = window.setTimeout(goIdle, timeoutMs);
70+
ACTIVITY_EVENTS.forEach((ev) =>
71+
document.addEventListener(ev, handleActivity, { passive: true })
72+
);
73+
74+
// When the app returns to the foreground, treat it as activity so the user
75+
// isn't shown as idle the moment they switch back to the tab/PWA.
76+
const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => {
77+
if (isVisible) handleActivity();
78+
});
79+
80+
return () => {
81+
ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity));
82+
clearTimer();
83+
unsubVisibility();
84+
};
85+
}, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
86+
87+
// Multi-device sync: if another device sets us back to online, clear auto-idle.
88+
useEffect(() => {
89+
if (!sendPresence) return undefined;
90+
const myUserId = mx.getUserId();
91+
if (!myUserId) return undefined;
92+
const user = mx.getUser(myUserId);
93+
if (!user) return undefined;
94+
95+
const handlePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
96+
if (u.userId !== myUserId) return;
97+
if (u.presence === 'online' && autoIdledRef.current) {
98+
debugLog.info('general', 'Remote device set Online — clearing auto-idle');
99+
autoIdledRef.current = false;
100+
setAutoIdled(false);
101+
}
102+
};
103+
104+
user.on(UserEvent.Presence, handlePresence);
105+
return () => {
106+
user.removeListener(UserEvent.Presence, handlePresence);
107+
};
108+
}, [mx, sendPresence, setAutoIdled]);
109+
}

0 commit comments

Comments
 (0)