Skip to content
Open
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
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
205 changes: 205 additions & 0 deletions src/app/hooks/useUserPresence.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useUserPresence, Presence } from './useUserPresence';

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

// Each test can override mockUser / mockGetPresence as needed.
let mockUser: ReturnType<typeof makeMockUser> | null = null;
let mockGetPresence: ReturnType<typeof vi.fn>;

vi.mock('$hooks/useMatrixClient', () => ({
useMatrixClient: () => mockMx,

Check failure on line 12 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

'mockMx' was used before it was defined
}));

// Listeners registered via user.on() – captured so tests can emit events.
const userListeners = new Map<string, ((...args: unknown[]) => void)[]>();

const makeMockUser = (opts: {

Check failure on line 18 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Insert `⏎··`
presence?: string;

Check failure on line 19 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Insert `··`
presenceStatusMsg?: string | undefined;

Check failure on line 20 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Insert `··`
currentlyActive?: boolean;

Check failure on line 21 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Insert `··`
lastActiveTs?: number;

Check failure on line 22 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Insert `··`
} = {}) => ({

Check failure on line 23 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `}·=·{}` with `··}·=·{}⏎`
userId: '@alice:test',
presence: opts.presence ?? 'online',
presenceStatusMsg: opts.presenceStatusMsg,
currentlyActive: opts.currentlyActive ?? true,
getLastActiveTs: vi.fn().mockReturnValue(opts.lastActiveTs ?? 1000),
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(),
});

const mockMx = {
getUser: vi.fn((): ReturnType<typeof makeMockUser> | null => mockUser),
getPresence: vi.fn(
(): Promise<{
presence: string;
status_msg?: string;
currently_active?: boolean;
last_active_ago?: number | null;
}> =>

Check failure on line 45 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `⏎·····`
mockGetPresence()

Check failure on line 46 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Value of type 'Mock<Procedure | Constructable>' is not callable. Did you mean to include 'new'?
),
on: vi.fn(),
removeListener: vi.fn(),
};

const USER_ID = '@alice:test';

beforeEach(() => {
vi.clearAllMocks();
userListeners.clear();
mockUser = null;
mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default
mockMx.getUser.mockImplementation(() => mockUser);
mockMx.getPresence.mockImplementation(() => mockGetPresence());

Check failure on line 60 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Value of type 'Mock<Procedure | Constructable>' is not callable. Did you mean to include 'new'?
});

// ------- tests -------

describe('useUserPresence', () => {
it('returns undefined when the user is not in the SDK and REST is pending', () => {
// mockUser is null; REST never resolves
const { result } = renderHook(() => useUserPresence(USER_ID));
expect(result.current).toBeUndefined();
});

it('initialises from SDK user when available with a non-zero lastActiveTs', () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 5000 });
// lastActiveTs > 0 — no REST fallback should be triggered
const { result } = renderHook(() => useUserPresence(USER_ID));

expect(result.current).toEqual({
presence: Presence.Online,
status: undefined,
active: true,
lastActiveTs: 5000,
});
expect(mockMx.getPresence).not.toHaveBeenCalled();
});

it('fires the REST fallback when getLastActiveTs() is 0 (sliding-sync server)', async () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 0 });
let resolvePresence!: (v: {
presence: string;
status_msg?: string;
currently_active?: boolean;
last_active_ago?: number;
}) => void;
mockGetPresence = vi

Check failure on line 94 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `⏎······.fn()⏎······.mockReturnValue(new·Promise((res)·=>·{·resolvePresence·=·res;·})` with `.fn().mockReturnValue(⏎······new·Promise((res)·=>·{⏎········resolvePresence·=·res;⏎······})⏎····`
.fn()
.mockReturnValue(new Promise((res) => { resolvePresence = res; }));

const { result } = renderHook(() => useUserPresence(USER_ID));

await act(async () => {
resolvePresence({
presence: 'unavailable',
status_msg: 'in a meeting',
currently_active: false,
last_active_ago: 60_000,
});
});

