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/dm-list-group-avatars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Show group DM composite avatars in the sidebar DM list.
144 changes: 88 additions & 56 deletions src/app/features/room-nav/RoomNavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -419,63 +426,88 @@ export function RoomNavItem({
style={hideTextStyling(hideText)}
>
<NavItemContent style={hideTextStyling(hideText)}>
<Box
as="span"
grow="Yes"
alignItems="Center"
justifyContent="Start"
gap="200"
style={hideTextStyling(hideText)}
>
<AvatarPresence
badge={
presence &&
presence.presence !== Presence.Offline && (
<PresenceBadge
presence={presence.presence}
size={hideText ? '300' : '200'}
/>
)
}
style={hideTextStyling(hideText)}
>
<Avatar
size={hideText ? undefined : '200'}
radii="400"
<Box as="span" grow="Yes" alignItems="Center" style={hideTextStyling(hideText)}>
{isGroupDM && showAvatar && groupMembers.length > 1 ? (
// Group DM: triangle layout of mini avatars
<div className={css.GroupAvatarRow}>
{groupMembers.map((member) => {
Comment on lines +429 to +433
const avatarSrc = member.avatarUrl
? (mxcUrlToHttp(
mx,
member.avatarUrl,
useAuthentication,
32,
32,
'crop'
) ?? undefined)
: undefined;
return (
<Avatar key={member.userId} className={css.GroupAvatarMini}>
<UserAvatar
userId={member.userId}
src={avatarSrc}
alt={member.displayName ?? member.userId}
renderFallback={() => (
<Text as="span" size="T200">
{nameInitials(member.displayName ?? member.userId)}
</Text>
)}
/>
</Avatar>
);
})}
</div>
) : (
<AvatarPresence
badge={
presence &&
presence.presence !== Presence.Offline && (
<PresenceBadge
presence={presence.presence}
size={hideText ? '300' : '200'}
/>
)
}
style={hideTextStyling(hideText)}
>
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
((!direct || customDMCards) &&
getRoomAvatarUrl(mx, room, 96, useAuthentication)) ||
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
}
uniformIcons
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
) : (
<RoomIcon
style={{
opacity:
unread || hasRoomUnread || isActiveCall
? config.opacity.P500
: config.opacity.P300,
}}
filled={selected || isActiveCall}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
</AvatarPresence>
<Avatar
size={hideText ? undefined : '200'}
radii="400"
style={hideTextStyling(hideText)}
>
{showAvatar ? (
<RoomAvatar
roomId={room.roomId}
src={
((!direct || customDMCards) &&
getRoomAvatarUrl(mx, room, 96, useAuthentication)) ||
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
}
uniformIcons
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(roomName)}
</Text>
)}
/>
) : (
<RoomIcon
style={{
opacity:
unread || hasRoomUnread || isActiveCall
? config.opacity.P500
: config.opacity.P300,
}}
filled={selected || isActiveCall}
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
</AvatarPresence>
)}
{unread && hideText && (
<SidebarUnreadBadge
highlight={unread.highlight > 0}
Expand Down
41 changes: 40 additions & 1 deletion src/app/features/room-nav/styles.css.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,48 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { color, config } from 'folds';

export const CategoryButton = style({
flexGrow: 1,
});
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,
},
},
});
42 changes: 40 additions & 2 deletions src/app/hooks/useGroupDMMembers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,51 @@ 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.
* Sorts members by who last sent messages (most recent first), with members who haven't sent messages last.
*/
export const useGroupDMMembers = (
mx: MatrixClient,
room: Room,
room: Room | undefined,
maxMembers = 3
): GroupMemberInfo[] => {
const [members, setMembers] = useState<GroupMemberInfo[]>([]);
// 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<GroupMemberInfo[]>(() =>
getInitialMembers(mx, room, maxMembers)
);

useEffect(() => {
let cancelled = false;
if (!room) {
setMembers([]);
return undefined;
}
const fetchMembers = async () => {
try {
const currentUserId = mx.getUserId();
Expand Down Expand Up @@ -106,14 +138,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;
Expand Down
Loading
Loading