Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
73beb8c
feat(presence): add presence badges to sidebar and fix sliding sync p…
Just-Insane Mar 31, 2026
4a6289e
chore: add changeset for presence-sidebar-badges
Just-Insane Mar 31, 2026
ac4e5b4
fix(presence): skip REST presence fetch when userId is empty string
Just-Insane Apr 9, 2026
35acb82
test(presence): add useUserPresence unit tests
Just-Insane Apr 11, 2026
4404e84
feat(presence): add presenceMode setting and Discord-style status picker
Just-Insane Apr 11, 2026
8f69b61
feat(presence): Discord-style presence picker with Idle, DND, and Inv…
Just-Insane Apr 11, 2026
c178b77
feat(presence): add presence badges to sidebar and fix sliding sync p…
Just-Insane Mar 31, 2026
ac75284
chore: add changeset for presence-sidebar-badges
Just-Insane Mar 31, 2026
c7d44d8
fix(presence): skip REST presence fetch when userId is empty string
Just-Insane Apr 9, 2026
ce458fb
test(presence): add useUserPresence unit tests
Just-Insane Apr 11, 2026
f7c7fee
feat(presence): add presenceMode setting and Discord-style status picker
Just-Insane Apr 11, 2026
b86b5de
feat(presence): Discord-style presence picker with Idle, DND, and Inv…
Just-Insane Apr 11, 2026
a71bdab
feat(presence): auto-idle after inactivity timeout
Just-Insane Apr 12, 2026
ca97c9b
chore: add changeset for presence-auto-idle
Just-Insane Apr 12, 2026
264e4ab
fix(presence): restore missing experiment config helpers and clean pr…
Just-Insane Apr 12, 2026
878f2fc
fix(presence): resolve missing deps and stabilize presence hook tests
Just-Insane Apr 12, 2026
e5bdd7c
fix(presence): address review feedback for presence sidebar badges
Just-Insane Apr 15, 2026
3f03876
fix(presence): address review feedback for presence-auto-idle
Just-Insane Apr 15, 2026
0231581
fix(presence): 5min default, wire visibility reset, add tests
Just-Insane Apr 15, 2026
29e4076
refactor: align presence-auto-idle with sw-push-session-recovery
Just-Insane Apr 15, 2026
12b379e
Merge branch 'feat/presence-sidebar-badges' into feat/presence
Just-Insane Apr 15, 2026
2b39316
Merge branch 'feat/presence-auto-idle' into feat/presence
Just-Insane Apr 15, 2026
69179c1
style: fix lint errors from merge
Just-Insane Apr 15, 2026
d7fd640
fix(presence): address review feedback
Just-Insane Apr 15, 2026
cb24302
chore: fix lint and format issues
Just-Insane Apr 15, 2026
8c3c0e7
fix(presence): retry setPresence on failure for app resume reliability
Just-Insane Apr 16, 2026
5f41827
Merge branch 'dev' into feat/presence
Just-Insane Apr 19, 2026
5cc0da5
fix(presence): normalize dnd state handling
Just-Insane Apr 19, 2026
ef01765
fix(presence): harden desktop auto-idle detection
Just-Insane Apr 19, 2026
a8b17b8
fix(presence): add heartbeat to win back online state on multi-device
Just-Insane Apr 22, 2026
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
5 changes: 5 additions & 0 deletions .changeset/presence-auto-idle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker
5 changes: 5 additions & 0 deletions .changeset/presence-sidebar-badges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Add presence status badges to sidebar DM list and account switcher
2 changes: 2 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"enabled": true
},

"presenceAutoIdleTimeoutMs": 300000,

"featuredCommunities": {
"openAsDefault": false,
"spaces": [
Expand Down
1 change: 1 addition & 0 deletions src/app/components/presence/Presence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const PresenceToColor: Record<Presence, MainColor> = {
[Presence.Online]: 'Success',
[Presence.Unavailable]: 'Warning',
[Presence.Offline]: 'Secondary',
[Presence.Dnd]: 'Critical',
};

type PresenceBadgeProps = {
Expand Down
7 changes: 5 additions & 2 deletions src/app/features/settings/account/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { CompactUploadCardRenderer } from '$components/upload-card';
import { useCapabilities } from '$hooks/useCapabilities';
import { profilesCacheAtom } from '$state/userRoomProfile';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { useUserPresence } from '$hooks/useUserPresence';
import { Presence, useUserPresence } from '$hooks/useUserPresence';
import { MSC1767Text } from '$types/matrix/common';
import { TimezoneEditor } from './TimezoneEditor';
import { PronounEditor } from './PronounEditor';
Expand Down Expand Up @@ -511,7 +511,10 @@ function ProfileExtended({ profile, userId }: Readonly<ProfileProps>) {

const handleSaveStatus = useCallback(
async (newStatus: string) => {
const currentState = presence?.presence || 'online';
const currentState =
presence?.presence === Presence.Dnd
? Presence.Online
: (presence?.presence ?? Presence.Online);

await mx.setPresence({
presence: currentState,
Expand Down
2 changes: 2 additions & 0 deletions src/app/hooks/useClientConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export type ClientConfig = {

matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
/** How long (ms) without input before auto-idling presence. 0 = disabled. */
presenceAutoIdleTimeoutMs?: number;
};

const ClientConfigContext = createContext<ClientConfig | null>(null);
Expand Down
240 changes: 240 additions & 0 deletions src/app/hooks/usePresenceAutoIdle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { act, renderHook } from '@testing-library/react';
import { Provider, useAtomValue } from 'jotai';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { presenceAutoIdledAtom } from '$state/settings';
import type { ReactNode } from 'react';
import { usePresenceAutoIdle } from './usePresenceAutoIdle';

// -------- mock setup --------

const userListeners = new Map<string, ((...args: unknown[]) => void)[]>();

const makeMockUser = () => ({
userId: '@alice:test',
presence: 'online',
on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
const list = userListeners.get(event) ?? [];
list.push(handler);
userListeners.set(event, list);
}),
removeListener: vi.fn(),
});

let mockUser: ReturnType<typeof makeMockUser> | null = null;

const makeMockMx = () => ({
getUserId: vi.fn(() => '@alice:test'),
getUser: vi.fn(() => 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
) {
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 the document becomes visible again', () => {
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
wrapper,
});

act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current).toBe(true);

const visibilityStateSpy = vi
.spyOn(document, 'visibilityState', 'get')
.mockReturnValue('visible');

act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current).toBe(false);

visibilityStateSpy.mockRestore();
});

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('still goes idle after the window loses focus', () => {
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
wrapper,
});

act(() => {
window.dispatchEvent(new Event('blur'));
vi.advanceTimersByTime(5000);
});

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('stops responding to focus events after cleanup', () => {
const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
wrapper,
});

// Go idle.
act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current).toBe(true);

unmount();

act(() => {
window.dispatchEvent(new Event('focus'));
});

expect(result.current).toBe(true);
});
});
Loading