Skip to content
Merged
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/feat-group-dm-triangle-avatars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sable': minor
---

Show group DM participants with triangle avatar layout. Group DMs now display up to 3 member avatars in a triangle formation (most recent sender on top), with bot filtering and DM count badge support.
118 changes: 118 additions & 0 deletions src/app/hooks/useGroupDMMembers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { MatrixClient, Room } from '$types/matrix-sdk';

export type GroupMemberInfo = {
userId: string;
displayName?: string;
avatarUrl?: string;
};

/**
* 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,
maxMembers = 3
): GroupMemberInfo[] => {
const [members, setMembers] = useState<GroupMemberInfo[]>([]);

useEffect(() => {
const fetchMembers = async () => {
try {
const currentUserId = mx.getUserId();

// Load members from server if needed (handles lazy-loading)
await room.loadMembersIfNeeded();

// Now get all members
const allMembers = room.getMembers();

// Filter out bridge bots (not bridged users)
const isBridgeBot = (userId: string): boolean => {
const localpart = userId.split(':')[0].substring(1); // Remove @ prefix
const lowerLocalpart = localpart.toLowerCase();

// Only filter out users ending with 'bot' (e.g., discordbot, blueskybot)
// Don't filter bridge users with IDs like discord_378405164077547520
if (lowerLocalpart.endsWith('bot')) return true;

return false;
};

const allUserIds = allMembers
.filter(
(m) => m.membership === 'join' && m.userId !== currentUserId && !isBridgeBot(m.userId)
)
.map((m) => m.userId);

// Get last message senders from timeline for sorting
const timeline = room.getLiveTimeline();
const events = timeline.getEvents();

// Extract senders in reverse chronological order (most recent first)
const recentSenders: string[] = [];
for (let i = events.length - 1; i >= 0; i -= 1) {
const sender = events[i].getSender();
if (
sender &&
sender !== currentUserId &&
!isBridgeBot(sender) &&
!recentSenders.includes(sender)
) {
recentSenders.push(sender);
}
}

// Sort allUserIds by who appears first in recentSenders
const sortedUserIds = allUserIds.sort((a, b) => {
const aIndex = recentSenders.indexOf(a);
const bIndex = recentSenders.indexOf(b);

// If both are in recent senders, sort by recency
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
// If only a is in recent senders, it comes first
if (aIndex !== -1) return -1;
// If only b is in recent senders, it comes first
if (bIndex !== -1) return 1;
// Neither in recent senders, maintain original order
return 0;
});

// Slice to max members
const limitedUserIds = sortedUserIds.slice(0, maxMembers);

// Fetch profiles for each user
const memberPromises = limitedUserIds.map(async (userId) => {
try {
const profile = await mx.getProfileInfo(userId);
return {
userId,
displayName: profile.displayname || userId,
avatarUrl: profile.avatar_url,
};
} catch {
// If profile fetch fails, return basic info
return {
userId,
displayName: userId,
avatarUrl: undefined,
};
}
});

const fetchedMembers = await Promise.all(memberPromises);
setMembers(fetchedMembers);
} catch {
// If fetching fails, set empty array
setMembers([]);
}
};

fetchMembers();
}, [mx, room, maxMembers]);

return members;
};
2 changes: 2 additions & 0 deletions src/app/pages/client/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Scroll } from 'folds';
import { Sidebar, SidebarContent, SidebarStackSeparator, SidebarStack } from '$components/sidebar';
import {
DirectTab,
DirectDMsList,
HomeTab,
SpaceTabs,
InboxTab,
Expand All @@ -25,6 +26,7 @@ export function SidebarNav() {
<SidebarStack>
<HomeTab />
<DirectTab />
<DirectDMsList />
</SidebarStack>
<SpaceTabs scrollRef={scrollRef} />
<SidebarStackSeparator />
Expand Down
47 changes: 47 additions & 0 deletions src/app/pages/client/sidebar/DirectDMsList.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { style } from '@vanilla-extract/css';
import { color } from 'folds';

export const GroupAvatarContainer = style({
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
});

export const GroupAvatarRow = style({
position: 'relative',
width: '100%',
height: '100%',
});

export const GroupAvatar = style({
position: 'absolute',
border: `2px solid ${color.Surface.Container}`,
borderRadius: '50%',
overflow: 'hidden',
width: '24px',
height: '24px',
selectors: {
// First avatar (most recent) - top center
'&:nth-child(1)': {
top: '0',
left: '50%',
transform: 'translateX(-50%)',
zIndex: '3',
},
// Second avatar - bottom left
'&:nth-child(2)': {
bottom: '0',
left: '0',
zIndex: '2',
},
// Third avatar - bottom right
'&:nth-child(3)': {
bottom: '0',
right: '0',
zIndex: '1',
},
},
});
179 changes: 179 additions & 0 deletions src/app/pages/client/sidebar/DirectDMsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useMemo, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar, Text, Box } from 'folds';
import { useAtomValue } from 'jotai';
import { Room, SyncState } from '$types/matrix-sdk';
import { useDirects } from '$state/hooks/roomList';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { mDirectAtom } from '$state/mDirectList';
import { allRoomsAtom } from '$state/room-list/roomList';
import { roomToUnreadAtom } from '$state/room/roomToUnread';
import { getDirectRoomPath } from '$pages/pathUtils';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '$components/sidebar';
import { UnreadBadge } from '$components/unread-badge';
import { RoomAvatar } from '$components/room-avatar';
import { UserAvatar } from '$components/user-avatar';
import { getDirectRoomAvatarUrl } from '$utils/room';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { nameInitials } from '$utils/common';
import { factoryRoomIdByActivity } from '$utils/sort';
import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix';
import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
import { useGroupDMMembers } from '$hooks/useGroupDMMembers';
import { useSyncState } from '$hooks/useSyncState';
import * as css from './DirectDMsList.css';

const MAX_DM_AVATARS = 3;
const MAX_GROUP_MEMBERS = 3;

type DMItemProps = {
room: Room;
selected: boolean;
};

function DMItem({ room, selected }: DMItemProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
const roomToUnread = useAtomValue(roomToUnreadAtom);

const handleClick = () => {
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, room.roomId)));
};

// Check if this is a group DM (more than 2 members)
const isGroupDM = room.getJoinedMemberCount() > 2;

// Get member info for group DMs using m.direct and profile API (doesn't require full room state)
// Members are sorted by who last sent messages (most recent first)
const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS);

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

return (
<SidebarItem active={selected}>
<SidebarItemTooltip tooltip={room.name}>
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleClick} size="400">
{isGroupDM ? (
<Box className={css.GroupAvatarContainer}>
<Box className={css.GroupAvatarRow}>
{groupMembers.map((member) => {
const avatarUrl = member.avatarUrl
? (mxcUrlToHttp(mx, member.avatarUrl, useAuthentication, 48, 48, 'crop') ??
undefined)
: undefined;

return (
<Avatar
key={member.userId}
size="200"
radii="300"
className={css.GroupAvatar}
>
<UserAvatar
userId={member.userId}
src={avatarUrl}
alt={member.displayName || member.userId}
renderFallback={() => (
<Text as="span" size="T300">
{nameInitials(member.displayName || member.userId)}
</Text>
)}
/>
</Avatar>
);
})}
</Box>
</Box>
) : (
<Avatar size="400" radii="400">
<RoomAvatar
roomId={room.roomId}
src={getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
</Avatar>
)}
</SidebarAvatar>
)}
</SidebarItemTooltip>
{unread && (unread.total > 0 || unread.highlight > 0) && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} dm />
</SidebarItemBadge>
)}
</SidebarItem>
);
}

export function DirectDMsList() {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const roomToUnread = useAtomValue(roomToUnreadAtom);
const selectedRoomId = useSelectedRoom();

// Track sync state to wait for initial sync completion
const [syncReady, setSyncReady] = useState(false);

useSyncState(
mx,
useCallback((state, prevState) => {
// Consider ready after initial sync reaches Syncing state
// This ensures m.direct and unread counts are populated
if (state === SyncState.Syncing && prevState !== SyncState.Syncing) {
setSyncReady(true);
}
// Also set ready if we're already syncing (e.g., after a refresh while still online)
if (state === SyncState.Syncing || state === SyncState.Catchup) {
setSyncReady(true);
}
}, [])
);

// Get up to MAX_DM_AVATARS recent DMs that have unread messages
const recentDMs = useMemo(() => {
// Don't show DMs until initial sync completes
if (!syncReady) {
return [];
}

// Filter to only DMs with unread messages
const withUnread = directs.filter((roomId) => {
const unread = roomToUnread.get(roomId);
return unread && (unread.total > 0 || unread.highlight > 0);
});

// Sort by activity
const sorted = withUnread.sort(factoryRoomIdByActivity(mx));

return sorted
.slice(0, MAX_DM_AVATARS)
.map((roomId) => mx.getRoom(roomId))
.filter((room): room is Room => room !== null);
}, [directs, mx, roomToUnread, syncReady]);

if (recentDMs.length === 0) {
return null;
}

return (
<>
{recentDMs.map((room) => (
<DMItem key={room.roomId} room={room} selected={selectedRoomId === room.roomId} />
))}
</>
);
}
1 change: 1 addition & 0 deletions src/app/pages/client/sidebar/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './HomeTab';
export * from './DirectTab';
export * from './DirectDMsList';
export * from './SpaceTabs';
export * from './InboxTab';
export * from './ExploreTab';
Expand Down
Loading