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/fix-lazy-loaded-member-roster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Refill the room member list from the server when sliding sync only delivered lazy-loaded members, so mention autocomplete and the members panel are complete.
4 changes: 2 additions & 2 deletions src/app/features/room-nav/RoomNavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { useNavigate } from 'react-router-dom';
import { NavButton, NavItem, NavItemContent, NavItemOptions } from '$components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
import { RoomAvatar, RoomIcon } from '$components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room/display';
import { getDirectRoomAvatarUrl, getDmOtherMember, getRoomAvatarUrl } from '$utils/room/display';
import { roomHaveUnread } from '$utils/room/unread';
import { nameInitials } from '$utils/common';
import { useMatrixClient } from '$hooks/useMatrixClient';
Expand Down Expand Up @@ -335,7 +335,7 @@ export function RoomNavItem({

const [roomIconOverlay] = useSetting(settingsAtom, 'roomIconOverlay');
const nicknames = useAtomValue(nicknamesAtom);
const dmUserId = direct ? room.getAvatarFallbackMember()?.userId : undefined;
const dmUserId = direct ? getDmOtherMember(mx, room)?.userId : undefined;
const matrixRoomName = useRoomName(room);
const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName;
const presence = useUserPresence(dmUserId ?? '');
Expand Down
14 changes: 1 addition & 13 deletions src/app/hooks/useGroupDMMembers.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
import type { MatrixClient, Room } from '$types/matrix-sdk';
import { getMemberDisplayName } from '$utils/room/display';
import { getMemberDisplayName, isBridgeBot } from '$utils/room/display';

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

// Filter out bridge bots (not bridged users)
const isBridgeBot = (userId: string): boolean => {
const localpart = userId.split(':')[0]?.substring(1) ?? '';
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;
};

/**
* Fetches member information for a group DM.
* Gets all joined members from room state and fetches their profiles.
Expand Down
7 changes: 5 additions & 2 deletions src/app/hooks/useRoomMembers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk';
import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk';
import { useEffect, useState } from 'react';
import { hydrateAllRoomMembers } from '$client/roomMemberHydration';

export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => {
const [members, setMembers] = useState<RoomMember[]>([]);
Expand All @@ -23,11 +24,13 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true)

if (room) {
setMembers(room.getMembers());
room.loadMembersIfNeeded().then(() => {
const stopLoading = () => {
loadingMembers = false;
if (disposed) return;
updateMemberList();
});
void hydrateAllRoomMembers(mx, roomId).then(() => updateMemberList());
};
room.loadMembersIfNeeded().then(stopLoading, stopLoading);
}

const handleStateEvent = (event: MatrixEvent) => {
Expand Down
21 changes: 14 additions & 7 deletions src/app/hooks/useRoomMeta.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { EventEmitter } from 'events';
import type { MatrixEvent, Room } from '$types/matrix-sdk';
import type { PropsWithChildren } from 'react';
import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk';
import { RoomStateEvent } from '$types/matrix-sdk';
import { MatrixClientProvider } from './useMatrixClient';
import { useRoomAvatar } from './useRoomMeta';

const AVATAR_MXC = 'mxc://server/abc';
Expand All @@ -28,6 +30,11 @@ const makeRoom = (roomId: string) => {
return {
room,
client,
wrapper: ({ children }: PropsWithChildren) => (
<MatrixClientProvider value={client as unknown as MatrixClient}>
{children}
</MatrixClientProvider>
),
setAvatarEvent: (event: MatrixEvent) => {
avatarEvent = event;
},
Expand All @@ -36,14 +43,14 @@ const makeRoom = (roomId: string) => {

describe('useRoomAvatar', () => {
it('returns undefined when no avatar state is loaded', () => {
const { room } = makeRoom('!space:server');
const { result } = renderHook(() => useRoomAvatar(room));
const { room, wrapper } = makeRoom('!space:server');
const { result } = renderHook(() => useRoomAvatar(room), { wrapper });
expect(result.current).toBeUndefined();
});

it('updates when the avatar state event arrives after mount', () => {
const { room, client, setAvatarEvent } = makeRoom('!space:server');
const { result } = renderHook(() => useRoomAvatar(room));
const { room, client, setAvatarEvent, wrapper } = makeRoom('!space:server');
const { result } = renderHook(() => useRoomAvatar(room), { wrapper });
expect(result.current).toBeUndefined();

const avatarEvent = makeAvatarEvent('!space:server', AVATAR_MXC);
Expand All @@ -56,8 +63,8 @@ describe('useRoomAvatar', () => {
});

it('ignores avatar state events from other rooms', () => {
const { room, client } = makeRoom('!space:server');
const { result } = renderHook(() => useRoomAvatar(room));
const { room, client, wrapper } = makeRoom('!space:server');
const { result } = renderHook(() => useRoomAvatar(room), { wrapper });

act(() => {
client.emit(RoomStateEvent.Events, makeAvatarEvent('!other:server', AVATAR_MXC));
Expand Down
25 changes: 21 additions & 4 deletions src/app/hooks/useRoomMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,30 @@ import type { RoomJoinRulesEventContent, Room } from '$types/matrix-sdk';
import { RoomEvent, RoomStateEvent, EventType } from '$types/matrix-sdk';

import { mDirectAtom } from '$state/mDirectList';
import { getDmOtherMember, getMemberDisplayName } from '$utils/room/display';
import { useMatrixClient } from './useMatrixClient';
import { useStateEvent } from './useStateEvent';
import { useNickname } from './useNickname';

const getRoomDisplayName = (
roomName: string,
stateName: unknown,
isDmTagged: boolean,
dmNickname?: string
dmNickname?: string,
dmOtherMemberName?: string
): string => {
if (isDmTagged && dmNickname) return dmNickname;
if (typeof stateName === 'string' && stateName) return stateName;
if (isDmTagged && dmOtherMemberName) return dmOtherMemberName;
return roomName;
};

export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const mx = useMatrixClient();
const avatarEvent = useStateEvent(room, EventType.RoomAvatar);

if (dm) {
return room.getAvatarFallbackMember()?.getMxcAvatarUrl();
return getDmOtherMember(mx, room)?.getMxcAvatarUrl();
}
const content = avatarEvent?.getContent();
const avatarMxc = content && typeof content.url === 'string' ? content.url : undefined;
Expand All @@ -31,6 +36,7 @@ export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
};

export const useRoomName = (room: Room): string => {
const mx = useMatrixClient();
const dmUserId = room.guessDMUserId();
const dmNickname = useNickname(dmUserId || '');
const mDirects = useAtomValue(mDirectAtom);
Expand All @@ -45,7 +51,18 @@ export const useRoomName = (room: Room): string => {
room.recalculate();
}

const nextName = getRoomDisplayName(room.name, stateName, isDmTagged, dmNickname);
const otherMember = isDmTagged ? getDmOtherMember(mx, room) : undefined;
const dmOtherMemberName = otherMember
? (getMemberDisplayName(room, otherMember.userId) ?? otherMember.userId)
: undefined;

const nextName = getRoomDisplayName(
room.name,
stateName,
isDmTagged,
dmNickname,
dmOtherMemberName
);
setName((prev) => (prev !== nextName ? nextName : prev));
};

Expand All @@ -58,7 +75,7 @@ export const useRoomName = (room: Room): string => {
room.removeListener(RoomEvent.Name, updateName);
room.removeListener(RoomStateEvent.Members, updateName);
};
}, [room, stateName, dmNickname, isDmTagged]);
}, [room, mx, stateName, dmNickname, isDmTagged]);

return name;
};
Expand Down
40 changes: 7 additions & 33 deletions src/app/pages/client/sidebar/DirectDMsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { nameInitials } from '$utils/common';
import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix';
import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom';
import { useGroupDMMembers } from '$hooks/useGroupDMMembers';
import { useRoomName } from '$hooks/useRoomMeta';
import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds';
import * as css from './DirectDMsList.css';

Expand All @@ -41,24 +42,16 @@ function DMItem({ room, selected }: DMItemProps) {
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, room.roomId)));
};

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

// Use already-synced room state only; sidebar rendering must not trigger member/profile requests.
const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS);

const isGroupDM = groupMembers.length > 1;

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

// Determine avatar src for single group DM member to avoid nested ternary
const getSingleMemberAvatarSrc = () => {
const member = groupMembers[0];
if (groupMembers.length !== 1 || !member?.avatarUrl) {
return undefined;
}
return mxcUrlToHttp(mx, member.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined;
};

// Render appropriate avatar based on DM type
const renderAvatar = () => {
if (!isGroupDM) {
Expand All @@ -71,29 +64,10 @@ function DMItem({ room, selected }: DMItemProps) {
getRoomAvatarUrl(mx, room, 96, useAuthentication) ||
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
</Avatar>
);
}

if (groupMembers.length === 1) {
const member = groupMembers[0];
if (!member) return null;
return (
<Avatar size="400" radii="400">
<UserAvatar
userId={member.userId}
src={getSingleMemberAvatarSrc()}
alt={member.displayName || member.userId}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(member.displayName || member.userId)}
{nameInitials(roomName)}
</Text>
)}
/>
Expand Down Expand Up @@ -132,7 +106,7 @@ function DMItem({ room, selected }: DMItemProps) {

return (
<SidebarItemLeft active={selected}>
<SidebarItemTooltip tooltip={room.name}>
<SidebarItemTooltip tooltip={roomName}>
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleClick} size="400">
{renderAvatar()}
Expand Down
16 changes: 15 additions & 1 deletion src/app/utils/room/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,27 @@ export const getRoomAvatarUrl = (
useAuthentication = false
): string | undefined => getAvatarUrl(mx, room.getMxcAvatarUrl(), size, useAuthentication);

// Bridges add a persistent bot member to 1:1 DM portals that the SDK counts as a real participant.
export const isBridgeBot = (userId: string): boolean => {
const localpart = userId.split(':')[0]?.substring(1) ?? '';
return localpart.toLowerCase().endsWith('bot');
};

export const getDmOtherMember = (mx: MatrixClient, room: Room): RoomMember | undefined => {
const currentUserId = mx.getUserId();
const others = room
.getJoinedMembers()
.filter((member) => member.userId !== currentUserId && !isBridgeBot(member.userId));
return others.length === 1 ? others[0] : undefined;
};

export const getDirectRoomAvatarUrl = (
mx: MatrixClient,
room: Room,
size: 32 | 96 = 32,
useAuthentication = false
): string | undefined => {
const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
const mxcUrl = getDmOtherMember(mx, room)?.getMxcAvatarUrl();

if (!mxcUrl) {
return getRoomAvatarUrl(mx, room, size, useAuthentication);
Expand Down
Loading
Loading