Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-perf-rerender-reduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Reduce unnecessary re-renders: memoize VList style in RoomTimeline, remove per-message UnreadNotifications listener from ThreadReplyChip, and reset presence state correctly when navigating between user profiles.
20 changes: 12 additions & 8 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,17 @@ export function RoomTimeline({

const [shift, setShift] = useState(false);
const [topSpacerHeight, setTopSpacerHeight] = useState(0);
const vListStyle = useMemo(
() => ({
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column' as const,
paddingTop: topSpacerHeight > 0 ? topSpacerHeight : config.space.S600,
paddingBottom: config.space.S600,
}),
[topSpacerHeight]
);

const topSpacerHeightRef = useRef(0);
const mountScrollWindowRef = useRef<number>(Date.now() + 3000);
Expand Down Expand Up @@ -839,14 +850,7 @@ export function RoomTimeline({
data={processedEvents}
shift={shift}
className={css.messageList}
style={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
paddingTop: topSpacerHeight > 0 ? topSpacerHeight : config.space.S600,
paddingBottom: config.space.S600,
}}
style={vListStyle}
onScroll={handleVListScroll}
>
{(eventData, index) => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/room/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
style={
room.isCallRoom() && screenSize === ScreenSize.Desktop
? { maxWidth: toRem(399), minWidth: toRem(399) }
: {}
: undefined
}
>
<SwipeableChatWrapper onOpenSidebar={onBack} onOpenMembers={handleOpenMembers}>
Expand Down
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
4 changes: 3 additions & 1 deletion src/app/styles/overrides/General.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ globalStyle(
button[class*="_1684mq51"]:has(img):hover,
[data-index] [class*="_1r9nvaso"]:hover,
[data-index] [class*="_1r9nvaso"] *:hover,
[data-index] button:has(p):hover
[data-index] button:has(p):hover,
[data-index] button:hover,
[data-index] [role="button"]:hover
`,
{
transform: 'none !important',
Expand Down
Loading