expect(result.current?.presence).toBe(Presence.Unavailable);
expect(result.current?.status).toBe('in a meeting');
expect(result.current?.active).toBe(false);
// lastActiveTs should be approximately Date.now() - 60_000
expect(result.current?.lastActiveTs).toBeGreaterThan(0);
});

it('fires the REST fallback when user object does not exist yet', async () => {
// user is null — REST should still be requested
let resolvePresence!: (v: { presence: string }) => void;
mockGetPresence = vi

Check failure on line 119 in src/app/hooks/useUserPresence.test.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `⏎······.fn()⏎······.mockReturnValue(new·Promise((res)·=>·{·resolvePresence·=·res;·})` with `.fn().mockReturnValue(⏎······new·Promise((res)·=>·{⏎········resolvePresence·=·res;⏎······})⏎····`
.fn()
.mockReturnValue(new Promise((res) => { resolvePresence = res; }));

const { result } = renderHook(() => useUserPresence(USER_ID));

expect(mockMx.getPresence).toHaveBeenCalledWith(USER_ID);

await act(async () => {
resolvePresence({ presence: 'online' });
});

expect(result.current?.presence).toBe(Presence.Online);
});

it('does NOT fire REST when userId is an empty string', () => {
const { result } = renderHook(() => useUserPresence(''));

expect(mockMx.getPresence).not.toHaveBeenCalled();
expect(result.current).toBeUndefined();
});

it('ignores the REST response after the component unmounts (cancelled flag)', async () => {
let resolvePresence!: (v: { presence: string }) => void;
mockGetPresence = vi
.fn()
.mockReturnValue(new Promise((res) => { resolvePresence = res; }));

const { result, unmount } = renderHook(() => useUserPresence(USER_ID));
unmount();

// Resolve after unmount — cancelled = true, so state should NOT be updated
await act(async () => {
resolvePresence({ presence: 'online' });
});

expect(result.current).toBeUndefined();
});

it('updates presence when UserEvent.Presence fires on the user object', () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));

const { result } = renderHook(() => useUserPresence(USER_ID));

// Mutate mock user to simulate a presence change, then fire the registered listener
mockUser!.presence = 'unavailable';
const handlers = userListeners.get('User.presence') ?? [];

act(() => {
handlers.forEach((h) => h({}, mockUser));
});

expect(result.current?.presence).toBe(Presence.Unavailable);
});

it('resets to undefined when userId changes to a user not in the SDK', () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));

const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), {
initialProps: { uid: USER_ID },
});

expect(result.current).not.toBeUndefined();

// Switch to unknown user
mockUser = null;
rerender({ uid: '@bob:test' });

expect(result.current).toBeUndefined();
});

it('silently ignores a REST error (presence not supported on this server)', async () => {
mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found')));

const { result } = renderHook(() => useUserPresence(USER_ID));

// Wait for the rejection to be processed
await act(async () => {
await Promise.resolve();
});

// Should still be undefined without throwing
expect(result.current).toBeUndefined();
});
});
52 changes: 48 additions & 4 deletions src/app/hooks/useUserPresence.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
import { useMatrixClient } from './useMatrixClient';

export enum Presence {
Expand Down Expand Up @@ -29,20 +29,64 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));

