diff --git a/src/app/hooks/useRoomMeta.test.tsx b/src/app/hooks/useRoomMeta.test.tsx index 6e13a43002..fc0db2ef7b 100644 --- a/src/app/hooks/useRoomMeta.test.tsx +++ b/src/app/hooks/useRoomMeta.test.tsx @@ -1,11 +1,13 @@ import { describe, it, expect } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { EventEmitter } from 'events'; +import { Provider as JotaiProvider, createStore } from 'jotai'; import type { PropsWithChildren } from 'react'; import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { RoomStateEvent } from '$types/matrix-sdk'; +import { EventType, RoomStateEvent } from '$types/matrix-sdk'; +import { mDirectAtom } from '$state/mDirectList'; import { MatrixClientProvider } from './useMatrixClient'; -import { useRoomAvatar } from './useRoomMeta'; +import { useRoomAvatar, useRoomName } from './useRoomMeta'; const AVATAR_MXC = 'mxc://server/abc'; @@ -74,30 +76,19 @@ describe('useRoomAvatar', () => { }); 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 { room, wrapper } = makeRoom('!dm:server'); const roomEvents = new EventEmitter(); - let members = [{ membership: 'join', userId: '@me:server' }]; + let fallbackMember: { getMxcAvatarUrl: () => string } | undefined; Object.assign(room, { - getMember: (userId: string) => members.find((roomMember) => roomMember.userId === userId), - getMembers: () => members, - getAvatarFallbackMember: () => undefined, + getAvatarFallbackMember: () => fallbackMember, 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]; + fallbackMember = { getMxcAvatarUrl: () => AVATAR_MXC }; act(() => { roomEvents.emit(RoomStateEvent.Members); }); @@ -105,3 +96,127 @@ describe('useRoomAvatar', () => { expect(result.current).toBe(AVATAR_MXC); }); }); + +const makeNameRoom = ( + roomId: string, + { + mDirects = {}, + members = {}, + fallbackMember, + functionalMemberIds = [], + name = 'Room', + nameEventId, + }: { + mDirects?: Record; + members?: Record; + fallbackMember?: { userId: string; rawDisplayName?: string; membership?: string }; + functionalMemberIds?: string[]; + name?: string; + nameEventId?: string; + } +) => { + const client = new EventEmitter(); + const stateEvents: Record = { + [EventType.RoomName]: nameEventId + ? ({ + getId: () => nameEventId, + getContent: () => ({ name }), + } as unknown as MatrixEvent) + : undefined, + 'io.element.functional_members': functionalMemberIds.length + ? ({ getContent: () => ({ service_members: functionalMemberIds }) } as unknown as MatrixEvent) + : undefined, + }; + const room = { + roomId, + client, + name, + guessDMUserId: () => '', + recalculate: () => {}, + getLiveTimeline: () => ({ + getState: () => ({ getStateEvents: (type: string) => stateEvents[type] }), + }), + getMember: (userId: string) => members[userId], + getMembers: () => [{ userId: '@me:server', membership: 'join' }, ...Object.values(members)], + getAvatarFallbackMember: () => fallbackMember, + on: client.on.bind(client), + removeListener: client.removeListener.bind(client), + } as unknown as Room; + Object.assign(client, { + getAccountData: () => ({ getContent: () => mDirects }), + getUserId: () => '@me:server', + }); + + const store = createStore(); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set(Object.values(mDirects).flat()) }); + + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + return { room, client, wrapper }; +}; + +describe('useRoomName', () => { + const fakeNameEventId = '$fake-sliding-sync-name-event-!dm:server'; + + it('prefers the counterpart over the sliding-sync server name for a bridged DM', () => { + const { room, wrapper } = makeNameRoom('!dm:server', { + mDirects: { '@alice:server': ['!dm:server'] }, + members: { + '@alice:server': { rawDisplayName: 'Alexia', membership: 'join' }, + '@signal-service:server': { membership: 'join' }, + }, + functionalMemberIds: ['@signal-service:server'], + name: 'Alexia, Signal Bridge bot', + nameEventId: fakeNameEventId, + }); + + const { result } = renderHook(() => useRoomName(room), { wrapper }); + + expect(result.current).toBe('Alexia'); + }); + + it('falls back to the hero member name when sliding sync has no member state', () => { + const { room, wrapper } = makeNameRoom('!dm:server', { + mDirects: { '@alice:server': ['!dm:server'] }, + functionalMemberIds: ['@signal-service:server'], + fallbackMember: { userId: '@alice:server', rawDisplayName: 'Alexia', membership: 'join' }, + name: 'Alexia, Signal Bridge bot', + nameEventId: fakeNameEventId, + }); + + const { result } = renderHook(() => useRoomName(room), { wrapper }); + + expect(result.current).toBe('Alexia'); + }); + + it('keeps a genuine m.room.name on a DM', () => { + const { room, wrapper } = makeNameRoom('!dm:server', { + mDirects: { '@alice:server': ['!dm:server'] }, + members: { + '@alice:server': { rawDisplayName: 'Alexia', membership: 'join' }, + }, + name: 'Movie Club', + nameEventId: '$real-name-event:server', + }); + + const { result } = renderHook(() => useRoomName(room), { wrapper }); + + expect(result.current).toBe('Movie Club'); + }); + + it('keeps the sliding-sync server name for non-DM rooms', () => { + const { room, wrapper } = makeNameRoom('!room:server', { + name: 'Bridge Lobby', + nameEventId: '$fake-sliding-sync-name-event-!room:server', + }); + + const { result } = renderHook(() => useRoomName(room), { wrapper }); + + expect(result.current).toBe('Bridge Lobby'); + }); +}); diff --git a/src/app/hooks/useRoomMeta.ts b/src/app/hooks/useRoomMeta.ts index c7fc53eba3..6b62a82c68 100644 --- a/src/app/hooks/useRoomMeta.ts +++ b/src/app/hooks/useRoomMeta.ts @@ -1,6 +1,6 @@ import { useAtomValue } from 'jotai'; import { useEffect, useState } from 'react'; -import type { RoomJoinRulesEventContent, Room } from '$types/matrix-sdk'; +import type { MatrixEvent, RoomJoinRulesEventContent, Room } from '$types/matrix-sdk'; import { RoomEvent, RoomStateEvent, EventType } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; @@ -9,21 +9,26 @@ import { useMatrixClient } from './useMatrixClient'; import { useStateEvent } from './useStateEvent'; import { useNickname } from './useNickname'; +// Sliding sync's ensureNameEvent injects the server-computed room name with this +// fake event id; it counts bridge bots, so DMs rank it below the member name. +const isServerGeneratedRoomName = (event?: MatrixEvent): boolean => + event?.getId()?.startsWith('$fake-sliding-sync-name-event-') === true; + const getRoomDisplayName = ( roomName: string, stateName: unknown, + stateNameIsServerGenerated: boolean, isDmTagged: boolean, dmNickname?: string, dmOtherMemberName?: string ): string => { if (isDmTagged && dmNickname) return dmNickname; - if (typeof stateName === 'string' && stateName) return stateName; + if (typeof stateName === 'string' && stateName && !stateNameIsServerGenerated) 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); const [, refreshDmAvatar] = useState(0); @@ -39,7 +44,7 @@ export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => { }, [room, dm]); if (dm) { - return getDmOtherMember(mx, room)?.getMxcAvatarUrl(); + return room.getAvatarFallbackMember()?.getMxcAvatarUrl(); } const content = avatarEvent?.getContent(); const avatarMxc = content && typeof content.url === 'string' ? content.url : undefined; @@ -49,28 +54,30 @@ 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); const isDmTagged = mDirects.has(room.roomId); + const dmUserId = room.guessDMUserId(); + const dmNickname = useNickname(dmUserId || ''); const nameEvent = useStateEvent(room, EventType.RoomName); const stateName = nameEvent?.getContent().name; + const stateNameIsServerGenerated = isServerGeneratedRoomName(nameEvent); const [name, setName] = useState(room.name); useEffect(() => { const updateName = () => { - if (room.name === 'Empty room') { + if (isDmTagged || room.name === 'Empty room') { room.recalculate(); } const otherMember = isDmTagged ? getDmOtherMember(mx, room) : undefined; const dmOtherMemberName = otherMember - ? (getMemberDisplayName(room, otherMember.userId) ?? otherMember.userId) + ? (getMemberDisplayName(room, otherMember.userId) ?? otherMember.rawDisplayName) : undefined; const nextName = getRoomDisplayName( room.name, stateName, + stateNameIsServerGenerated, isDmTagged, dmNickname, dmOtherMemberName @@ -87,7 +94,7 @@ export const useRoomName = (room: Room): string => { room.removeListener(RoomEvent.Name, updateName); room.removeListener(RoomStateEvent.Members, updateName); }; - }, [room, mx, stateName, dmNickname, isDmTagged]); + }, [room, mx, stateName, stateNameIsServerGenerated, dmNickname, isDmTagged]); return name; }; diff --git a/src/app/utils/room/directMember.test.ts b/src/app/utils/room/directMember.test.ts new file mode 100644 index 0000000000..f71ac00af4 --- /dev/null +++ b/src/app/utils/room/directMember.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { isFunctionalMember, resolveDmOtherMember } from './directMember'; + +const me = { membership: 'join', userId: '@me:example.org' }; +const alice = { membership: 'join', userId: '@alice:example.org' }; +const bridge = { membership: 'join', userId: '@signal-service:example.org' }; + +describe('resolveDmOtherMember', () => { + it('prefers the sole active m.direct member', () => { + expect( + resolveDmOtherMember({ + currentUserId: me.userId, + directMembers: [alice], + members: [me, alice, bridge], + functionalMemberIds: [], + fallbackMember: bridge, + }) + ).toBe(alice); + }); + + it('excludes functional members from m.direct and membership fallbacks', () => { + expect( + resolveDmOtherMember({ + currentUserId: me.userId, + directMembers: [bridge], + members: [me, alice, bridge], + functionalMemberIds: [bridge.userId], + fallbackMember: bridge, + }) + ).toBe(alice); + }); + + it('does not infer service membership from an MXID', () => { + const alicebot = { membership: 'join', userId: '@alicebot:example.org' }; + + expect( + resolveDmOtherMember({ + currentUserId: me.userId, + directMembers: [alicebot], + members: [me, alicebot], + functionalMemberIds: [], + }) + ).toBe(alicebot); + }); +}); + +describe('isFunctionalMember', () => { + it('can be reused by other member surfaces', () => { + expect(isFunctionalMember(bridge, [bridge.userId])).toBe(true); + expect(isFunctionalMember(alice, [bridge.userId])).toBe(false); + }); +}); diff --git a/src/app/utils/room/directMember.ts b/src/app/utils/room/directMember.ts new file mode 100644 index 0000000000..53863c3da8 --- /dev/null +++ b/src/app/utils/room/directMember.ts @@ -0,0 +1,41 @@ +export type DirectRoomMember = { + userId: string; + membership?: string; +}; + +type ResolveDmOtherMemberInput = { + currentUserId: string | null; + directMembers: Member[]; + members: Member[]; + functionalMemberIds: string[]; + fallbackMember?: Member; +}; + +export const isFunctionalMember = ( + member: DirectRoomMember, + functionalMemberIds: readonly string[] +): boolean => functionalMemberIds.includes(member.userId); + +const isActiveMember = (member: DirectRoomMember): boolean => + member.membership === 'join' || member.membership === 'invite'; + +export const resolveDmOtherMember = ({ + currentUserId, + directMembers, + members, + functionalMemberIds, + fallbackMember, +}: ResolveDmOtherMemberInput): Member | undefined => { + const isFunctional = (member: DirectRoomMember) => + isFunctionalMember(member, functionalMemberIds); + + const directMember = directMembers.filter((member) => !isFunctional(member)); + if (directMember.length === 1 && isActiveMember(directMember[0]!)) return directMember[0]; + + const otherMembers = members.filter( + (member) => member.userId !== currentUserId && !isFunctional(member) && isActiveMember(member) + ); + if (otherMembers.length === 1) return otherMembers[0]; + + return fallbackMember && !isFunctional(fallbackMember) ? fallbackMember : undefined; +}; diff --git a/src/app/utils/room/display.test.ts b/src/app/utils/room/display.test.ts index 5830acc086..4d5e7c9992 100644 --- a/src/app/utils/room/display.test.ts +++ b/src/app/utils/room/display.test.ts @@ -10,6 +10,7 @@ describe('getDmOtherMember', () => { const invitedMember = makeMember('@alice:example.org', 'invite'); const room = { getMember: (userId: string) => (userId === invitedMember.userId ? invitedMember : undefined), + getMembers: () => [invitedMember], getAvatarFallbackMember: () => undefined, roomId: '!dm:example.org', } as unknown as Room; @@ -21,10 +22,36 @@ describe('getDmOtherMember', () => { expect(getDmOtherMember(mx, room)).toBe(invitedMember); }); + it('ignores missing m.direct members', () => { + const counterpart = makeMember('@alice:example.org', 'join'); + const room = { + getMember: (userId: string) => { + if (userId === counterpart.userId) return counterpart; + if (userId === '@missing:example.org') return null; + return undefined; + }, + getMembers: () => [counterpart], + getAvatarFallbackMember: () => undefined, + roomId: '!dm:example.org', + } as unknown as Room; + const mx = { + getAccountData: () => ({ + getContent: () => ({ + [counterpart.userId]: [room.roomId], + '@missing:example.org': [room.roomId], + }), + }), + getUserId: () => '@me:example.org', + } as unknown as MatrixClient; + + expect(getDmOtherMember(mx, room)).toBe(counterpart); + }); + 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), + getMembers: () => [counterpart], getAvatarFallbackMember: () => makeMember('@bridgebot:example.org', 'join'), roomId: '!dm:example.org', } as unknown as Room; @@ -35,4 +62,99 @@ describe('getDmOtherMember', () => { expect(getDmOtherMember(mx, room)).toBe(counterpart); }); + + it('does not select a functional member when it is listed in m.direct', () => { + const counterpart = makeMember('@alice:example.org', 'join'); + const bridgeMember = makeMember('@signal-service:example.org', 'join'); + const room = { + getAvatarFallbackMember: () => bridgeMember, + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: () => ({ + getContent: () => ({ service_members: [bridgeMember.userId] }), + }), + }), + }), + getMember: (userId: string) => + [counterpart, bridgeMember].find((member) => member.userId === userId), + getMembers: () => [makeMember('@me:example.org', 'join'), counterpart, bridgeMember], + roomId: '!dm:example.org', + } as unknown as Room; + const mx = { + getAccountData: () => ({ getContent: () => ({ [bridgeMember.userId]: [room.roomId] }) }), + getUserId: () => '@me:example.org', + } as unknown as MatrixClient; + + expect(getDmOtherMember(mx, room)).toBe(counterpart); + }); + + it('selects a direct-chat participant whose MXID ends in bot', () => { + const counterpart = makeMember('@alicebot:example.org', 'join'); + const room = { + getAvatarFallbackMember: () => counterpart, + getMember: (userId: string) => (userId === counterpart.userId ? counterpart : undefined), + getMembers: () => [counterpart], + roomId: '!dm:example.org', + } as unknown as Room; + const mx = { + getAccountData: () => ({ + getContent: () => ({ + [counterpart.userId]: [room.roomId], + }), + }), + getUserId: () => '@me:example.org', + } as unknown as MatrixClient; + + expect(getDmOtherMember(mx, room)).toBe(counterpart); + }); + + it('does not select a functional bridge member from m.direct', () => { + const counterpart = makeMember('@alice:example.org', 'join'); + const bridgeMember = makeMember('@service:example.org', 'join'); + const room = { + getAvatarFallbackMember: () => bridgeMember, + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: () => ({ + getContent: () => ({ service_members: [bridgeMember.userId] }), + }), + }), + }), + getMember: (userId: string) => + [counterpart, bridgeMember].find((member) => member.userId === userId), + getMembers: () => [makeMember('@me:example.org', 'join'), counterpart, bridgeMember], + roomId: '!dm:example.org', + } as unknown as Room; + const mx = { + getAccountData: () => ({ getContent: () => ({ '@service:example.org': [room.roomId] }) }), + getUserId: () => '@me:example.org', + } as unknown as MatrixClient; + + expect(getDmOtherMember(mx, room)).toBe(counterpart); + }); + + it('ignores a functional members event whose service_members is not an array', () => { + const counterpart = makeMember('@alice:example.org', 'join'); + const bridgeMember = makeMember('@signal-service:example.org', 'join'); + const room = { + getAvatarFallbackMember: () => bridgeMember, + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: () => ({ + getContent: () => ({ service_members: counterpart.userId }), + }), + }), + }), + getMember: (userId: string) => + [counterpart, bridgeMember].find((member) => member.userId === userId), + getMembers: () => [makeMember('@me:example.org', 'join'), counterpart, bridgeMember], + roomId: '!dm:example.org', + } as unknown as Room; + const mx = { + getAccountData: () => ({ getContent: () => ({ [counterpart.userId]: [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 f4a7ac5cd0..9394cdb13b 100644 --- a/src/app/utils/room/display.ts +++ b/src/app/utils/room/display.ts @@ -1,8 +1,9 @@ -import { EventType } from '$types/matrix-sdk'; -import type { MatrixClient, Room, RoomMember } from '$types/matrix-sdk'; +import { EventTimeline, EventType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from '$types/matrix-sdk'; +import type { MatrixClient, Room, RoomMember, StateEvents } from '$types/matrix-sdk'; import { getMxIdLocalPart } from '$utils/matrix'; import { mxcUrlToHttp } from '../mediaUrl'; +import { resolveDmOtherMember } from './directMember'; /** * The square-cropped avatar conversion every avatar call site repeats. Goes @@ -26,6 +27,15 @@ export const getRoomAvatarUrl = ( ): string | undefined => getAvatarUrl(mx, room.getMxcAvatarUrl(), size, useAuthentication); export const getDmOtherMember = (mx: MatrixClient, room: Room): RoomMember | undefined => { + // A non-array service_members would otherwise make .includes a substring match. + const serviceMembers = room + .getLiveTimeline?.() + ?.getState(EventTimeline.FORWARDS) + ?.getStateEvents(UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name as keyof StateEvents, '') + ?.getContent<{ service_members?: unknown }>().service_members; + const functionalMembers = Array.isArray(serviceMembers) + ? serviceMembers.filter((userId): userId is string => typeof userId === 'string') + : []; const currentUserId = mx.getUserId(); const mDirect = mx.getAccountData(EventType.Direct)?.getContent>(); const directUserIds = Object.entries(mDirect ?? {}) @@ -35,12 +45,17 @@ export const getDmOtherMember = (mx: MatrixClient, room: Room): RoomMember | und ) .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(); + const directMembers = directUserIds + .map((userId) => room.getMember(userId)) + .filter((member): member is RoomMember => member != null); + + return resolveDmOtherMember({ + currentUserId, + directMembers, + members: room.getMembers(), + functionalMemberIds: functionalMembers, + fallbackMember: room.getAvatarFallbackMember(), + }); }; export const getDirectRoomAvatarUrl = ( diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 3324f4b002..242b35ec86 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -4,7 +4,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { MatrixClient, MatrixEvent, MSC3575List } from '$types/matrix-sdk'; -import { EventType, KnownMembership, SlidingSyncEvent, SlidingSyncState } from '$types/matrix-sdk'; +import { + EventType, + KnownMembership, + SlidingSyncEvent, + SlidingSyncState, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS, +} from '$types/matrix-sdk'; import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync'; import type { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; @@ -170,8 +176,9 @@ describe('SlidingSyncManager initial request', () => { expect(joined?.ranges).toEqual([[0, 29]]); expect(joined?.timeline_limit).toBe(1); - expect(joined?.required_state).toHaveLength(10); + expect(joined?.required_state).toHaveLength(11); expect(joined?.required_state).toContainEqual([EventType.RoomJoinRules, '']); + expect(joined?.required_state).toContainEqual([UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, '']); expect(joined?.required_state).not.toContainEqual(['m.space.child', '*']); expect(updates).toMatchObject({ ranges: [[0, 29]], @@ -706,6 +713,10 @@ describe('SlidingSyncManager room subscription coordination', () => { expect(activeRoom).toBeDefined(); expect(activeRoom![1].timeline_limit).toBe(50); expect(activeRoom![1].required_state).toContainEqual([EventType.RoomMember, '$LAZY']); + expect(activeRoom![1].required_state).toContainEqual([ + UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + '', + ]); expect(sidebarRoom).toBeDefined(); expect(sidebarRoom![1].timeline_limit).toBe(1); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 3eec08431e..96e9eb821b 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -22,6 +22,7 @@ import { EventTimeline, EventEmitterEvents, ClientEvent, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; @@ -159,6 +160,8 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomMember, MSC3575_STATE_KEY_ME], [EventType.GroupCallPrefix, ''], [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], + // Feeds functional-member filtering for bridged DM names/avatars. + [UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, ''], ]; const SPACE_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ @@ -196,6 +199,7 @@ const ACTIVE_ROOM_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ [EventType.SpaceParent, MSC3575_WILDCARD], [EventType.GroupCallPrefix, ''], [EventType.GroupCallMemberPrefix, MSC3575_WILDCARD], + [UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, ''], ...Object.values(CustomStateEvent).map((type) => [type, MSC3575_WILDCARD] as [string, string]), ]; diff --git a/src/client/slidingSyncSidebarCache.test.ts b/src/client/slidingSyncSidebarCache.test.ts index adff12ec05..510f36b599 100644 --- a/src/client/slidingSyncSidebarCache.test.ts +++ b/src/client/slidingSyncSidebarCache.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk'; -import { EventType, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk'; +import { + EventType, + MatrixEvent, + SlidingSyncEvent, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS, +} from '$types/matrix-sdk'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; @@ -46,6 +51,9 @@ describe('SlidingSyncSidebarCache', () => { stateEvent(EventType.SpaceChild, '!child:example.com', { via: ['example.com'] }), stateEvent(EventType.RoomMember, userId, { membership: 'join' }), stateEvent(EventType.RoomMember, '@other:example.com', { membership: 'join' }), + stateEvent(UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, '', { + service_members: ['@bridgebot:example.com'], + }), ], timeline: [ { @@ -90,7 +98,13 @@ describe('SlidingSyncSidebarCache', () => { }) ); const hydratedRoom = emitPromised.mock.calls[0]?.[2] as MSC3575RoomData; - expect(hydratedRoom.required_state).toHaveLength(3); + expect(hydratedRoom.required_state).toHaveLength(4); + expect(hydratedRoom.required_state).toContainEqual( + expect.objectContaining({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + state_key: '', + }) + ); expect(hydratedRoom.required_state).toContainEqual( expect.objectContaining({ type: EventType.SpaceChild, diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts index 9ee118a35c..8bdcff1d8e 100644 --- a/src/client/slidingSyncSidebarCache.ts +++ b/src/client/slidingSyncSidebarCache.ts @@ -1,5 +1,11 @@ import type { MatrixClient, MSC3575RoomData, SlidingSync } from '$types/matrix-sdk'; -import { EventType, KnownMembership, MatrixEvent, SlidingSyncEvent } from '$types/matrix-sdk'; +import { + EventType, + KnownMembership, + MatrixEvent, + SlidingSyncEvent, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS, +} from '$types/matrix-sdk'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; import { CustomStateEvent } from '$types/matrix/room'; @@ -44,6 +50,7 @@ const CACHED_STATE_TYPES = new Set([ EventType.RoomServerAcl, EventType.SpaceChild, EventType.SpaceParent, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, CustomStateEvent.PowerLevelTags, CustomStateEvent.RoomWidget, CustomStateEvent.RoomAbbreviations,