From b2a02d57ca95751e85dd0c5d63aa8cf32d387078 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 18 Jul 2026 10:40:04 +0200 Subject: [PATCH 1/4] fix: space icons not appearing on first load with sliding sync --- .changeset/fix-space-icons-sliding-sync.md | 5 ++ src/app/hooks/useRoomMeta.test.tsx | 68 ++++++++++++++++++++++ src/app/pages/client/sidebar/SpaceTabs.tsx | 44 +++++++++----- 3 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-space-icons-sliding-sync.md create mode 100644 src/app/hooks/useRoomMeta.test.tsx diff --git a/.changeset/fix-space-icons-sliding-sync.md b/.changeset/fix-space-icons-sliding-sync.md new file mode 100644 index 0000000000..1a10dfca81 --- /dev/null +++ b/.changeset/fix-space-icons-sliding-sync.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix space icons staying blank on first open with sliding sync until the sidebar was remounted. diff --git a/src/app/hooks/useRoomMeta.test.tsx b/src/app/hooks/useRoomMeta.test.tsx new file mode 100644 index 0000000000..7721813f2f --- /dev/null +++ b/src/app/hooks/useRoomMeta.test.tsx @@ -0,0 +1,68 @@ +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 { RoomStateEvent } from '$types/matrix-sdk'; +import { useRoomAvatar } from './useRoomMeta'; + +const AVATAR_MXC = 'mxc://server/abc'; + +const makeAvatarEvent = (roomId: string, url: string): MatrixEvent => + ({ + getRoomId: () => roomId, + getType: () => 'm.room.avatar', + getStateKey: () => '', + getContent: () => ({ url }), + }) as unknown as MatrixEvent; + +const makeRoom = (roomId: string) => { + const client = new EventEmitter(); + let avatarEvent: MatrixEvent | undefined; + const room = { + roomId, + client, + getLiveTimeline: () => ({ + getState: () => ({ getStateEvents: () => avatarEvent }), + }), + } as unknown as Room; + return { + room, + client, + setAvatarEvent: (event: MatrixEvent) => { + avatarEvent = event; + }, + }; +}; + +describe('useRoomAvatar', () => { + it('returns undefined when no avatar state is loaded', () => { + const { room } = makeRoom('!space:server'); + const { result } = renderHook(() => useRoomAvatar(room)); + 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)); + expect(result.current).toBeUndefined(); + + const avatarEvent = makeAvatarEvent('!space:server', AVATAR_MXC); + setAvatarEvent(avatarEvent); + act(() => { + client.emit(RoomStateEvent.Events, avatarEvent); + }); + + expect(result.current).toBe(AVATAR_MXC); + }); + + it('ignores avatar state events from other rooms', () => { + const { room, client } = makeRoom('!space:server'); + const { result } = renderHook(() => useRoomAvatar(room)); + + act(() => { + client.emit(RoomStateEvent.Events, makeAvatarEvent('!other:server', AVATAR_MXC)); + }); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index b88f9db797..a9ee1cbc05 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -68,7 +68,7 @@ import { } from '$components/sidebar'; import { RoomUnreadProvider, RoomsUnreadProvider } from '$components/RoomUnreadProvider'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix'; +import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '$utils/matrix'; import { RoomAvatar } from '$components/room-avatar'; import { nameInitials, randomStr } from '$utils/common'; import type { ISidebarFolder, SidebarItems, TSidebarItem } from '$hooks/useSidebarItems'; @@ -91,8 +91,8 @@ import { copyToClipboard } from '$utils/dom'; import { stopPropagation } from '$utils/keyboard'; import { getMatrixToRoom } from '$plugins/matrix-to'; import { getViaServers } from '$plugins/via-servers'; -import { getRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomAvatar } from '$hooks/useRoomMeta'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useOpenSpaceSettings } from '$state/hooks/spaceSettings'; @@ -491,6 +491,29 @@ const useDnDMonitor = ( }, [scrollRef, onDragging, onReorder]); }; +type SpaceAvatarProps = { + space: Room; + renderFallback: () => ReactNode; +}; +function SpaceAvatar({ space, renderFallback }: Readonly) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const avatarMxc = useRoomAvatar(space); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined; + + return ( + + ); +} + type SpaceTabProps = { space: Room; selected: boolean; @@ -509,8 +532,6 @@ function SpaceTab({ disabled, onUnpin, }: Readonly) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); const targetRef = useRef(null); const spaceDraggable: SidebarDraggable = useMemo( @@ -561,11 +582,8 @@ function SpaceTab({ onClick={onClick} onContextMenu={handleContextMenu} > - ( {nameInitials(space.name, 2)} )} @@ -666,7 +684,6 @@ function ClosedSpaceFolder({ onFolderContextMenu, }: Readonly) { const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); const handlerRef = useRef(null); const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); @@ -702,11 +719,8 @@ function ClosedSpaceFolder({ return ( - ( {nameInitials(space.name, 2)} From 1e1b2fca30ef54ceb50d031efaab4b63a874b920 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 18 Jul 2026 10:40:04 +0200 Subject: [PATCH 2/4] fix: missing avatars for bridged users in timeline with sliding sync --- .../fix-bridged-avatars-sliding-sync.md | 5 + .../user-profile/UserRoomProfile.tsx | 16 +-- src/app/features/room/message/Message.tsx | 2 + src/app/hooks/useRoomMemberHydration.ts | 27 ++++ src/client/roomMemberHydration.test.ts | 129 ++++++++++++++++++ src/client/roomMemberHydration.ts | 15 +- 6 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-bridged-avatars-sliding-sync.md create mode 100644 src/app/hooks/useRoomMemberHydration.ts create mode 100644 src/client/roomMemberHydration.test.ts diff --git a/.changeset/fix-bridged-avatars-sliding-sync.md b/.changeset/fix-bridged-avatars-sliding-sync.md new file mode 100644 index 0000000000..29137df5c3 --- /dev/null +++ b/.changeset/fix-bridged-avatars-sliding-sync.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix missing profile pictures for bridged users in the timeline when using sliding sync. diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index d5f529518a..6f0da608d9 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -1,6 +1,6 @@ import { Box, Button, color, config, Menu, MenuItem, Scroll, Text, toRem } from 'folds'; import type { CSSProperties, SyntheticEvent } from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAtomValue } from 'jotai'; import type { Opts as LinkifyOpts } from 'linkifyjs'; @@ -59,7 +59,7 @@ import { PowerChip } from './PowerChip'; import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; import { UserHero, UserHeroName } from './UserHero'; import { KnownMembership } from '$types/matrix-sdk'; -import { hydrateRoomMember } from '$client/roomMemberHydration'; +import { useRoomMemberHydration } from '$hooks/useRoomMemberHydration'; import * as css from './styles.css'; import * as prefix from '$unstable/prefixes'; @@ -445,17 +445,7 @@ export function UserRoomProfile({ userId, initialProfile }: Readonly { - if (room.getMember(userId)) return undefined; - let disposed = false; - void hydrateRoomMember(mx, room.roomId, userId).then(() => { - if (!disposed) refreshMemberProfile((version) => version + 1); - }); - return () => { - disposed = true; - }; - }, [mx, room, userId]); + useRoomMemberHydration(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId) ?? extendedProfile.avatarUrl; const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 81293f9a19..ed39bc510f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -47,6 +47,7 @@ import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { SwipeableMessageWrapper } from '$components/SwipeableMessageWrapper'; import { mobileOrTablet } from '$utils/user-agent'; import { useUserProfile } from '$hooks/useUserProfile'; +import { useRoomMemberHydration } from '$hooks/useRoomMemberHydration'; import { useSetting } from '$state/hooks/settings'; import { useBlobCache } from '$hooks/useBlobCache'; import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns'; @@ -459,6 +460,7 @@ function MessageInternal( // Avatars // Prefer the room-scoped member avatar (m.room.member) over the global profile // avatar so per-room avatar overrides are respected in the timeline. + useRoomMemberHydration(room, senderId); const memberAvatarMxc = getMemberAvatarMxc(room, senderId); const avatarUrl = useMemo(() => { const mxc = pmp?.avatar_url || memberAvatarMxc || profile.avatarUrl; diff --git a/src/app/hooks/useRoomMemberHydration.ts b/src/app/hooks/useRoomMemberHydration.ts new file mode 100644 index 0000000000..1159294b84 --- /dev/null +++ b/src/app/hooks/useRoomMemberHydration.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import type { Room } from '$types/matrix-sdk'; +import { hydrateRoomMember } from '$client/roomMemberHydration'; +import { useMatrixClient } from './useMatrixClient'; + +/** + * Under sliding sync, m.room.member events only arrive for senders in the + * lazy-loaded sync window. Fetch the member on demand and bump local state so + * the caller re-reads room member state (avatar, display name) once hydrated. + */ +export const useRoomMemberHydration = (room: Room, userId: string): number => { + const mx = useMatrixClient(); + const [version, setVersion] = useState(0); + + useEffect(() => { + if (!userId.startsWith('@') || room.getMember(userId)) return undefined; + let disposed = false; + void hydrateRoomMember(mx, room.roomId, userId).then(() => { + if (!disposed && room.getMember(userId)) setVersion((v) => v + 1); + }); + return () => { + disposed = true; + }; + }, [mx, room, userId]); + + return version; +}; diff --git a/src/client/roomMemberHydration.test.ts b/src/client/roomMemberHydration.test.ts new file mode 100644 index 0000000000..0d428496ed --- /dev/null +++ b/src/client/roomMemberHydration.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { MatrixClient, Room, RoomMember } from '$types/matrix-sdk'; +import { EventType } from '$types/matrix-sdk'; + +import { hydrateRoomMember, hydrateRoomMembers } from './roomMemberHydration'; + +const ROOM_ID = '!room:server'; +const USER_ID = '@ghost:server'; + +type FakeSetup = { + mx: MatrixClient; + room: Room; + getStateEvent: ReturnType; + setStateEvents: ReturnType; + getMember: ReturnType; +}; + +const makeFakes = (): FakeSetup => { + const getMember = vi.fn<() => RoomMember | null>(() => null); + const setStateEvents = vi.fn<() => void>(); + const room = { + roomId: ROOM_ID, + getMember, + currentState: { setStateEvents }, + } as unknown as Room; + const getStateEvent = vi.fn<() => Promise>(() => + Promise.resolve({ membership: 'join', avatar_url: 'mxc://server/abc' }) + ); + const mx = { + getRoom: () => room, + getStateEvent, + } as unknown as MatrixClient; + return { mx, room, getStateEvent, setStateEvents, getMember }; +}; + +describe('hydrateRoomMember', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fetches and injects a missing member', async () => { + const { mx, getStateEvent, setStateEvents } = makeFakes(); + + await hydrateRoomMember(mx, ROOM_ID, USER_ID); + + expect(getStateEvent).toHaveBeenCalledWith(ROOM_ID, EventType.RoomMember, USER_ID); + expect(setStateEvents).toHaveBeenCalledTimes(1); + const [events] = setStateEvents.mock.calls[0] as [Array<{ getType: () => string }>]; + expect(events[0]?.getType()).toBe(EventType.RoomMember); + }); + + it('does nothing when the member already exists', async () => { + const { mx, getStateEvent, getMember } = makeFakes(); + getMember.mockReturnValue({} as RoomMember); + + await hydrateRoomMember(mx, ROOM_ID, USER_ID); + + expect(getStateEvent).not.toHaveBeenCalled(); + }); + + it('skips injection when the member appears while the fetch is in flight', async () => { + const { mx, getMember, setStateEvents } = makeFakes(); + const promise = hydrateRoomMember(mx, ROOM_ID, USER_ID); + getMember.mockReturnValue({} as RoomMember); + + await promise; + + expect(setStateEvents).not.toHaveBeenCalled(); + }); + + it('dedups concurrent requests for the same member', async () => { + const { mx, getStateEvent } = makeFakes(); + + await Promise.all([ + hydrateRoomMember(mx, ROOM_ID, USER_ID), + hydrateRoomMember(mx, ROOM_ID, USER_ID), + ]); + + expect(getStateEvent).toHaveBeenCalledTimes(1); + }); + + it('negative-caches failed fetches and retries after the TTL', async () => { + const { mx, getStateEvent } = makeFakes(); + getStateEvent.mockRejectedValueOnce(new Error('404')); + + await expect(hydrateRoomMember(mx, ROOM_ID, USER_ID)).resolves.toBeUndefined(); + expect(getStateEvent).toHaveBeenCalledTimes(1); + + // Within the TTL the failure is cached — no refetch. + await hydrateRoomMember(mx, ROOM_ID, USER_ID); + expect(getStateEvent).toHaveBeenCalledTimes(1); + + // After the TTL the fetch is retried. + vi.advanceTimersByTime(5 * 60_000 + 1); + await hydrateRoomMember(mx, ROOM_ID, USER_ID); + expect(getStateEvent).toHaveBeenCalledTimes(2); + }); + + it('clears the failure entry on a later success', async () => { + const { mx, getStateEvent, setStateEvents, getMember } = makeFakes(); + getStateEvent.mockRejectedValueOnce(new Error('404')); + + await hydrateRoomMember(mx, ROOM_ID, USER_ID); + vi.advanceTimersByTime(5 * 60_000 + 1); + await hydrateRoomMember(mx, ROOM_ID, USER_ID); + expect(setStateEvents).toHaveBeenCalledTimes(1); + + // The stale failure no longer blocks further hydration attempts. + getMember.mockReturnValue(null); + await hydrateRoomMember(mx, ROOM_ID, USER_ID); + expect(getStateEvent).toHaveBeenCalledTimes(3); + }); +}); + +describe('hydrateRoomMembers', () => { + it('dedups user ids and filters non-user ids', async () => { + const { mx, getStateEvent } = makeFakes(); + + await hydrateRoomMembers(mx, ROOM_ID, [USER_ID, USER_ID, 'not-a-user', '@other:server']); + + expect(getStateEvent).toHaveBeenCalledTimes(2); + expect(getStateEvent).toHaveBeenCalledWith(ROOM_ID, EventType.RoomMember, USER_ID); + expect(getStateEvent).toHaveBeenCalledWith(ROOM_ID, EventType.RoomMember, '@other:server'); + }); +}); diff --git a/src/client/roomMemberHydration.ts b/src/client/roomMemberHydration.ts index f6b2622118..054519c674 100644 --- a/src/client/roomMemberHydration.ts +++ b/src/client/roomMemberHydration.ts @@ -3,6 +3,11 @@ import { EventType, MatrixEvent } from '$types/matrix-sdk'; const inFlight = new WeakMap>>(); +// Members whose state event could not be fetched (e.g. defunct bridge ghosts) +// are skipped for a while so virtualized-timeline remounts don't refetch them. +const FAILURE_TTL_MS = 5 * 60_000; +const failedAt = new WeakMap>(); + export const hydrateRoomMember = ( mx: MatrixClient, roomId: string, @@ -12,6 +17,9 @@ export const hydrateRoomMember = ( if (!room || room.getMember(userId)) return Promise.resolve(); const key = `${roomId}\u0000${userId}`; + const failedTs = failedAt.get(mx)?.get(key); + if (failedTs !== undefined && Date.now() - failedTs < FAILURE_TTL_MS) return Promise.resolve(); + const pending = inFlight.get(mx) ?? new Map>(); inFlight.set(mx, pending); const existing = pending.get(key); @@ -20,6 +28,7 @@ export const hydrateRoomMember = ( const request = mx .getStateEvent(roomId, EventType.RoomMember, userId) .then((content) => { + failedAt.get(mx)?.delete(key); const currentRoom = mx.getRoom(roomId); if (!currentRoom || currentRoom.getMember(userId)) return; currentRoom.currentState.setStateEvents([ @@ -32,7 +41,11 @@ export const hydrateRoomMember = ( }), ]); }) - .catch(() => undefined) + .catch(() => { + const failures = failedAt.get(mx) ?? new Map(); + failedAt.set(mx, failures); + failures.set(key, Date.now()); + }) .finally(() => pending.delete(key)); pending.set(key, request); From b8b9dd818c00de41794d5742d6276a8a2dfbe8aa Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 18 Jul 2026 10:40:05 +0200 Subject: [PATCH 3/4] fix: sticker packs hosted on space rooms missing with sliding sync --- .changeset/fix-sticker-packs-sliding-sync.md | 5 ++ src/app/hooks/useImagePacks.ts | 10 +-- src/app/hooks/useSlidingSyncActiveRoom.ts | 34 ++++++- src/client/slidingSync.test.ts | 94 ++++++++++++++++++++ src/client/slidingSync.ts | 29 ++++-- 5 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-sticker-packs-sliding-sync.md diff --git a/.changeset/fix-sticker-packs-sliding-sync.md b/.changeset/fix-sticker-packs-sliding-sync.md new file mode 100644 index 0000000000..b2394926bc --- /dev/null +++ b/.changeset/fix-sticker-packs-sliding-sync.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix sticker and emote packs hosted on space rooms not loading with sliding sync, and load pack rooms before the emoji board is first opened. diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index 516aba467b..33777f737f 100644 --- a/src/app/hooks/useImagePacks.ts +++ b/src/app/hooks/useImagePacks.ts @@ -159,25 +159,19 @@ export const useGlobalImagePacks = (): ImagePack[] => { (mEvent) => { const eventType = mEvent.getType(); const roomId = mEvent.getRoomId(); - const stateKey = mEvent.getStateKey(); if ( (eventType === (CustomStateEvent.ImagePack as string) || eventType === (CustomStateEvent.PoniesRoomEmotes as string)) && roomId && - typeof stateKey === 'string' + packRoomIds.includes(roomId) ) { setGlobalPacks((prev) => { - const global = !!prev.find( - (pack) => - pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey - ); - if (!global) return prev; const next = getGlobalImagePacks(mx); return imagePackListEqual(prev, next) ? prev : next; }); } }, - [mx] + [mx, packRoomIds] ) ); diff --git a/src/app/hooks/useSlidingSyncActiveRoom.ts b/src/app/hooks/useSlidingSyncActiveRoom.ts index a46e56b45c..9906fbd994 100644 --- a/src/app/hooks/useSlidingSyncActiveRoom.ts +++ b/src/app/hooks/useSlidingSyncActiveRoom.ts @@ -1,11 +1,14 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { matchPath, useLocation } from 'react-router-dom'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useAccountDataCallback } from '$hooks/useAccountDataCallback'; import { getSlidingSyncManager } from '$client/initMatrix'; import { useResolvedRoomIdOrAlias } from '$hooks/router/useResolvedRoomId'; import { useSpaces } from '$state/hooks/roomList'; import { allRoomsAtom } from '$state/room-list/roomList'; +import { getGlobalImagePackRoomIds } from '$plugins/custom-emoji'; import { ClientEvent } from '$types/matrix-sdk'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; import { DIRECT_ROOM_PATH, HOME_ROOM_PATH, @@ -107,6 +110,34 @@ export const useSlidingSyncSpaceSubscriptions = (): void => { }, [manager, spaces]); }; +// Subscribe pack rooms before the emoji board first opens so their +// pack state is already available. +export const useSlidingSyncImagePackSubscriptions = (): void => { + const manager = useAvailableSlidingSyncManager(); + const mx = useMatrixClient(); + + useEffect(() => { + if (!manager) return undefined; + manager.setImagePackSubscriptions(getGlobalImagePackRoomIds(mx)); + return undefined; + }, [manager, mx]); + + useAccountDataCallback( + mx, + useCallback( + (mEvent) => { + if ( + mEvent.getType() === (CustomAccountDataEvent.ImagePackRooms as string) || + mEvent.getType() === (CustomAccountDataEvent.PoniesEmoteRooms as string) + ) { + manager?.setImagePackSubscriptions(getGlobalImagePackRoomIds(mx)); + } + }, + [manager, mx] + ) + ); +}; + export const useSlidingSyncRoomLoading = (roomId: string): boolean => { const manager = useAvailableSlidingSyncManager(); const [loading, setLoading] = useState(() => manager?.isRoomSubscriptionLoading(roomId) ?? false); @@ -126,4 +157,5 @@ export const useSlidingSyncRoomLoading = (roomId: string): boolean => { export const useSlidingSyncActiveRoom = (): void => { useSlidingSyncRouteRooms(); useSlidingSyncSpaceSubscriptions(); + useSlidingSyncImagePackSubscriptions(); }; diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index b70dfc2482..20bb1f6899 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -408,6 +408,100 @@ describe('SlidingSyncManager room subscription coordination', () => { new Set(['!current:example.com']) ); }); + + it('registers the composite space+image-pack subscription', () => { + makeManager(makeMockMx()); + + const call = ( + mocks.slidingSyncInstance.addCustomSubscription.mock.calls as unknown as [ + string, + { timeline_limit: number; required_state: [string, string][] }, + ][] + ).find(([name]) => name === 'space_image_packs'); + expect(call).toBeDefined(); + const [, subscription] = call!; + expect(subscription.timeline_limit).toBe(0); + expect(subscription.required_state).toEqual( + expect.arrayContaining([ + ['m.space.child', '*'], + ['m.room.image_pack', '*'], + ['im.ponies.room_emotes', '*'], + ]) + ); + }); + + it('uses the composite subscription when a pack room is also a space', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!spacepack:example.com'; + (manager as unknown as { listsFullyLoaded: boolean }).listsFullyLoaded = true; + + manager.setImagePackSubscriptions([roomId]); + manager.setSpaceSubscriptions([roomId]); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'space_image_packs' + ); + }); + + it('uses the composite subscription regardless of registration order', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!spacepack:example.com'; + (manager as unknown as { listsFullyLoaded: boolean }).listsFullyLoaded = true; + + manager.setSpaceSubscriptions([roomId]); + manager.setImagePackSubscriptions([roomId]); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'space_image_packs' + ); + }); + + it('prefers the active subscription over the composite and falls back on unsubscribe', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!spacepack:example.com'; + (manager as unknown as { listsFullyLoaded: boolean }).listsFullyLoaded = true; + + manager.setImagePackSubscriptions([roomId]); + manager.setSpaceSubscriptions([roomId]); + manager.subscribeToRoom(roomId); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'active_room' + ); + + manager.unsubscribeFromRoom(roomId); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'space_image_packs' + ); + }); + + it('reverts to the plain subscription when the other role is removed', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!spacepack:example.com'; + (manager as unknown as { listsFullyLoaded: boolean }).listsFullyLoaded = true; + + manager.setImagePackSubscriptions([roomId]); + manager.setSpaceSubscriptions([roomId]); + + manager.setImagePackSubscriptions([]); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'space' + ); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + + manager.setImagePackSubscriptions([roomId]); + manager.setSpaceSubscriptions([]); + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'image_packs' + ); + }); }); describe('scopeEphemeralExtensions', () => { diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 1f3d2bf78f..031d9cffaf 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -46,6 +46,7 @@ const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room'; const SIDEBAR_ROOM_SUBSCRIPTION_KEY = 'sidebar_room'; const SPACE_SUBSCRIPTION_KEY = 'space'; const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; +const SPACE_IMAGE_PACK_SUBSCRIPTION_KEY = 'space_image_packs'; const ACTIVE_ROOM_TIMELINE_LIMIT = 30; export type PartialSlidingSyncRequest = { @@ -138,12 +139,21 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri required_state: ACTIVE_ROOM_REQUIRED_STATE, }); +const IMAGE_PACK_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ + [CustomStateEvent.ImagePack, MSC3575_WILDCARD], + [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], +]; + const buildImagePackSubscription = (): MSC3575RoomSubscription => ({ timeline_limit: 0, - required_state: [ - [CustomStateEvent.ImagePack, MSC3575_WILDCARD], - [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], - ], + required_state: IMAGE_PACK_REQUIRED_STATE, +}); + +// Pack rooms that are also spaces need both state sets; room subscriptions +// use exactly one named key, so register the union as its own subscription. +const buildSpaceImagePackSubscription = (): MSC3575RoomSubscription => ({ + timeline_limit: 0, + required_state: [...SPACE_REQUIRED_STATE, ...IMAGE_PACK_REQUIRED_STATE], }); const buildSpaceSubscription = (): MSC3575RoomSubscription => ({ @@ -344,6 +354,10 @@ export class SlidingSyncManager { buildImagePackSubscription() ); this.slidingSync.addCustomSubscription(SPACE_SUBSCRIPTION_KEY, buildSpaceSubscription()); + this.slidingSync.addCustomSubscription( + SPACE_IMAGE_PACK_SUBSCRIPTION_KEY, + buildSpaceImagePackSubscription() + ); this.onLifecycle = (state, resp, err) => { debugLog.info('sync', `Sliding sync lifecycle: ${state}`, { @@ -1009,7 +1023,12 @@ export class SlidingSyncManager { } else if (this.sidebarRoomSubscriptions.has(roomId)) { this.slidingSync.useCustomSubscription(roomId, SIDEBAR_ROOM_SUBSCRIPTION_KEY); } else if (this.spaceSubscriptions.has(roomId)) { - this.slidingSync.useCustomSubscription(roomId, SPACE_SUBSCRIPTION_KEY); + this.slidingSync.useCustomSubscription( + roomId, + this.imagePackRoomSubscriptions.has(roomId) + ? SPACE_IMAGE_PACK_SUBSCRIPTION_KEY + : SPACE_SUBSCRIPTION_KEY + ); } else { this.slidingSync.useCustomSubscription(roomId, IMAGE_PACK_SUBSCRIPTION_KEY); } From bfae3122030831068f0035321b04e33f0626f2c8 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 18 Jul 2026 12:47:03 -0500 Subject: [PATCH 4/4] remove unused function --- src/client/roomMemberHydration.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/client/roomMemberHydration.ts b/src/client/roomMemberHydration.ts index 054519c674..276643c216 100644 --- a/src/client/roomMemberHydration.ts +++ b/src/client/roomMemberHydration.ts @@ -1,4 +1,4 @@ -import type { MatrixClient, Room } from '$types/matrix-sdk'; +import type { MatrixClient } from '$types/matrix-sdk'; import { EventType, MatrixEvent } from '$types/matrix-sdk'; const inFlight = new WeakMap>>(); @@ -62,6 +62,3 @@ export const hydrateRoomMembers = ( .filter((userId) => userId.startsWith('@')) .map((userId) => hydrateRoomMember(mx, roomId, userId)) ); - -export const getRoomMemberAvatarMxc = (room: Room, userId: string): string | undefined => - room.getMember(userId)?.getMxcAvatarUrl();