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
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
50 changes: 46 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,62 @@ 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.
if (!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 @@ -799,7 +799,7 @@
visibilityState: document.visibilityState,
});
} catch (err) {
console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err);

Check warning on line 802 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 @@ -831,6 +831,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 { Settings } 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 @@ -271,19 +274,27 @@ export function AccountSwitcherTab() {
<SidebarItem active={!!menuAnchor || settingsOpen}>
<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
34 changes: 30 additions & 4 deletions src/app/pages/client/sidebar/DirectDMsList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useRef, useEffect } from 'react';
import { useMemo, useRef, useEffect, ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { useNavigate } from 'react-router-dom';
import { Avatar, Text, Box } from 'folds';
Expand All @@ -15,6 +15,8 @@ import {
} from '$components/sidebar';
import { RoomAvatar } from '$components/room-avatar';
import { UserAvatar } from '$components/user-avatar';
import { AvatarPresence, PresenceBadge } from '$components/presence';
import { useUserPresence, Presence } from '$hooks/useUserPresence';
import { getDirectRoomAvatarUrl } from '$utils/room';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { nameInitials } from '$utils/common';
Expand Down Expand Up @@ -48,6 +50,28 @@ function DMItem({ room, selected }: DMItemProps) {
// Members are sorted by who last sent messages (most recent first)
const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS);

// Presence hooks — always called unconditionally (React rules of hooks).
// For single DMs: guessDMUserId() is synchronous; group slots use '' → undefined.
// For group DMs: singleDMUserId is '' → undefined; member slots use groupMembers.
const singleDMUserId = isGroupDM ? '' : room.guessDMUserId();
const singleDMPresence = useUserPresence(singleDMUserId);
const member0Presence = useUserPresence(isGroupDM ? (groupMembers[0]?.userId ?? '') : '');
const member1Presence = useUserPresence(isGroupDM ? (groupMembers[1]?.userId ?? '') : '');
const member2Presence = useUserPresence(isGroupDM ? (groupMembers[2]?.userId ?? '') : '');

const groupDMOnline =
isGroupDM &&
[member0Presence, member1Presence, member2Presence].some(
(p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online
);

let presenceBadge: ReactNode;
if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) {
presenceBadge = <PresenceBadge presence={singleDMPresence.presence} size="200" />;
} else if (isGroupDM && groupDMOnline) {
presenceBadge = <PresenceBadge presence={Presence.Online} size="200" />;
}

// Get unread info for badge
const unread = roomToUnread.get(room.roomId);

Expand Down Expand Up @@ -132,9 +156,11 @@ function DMItem({ room, selected }: DMItemProps) {
<SidebarItem active={selected}>
<SidebarItemTooltip tooltip={room.name}>
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleClick} size="400">
{renderAvatar()}
</SidebarAvatar>
<AvatarPresence badge={presenceBadge}>
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleClick} size="400">
{renderAvatar()}
</SidebarAvatar>
</AvatarPresence>
)}
</SidebarItemTooltip>
{unread && (unread.total > 0 || unread.highlight > 0) && (
Expand Down
Loading