diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index f8015727b7..c0ad516945 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -1,5 +1,5 @@ import type { MatrixClient, Room } from '$types/matrix-sdk'; -import { getMemberDisplayName, isBridgeBot } from '$utils/room/display'; +import { getMemberDisplayName } from '$utils/room/display'; export type GroupMemberInfo = { userId: string; @@ -20,23 +20,13 @@ export const useGroupDMMembers = ( const currentUserId = mx.getUserId(); const members = room .getMembers() - .filter( - (member) => - member.membership === 'join' && - member.userId !== currentUserId && - !isBridgeBot(member.userId) - ); + .filter((member) => member.membership === 'join' && member.userId !== currentUserId); const recentSenderRank = new Map(); const events = room.getLiveTimeline().getEvents(); for (let index = events.length - 1; index >= 0; index -= 1) { const sender = events[index]?.getSender(); - if ( - sender && - sender !== currentUserId && - !isBridgeBot(sender) && - !recentSenderRank.has(sender) - ) { + if (sender && sender !== currentUserId && !recentSenderRank.has(sender)) { recentSenderRank.set(sender, recentSenderRank.size); } } diff --git a/src/app/hooks/useRoomMeta.test.tsx b/src/app/hooks/useRoomMeta.test.tsx index 1150b55b47..6e13a43002 100644 --- a/src/app/hooks/useRoomMeta.test.tsx +++ b/src/app/hooks/useRoomMeta.test.tsx @@ -72,4 +72,36 @@ describe('useRoomAvatar', () => { expect(result.current).toBeUndefined(); }); + + it('updates a DM avatar when its member state arrives after the sidebar rendered', () => { + const member = { + getMxcAvatarUrl: () => AVATAR_MXC, + membership: 'join', + userId: '@alice:server', + }; + const { room, client, wrapper } = makeRoom('!dm:server'); + const roomEvents = new EventEmitter(); + let members = [{ membership: 'join', userId: '@me:server' }]; + Object.assign(room, { + getMember: (userId: string) => members.find((roomMember) => roomMember.userId === userId), + getMembers: () => members, + getAvatarFallbackMember: () => undefined, + on: roomEvents.on.bind(roomEvents), + removeListener: roomEvents.removeListener.bind(roomEvents), + }); + Object.assign(client, { + getAccountData: () => ({ getContent: () => ({ '@alice:server': [room.roomId] }) }), + getUserId: () => '@me:server', + }); + + const { result } = renderHook(() => useRoomAvatar(room, true), { wrapper }); + expect(result.current).toBeUndefined(); + + members = [{ membership: 'join', userId: '@me:server' }, member]; + act(() => { + roomEvents.emit(RoomStateEvent.Members); + }); + + expect(result.current).toBe(AVATAR_MXC); + }); }); diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts index fdf8d8d93e..c7fc53eba3 100644 --- a/src/app/hooks/useRoomMeta.ts +++ b/src/app/hooks/useRoomMeta.ts @@ -25,6 +25,18 @@ const getRoomDisplayName = ( export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => { const mx = useMatrixClient(); const avatarEvent = useStateEvent(room, EventType.RoomAvatar); + const [, refreshDmAvatar] = useState(0); + + useEffect(() => { + if (!dm) return undefined; + + const updateAvatar = () => refreshDmAvatar((version) => version + 1); + room.on(RoomStateEvent.Members, updateAvatar); + + return () => { + room.removeListener(RoomStateEvent.Members, updateAvatar); + }; + }, [room, dm]); if (dm) { return getDmOtherMember(mx, room)?.getMxcAvatarUrl(); diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index e2ded23c24..9b35447e2b 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -15,13 +15,13 @@ import { } from '$components/sidebar'; import { RoomAvatar } from '$components/room-avatar'; import { UserAvatar } from '$components/user-avatar'; -import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room/display'; +import { getAvatarUrl, getDmOtherMember, getRoomAvatarUrl } from '$utils/room/display'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; 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 { useRoomAvatar, useRoomName } from '$hooks/useRoomMeta'; import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import * as css from './DirectDMsList.css'; @@ -43,11 +43,13 @@ function DMItem({ room, selected }: DMItemProps) { }; const roomName = useRoomName(room); + const dmAvatarMxc = useRoomAvatar(room, true); + const dmAvatarUrl = getAvatarUrl(mx, dmAvatarMxc, 96, useAuthentication); // 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; + const isGroupDM = !getDmOtherMember(mx, room) && groupMembers.length > 1; // Get unread info for badge const unread = roomToUnread.get(room.roomId); @@ -60,10 +62,7 @@ function DMItem({ room, selected }: DMItemProps) { ( diff --git a/src/app/utils/room/display.test.ts b/src/app/utils/room/display.test.ts new file mode 100644 index 0000000000..5830acc086 --- /dev/null +++ b/src/app/utils/room/display.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import type { MatrixClient, Room, RoomMember } from '$types/matrix-sdk'; +import { getDmOtherMember } from './display'; + +const makeMember = (userId: string, membership: 'join' | 'invite'): RoomMember => + ({ userId, membership }) as RoomMember; + +describe('getDmOtherMember', () => { + it('resolves an invited direct-chat participant from m.direct', () => { + const invitedMember = makeMember('@alice:example.org', 'invite'); + const room = { + getMember: (userId: string) => (userId === invitedMember.userId ? invitedMember : undefined), + getAvatarFallbackMember: () => undefined, + roomId: '!dm:example.org', + } as unknown as Room; + const mx = { + getAccountData: () => ({ getContent: () => ({ '@alice:example.org': [room.roomId] }) }), + getUserId: () => '@me:example.org', + } as unknown as MatrixClient; + + expect(getDmOtherMember(mx, room)).toBe(invitedMember); + }); + + it('uses the m.direct counterpart even when a bridge bot is also in the room', () => { + const counterpart = makeMember('@alice:example.org', 'join'); + const room = { + getMember: (userId: string) => (userId === counterpart.userId ? counterpart : undefined), + getAvatarFallbackMember: () => makeMember('@bridgebot:example.org', 'join'), + roomId: '!dm:example.org', + } as unknown as Room; + const mx = { + getAccountData: () => ({ getContent: () => ({ '@alice:example.org': [room.roomId] }) }), + getUserId: () => '@me:example.org', + } as unknown as MatrixClient; + + expect(getDmOtherMember(mx, room)).toBe(counterpart); + }); +}); diff --git a/src/app/utils/room/display.ts b/src/app/utils/room/display.ts index afe92e0b95..f4a7ac5cd0 100644 --- a/src/app/utils/room/display.ts +++ b/src/app/utils/room/display.ts @@ -1,3 +1,4 @@ +import { EventType } from '$types/matrix-sdk'; import type { MatrixClient, Room, RoomMember } from '$types/matrix-sdk'; import { getMxIdLocalPart } from '$utils/matrix'; @@ -24,18 +25,22 @@ 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; + const mDirect = mx.getAccountData(EventType.Direct)?.getContent>(); + const directUserIds = Object.entries(mDirect ?? {}) + .filter( + ([userId, roomIds]) => + userId !== currentUserId && Array.isArray(roomIds) && roomIds.includes(room.roomId) + ) + .map(([userId]) => userId); + + if (directUserIds.length === 1) { + const member = room.getMember(directUserIds[0]!); + if (member?.membership === 'join' || member?.membership === 'invite') return member; + } + + return room.getAvatarFallbackMember(); }; export const getDirectRoomAvatarUrl = (