Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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 thread
Just-Insane marked this conversation as resolved.
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([]);
Comment thread
Just-Insane marked this conversation as resolved.
Outdated
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