From 1a6aba1990a8d77ddd2de48f41b34cff82d57cd8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 15:33:08 -0400 Subject: [PATCH 1/7] feat(dm-list): show group DM avatars Show multiple member avatars for group DMs in the room list and re-sort direct rooms after timeline resets so activity order recovers after limited sync. --- src/app/features/room-nav/RoomNavItem.tsx | 144 ++++++++----- src/app/features/room-nav/styles.css.ts | 41 +++- src/app/hooks/useGroupDMMembers.ts | 6 +- src/app/pages/client/direct/Direct.tsx | 251 +++++++++++----------- 4 files changed, 263 insertions(+), 179 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 7262e8fbe..785575d2c 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -42,7 +42,7 @@ import { useRoomTypingMember } from '$hooks/useRoomTypingMembers'; import { TypingIndicator } from '$components/typing-indicator'; import { stopPropagation } from '$utils/keyboard'; import { getMatrixToRoom } from '$plugins/matrix-to'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix'; +import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '$utils/matrix'; import { getViaServers } from '$plugins/via-servers'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSetting } from '$state/hooks/settings'; @@ -72,6 +72,9 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; +import { UserAvatar } from '$components/user-avatar'; +import * as css from './styles.css'; import { RoomNavUser } from './RoomNavUser'; import { SidebarUnreadBadge } from '$components/sidebar'; @@ -292,6 +295,10 @@ export function RoomNavItem({ (receipt) => receipt.userId !== mx.getUserId() ); + const isGroupDM = direct === true && room.getJoinedMemberCount() > 2; + // Keep hook call unconditional; pass undefined when not a group DM so the hook no-ops. + const groupMembers = useGroupDMMembers(mx, isGroupDM ? room : undefined, 3); + const nicknames = useAtomValue(nicknamesAtom); const dmUserId = direct ? room.getAvatarFallbackMember()?.userId : undefined; const matrixRoomName = useRoomName(room); @@ -419,63 +426,88 @@ export function RoomNavItem({ style={hideTextStyling(hideText)} > - - - ) - } - style={hideTextStyling(hideText)} - > - + {isGroupDM && showAvatar && groupMembers.length > 1 ? ( + // Group DM: triangle layout of mini avatars +
+ {groupMembers.map((member) => { + const avatarSrc = member.avatarUrl + ? (mxcUrlToHttp( + mx, + member.avatarUrl, + useAuthentication, + 32, + 32, + 'crop' + ) ?? undefined) + : undefined; + return ( + + ( + + {nameInitials(member.displayName ?? member.userId)} + + )} + /> + + ); + })} +
+ ) : ( + + ) + } style={hideTextStyling(hideText)} > - {showAvatar ? ( - ( - - {nameInitials(roomName)} - - )} - /> - ) : ( - - )} -
-
+ + {showAvatar ? ( + ( + + {nameInitials(roomName)} + + )} + /> + ) : ( + + )} + + + )} {unread && hideText && ( 0} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b5701a6f6..b60edab6f 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,5 +1,5 @@ import { style } from '@vanilla-extract/css'; -import { config } from 'folds'; +import { color, config } from 'folds'; export const CategoryButton = style({ flexGrow: 1, @@ -7,3 +7,42 @@ export const CategoryButton = style({ export const CategoryButtonIcon = style({ opacity: config.opacity.P400, }); + +/** + * Group DM multi-avatar layout for the nav item's Avatar size="200" (24 px) slot. + * Three mini avatars are stacked in a triangle: top-centre, bottom-left, bottom-right. + */ +export const GroupAvatarRow = style({ + position: 'relative', + // Match the Avatar size="200" footprint so layout is not disrupted. + width: '24px', + height: '24px', + flexShrink: 0, +}); + +export const GroupAvatarMini = style({ + position: 'absolute', + width: '14px', + height: '14px', + border: `1.5px solid ${color.Surface.Container}`, + borderRadius: '50%', + overflow: 'hidden', + selectors: { + '&:nth-child(1)': { + top: '0', + left: '50%', + transform: 'translateX(-50%)', + zIndex: 3, + }, + '&:nth-child(2)': { + bottom: '0', + left: '0', + zIndex: 2, + }, + '&:nth-child(3)': { + bottom: '0', + right: '0', + zIndex: 1, + }, + }, +}); diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index f4369fe70..00f5e8b66 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -26,12 +26,16 @@ const isBridgeBot = (userId: string): boolean => { */ export const useGroupDMMembers = ( mx: MatrixClient, - room: Room, + room: Room | undefined, maxMembers = 3 ): GroupMemberInfo[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!room) { + setMembers([]); + return; + } const fetchMembers = async () => { try { const currentUserId = mx.getUserId(); diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 2a81b849d..72579f28d 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -102,27 +102,25 @@ function DirectHeader({ hideText }: { hideText?: boolean }) { }; return ( <> - - {hideText ? ( - - - - - - ) : ( - - + + + {!hideText && ( + Direct Messages - - - - - + )} + + + + - )} + { const room = mx.getRoom(roomId); room?.on(RoomEvent.Timeline, handleTimeline); + // Also re-sort when a limited sync resets the room's timeline, as + // getLastActiveTimestamp() will return an updated value afterwards. + room?.on(RoomEvent.TimelineReset, handleTimeline); }); + // Re-sort once immediately after (re-)attaching listeners. + // The initial sliding sync batch fires timeline events before the + // component mounts; bumping the counter here ensures the first + // render picks up up-to-date timestamps from getLastActiveTimestamp(). + setActivityCounter((prev) => prev + 1); + return () => { directsSetRef.current.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); + room?.off(RoomEvent.TimelineReset, handleTimeline); }); }; }, [mx, directs]); @@ -259,113 +267,114 @@ export function Direct() { const hideText = curWidth <= 80 && !isMobile; return ( - - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getDirectCreatePath())}> - - - - - - {!hideText && ( - - - Create Chat - - - )} - - - - - - - - + + + + {noRoomToDisplay ? ( + + ) : ( + + + + + navigate(getDirectCreatePath())}> + + + + + + {!hideText && ( + + + Create Chat + + + )} + + + + + + + + + {!hideText && 'Chats'} + + +
- {!hideText && 'Chats'} - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedDirects[vItem.index]; - if (!roomId) return null; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; + {virtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedDirects[vItem.index]; + if (!roomId) return null; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; - return ( - -
- -
-
- ); - })} -
- - - - )} - +
+ +
+ + ); + })} +
+
+
+
+ )} +
+
{!isMobile && ( )} -
+ ); } From c600d71ed3447c4e7584bf6929164ae54ee53ec9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 15:35:24 -0400 Subject: [PATCH 2/7] fix(dm-list): clean up listener and profile loading lifecycle --- src/app/hooks/useGroupDMMembers.ts | 9 ++++++++- src/app/pages/client/direct/Direct.tsx | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index 00f5e8b66..4f84fb49f 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -32,9 +32,10 @@ export const useGroupDMMembers = ( const [members, setMembers] = useState([]); useEffect(() => { + let cancelled = false; if (!room) { setMembers([]); - return; + return undefined; } const fetchMembers = async () => { try { @@ -110,14 +111,20 @@ export const useGroupDMMembers = ( }); const fetchedMembers = await Promise.all(memberPromises); + if (cancelled) return; setMembers(fetchedMembers); } catch { + if (cancelled) return; // If fetching fails, set empty array setMembers([]); } }; fetchMembers(); + + return () => { + cancelled = true; + }; }, [mx, room, maxMembers]); return members; diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 72579f28d..3898b7222 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -209,13 +209,14 @@ export function Direct() { directsSetRef.current = directs; useEffect(() => { + const directRoomIds = Array.from(directs); const handleTimeline = () => { // Increment counter to trigger re-sort when any timeline event happens setActivityCounter((prev) => prev + 1); }; // Listen to timeline events only for direct message rooms - directsSetRef.current.forEach((roomId) => { + directRoomIds.forEach((roomId) => { const room = mx.getRoom(roomId); room?.on(RoomEvent.Timeline, handleTimeline); // Also re-sort when a limited sync resets the room's timeline, as @@ -230,7 +231,7 @@ export function Direct() { setActivityCounter((prev) => prev + 1); return () => { - directsSetRef.current.forEach((roomId) => { + directRoomIds.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); room?.off(RoomEvent.TimelineReset, handleTimeline); From 29ae5b47ea3c9ba3b87f3d2b6c0a3e37f505f1fe Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 22:30:40 -0400 Subject: [PATCH 3/7] fix(dm-list): seed group DM avatars synchronously to prevent flash --- src/app/hooks/useGroupDMMembers.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index 4f84fb49f..c0a136921 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -19,6 +19,29 @@ const isBridgeBot = (userId: string): boolean => { return false; }; +/** + * Read member info synchronously from already-loaded room state. + * Returns partial data (no profile API) so the first render has something to + * show rather than being empty while the async fetch is in-flight. + */ +function getInitialMembers( + mx: MatrixClient, + room: Room | undefined, + maxMembers: number +): GroupMemberInfo[] { + if (!room) return []; + const currentUserId = mx.getUserId(); + return room + .getMembers() + .filter((m) => m.membership === 'join' && m.userId !== currentUserId && !isBridgeBot(m.userId)) + .slice(0, maxMembers) + .map((m) => ({ + userId: m.userId, + displayName: m.name || m.userId, + avatarUrl: m.getMxcAvatarUrl() ?? undefined, + })); +} + /** * Fetches member information for a group DM. * Gets all joined members from room state and fetches their profiles. @@ -29,7 +52,11 @@ export const useGroupDMMembers = ( room: Room | undefined, maxMembers = 3 ): GroupMemberInfo[] => { - const [members, setMembers] = useState([]); + // Seed from local room state so the triple-avatar layout renders on the + // first paint instead of flashing in after the async profile fetch. + const [members, setMembers] = useState(() => + getInitialMembers(mx, room, maxMembers) + ); useEffect(() => { let cancelled = false; From e33ab68d4e92df90387365de950a3e90406c411a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:24:59 -0400 Subject: [PATCH 4/7] chore: add changeset --- .changeset/dm-list-group-avatars.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dm-list-group-avatars.md diff --git a/.changeset/dm-list-group-avatars.md b/.changeset/dm-list-group-avatars.md new file mode 100644 index 000000000..8eff7f5c3 --- /dev/null +++ b/.changeset/dm-list-group-avatars.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Show group DM composite avatars in the sidebar DM list. From 6066ec0de1df0932de724943a9979e4a9e3b5aac Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 20:00:23 -0400 Subject: [PATCH 5/7] fix(dm-list): address Copilot review feedback - Use stable roomId as VirtualTile key (prevents stale state when sort order changes) - Add getItemKey to virtualizer so it tracks items by roomId not index - Move SidebarResizer inside the position:relative Box so it positions correctly - Guard setMembers([]) with functional update to skip re-render when already empty - Scale GroupAvatarRow to 32px in hideText mode to match Avatar size='300' slot --- src/app/features/room-nav/RoomNavItem.tsx | 6 +++++- src/app/hooks/useGroupDMMembers.ts | 4 +++- src/app/pages/client/direct/Direct.tsx | 26 ++++++++++++----------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 785575d2c..b55d295e0 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -429,7 +429,11 @@ export function RoomNavItem({ {isGroupDM && showAvatar && groupMembers.length > 1 ? ( // Group DM: triangle layout of mini avatars -
+ // In hideText mode the Avatar slot is 32px (size="300"); scale to match. +
{groupMembers.map((member) => { const avatarSrc = member.avatarUrl ? (mxcUrlToHttp( diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index c0a136921..6c256deac 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -61,7 +61,9 @@ export const useGroupDMMembers = ( useEffect(() => { let cancelled = false; if (!room) { - setMembers([]); + // Use functional update to avoid a re-render when state is already empty + // (e.g. every 1:1 DM nav item that never had group members). + setMembers((prev) => (prev.length > 0 ? [] : prev)); return undefined; } const fetchMembers = async () => { diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 3898b7222..4e24e04e2 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -257,6 +257,7 @@ export function Direct() { getScrollElement: () => scrollRef.current, estimateSize: () => 38, overscan: 10, + getItemKey: (index) => sortedDirects[index] ?? index, }); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => @@ -272,6 +273,7 @@ export function Direct() { @@ -335,7 +337,7 @@ export function Direct() { return (
)} + {!isMobile && ( + + )} - {!isMobile && ( - - )} ); } From c8158f8ce02a6e9593c5f37920a73e5e3f036033 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 22:50:44 -0400 Subject: [PATCH 6/7] fix(dm-list-group-avatars): scale composite avatar properly in hideText mode Add GroupAvatarRowHideText (32px) and GroupAvatarMiniHideText (18px) CSS classes for the icon-only sidebar mode. Previously only the container was scaled to 32px via inline style while mini avatars remained at 14px, leaving them sparse and misaligned. Now both container and minis use proportionally larger sizes. Addresses Copilot review comment on #816. --- src/app/features/room-nav/RoomNavItem.tsx | 10 +++--- src/app/features/room-nav/styles.css.ts | 38 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index b55d295e0..b141bdf20 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -428,11 +428,11 @@ export function RoomNavItem({ {isGroupDM && showAvatar && groupMembers.length > 1 ? ( - // Group DM: triangle layout of mini avatars - // In hideText mode the Avatar slot is 32px (size="300"); scale to match. + // Group DM: triangle layout of mini avatars. + // In hideText (icon-only) mode the Avatar slot is 32px (size="300"); + // use the larger container+mini variant so the composite scales properly.
{groupMembers.map((member) => { const avatarSrc = member.avatarUrl @@ -446,7 +446,7 @@ export function RoomNavItem({ ) ?? undefined) : undefined; return ( - + Date: Wed, 20 May 2026 13:31:32 -0400 Subject: [PATCH 7/7] style: fix formatting --- src/app/features/room-nav/RoomNavItem.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index b141bdf20..6efc226d8 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -431,9 +431,7 @@ export function RoomNavItem({ // Group DM: triangle layout of mini avatars. // In hideText (icon-only) mode the Avatar slot is 32px (size="300"); // use the larger container+mini variant so the composite scales properly. -
+
{groupMembers.map((member) => { const avatarSrc = member.avatarUrl ? (mxcUrlToHttp( @@ -446,7 +444,12 @@ export function RoomNavItem({ ) ?? undefined) : undefined; return ( - +