From 33f9f99b562393ac7ff22f33bbeb7bc1bf4d6669 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 10:01:36 -0400 Subject: [PATCH 1/9] feat(room-nav): show topic/last-message preview for space and home rooms --- .changeset/room-message-preview.md | 5 + src/app/features/room-nav/RoomNavItem.tsx | 10 +- .../features/settings/cosmetics/Themes.tsx | 31 ++++++ src/app/hooks/useRoomLastMessage.ts | 95 +++++++++++++++++++ src/app/pages/client/home/Home.tsx | 8 +- src/app/pages/client/space/Space.tsx | 2 + src/app/state/settings.ts | 4 + src/client/slidingSync.ts | 9 +- 8 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 .changeset/room-message-preview.md create mode 100644 src/app/hooks/useRoomLastMessage.ts diff --git a/.changeset/room-message-preview.md b/.changeset/room-message-preview.md new file mode 100644 index 000000000..4f8d1cef8 --- /dev/null +++ b/.changeset/room-message-preview.md @@ -0,0 +1,5 @@ +--- +'@sable/client': 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 7262e8fbe..1d4f0e769 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -72,6 +72,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'; import { SidebarUnreadBadge } from '$components/sidebar'; @@ -266,6 +267,8 @@ type RoomNavItemProps = { customDMCards?: boolean; hideText?: boolean; joinCallOnSingleClick?: boolean; + roomTopicPreview?: boolean; + roomMessagePreview?: boolean; }; export function RoomNavItem({ @@ -274,6 +277,8 @@ export function RoomNavItem({ showAvatar, direct, customDMCards, + roomTopicPreview = false, + roomMessagePreview = false, notificationMode, linkPath, hideText, @@ -297,8 +302,11 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); + const lastMessage = useRoomLastMessage(!direct && roomMessagePreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); - const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined; + const roomTopic = direct + ? ((customDMCards && getRoomTopic) ?? 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 e00e5f425..aa028d301 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -790,6 +790,11 @@ export function Appearance({ settingsAtom, 'closeFoldersByDefault' ); + const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview, setRoomMessagePreview] = useSetting( + settingsAtom, + 'roomMessagePreview' + ); return ( @@ -842,6 +847,32 @@ export function Appearance({ /> + + + } + /> + + + + + } + /> + + 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 = room.getMember(senderId ?? '')?.name ?? 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 + ); + + useEffect(() => { + if (!room || !mx) { + setText(undefined); + return undefined; + } + setText(getLastMessageText(room, mx)); + + const update = () => setText(getLastMessageText(room, mx)); + room.on(RoomEventEnum.Timeline, update); + room.on(RoomEventEnum.LocalEchoUpdated, update); + + // Re-check when any event in this room is decrypted (encrypted β†’ plaintext). + const onDecrypted = (ev: MatrixEvent) => { + if (ev.getRoomId() === room.roomId) update(); + }; + mx.on(MatrixEventEvent.Decrypted, onDecrypted); + + return () => { + 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/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 38022a2e6..71bec7e5f 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -210,6 +210,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 [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth'); const [curWidth, setCurWidth] = useState(roomSidebarWidth); @@ -438,6 +440,8 @@ export function Home() { room.roomId )} joinCallOnSingleClick={joinCallOnSingleClick} + roomTopicPreview={roomTopicPreview} + roomMessagePreview={roomMessagePreview} /> @@ -447,8 +451,8 @@ export function Home() { - )} - + )} + {!mobileOrTablet() && ( Date: Sat, 11 Apr 2026 22:17:21 -0400 Subject: [PATCH 2/9] feat(dm-list): show latest message preview below room name --- .changeset/feat-dm-message-preview.md | 5 +++++ src/app/features/room-nav/RoomNavItem.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/feat-dm-message-preview.md diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md new file mode 100644 index 000000000..ab8e37801 --- /dev/null +++ b/.changeset/feat-dm-message-preview.md @@ -0,0 +1,5 @@ +--- +'@sable/client': minor +--- + +feat(dm-list): show last-message preview below DM room name diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 1d4f0e769..140078d55 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -302,10 +302,10 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); - const lastMessage = useRoomLastMessage(!direct && roomMessagePreview ? room : undefined, mx); + const lastMessage = useRoomLastMessage(roomMessagePreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); const roomTopic = direct - ? ((customDMCards && getRoomTopic) ?? presence?.status) + ? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status) : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined); const { navigateRoom } = useRoomNavigate(); From ef850098d2d2ee2394dede0aa16716a13efef889 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 00:18:31 -0400 Subject: [PATCH 3/9] feat(dm-list): add toggle to hide DM message preview --- .changeset/feat-dm-message-preview.md | 2 +- .changeset/room-message-preview.md | 2 +- src/app/features/room-nav/RoomNavItem.tsx | 7 ++++-- .../features/settings/cosmetics/Themes.tsx | 12 ++++++++- src/app/hooks/useRoomLastMessage.ts | 20 ++++++++++++++- src/app/pages/client/ClientRoot.tsx | 15 ++++++++--- src/app/pages/client/direct/Direct.tsx | 6 +++-- src/app/pages/client/space/Space.tsx | 4 +++ src/app/state/settings.ts | 2 ++ src/client/slidingSync.ts | 25 +++++++++++-------- 10 files changed, 72 insertions(+), 23 deletions(-) diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md index ab8e37801..46cbcff81 100644 --- a/.changeset/feat-dm-message-preview.md +++ b/.changeset/feat-dm-message-preview.md @@ -1,5 +1,5 @@ --- -'@sable/client': minor +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 index 4f8d1cef8..3f8587b85 100644 --- a/.changeset/room-message-preview.md +++ b/.changeset/room-message-preview.md @@ -1,5 +1,5 @@ --- -'@sable/client': minor +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 140078d55..d92266451 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -269,6 +269,7 @@ type RoomNavItemProps = { joinCallOnSingleClick?: boolean; roomTopicPreview?: boolean; roomMessagePreview?: boolean; + dmMessagePreview?: boolean; }; export function RoomNavItem({ @@ -279,6 +280,7 @@ export function RoomNavItem({ customDMCards, roomTopicPreview = false, roomMessagePreview = false, + dmMessagePreview = true, notificationMode, linkPath, hideText, @@ -302,10 +304,11 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); - const lastMessage = useRoomLastMessage(roomMessagePreview ? room : undefined, mx); + const showPreview = direct ? dmMessagePreview : roomMessagePreview; + const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); const roomTopic = direct - ? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status) + ? (customDMCards && getRoomTopic) || lastMessage || presence?.status : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined); const { navigateRoom } = useRoomNavigate(); diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index aa028d301..184d074a8 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -784,6 +784,7 @@ export function Appearance({ const [sidebarSelector, setSidebarSelector] = useState('roomSidebarWidth'); 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 [themeBrowserOpen, setThemeBrowserOpen] = useState(false); const [closeFoldersByDefault, setCloseFoldersByDefault] = useSetting( @@ -841,8 +842,17 @@ export function Appearance({ title="Customize DM cards" focusId="customize-dm-cards" description="Show a custom DM card instead of the DM-ed's details" + after={} + /> + + + + + } /> diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index b4c829f10..1e87d0092 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -9,18 +9,36 @@ import { } from '$types/matrix-sdk'; import { MessageEvent } from '$types/matrix/room'; +/** + * Strip the legacy reply fallback (lines starting with `> `) that some + * clients prepend when replying to a message. + */ +function stripReplyFallback(body: string): string { + const lines = body.split('\n'); + let i = 0; + while (i < lines.length && lines[i].startsWith('> ')) i++; + // Skip the blank separator line that follows the fallback block. + if (i > 0 && i < lines.length && lines[i] === '') i++; + return lines.slice(i).join('\n'); +} + function eventToPreviewText(ev: MatrixEvent): string | undefined { if (ev.isRedacted()) return undefined; const type = ev.getType(); + // Skip reactions and edits β€” they aren't standalone messages. + if (type === MessageEvent.Reaction) return undefined; + const relType = ev.getContent()?.['m.relates_to']?.rel_type; + if (relType === 'm.replace') return undefined; + if (type === MessageEvent.RoomMessageEncrypted) return 'πŸ”’ Encrypted message'; if (type === MessageEvent.RoomMessage) { const content = ev.getContent(); const { msgtype } = content; if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) { - return content.body; + return stripReplyFallback(content.body); } if (msgtype === MsgType.Image) return 'πŸ“· Image'; if (msgtype === MsgType.Video) return 'πŸ“Ή Video'; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 70ba9221e..dba111d54 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -50,6 +50,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'; @@ -214,12 +215,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 dce68f104..005863aa6 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -197,6 +197,7 @@ export function Direct() { }, [roomSidebarWidth]); const [joinCallOnSingleClick] = useSetting(settingsAtom, 'joinCallOnSingleClick'); + const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); const createDirectSelected = useDirectCreateSelected(); @@ -355,6 +356,7 @@ export function Direct() { room.roomId )} joinCallOnSingleClick={joinCallOnSingleClick} + dmMessagePreview={dmMessagePreview} /> @@ -364,8 +366,8 @@ export function Direct() { - )} - + )} + {!mobileOrTablet() && ( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 523b39b52..dc0186a7d 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -171,6 +171,7 @@ export interface Settings { widgetSidebarWidth: number; roomTopicPreview: boolean; roomMessagePreview: boolean; + dmMessagePreview: boolean; // furry stuff renderAnimals: boolean; @@ -305,6 +306,7 @@ export const defaultSettings: Settings = { widgetSidebarWidth: 420, roomTopicPreview: false, roomMessagePreview: false, + dmMessagePreview: true, // furry stuff renderAnimals: true, diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 3c0e250ad..015e0c56d 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -45,10 +45,9 @@ export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; // A small number of timeline events per list room. Unread counts come from // the server-side notification_count field, so a full history isn't needed. -// We fetch a few events (rather than 1) so that reactions and edits β€” which -// the SDK excludes from the main timeline when their parent event is absent β€” -// don't leave the timeline empty and break message previews. -const LIST_TIMELINE_LIMIT = 5; +// When message previews are enabled, a higher limit (e.g. 5) avoids empty +// timelines caused by reactions/edits whose parent event is absent. +const DEFAULT_LIST_TIMELINE_LIMIT = 1; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; @@ -62,7 +61,7 @@ const LIST_SORT_ORDER = ['by_recency', 'by_name']; // Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members. const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; // Timeline limit for the active-room subscription (full history load). -// List entries always use LIST_TIMELINE_LIMIT=1 for lightweight previews. +// List entries use a small timeline limit (default 1) for lightweight previews. const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { @@ -76,6 +75,7 @@ export type SlidingSyncConfig = { proxyBaseUrl?: string; bootstrapClassicOnColdCache?: boolean; listPageSize?: number; + listTimelineLimit?: number; timelineLimit?: number; pollTimeoutMs?: number; maxRooms?: number; @@ -156,7 +156,7 @@ 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(); @@ -168,7 +168,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map void; @@ -310,12 +312,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); @@ -746,7 +749,7 @@ export class SlidingSyncManager { list = { ranges: [[0, 20]], sort: LIST_SORT_ORDER, - timeline_limit: LIST_TIMELINE_LIMIT, + timeline_limit: this.listTimelineLimit, required_state: buildListRequiredState(), ...updateArgs, }; From 96f41afb52fdfa412d9bcc01188507f5b7dcacf0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:25:13 -0400 Subject: [PATCH 4/9] test(room-nav): add useRoomLastMessage unit tests (28 tests) - Test stripReplyFallback: plain text, quoted lines, no separator, multi-line - Test eventToPreviewText: all msg types, encrypted, sticker, reactions, edits, reply fallback - Test getLastMessageText: You prefix, display name, userId fallback, skip reactions, empty timeline - Test useRoomLastMessage hook: undefined room, initial render, Timeline event updates - Export pure functions for testability --- src/app/hooks/useRoomLastMessage.test.tsx | 258 ++++++++++++++++++++++ src/app/hooks/useRoomLastMessage.ts | 22 +- src/client/slidingSync.ts | 8 +- 3 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 src/app/hooks/useRoomLastMessage.test.tsx diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx new file mode 100644 index 000000000..e58357834 --- /dev/null +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -0,0 +1,258 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + stripReplyFallback, + eventToPreviewText, + getLastMessageText, + useRoomLastMessage, +} from './useRoomLastMessage'; + +// -------- helpers -------- + +function makeEvent(overrides: { + type?: string; + content?: Record; + sender?: string; + roomId?: string; + redacted?: boolean; + effectiveType?: string; +}) { + 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, + 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 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] } : 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 userId 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:test: 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', () => { + 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)); + expect(result.current).toBe('You: first'); + + // 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()); + }); + + expect(result.current).toBe('You: second'); + }); +}); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 1e87d0092..e0d6d99f4 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -13,29 +13,33 @@ import { MessageEvent } from '$types/matrix/room'; * Strip the legacy reply fallback (lines starting with `> `) that some * clients prepend when replying to a message. */ -function stripReplyFallback(body: string): string { +export function stripReplyFallback(body: string): string { const lines = body.split('\n'); let i = 0; - while (i < lines.length && lines[i].startsWith('> ')) i++; + 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++; + if (i > 0 && i < lines.length && lines[i] === '') i += 1; return lines.slice(i).join('\n'); } -function eventToPreviewText(ev: MatrixEvent): string | undefined { +export function eventToPreviewText(ev: MatrixEvent): string | undefined { if (ev.isRedacted()) return undefined; - const type = ev.getType(); + // 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 = ev.getContent()?.['m.relates_to']?.rel_type; + 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 content = ev.getContent(); const { msgtype } = content; if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) { return stripReplyFallback(content.body); @@ -47,13 +51,13 @@ function eventToPreviewText(ev: MatrixEvent): string | undefined { } if (type === MessageEvent.Sticker) { - return `πŸŽ‰ ${ev.getContent().body ?? 'Sticker'}`; + return `πŸŽ‰ ${content.body ?? 'Sticker'}`; } return undefined; } -function getLastMessageText(room: Room, mx: MatrixClient): string | undefined { +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; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 015e0c56d..5da2713ab 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -61,7 +61,7 @@ const LIST_SORT_ORDER = ['by_recency', 'by_name']; // Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members. const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; // Timeline limit for the active-room subscription (full history load). -// List entries use a small timeline limit (default 1) for lightweight previews. +// List entries use a configurable timeline limit (default 1; raised to 5 when message previews are enabled). const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { @@ -156,7 +156,11 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri ], }); -const buildLists = (pageSize: number, includeInviteList: boolean, listTimelineLimit: number): Map => { +const buildLists = ( + pageSize: number, + includeInviteList: boolean, + listTimelineLimit: number +): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); From 97f19e71786ec7366fad7f0492ddc37423d18f3c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 23:37:30 -0400 Subject: [PATCH 5/9] fix(preview): close decryption race in useRoomLastMessage Subscribe to Decrypted events before reading current state so events that decrypt between the initial render and listener mount are not missed. Explicitly request decryption for the last encrypted event on mount so rooms not yet opened (e.g. sliding-sync previews) resolve their preview text without requiring the user to visit the room. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.test.tsx | 27 ++++++++++++-- src/app/hooks/useRoomLastMessage.ts | 44 +++++++++++++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index e58357834..a049a8e3f 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -16,6 +16,7 @@ function makeEvent(overrides: { roomId?: string; redacted?: boolean; effectiveType?: string; + encrypted?: boolean; }) { const type = overrides.type ?? 'm.room.message'; const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' }; @@ -25,6 +26,7 @@ function makeEvent(overrides: { 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; } @@ -141,6 +143,27 @@ describe('eventToPreviewText', () => { 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(); @@ -172,10 +195,10 @@ describe('getLastMessageText', () => { expect(getLastMessageText(room, makeMx())).toBe('Bob: hey'); }); - it('falls back to userId when no display name is available', () => { + 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:test: hey'); + expect(getLastMessageText(room, makeMx())).toBe('bob: hey'); }); it('skips reactions and picks the last real message', () => { diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index e0d6d99f4..04dc4fd9b 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -48,15 +48,36 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined { 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); @@ -69,7 +90,8 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef if (senderId === mx.getUserId()) { prefix = 'You'; } else { - prefix = room.getMember(senderId ?? '')?.name ?? senderId ?? 'Unknown'; + prefix = + room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); } return `${prefix}: ${text}`; } @@ -94,18 +116,34 @@ export function useRoomLastMessage( setText(undefined); return undefined; } - setText(getLastMessageText(room, mx)); const update = () => setText(getLastMessageText(room, mx)); + + // 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); - // Re-check when any event in this room is decrypted (encrypted β†’ plaintext). 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 () => { room.off(RoomEventEnum.Timeline, update); room.off(RoomEventEnum.LocalEchoUpdated, update); From b16efa69a87ce5b4971a1beeb1c08f8958ba9972 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 6/9] fix(timeline): restore useLayoutEffect auto-scroll, fix new-message scroll, fix eventId drag-to-bottom, increase list timeline limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTimelineSync: change auto-scroll recovery useEffect β†’ useLayoutEffect to prevent one-frame flash after timeline reset - useTimelineSync: remove premature scrollToBottom from useLiveTimelineRefresh (operated on pre-commit DOM with stale scrollSize) - useTimelineSync: remove scrollToBottom + eventsLengthRef suppression from useLiveEventArrive; let useLayoutEffect handle scroll after React commits - RoomTimeline: init atBottomState to false when eventId is set, and reset it in the eventId useEffect, so auto-scroll doesn't drag to bottom on bookmark nav - RoomTimeline: change instant scrollToBottom to use scrollToIndex instead of scrollTo(scrollSize) β€” works correctly regardless of VList measurement state - slidingSync: increase DEFAULT_LIST_TIMELINE_LIMIT 1β†’3 to reduce empty previews when recent events are reactions/edits/state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 10 +++++----- src/client/slidingSync.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9d253c6a..5dfc0c9ed 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -130,7 +130,7 @@ describe('useTimelineSync', () => { await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalledWith('instant'); + expect(scrollToBottom).toHaveBeenCalled(); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index c10b762b8..fb7976462 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -352,7 +352,7 @@ export interface UseTimelineSyncOptions { eventId?: string; isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; - scrollToBottom: (behavior?: 'instant' | 'smooth') => void; + scrollToBottom: () => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -461,7 +461,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); + scrollToBottom(); }, [alive, room, scrollToBottom]) ); @@ -489,7 +489,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth'); + scrollToBottom(); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -527,7 +527,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom('instant'); + scrollToBottom(); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -564,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 5da2713ab..949c1c7d0 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -45,9 +45,9 @@ export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; // A small number of timeline events per list room. Unread counts come from // the server-side notification_count field, so a full history isn't needed. -// When message previews are enabled, a higher limit (e.g. 5) avoids empty -// timelines caused by reactions/edits whose parent event is absent. -const DEFAULT_LIST_TIMELINE_LIMIT = 1; +// Higher limit avoids empty previews when the most-recent events are +// reactions/edits/state that useRoomLatestRenderedEvent skips over. +const DEFAULT_LIST_TIMELINE_LIMIT = 3; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; From fa7bc005cd69ea80cfa1ffbd1927dd00ba0158dd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 23:43:28 -0400 Subject: [PATCH 7/9] perf(sidebar): debounce room preview and DM sort updates - Debounce useRoomLastMessage update handler (300ms) to avoid re-rendering every room preview on each timeline event - Debounce Direct.tsx activityCounter (500ms) to batch DM list re-sorts during rapid event bursts (reactions, edits, etc.) - Update test to account for debounced update timing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.test.tsx | 8 +++++++- src/app/hooks/useRoomLastMessage.ts | 16 ++++++++++++---- src/app/pages/client/direct/Direct.tsx | 11 +++++++---- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index a049a8e3f..2e4b725a3 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -259,13 +259,13 @@ describe('useRoomLastMessage', () => { }); 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)); - expect(result.current).toBe('You: first'); // Simulate a new message arriving. const ev2 = makeEvent({ content: { msgtype: 'm.text', body: 'second' } }); @@ -276,6 +276,12 @@ describe('useRoomLastMessage', () => { 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 index 04dc4fd9b..e40daf938 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { MatrixClient, MatrixEvent, @@ -90,8 +90,7 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef if (senderId === mx.getUserId()) { prefix = 'You'; } else { - prefix = - room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); + prefix = room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); } return `${prefix}: ${text}`; } @@ -111,13 +110,21 @@ export function useRoomLastMessage( 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 = () => setText(getLastMessageText(room, mx)); + 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. @@ -145,6 +152,7 @@ export function useRoomLastMessage( } return () => { + clearTimeout(debounceRef.current); room.off(RoomEventEnum.Timeline, update); room.off(RoomEventEnum.LocalEchoUpdated, update); mx.off(MatrixEventEvent.Decrypted, onDecrypted); diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 005863aa6..ce25fbd98 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -206,16 +206,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 @@ -225,6 +227,7 @@ export function Direct() { }); return () => { + clearTimeout(activityTimerRef.current); directsSetRef.current.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); From bf4eaa7513b177b54644324d516a69536472a9c1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:41:03 -0400 Subject: [PATCH 8/9] fix(preview): resolve display names in room previews --- .../features/settings/cosmetics/Themes.tsx | 16 ++++-- .../hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 8 +-- src/app/hooks/useRoomLastMessage.test.tsx | 52 +++++++++++------- src/app/hooks/useRoomLastMessage.ts | 54 ++++++++++++++----- src/app/pages/client/ClientRoot.tsx | 2 +- src/client/slidingSync.ts | 22 +++++--- 7 files changed, 106 insertions(+), 50 deletions(-) diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 184d074a8..ea308a0a1 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -842,7 +842,9 @@ export function Appearance({ title="Customize DM cards" focusId="customize-dm-cards" description="Show a custom DM card instead of the DM-ed's details" - after={} + after={ + + } /> @@ -852,7 +854,11 @@ export function Appearance({ focusId="dm-message-preview" description="Show a preview of the last message below DM room names." after={ - + } /> @@ -863,7 +869,11 @@ export function Appearance({ focusId="room-topic-preview" description="Show the room topic below room names in spaces and Home." after={ - + } /> diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 5dfc0c9ed..b9d253c6a 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -130,7 +130,7 @@ describe('useTimelineSync', () => { await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalled(); + expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index fb7976462..5fa692995 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -352,7 +352,7 @@ export interface UseTimelineSyncOptions { eventId?: string; isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; - scrollToBottom: () => void; + scrollToBottom: (behavior?: 'instant' | 'smooth') => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -461,7 +461,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom(); + scrollToBottom('instant'); }, [alive, room, scrollToBottom]) ); @@ -527,7 +527,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom(); + scrollToBottom('instant'); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -564,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom(); + scrollToBottom('instant'); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 2e4b725a3..5165557a4 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -31,6 +31,21 @@ function makeEvent(overrides: { } as never; } +const makeLastMessageMx = (userId = '@alice:test') => ({ getUserId: () => userId }) as never; + +const makeLastMessageRoom = ( + events: ReturnType[], + members?: Record +) => + ({ + roomId: '!room:test', + getLiveTimeline: () => ({ + getEvents: () => events, + }), + getMember: (id: string) => + members?.[id] ? { name: members[id], rawDisplayName: members[id] } : null, + }) as never; + // -------- stripReplyFallback -------- describe('stripReplyFallback', () => { @@ -173,47 +188,46 @@ describe('eventToPreviewText', () => { // -------- 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] } : 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'); + expect(getLastMessageText(makeLastMessageRoom([ev]), makeLastMessageMx())).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'); + const room = makeLastMessageRoom([ev], { '@bob:test': 'Bob' }); + expect(getLastMessageText(room, makeLastMessageMx())).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'); + const room = makeLastMessageRoom([ev]); + expect(getLastMessageText(room, makeLastMessageMx())).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 = makeLastMessageRoom([ev], { '@bob:test': '@bob:test' }); + expect(getLastMessageText(room, makeLastMessageMx())).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'); + expect(getLastMessageText(makeLastMessageRoom([msg, reaction]), makeLastMessageMx())).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(); + expect( + getLastMessageText(makeLastMessageRoom([reaction]), makeLastMessageMx()) + ).toBeUndefined(); }); it('returns undefined for an empty timeline', () => { - expect(getLastMessageText(makeRoom([]), makeMx())).toBeUndefined(); + expect(getLastMessageText(makeLastMessageRoom([]), makeLastMessageMx())).toBeUndefined(); }); }); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index e40daf938..7077f6f88 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { + EventType, MatrixClient, MatrixEvent, MatrixEventEvent, @@ -7,7 +8,12 @@ import { Room, RoomEvent as RoomEventEnum, } from '$types/matrix-sdk'; -import { MessageEvent } from '$types/matrix/room'; +import { getMemberDisplayName } from '$utils/room'; + +const REACTION_EVENT_TYPE: string = EventType.Reaction; +const ENCRYPTED_EVENT_TYPE: string = EventType.RoomMessageEncrypted; +const ROOM_MESSAGE_EVENT_TYPE: string = EventType.RoomMessage; +const STICKER_EVENT_TYPE: string = EventType.Sticker; /** * Strip the legacy reply fallback (lines starting with `> `) that some @@ -16,7 +22,7 @@ import { MessageEvent } from '$types/matrix/room'; export function stripReplyFallback(body: string): string { const lines = body.split('\n'); let i = 0; - while (i < lines.length && lines[i].startsWith('> ')) i += 1; + 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'); @@ -32,14 +38,14 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined { const content = ev.getContent(); // Skip reactions and edits β€” they aren't standalone messages. - if (type === MessageEvent.Reaction) return undefined; + if (type === REACTION_EVENT_TYPE) 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 === ENCRYPTED_EVENT_TYPE) return 'πŸ”’ Encrypted message'; - if (type === MessageEvent.RoomMessage) { + if (type === ROOM_MESSAGE_EVENT_TYPE) { const { msgtype } = content; if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) { return stripReplyFallback(content.body); @@ -51,16 +57,29 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined { if (msgtype === 'm.location') return 'πŸ“ Location'; } - if (type === MessageEvent.Sticker) { + if (type === STICKER_EVENT_TYPE) { return `πŸŽ‰ ${content.body ?? 'Sticker'}`; } // Polls β€” show the question text when available. if (type === 'org.matrix.msc3381.poll.start' || type === 'm.poll.start') { + const pollContent = content?.['org.matrix.msc3381.poll.start'] ?? content?.['m.poll.start']; + const question = + typeof pollContent === 'object' && pollContent !== null + ? (pollContent as { question?: Record }).question + : undefined; + const textParts = question?.['m.text']; + const pollBodyCandidate = + (Array.isArray(textParts) + ? (textParts[0] as { body?: unknown } | undefined)?.body + : undefined) ?? + question?.['org.matrix.msc1767.text'] ?? + question?.body; const pollBody = - content?.['org.matrix.msc3381.poll.start']?.question?.body ?? - content?.['m.poll.start']?.question?.body; - return `πŸ“Š ${pollBody ?? 'Poll'}`; + typeof pollBodyCandidate === 'string' && pollBodyCandidate.trim() + ? pollBodyCandidate.trim() + : 'Poll'; + return `πŸ“Š ${pollBody}`; } return undefined; @@ -78,9 +97,17 @@ function displayNameFromMxid(mxid: string): string { return mxid; } +function findLastDisplayableEvent(events: MatrixEvent[]): MatrixEvent | undefined { + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (event && eventToPreviewText(event) !== undefined) return event; + } + return undefined; +} + export function getLastMessageText(room: Room, mx: MatrixClient): string | undefined { const events = room.getLiveTimeline().getEvents(); - const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined); + const match = findLastDisplayableEvent(events); if (!match) return undefined; const text = eventToPreviewText(match); if (!text) return undefined; @@ -90,7 +117,8 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef if (senderId === mx.getUserId()) { prefix = 'You'; } else { - prefix = room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); + prefix = + getMemberDisplayName(room, senderId ?? '') ?? displayNameFromMxid(senderId ?? 'Unknown'); } return `${prefix}: ${text}`; } @@ -144,9 +172,7 @@ export function useRoomLastMessage( // 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); + const lastDisplayable = findLastDisplayableEvent(events); if (lastDisplayable && lastDisplayable.isEncrypted()) { mx.decryptEventIfNeeded(lastDisplayable).catch(() => undefined); } diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index dba111d54..934e3afcb 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -222,7 +222,7 @@ export function ClientRoot({ children }: ClientRootProps) { baseUrl: activeSession?.baseUrl, slidingSync: { ...clientConfig.slidingSync, - listTimelineLimit: needsPreviewTimeline ? 5 : undefined, + listTimelineLimit: needsPreviewTimeline ? 3 : undefined, }, sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn, }); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 949c1c7d0..e170983e0 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -45,9 +45,9 @@ export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; // A small number of timeline events per list room. Unread counts come from // the server-side notification_count field, so a full history isn't needed. -// Higher limit avoids empty previews when the most-recent events are -// reactions/edits/state that useRoomLatestRenderedEvent skips over. -const DEFAULT_LIST_TIMELINE_LIMIT = 3; +// Matches upstream's LIST_TIMELINE_LIMIT=1 baseline; message-preview feature +// requests 3 events via ClientRoot when previews are enabled. +const DEFAULT_LIST_TIMELINE_LIMIT = 1; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; @@ -106,8 +106,11 @@ const clampPositive = (value: number | undefined, fallback: number): number => { // 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 @@ -125,7 +128,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, ''], @@ -134,6 +139,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', ''], @@ -162,7 +168,7 @@ const buildLists = ( 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 @@ -754,7 +760,7 @@ export class SlidingSyncManager { ranges: [[0, 20]], sort: LIST_SORT_ORDER, timeline_limit: this.listTimelineLimit, - required_state: buildListRequiredState(), + required_state: buildListRequiredState(this.listTimelineLimit > 0), ...updateArgs, }; } else { From 7ac6750312b9d1c490ed23d4b50e13e9cf7927bf Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 15:35:46 -0400 Subject: [PATCH 9/9] fix(preview): remove timeline sync spillover --- src/app/hooks/timeline/useTimelineSync.ts | 2 +- src/app/hooks/useRoomLastMessage.test.tsx | 18 ++++++++++-------- src/app/hooks/useRoomLastMessage.ts | 4 +--- src/app/pages/client/direct/Direct.tsx | 7 +++---- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 5fa692995..c10b762b8 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -489,7 +489,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(); + scrollToBottom(mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth'); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 5165557a4..4eff2e17b 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -236,8 +236,8 @@ describe('getLastMessageText', () => { describe('useRoomLastMessage', () => { const makeMx = (userId = '@alice:test') => ({ getUserId: () => userId, - on: vi.fn(), - off: vi.fn(), + on: vi.fn<(event: string, handler: (...args: unknown[]) => void) => void>(), + off: vi.fn<(event: string, handler: (...args: unknown[]) => void) => void>(), }); const roomListeners = new Map void)[]>(); @@ -246,12 +246,14 @@ describe('useRoomLastMessage', () => { 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(), + on: vi + .fn<(event: string, handler: (...args: unknown[]) => void) => void>() + .mockImplementation((event, handler) => { + const list = roomListeners.get(event) ?? []; + list.push(handler); + roomListeners.set(event, list); + }), + off: vi.fn<(event: string, handler: (...args: unknown[]) => void) => void>(), }); beforeEach(() => { diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 7077f6f88..2a36204d3 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -1,13 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { EventType, - MatrixClient, - MatrixEvent, MatrixEventEvent, MsgType, - Room, RoomEvent as RoomEventEnum, } from '$types/matrix-sdk'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { getMemberDisplayName } from '$utils/room'; const REACTION_EVENT_TYPE: string = EventType.Reaction; diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index ce25fbd98..a9f4cbcdf 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -208,11 +208,10 @@ export function Direct() { // Track timeline activity to trigger re-sorting when messages arrive. // 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 directRoomIds = Array.from(directs); const handleTimeline = () => { clearTimeout(activityTimerRef.current); activityTimerRef.current = setTimeout(() => { @@ -221,14 +220,14 @@ export function Direct() { }; // Listen to timeline events only for direct message rooms - directsSetRef.current.forEach((roomId) => { + directRoomIds.forEach((roomId) => { const room = mx.getRoom(roomId); room?.on(RoomEvent.Timeline, handleTimeline); }); return () => { clearTimeout(activityTimerRef.current); - directsSetRef.current.forEach((roomId) => { + directRoomIds.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); });