Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/app/features/room/MembersDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -150,7 +150,7 @@ function MemberItem({
>
<AvatarPresence
badge={
presence && presence.lastActiveTs !== 0 ? (
presence && presence.presence !== Presence.Offline ? (
<PresenceBadge presence={presence.presence} size="200" />
) : undefined
}
Expand Down
73 changes: 73 additions & 0 deletions src/app/features/settings/general/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -476,6 +477,32 @@ 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"
Expand Down Expand Up @@ -840,6 +867,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,
Expand Down
10 changes: 4 additions & 6 deletions src/app/hooks/useAppVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
};

Expand All @@ -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]);
}
226 changes: 226 additions & 0 deletions src/app/hooks/usePresenceAutoIdle.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading