diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md new file mode 100644 index 000000000..46cbcff81 --- /dev/null +++ b/.changeset/feat-dm-message-preview.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +feat(dm-list): show last-message preview below DM room name diff --git a/.changeset/room-message-preview.md b/.changeset/room-message-preview.md new file mode 100644 index 000000000..3f8587b85 --- /dev/null +++ b/.changeset/room-message-preview.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +feat(room-nav): show topic and last-message preview for rooms in the sidebar, fetching enough timeline events to handle reactions and edits correctly diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 22886c224..68ead11d8 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -70,6 +70,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useRoomLastMessage } from '$hooks/useRoomLastMessage'; import { RoomNavUser } from './RoomNavUser'; /** @@ -258,6 +259,9 @@ type RoomNavItemProps = { showAvatar?: boolean; direct?: boolean; customDMCards?: boolean; + roomTopicPreview?: boolean; + roomMessagePreview?: boolean; + dmMessagePreview?: boolean; }; export function RoomNavItem({ @@ -266,6 +270,9 @@ export function RoomNavItem({ showAvatar, direct, customDMCards, + roomTopicPreview = false, + roomMessagePreview = false, + dmMessagePreview = true, notificationMode, linkPath, }: RoomNavItemProps) { @@ -287,8 +294,12 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); + const showPreview = direct ? dmMessagePreview : roomMessagePreview; + const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); - const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined; + const roomTopic = direct + ? (customDMCards && getRoomTopic) || lastMessage || presence?.status + : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined); const { navigateRoom } = useRoomNavigate(); const navigate = useNavigate(); diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index f543a19ea..0df6e5ade 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -482,11 +482,17 @@ function PageZoomInput() { export function Appearance() { const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); const [customDMCards, setCustomDMCards] = useSetting(settingsAtom, 'customDMCards'); + const [dmMessagePreview, setDmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs'); const [closeFoldersByDefault, setCloseFoldersByDefault] = useSetting( settingsAtom, 'closeFoldersByDefault' ); + const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview, setRoomMessagePreview] = useSetting( + settingsAtom, + 'roomMessagePreview' + ); return ( @@ -529,6 +535,43 @@ export function Appearance() { /> + + + } + /> + + + + + } + /> + + + + + } + /> + + ; + sender?: string; + roomId?: string; + redacted?: boolean; + effectiveType?: string; + encrypted?: boolean; +}) { + const type = overrides.type ?? 'm.room.message'; + const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' }; + return { + getType: () => type, + getContent: () => content, + getSender: () => overrides.sender ?? '@alice:test', + getRoomId: () => overrides.roomId ?? '!room:test', + isRedacted: () => overrides.redacted ?? false, + isEncrypted: () => overrides.encrypted ?? false, + getEffectiveEvent: () => ({ type: overrides.effectiveType ?? type, content }), + } as never; +} + +// -------- stripReplyFallback -------- + +describe('stripReplyFallback', () => { + it('returns the body unchanged when there is no fallback', () => { + expect(stripReplyFallback('hello world')).toBe('hello world'); + }); + + it('strips lines starting with > and the blank separator', () => { + const body = '> reply line 1\n> reply line 2\n\nactual message'; + expect(stripReplyFallback(body)).toBe('actual message'); + }); + + it('strips fallback with no separator line', () => { + const body = '> quoted\nrest'; + expect(stripReplyFallback(body)).toBe('rest'); + }); + + it('returns empty string when the entire body is a fallback', () => { + expect(stripReplyFallback('> only quote\n')).toBe(''); + }); + + it('handles multi-line actual message after fallback', () => { + const body = '> quote\n\nline 1\nline 2'; + expect(stripReplyFallback(body)).toBe('line 1\nline 2'); + }); +}); + +// -------- eventToPreviewText -------- + +describe('eventToPreviewText', () => { + it('returns body for m.text message', () => { + const ev = makeEvent({ content: { msgtype: 'm.text', body: 'hi' } }); + expect(eventToPreviewText(ev)).toBe('hi'); + }); + + it('returns body for m.emote message', () => { + const ev = makeEvent({ content: { msgtype: 'm.emote', body: 'waves' } }); + expect(eventToPreviewText(ev)).toBe('waves'); + }); + + it('returns body for m.notice message', () => { + const ev = makeEvent({ content: { msgtype: 'm.notice', body: 'notice' } }); + expect(eventToPreviewText(ev)).toBe('notice'); + }); + + it('returns image icon for m.image', () => { + const ev = makeEvent({ content: { msgtype: 'm.image', body: 'photo.png' } }); + expect(eventToPreviewText(ev)).toBe('πŸ“· Image'); + }); + + it('returns video icon for m.video', () => { + const ev = makeEvent({ content: { msgtype: 'm.video', body: 'clip.mp4' } }); + expect(eventToPreviewText(ev)).toBe('πŸ“Ή Video'); + }); + + it('returns audio icon for m.audio', () => { + const ev = makeEvent({ content: { msgtype: 'm.audio', body: 'song.mp3' } }); + expect(eventToPreviewText(ev)).toBe('🎡 Audio'); + }); + + it('returns file icon for m.file', () => { + const ev = makeEvent({ content: { msgtype: 'm.file', body: 'doc.pdf' } }); + expect(eventToPreviewText(ev)).toBe('πŸ“Ž File'); + }); + + it('returns encrypted placeholder for encrypted events', () => { + const ev = makeEvent({ type: 'm.room.encrypted', content: {} }); + expect(eventToPreviewText(ev)).toBe('πŸ”’ Encrypted message'); + }); + + it('returns decrypted content when event has been decrypted', () => { + const ev = makeEvent({ + type: 'm.room.encrypted', + content: { msgtype: 'm.text', body: 'decrypted text' }, + effectiveType: 'm.room.message', + }); + expect(eventToPreviewText(ev)).toBe('decrypted text'); + }); + + it('returns sticker text', () => { + const ev = makeEvent({ type: 'm.sticker', content: { body: 'party' } }); + expect(eventToPreviewText(ev)).toBe('πŸŽ‰ party'); + }); + + it('returns undefined for redacted events', () => { + const ev = makeEvent({ redacted: true }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); + + it('returns undefined for reaction events', () => { + const ev = makeEvent({ type: 'm.reaction', content: {} }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); + + it('returns undefined for edit events (m.replace)', () => { + const ev = makeEvent({ + content: { + msgtype: 'm.text', + body: 'edited', + 'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' }, + }, + }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); + + it('strips reply fallback from text body', () => { + const ev = makeEvent({ + content: { msgtype: 'm.text', body: '> quoted\n\nreal message' }, + }); + expect(eventToPreviewText(ev)).toBe('real message'); + }); + + it('returns poll text for MSC3381 poll start events', () => { + const ev = makeEvent({ + type: 'org.matrix.msc3381.poll.start', + content: { 'org.matrix.msc3381.poll.start': { question: { body: 'Lunch?' } } }, + }); + expect(eventToPreviewText(ev)).toBe('πŸ“Š Lunch?'); + }); + + it('returns poll text for stable poll start events', () => { + const ev = makeEvent({ + type: 'm.poll.start', + content: { 'm.poll.start': { question: { body: 'Dinner?' } } }, + }); + expect(eventToPreviewText(ev)).toBe('πŸ“Š Dinner?'); + }); + + it('returns location icon for m.location message', () => { + const ev = makeEvent({ content: { msgtype: 'm.location', body: 'geo:0,0' } }); + expect(eventToPreviewText(ev)).toBe('πŸ“ Location'); + }); + + it('returns undefined for unknown event types', () => { + const ev = makeEvent({ type: 'm.room.power_levels', content: {} }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); +}); + +// -------- getLastMessageText -------- + +describe('getLastMessageText', () => { + const makeMx = (userId = '@alice:test') => ({ getUserId: () => userId }) as never; + + const makeRoom = (events: ReturnType[], members?: Record) => + ({ + roomId: '!room:test', + getLiveTimeline: () => ({ + getEvents: () => events, + }), + getMember: (id: string) => + members?.[id] ? { name: members[id], rawDisplayName: members[id] } : null, + }) as never; + + it('returns "You: text" when the sender is the current user', () => { + const ev = makeEvent({ sender: '@alice:test', content: { msgtype: 'm.text', body: 'hi' } }); + expect(getLastMessageText(makeRoom([ev]), makeMx())).toBe('You: hi'); + }); + + it('returns "DisplayName: text" for another user', () => { + const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } }); + const room = makeRoom([ev], { '@bob:test': 'Bob' }); + expect(getLastMessageText(room, makeMx())).toBe('Bob: hey'); + }); + + it('falls back to localpart when no display name is available', () => { + const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } }); + const room = makeRoom([ev]); + expect(getLastMessageText(room, makeMx())).toBe('bob: hey'); + }); + + it('falls back to localpart when member is loaded but has no display name', () => { + const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } }); + const room = makeRoom([ev], { '@bob:test': '@bob:test' }); + expect(getLastMessageText(room, makeMx())).toBe('bob: hey'); + }); + + it('skips reactions and picks the last real message', () => { + const msg = makeEvent({ content: { msgtype: 'm.text', body: 'real' } }); + const reaction = makeEvent({ type: 'm.reaction', content: {} }); + expect(getLastMessageText(makeRoom([msg, reaction]), makeMx())).toBe('You: real'); + }); + + it('returns undefined when there are no displayable events', () => { + const reaction = makeEvent({ type: 'm.reaction', content: {} }); + expect(getLastMessageText(makeRoom([reaction]), makeMx())).toBeUndefined(); + }); + + it('returns undefined for an empty timeline', () => { + expect(getLastMessageText(makeRoom([]), makeMx())).toBeUndefined(); + }); +}); + +// -------- useRoomLastMessage hook -------- + +describe('useRoomLastMessage', () => { + const makeMx = (userId = '@alice:test') => ({ + getUserId: () => userId, + on: vi.fn(), + off: vi.fn(), + }); + + const roomListeners = new Map void)[]>(); + + const makeRoom = (events: ReturnType[]) => ({ + roomId: '!room:test', + getLiveTimeline: () => ({ getEvents: () => events }), + getMember: () => null, + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = roomListeners.get(event) ?? []; + list.push(handler); + roomListeners.set(event, list); + }), + off: vi.fn(), + }); + + beforeEach(() => { + roomListeners.clear(); + }); + + it('returns undefined when room is undefined', () => { + const mx = makeMx(); + const { result } = renderHook(() => useRoomLastMessage(undefined, mx as never)); + expect(result.current).toBeUndefined(); + }); + + it('returns the last message preview on mount', () => { + const ev = makeEvent({ content: { msgtype: 'm.text', body: 'hello' } }); + const room = makeRoom([ev]); + const mx = makeMx(); + const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never)); + expect(result.current).toBe('You: hello'); + }); + + it('updates when a Timeline event fires', () => { + vi.useFakeTimers(); + const ev1 = makeEvent({ content: { msgtype: 'm.text', body: 'first' } }); + const events = [ev1]; + const room = makeRoom(events); + const mx = makeMx(); + + const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never)); + + // Simulate a new message arriving. + const ev2 = makeEvent({ content: { msgtype: 'm.text', body: 'second' } }); + events.push(ev2); + + const timelineHandlers = roomListeners.get('Room.timeline') ?? []; + act(() => { + timelineHandlers.forEach((h) => h()); + }); + + // The update is debounced β€” advance past the 300ms timer. + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(result.current).toBe('You: second'); + vi.useRealTimers(); + }); +}); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts new file mode 100644 index 000000000..92b4c3128 --- /dev/null +++ b/src/app/hooks/useRoomLastMessage.ts @@ -0,0 +1,165 @@ +import { useEffect, useRef, useState } from 'react'; +import { + MatrixClient, + MatrixEvent, + MatrixEventEvent, + MsgType, + Room, + RoomEvent as RoomEventEnum, +} from '$types/matrix-sdk'; +import { MessageEvent } from '$types/matrix/room'; +import { getMemberDisplayName } from '$utils/room'; + +/** + * Strip the legacy reply fallback (lines starting with `> `) that some + * clients prepend when replying to a message. + */ +export function stripReplyFallback(body: string): string { + const lines = body.split('\n'); + let i = 0; + while (i < lines.length && lines[i].startsWith('> ')) i += 1; + // Skip the blank separator line that follows the fallback block. + if (i > 0 && i < lines.length && lines[i] === '') i += 1; + return lines.slice(i).join('\n'); +} + +export function eventToPreviewText(ev: MatrixEvent): string | undefined { + if (ev.isRedacted()) return undefined; + + // After decryption, getType() still returns 'm.room.encrypted' (the wire type). + // Use the effective event type to get the decrypted type when available. + const effectiveType = (ev.getEffectiveEvent()?.type as string | undefined) ?? ev.getType(); + const type = effectiveType; + const content = ev.getContent(); + + // Skip reactions and edits β€” they aren't standalone messages. + if (type === MessageEvent.Reaction) return undefined; + const relType = content?.['m.relates_to']?.rel_type; + if (relType === 'm.replace') return undefined; + + // Only show encrypted placeholder if the event is still encrypted (not yet decrypted). + if (type === MessageEvent.RoomMessageEncrypted) return 'πŸ”’ Encrypted message'; + + if (type === MessageEvent.RoomMessage) { + const { msgtype } = content; + if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) { + return stripReplyFallback(content.body); + } + if (msgtype === MsgType.Image) return 'πŸ“· Image'; + if (msgtype === MsgType.Video) return 'πŸ“Ή Video'; + if (msgtype === MsgType.Audio) return '🎡 Audio'; + if (msgtype === MsgType.File) return 'πŸ“Ž File'; + if (msgtype === 'm.location') return 'πŸ“ Location'; + } + + if (type === MessageEvent.Sticker) { + return `πŸŽ‰ ${content.body ?? 'Sticker'}`; + } + + // Polls β€” show the question text when available. + if (type === 'org.matrix.msc3381.poll.start' || type === 'm.poll.start') { + const pollBody = + content?.['org.matrix.msc3381.poll.start']?.question?.body ?? + content?.['m.poll.start']?.question?.body; + return `πŸ“Š ${pollBody ?? 'Poll'}`; + } + + return undefined; +} + +/** + * Extract a human-readable name from a Matrix user ID (@localpart:server). + * Falls back to the raw id if the format is unexpected. + */ +function displayNameFromMxid(mxid: string): string { + if (mxid.startsWith('@')) { + const localpart = mxid.slice(1).split(':')[0]; + if (localpart) return localpart; + } + return mxid; +} + +export function getLastMessageText(room: Room, mx: MatrixClient): string | undefined { + const events = room.getLiveTimeline().getEvents(); + const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined); + if (!match) return undefined; + const text = eventToPreviewText(match); + if (!text) return undefined; + + const senderId = match.getSender(); + let prefix: string; + if (senderId === mx.getUserId()) { + prefix = 'You'; + } else { + prefix = + getMemberDisplayName(room, senderId ?? '') ?? displayNameFromMxid(senderId ?? 'Unknown'); + } + return `${prefix}: ${text}`; +} + +/** + * Reactively returns a human-readable preview of the last message in a room's + * live timeline, prefixed with "You:" or the sender's display name. + * Listens to Timeline and Decrypted events so the preview updates as messages + * arrive or are decrypted. + * Pass `undefined` for room to disable (returns `undefined`). + */ +export function useRoomLastMessage( + room: Room | undefined, + mx: MatrixClient | undefined +): string | undefined { + const [text, setText] = useState(() => + room && mx ? getLastMessageText(room, mx) : undefined + ); + + // Debounce timer ref β€” cleared on unmount and room change. + const debounceRef = useRef | undefined>(undefined); + + useEffect(() => { + if (!room || !mx) { + setText(undefined); + return undefined; + } + + const update = () => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setText(getLastMessageText(room, mx)); + }, 300); + }; + + // Subscribe before reading to close the race window: any decryption that + // completes after this point will trigger an update via the listener. + room.on(RoomEventEnum.Timeline, update); + room.on(RoomEventEnum.LocalEchoUpdated, update); + + const onDecrypted = (ev: MatrixEvent) => { + if (ev.getRoomId() === room.roomId) update(); + }; + mx.on(MatrixEventEvent.Decrypted, onDecrypted); + + // Read current state after subscribing to catch any events that decrypted + // between the initial render and the listener mount. + update(); + + // If the last displayable event is still encrypted, explicitly request + // decryption. Sliding sync may not auto-decrypt events in rooms that + // haven't been opened yet; this ensures the preview resolves on mount. + const events = room.getLiveTimeline().getEvents(); + const lastDisplayable = [...events] + .reverse() + .find((ev) => eventToPreviewText(ev) !== undefined); + if (lastDisplayable && lastDisplayable.isEncrypted()) { + mx.decryptEventIfNeeded(lastDisplayable).catch(() => undefined); + } + + return () => { + clearTimeout(debounceRef.current); + room.off(RoomEventEnum.Timeline, update); + room.off(RoomEventEnum.LocalEchoUpdated, update); + mx.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [room, mx]); + + return text; +} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1a653e950..754c28bef 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -48,6 +48,7 @@ import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { getHomePath } from '$pages/pathUtils'; import { useClientConfig } from '$hooks/useClientConfig'; +import { getSettings } from '$state/settings'; import { pushSessionToSW } from '../../../sw-session'; import { SyncStatus } from './SyncStatus'; import { SpecVersions } from './SpecVersions'; @@ -212,12 +213,18 @@ export function ClientRoot({ children }: ClientRootProps) { const [startState, startMatrix] = useAsyncCallback( useCallback( - (m) => - startClient(m, { + (m) => { + const s = getSettings(); + const needsPreviewTimeline = s.dmMessagePreview || s.roomMessagePreview; + return startClient(m, { baseUrl: activeSession?.baseUrl, - slidingSync: clientConfig.slidingSync, + slidingSync: { + ...clientConfig.slidingSync, + listTimelineLimit: needsPreviewTimeline ? 5 : undefined, + }, sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn, - }), + }); + }, [activeSession?.baseUrl, activeSession?.slidingSyncOptIn, clientConfig.slidingSync] ) ); diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 11eae40c3..7bf4d153b 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -178,6 +178,7 @@ export function Direct() { const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); const [customDMCards] = useSetting(settingsAtom, 'customDMCards'); + const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); const createDirectSelected = useDirectCreateSelected(); @@ -186,16 +187,18 @@ export function Direct() { const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); // Track timeline activity to trigger re-sorting when messages arrive. - // Without this, DMs only re-sort when you switch rooms because getLastActiveTimestamp() - // is internal SDK state not tracked by React dependencies. + // Debounced to prevent excessive re-renders on rapid events (reactions, edits, etc.). const [activityCounter, setActivityCounter] = useState(0); const directsSetRef = useRef(directs); + const activityTimerRef = useRef | undefined>(undefined); directsSetRef.current = directs; useEffect(() => { const handleTimeline = () => { - // Increment counter to trigger re-sort when any timeline event happens - setActivityCounter((prev) => prev + 1); + clearTimeout(activityTimerRef.current); + activityTimerRef.current = setTimeout(() => { + setActivityCounter((prev) => prev + 1); + }, 500); }; // Listen to timeline events only for direct message rooms @@ -205,6 +208,7 @@ export function Direct() { }); return () => { + clearTimeout(activityTimerRef.current); directsSetRef.current.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); @@ -296,6 +300,7 @@ export function Direct() { showAvatar direct customDMCards={customDMCards} + dmMessagePreview={dmMessagePreview} linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))} notificationMode={getRoomNotificationMode( notificationPreferences, diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index c25d99e30..5ceda0a1e 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -199,6 +199,8 @@ export function Home() { const notificationPreferences = useRoomsNotificationPreferencesContext(); const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); + const [roomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview] = useSetting(settingsAtom, 'roomMessagePreview'); const selectedRoomId = useSelectedRoom(); const createRoomSelected = useHomeCreateSelected(); @@ -344,6 +346,8 @@ export function Home() { { // Notes: // - RoomName/RoomCanonicalAlias are omitted: sliding sync returns the room name as a // top-level field in every list response, so fetching them as state events is redundant. -// - MSC3575_STATE_KEY_LAZY is omitted: lazy-loading members is only needed when the -// user is actively viewing a room; loading them for every list entry wastes bandwidth. +// - MSC3575_STATE_KEY_LAZY is included only when `includeMembers=true` (i.e. when +// message previews are enabled and listTimelineLimit > 0). Lazy loading brings in +// m.room.member state events for senders of the preview timeline events so that +// display names resolve correctly. When previews are disabled, lazy loading is +// omitted to avoid wasteful member fetches for every list entry. // - SpaceChild with wildcard is required: the roomToParents atom reads m.space.child // state events (one per child, keyed by child room ID) to build the space hierarchy. // Without these events the SDK has no parentβ†’child mapping, so all rooms appear as @@ -113,7 +119,9 @@ const clampPositive = (value: number | undefined, fallback: number): number => { // for non-active rooms β€” notification serverName extraction, mention autocomplete // alias display, and getCanonicalAliasOrRoomId for navigation. Without it, aliases // fall back silently to room IDs. -const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [ +const buildListRequiredState = ( + includeMembers: boolean +): MSC3575RoomSubscription['required_state'] => [ [EventType.RoomJoinRules, ''], [EventType.RoomAvatar, ''], [EventType.RoomTombstone, ''], @@ -122,6 +130,7 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomTopic, ''], [EventType.RoomCanonicalAlias, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], + ...(includeMembers ? [[EventType.RoomMember, MSC3575_STATE_KEY_LAZY] as [string, string]] : []), ['m.space.child', MSC3575_WILDCARD], ['im.ponies.room_emotes', MSC3575_WILDCARD], ['moe.sable.room.abbreviations', ''], @@ -144,9 +153,13 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri ], }); -const buildLists = (pageSize: number, includeInviteList: boolean): Map => { +const buildLists = ( + pageSize: number, + includeInviteList: boolean, + listTimelineLimit: number +): Map => { const lists = new Map(); - const listRequiredState = buildListRequiredState(); + const listRequiredState = buildListRequiredState(listTimelineLimit > 0); // Start with a reasonable initial range that will quickly expand to full list // Since timeline_limit=1, loading many rooms is very cheap @@ -156,7 +169,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map void; @@ -300,12 +315,13 @@ export class SlidingSyncManager { this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS); this.listPageSize = listPageSize; const includeInviteList = config.includeInviteList !== false; + this.listTimelineLimit = clampPositive(config.listTimelineLimit, DEFAULT_LIST_TIMELINE_LIMIT); const roomTimelineLimit = clampPositive(config.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); - const lists = buildLists(listPageSize, includeInviteList); + const lists = buildLists(listPageSize, includeInviteList, this.listTimelineLimit); this.listKeys = Array.from(lists.keys()); this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs); @@ -717,8 +733,8 @@ export class SlidingSyncManager { list = { ranges: [[0, 20]], sort: LIST_SORT_ORDER, - timeline_limit: LIST_TIMELINE_LIMIT, - required_state: buildListRequiredState(), + timeline_limit: this.listTimelineLimit, + required_state: buildListRequiredState(this.listTimelineLimit > 0), ...updateArgs, }; } else {