useEffect(() => {
setPresence(user ? getUserPresence(user) : undefined);

let cancelled = false;

// Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never
// delivered via sync. As a result, User.presence stays at the SDK default and
// getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state.
// Guard against empty userId — callers that render a fixed number of hooks (e.g. group DM
// slots) pass '' for absent members; firing getPresence('') would be a malformed request.
if (userId && (!user || user.getLastActiveTs() === 0)) {
mx.getPresence(userId)
.then((resp) => {
if (cancelled) return;
setPresence({
presence: resp.presence as Presence,
status: resp.status_msg,
active: resp.currently_active ?? false,
lastActiveTs:
resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined,
});
})
.catch(() => {
// Presence not available on this server (404 or not supported) — keep existing state.
});
}

const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
if (u.userId === user?.userId) {
setPresence(getUserPresence(user));
if (u.userId === userId) {
setPresence(getUserPresence(u));
}
};
user?.on(UserEvent.Presence, updatePresence);
user?.on(UserEvent.CurrentlyActive, updatePresence);
user?.on(UserEvent.LastPresenceTs, updatePresence);

// If the User object doesn't exist yet, subscribe at client level as a fallback.
// ExtensionPresence emits ClientEvent.Event after creating and updating the User object,
// so by the time this fires mx.getUser(userId) is guaranteed to be non-null.
let removeClientListener: (() => void) | undefined;
if (!user) {
const onClientEvent = (event: MatrixEvent) => {
if (event.getSender() !== userId || event.getType() !== 'm.presence') return;
const u = mx.getUser(userId);
if (!u) return;
setPresence(getUserPresence(u));
};
mx.on(ClientEvent.Event, onClientEvent);
removeClientListener = () => mx.removeListener(ClientEvent.Event, onClientEvent);
}

return () => {
cancelled = true;
user?.removeListener(UserEvent.Presence, updatePresence);
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
removeClientListener?.();
};
}, [user]);
}, [mx, userId, user]);

return presence;
};
Expand Down
6 changes: 6 additions & 0 deletions src/app/pages/client/ClientNonUIFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@
visibilityState: document.visibilityState,
});
} catch (err) {
console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err);

Check warning on line 806 in src/app/pages/client/ClientNonUIFeatures.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
pushRelayLog.error(
'notification',
'Push relay decryption failed',
Expand Down Expand Up @@ -835,6 +835,12 @@
mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline);
// Sliding sync: enable/disable the presence extension on the next poll.
getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
// Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no
// effect. Explicitly PUT /presence/{userId}/status so the server knows the user's
// state — otherwise GET /presence returns stale offline and own presence badge is grey.
mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => {
// Server doesn't support presence — ignore.
});
}, [mx, sendPresence]);

return null;
Expand Down
35 changes: 23 additions & 12 deletions src/app/pages/client/sidebar/AccountSwitcherTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils';
import { logoutClient, initClient, stopClient } from '$client/initMatrix';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useUserProfile } from '$hooks/useUserProfile';
import { useUserPresence } from '$hooks/useUserPresence';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useSessionProfiles } from '$hooks/useSessionProfiles';
import { useOpenSettings } from '$features/settings';
import { Modal500 } from '$components/Modal500';
import { AvatarPresence, PresenceBadge } from '$components/presence';
import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import { useClientConfig } from '$hooks/useClientConfig';
Expand Down Expand Up @@ -173,6 +175,7 @@ export function AccountSwitcherTab() {

const myUserId = mx.getUserId() ?? '';
const activeProfile = useUserProfile(myUserId);
const myPresence = useUserPresence(myUserId);
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
Expand Down Expand Up @@ -270,19 +273,27 @@ export function AccountSwitcherTab() {
<SidebarItem active={!!menuAnchor}>
<SidebarItemTooltip tooltip={label}>
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
onClick={handleToggle}
outlined={sessions.length > 1}
<AvatarPresence
badge={
myPresence && myPresence.lastActiveTs !== 0 ? (
<PresenceBadge presence={myPresence.presence} size="200" />
) : undefined
}
>
<UserAvatar
userId={activeSession.userId}
src={activeAvatarUrl}
alt={label}
renderFallback={() => <Text size="H4">{nameInitials(label)}</Text>}
/>
</SidebarAvatar>
<SidebarAvatar
as="button"
ref={triggerRef}
onClick={handleToggle}
outlined={sessions.length > 1}
>
<UserAvatar
userId={activeSession.userId}
src={activeAvatarUrl}
alt={label}
renderFallback={() => <Text size="H4">{nameInitials(label)}</Text>}
/>
</SidebarAvatar>
</AvatarPresence>
)}
</SidebarItemTooltip>
{(totalBackgroundUnread > 0 || anyBackgroundHighlight) && (
Expand Down
Loading
Loading