From 4eeaa380192efac2b1f44bfdc63bbb255b86a133 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 10:01:36 -0400 Subject: [PATCH 01/22] feat(room-nav): show topic/last-message preview for space and home rooms --- 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 | 4 + src/app/pages/client/space/Space.tsx | 4 + src/app/state/settings.ts | 4 + 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/app/hooks/useRoomLastMessage.ts diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 22886c224..9689e2852 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,8 @@ type RoomNavItemProps = { showAvatar?: boolean; direct?: boolean; customDMCards?: boolean; + roomTopicPreview?: boolean; + roomMessagePreview?: boolean; }; export function RoomNavItem({ @@ -266,6 +269,8 @@ export function RoomNavItem({ showAvatar, direct, customDMCards, + roomTopicPreview = false, + roomMessagePreview = false, notificationMode, linkPath, }: RoomNavItemProps) { @@ -287,8 +292,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 f543a19ea..0af66fb93 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -487,6 +487,11 @@ export function Appearance() { settingsAtom, 'closeFoldersByDefault' ); + const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview, setRoomMessagePreview] = useSetting( + settingsAtom, + 'roomMessagePreview' + ); return ( @@ -529,6 +534,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 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() { Date: Sun, 12 Apr 2026 11:50:31 -0400 Subject: [PATCH 02/22] fix(sliding-sync): increase LIST_TIMELINE_LIMIT to 5 for message previews With timeline_limit: 1, if the latest event is a reaction or edit, the SDK drops it from getLiveTimeline() because it cannot resolve the parent event from a single-event batch. This leaves the timeline empty and breaks the room message preview. Fetching 5 events ensures the parent message is present alongside reactions/edits so the SDK places them correctly and getLastMessageText finds a displayable preview. --- src/client/slidingSync.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 43fdf39ea..9e3590098 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -34,9 +34,12 @@ export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; // Dynamic list key used for space-scoped room views. export const LIST_SPACE = 'space'; -// One event of timeline per list room is enough to compute unread counts; -// the full history is loaded when the user opens the room. -const LIST_TIMELINE_LIMIT = 1; +// 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; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; From 9036ec9ea7ff4632c5ad4d06b2de3056cfc88215 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 12:10:58 -0400 Subject: [PATCH 03/22] chore: add changeset for room-message-preview --- .changeset/room-message-preview.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/room-message-preview.md 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 From db9c1a41cf2cd84f115773efaa6e84a156f962ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 22:17:21 -0400 Subject: [PATCH 04/22] feat(dm-list): show latest message preview below room name --- src/app/features/room-nav/RoomNavItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 9689e2852..3ded50a4a 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -292,10 +292,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 216aa6a77d67141e4cb46e9e7ad1c4dbdb2f7692 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 23:13:41 -0400 Subject: [PATCH 05/22] chore: add changeset for dm message preview --- .changeset/feat-dm-message-preview.md | 5 +++++ 1 file changed, 5 insertions(+) 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 From b848ac23aaa4df18a98630c6d74863a4ab05056f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 00:18:31 -0400 Subject: [PATCH 06/22] feat(dm-list): add toggle to hide DM message preview --- src/app/features/room-nav/RoomNavItem.tsx | 5 ++++- src/app/features/settings/cosmetics/Themes.tsx | 9 +++++++++ src/app/pages/client/direct/Direct.tsx | 2 ++ src/app/state/settings.ts | 2 ++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 3ded50a4a..be950e3e1 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -261,6 +261,7 @@ type RoomNavItemProps = { customDMCards?: boolean; roomTopicPreview?: boolean; roomMessagePreview?: boolean; + dmMessagePreview?: boolean; }; export function RoomNavItem({ @@ -271,6 +272,7 @@ export function RoomNavItem({ customDMCards, roomTopicPreview = false, roomMessagePreview = false, + dmMessagePreview = true, notificationMode, linkPath, }: RoomNavItemProps) { @@ -292,7 +294,8 @@ 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) diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 0af66fb93..7dc978dcd 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -482,6 +482,7 @@ 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, @@ -532,6 +533,14 @@ export function Appearance() { description="Show a custom DM card instead of the DM-ed's details" after={} /> + + } + /> diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 11eae40c3..3b78f43aa 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(); @@ -296,6 +297,7 @@ export function Direct() { showAvatar direct customDMCards={customDMCards} + dmMessagePreview={dmMessagePreview} linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))} notificationMode={getRoomNotificationMode( notificationPreferences, diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d60f3e829..36dba8cb0 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -120,6 +120,7 @@ export interface Settings { closeFoldersByDefault: boolean; roomTopicPreview: boolean; roomMessagePreview: boolean; + dmMessagePreview: boolean; // furry stuff renderAnimals: boolean; @@ -223,6 +224,7 @@ const defaultSettings: Settings = { closeFoldersByDefault: false, roomTopicPreview: false, roomMessagePreview: false, + dmMessagePreview: true, // furry stuff renderAnimals: true, From ec10020bd83a4be97df9bf556225d86249ec91c0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 09:32:44 -0400 Subject: [PATCH 07/22] fix(settings): give DM Message Preview its own card in Visual Tweaks --- src/app/features/settings/cosmetics/Themes.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 7dc978dcd..0df6e5ade 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -533,6 +533,9 @@ export function Appearance() { description="Show a custom DM card instead of the DM-ed's details" after={} /> + + + Date: Mon, 13 Apr 2026 22:50:43 -0400 Subject: [PATCH 08/22] refactor(sliding-sync): gate listTimelineLimit behind message preview settings LIST_TIMELINE_LIMIT is now configurable via SlidingSyncConfig.listTimelineLimit (default: 1). When dmMessagePreview or roomMessagePreview is enabled, the limit is bumped to 5 so reactions/edits don't leave the preview empty. Users with both preview settings disabled keep the lightweight limit of 1. --- src/app/pages/client/ClientRoot.tsx | 15 +++++++++++---- src/client/slidingSync.ts | 25 ++++++++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) 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/client/slidingSync.ts b/src/client/slidingSync.ts index 9e3590098..802157123 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -36,10 +36,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; @@ -53,7 +52,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 = { @@ -67,6 +66,7 @@ export type SlidingSyncConfig = { proxyBaseUrl?: string; bootstrapClassicOnColdCache?: boolean; listPageSize?: number; + listTimelineLimit?: number; timelineLimit?: number; pollTimeoutMs?: number; maxRooms?: number; @@ -147,7 +147,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(); @@ -159,7 +159,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map void; @@ -303,12 +305,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); @@ -720,7 +723,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 4b29d2990ea447f2a005f2fa184d2cfae91085da Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 15:20:39 -0400 Subject: [PATCH 09/22] fix: use || instead of ?? for DM preview fallback chain The nullish coalescing operator (??) only falls through on null/undefined, but (customDMCards && getRoomTopic) can evaluate to false or empty string, which blocked the fallback to lastMessage. Using || ensures all falsy values correctly fall through to show the message preview. This caused DM message previews to not appear in /direct while the same rooms showed previews in space views (where customDMCards was undefined). --- src/app/features/room-nav/RoomNavItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index be950e3e1..68ead11d8 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -298,7 +298,7 @@ export function RoomNavItem({ 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(); From 4cb00a58022c1341086bc63d582cd080befa29ab Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:02:46 -0400 Subject: [PATCH 10/22] fix(room-nav): address review feedback for message preview - Filter reaction and edit events from last-message preview - Strip reply fallback prefix from preview text - Pass dmMessagePreview setting to RoomNavItem in Space view - Fix changeset frontmatter to use default: minor --- .changeset/feat-dm-message-preview.md | 2 +- .changeset/room-message-preview.md | 2 +- src/app/hooks/useRoomLastMessage.ts | 20 +++++++++++++++++++- src/app/pages/client/space/Space.tsx | 2 ++ 4 files changed, 23 insertions(+), 3 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/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/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 72efaaea6..2ccd15171 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -535,6 +535,7 @@ export function Space() { const [subspaceHierarchyLimit] = useSetting(settingsAtom, 'subspaceHierarchyLimit'); const [roomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); const [roomMessagePreview] = useSetting(settingsAtom, 'roomMessagePreview'); + const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); /** * Creates an SVG used for connecting spaces to their subrooms. * @param virtualizedItems - The virtualized item list that will be used to render elements in the nav @@ -830,6 +831,7 @@ export function Space() { direct={mDirects.has(roomId)} roomTopicPreview={roomTopicPreview} roomMessagePreview={roomMessagePreview} + dmMessagePreview={dmMessagePreview} linkPath={getToLink(roomId)} notificationMode={getRoomNotificationMode( notificationPreferences, From 1f9dae9d3ba4930e519c56386f10e93499834003 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:25:13 -0400 Subject: [PATCH 11/22] 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 | 246 ++++++++++++++++++++++ src/app/hooks/useRoomLastMessage.ts | 6 +- 2 files changed, 249 insertions(+), 3 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..4e3065583 --- /dev/null +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -0,0 +1,246 @@ +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; +}) { + return { + getType: () => overrides.type ?? 'm.room.message', + getContent: () => overrides.content ?? { msgtype: 'm.text', body: 'hello' }, + getSender: () => overrides.sender ?? '@alice:test', + getRoomId: () => overrides.roomId ?? '!room:test', + isRedacted: () => overrides.redacted ?? false, + } 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 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..7f773ec97 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -13,7 +13,7 @@ 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++; @@ -22,7 +22,7 @@ function stripReplyFallback(body: string): string { 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(); @@ -53,7 +53,7 @@ function eventToPreviewText(ev: MatrixEvent): string | undefined { 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; From 850e025e7c370f73bef8a302520508c8194dbbd1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 13:34:33 -0400 Subject: [PATCH 12/22] fix(room-nav): use effective event type for decrypted message previews Use getEffectiveEvent()?.type instead of getType() to get the decrypted event type. getType() returns the wire type (m.room.encrypted) even after decryption, causing previews to always show 'Encrypted message' instead of the actual message content. --- src/app/hooks/useRoomLastMessage.test.tsx | 17 +++++++++++++++-- src/app/hooks/useRoomLastMessage.ts | 12 ++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 4e3065583..5f685f342 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -15,13 +15,17 @@ function makeEvent(overrides: { 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: () => overrides.type ?? 'm.room.message', - getContent: () => overrides.content ?? { msgtype: 'm.text', body: 'hello' }, + 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; } @@ -95,6 +99,15 @@ describe('eventToPreviewText', () => { 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'); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 7f773ec97..c8f27e2a3 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -25,17 +25,21 @@ export function stripReplyFallback(body: string): string { 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,7 +51,7 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined { } if (type === MessageEvent.Sticker) { - return `πŸŽ‰ ${ev.getContent().body ?? 'Sticker'}`; + return `πŸŽ‰ ${content.body ?? 'Sticker'}`; } return undefined; From 3149a3797c6277be4dbad392f864cfbff553118a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:18:01 -0400 Subject: [PATCH 13/22] chore: fix lint and format issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.test.tsx | 3 +-- src/app/hooks/useRoomLastMessage.ts | 4 ++-- src/client/slidingSync.ts | 6 +++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 5f685f342..e58357834 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -150,8 +150,7 @@ describe('eventToPreviewText', () => { // -------- getLastMessageText -------- describe('getLastMessageText', () => { - const makeMx = (userId = '@alice:test') => - ({ getUserId: () => userId }) as never; + const makeMx = (userId = '@alice:test') => ({ getUserId: () => userId }) as never; const makeRoom = (events: ReturnType[], members?: Record) => ({ diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index c8f27e2a3..e0d6d99f4 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -16,9 +16,9 @@ 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++; + 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'); } diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 802157123..91c90c2a2 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -147,7 +147,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 8fee117edbf2e36f3eea394065c84df68ed18be4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:26:30 -0400 Subject: [PATCH 14/22] docs: clarify that listTimelineLimit scales with message preview setting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/client/slidingSync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 91c90c2a2..dd4a880b6 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -52,7 +52,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 = { From 809a9bbae90e3910d82aade3ae76b15f5eece4c8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 23:37:30 -0400 Subject: [PATCH 15/22] 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.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index e0d6d99f4..a27c86d5a 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -94,18 +94,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 dc218731011d18afb9792346e1984349061af1c9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 00:42:28 -0400 Subject: [PATCH 16/22] fix(preview): poll/location preview, mxid localpart fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add poll start event preview (πŸ“Š + question text) and m.location preview. When room.getMember() returns null (common with sliding sync list subscriptions), fall back to localpart extracted from mxid instead of showing the raw @user:server string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.test.tsx | 27 +++++++++++++++++++++-- src/app/hooks/useRoomLastMessage.ts | 24 +++++++++++++++++++- 2 files changed, 48 insertions(+), 3 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 a27c86d5a..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}`; } From 83031b2371d4752be7393929c5f78ba23f6649e2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 17/22] 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/features/room/RoomTimeline.tsx | 23 ++++++++++++++--- src/app/hooks/timeline/useTimelineSync.ts | 31 +++++++++++++---------- src/client/slidingSync.ts | 6 ++--- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d026ec1fa..e48684ea8 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -201,7 +201,14 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(true); + // Load any cached scroll state for this room on mount. A fresh RoomTimeline is + // mounted per room (via key={roomId} in RoomView) so this is the only place we + // need to read the cache β€” the render-phase room-change block below only fires + // in the (hypothetical) case where the room prop changes without a remount. + const scrollCacheForRoomRef = useRef( + roomScrollCache.load(mxUserId, room.roomId) + ); + const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -245,7 +252,14 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - vListRef.current.scrollTo(vListRef.current.scrollSize); + if (behavior === 'smooth') { + vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); + } else { + // scrollToIndex works reliably regardless of VList measurement state. + // The auto-scroll useLayoutEffect fires after React commits new items, + // so lastIndex is always valid when this is called. + vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + } }, []); const timelineSync = useTimelineSync({ @@ -408,8 +422,11 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); + // Ensure auto-scroll to bottom doesn't fire while we're navigating to a + // specific event β€” atBottom will be updated correctly once the user scrolls. + setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId]); + }, [eventId, room.roomId, setAtBottom]); useEffect(() => { if (eventId) return; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 51c85dda8..948630887 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,4 +1,13 @@ -import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'; +import { + useState, + useMemo, + useCallback, + useRef, + useEffect, + useLayoutEffect, + Dispatch, + SetStateAction, +} from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import { @@ -466,9 +475,6 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); - const eventsLengthRef = useRef(eventsLength); - eventsLengthRef.current = eventsLength; - useLiveEventArrive( room, useCallback( @@ -490,9 +496,6 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); - lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; - setTimeline((ct) => ({ ...ct })); return; } @@ -502,7 +505,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] ) ); @@ -527,10 +530,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - if (wasAtBottom) { - scrollToBottom('instant'); - } - }, [room, isAtBottomRef, scrollToBottom]) + // Scroll is handled by the useLayoutEffect auto-scroll recovery which + // fires after React commits the new timeline state β€” scrolling here + // would operate on the pre-commit DOM with a stale scrollSize. + }, [room, isAtBottomRef]) ); useRelationUpdate( @@ -547,7 +550,9 @@ export function useTimelineSync({ }, []) ); - useEffect(() => { + // useLayoutEffect so scroll fires before paint β€” prevents the one-frame flash + // where new VList content is briefly visible at the wrong position. + useLayoutEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index dd4a880b6..d7acfb35f 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -36,9 +36,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 17ccebddec214508325acc698c3629b8897ebe92 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 19:33:47 -0400 Subject: [PATCH 18/22] fix(timeline): restore upstream scroll pattern for new messages Restore scrollToBottom call in useLiveEventArrive with instant/smooth based on sender, add back eventsLengthRef and lastScrolledAt suppression, restore scrollToBottom in useLiveTimelineRefresh when wasAtBottom, and revert instant scrollToBottom to scrollTo(scrollSize) matching upstream. The previous changes removed all scroll calls from event arrival handlers and relied solely on the useLayoutEffect auto-scroll recovery, which has timing issues with VList measurement. Upstream's pattern of scrolling in the event handler and suppressing the effect works reliably. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 5 +---- src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e48684ea8..ee66ac28c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -255,10 +255,7 @@ export function RoomTimeline({ if (behavior === 'smooth') { vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); } else { - // scrollToIndex works reliably regardless of VList measurement state. - // The auto-scroll useLayoutEffect fires after React commits new items, - // so lastIndex is always valid when this is called. - vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + vListRef.current.scrollTo(vListRef.current.scrollSize); } }, []); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 948630887..f6d50c904 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -475,6 +475,9 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); + const eventsLengthRef = useRef(eventsLength); + eventsLengthRef.current = eventsLength; + useLiveEventArrive( room, useCallback( @@ -496,6 +499,9 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; + setTimeline((ct) => ({ ...ct })); return; } @@ -505,7 +511,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] ) ); @@ -530,10 +536,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - // Scroll is handled by the useLayoutEffect auto-scroll recovery which - // fires after React commits the new timeline state β€” scrolling here - // would operate on the pre-commit DOM with a stale scrollSize. - }, [room, isAtBottomRef]) + if (wasAtBottom) { + scrollToBottom('instant'); + } + }, [room, isAtBottomRef, scrollToBottom]) ); useRelationUpdate( From 399418be13d572c5123d831084e0776828d738c0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:26:19 -0400 Subject: [PATCH 19/22] fix(timeline): align scrollToBottom with upstream, fix eventId race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove behavior parameter from scrollToBottom β€” always use scrollTo(scrollSize) matching upstream. The smooth scrollToIndex was scrolling to stale lastIndex (before new item measured), leaving new messages below the fold. - Revert auto-scroll recovery from useLayoutEffect back to useEffect (matches upstream). useLayoutEffect fires before VList measures new items and before setAtBottom(false) in eventId effect. - Remove stale scrollCacheForRoomRef that referenced missing imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 15 +---------- .../hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 25 ++++++------------- 3 files changed, 9 insertions(+), 33 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ee66ac28c..0630dcc9b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -201,13 +201,6 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - // Load any cached scroll state for this room on mount. A fresh RoomTimeline is - // mounted per room (via key={roomId} in RoomView) so this is the only place we - // need to read the cache β€” the render-phase room-change block below only fires - // in the (hypothetical) case where the room prop changes without a remount. - const scrollCacheForRoomRef = useRef( - roomScrollCache.load(mxUserId, room.roomId) - ); const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -252,11 +245,7 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - if (behavior === 'smooth') { - vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); - } else { - vListRef.current.scrollTo(vListRef.current.scrollSize); - } + vListRef.current.scrollTo(vListRef.current.scrollSize); }, []); const timelineSync = useTimelineSync({ @@ -419,8 +408,6 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - // Ensure auto-scroll to bottom doesn't fire while we're navigating to a - // specific event β€” atBottom will be updated correctly once the user scrolls. setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId, setAtBottom]); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index d53d74143..e5e7c4cfd 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -129,7 +129,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 f6d50c904..dda1e0207 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,13 +1,4 @@ -import { - useState, - useMemo, - useCallback, - useRef, - useEffect, - useLayoutEffect, - Dispatch, - SetStateAction, -} from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import { @@ -360,7 +351,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; @@ -469,7 +460,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); + scrollToBottom(); }, [alive, room, scrollToBottom]) ); @@ -499,7 +490,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + scrollToBottom(); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -537,7 +528,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom('instant'); + scrollToBottom(); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -556,9 +547,7 @@ export function useTimelineSync({ }, []) ); - // useLayoutEffect so scroll fires before paint β€” prevents the one-frame flash - // where new VList content is briefly visible at the wrong position. - useLayoutEffect(() => { + useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; @@ -576,7 +565,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { From 7579368db1f98508c28c8d2d84b24e74a45929a5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 23:43:28 -0400 Subject: [PATCH 20/22] 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 3b78f43aa..7bf4d153b 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -187,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 @@ -206,6 +208,7 @@ export function Direct() { }); return () => { + clearTimeout(activityTimerRef.current); directsSetRef.current.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); From f8986c14acda013f4e29318f178c8b2c5e65af64 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:41:03 -0400 Subject: [PATCH 21/22] fix(preview): resolve display names in room previews --- src/app/hooks/useRoomLastMessage.test.tsx | 9 ++++++++- src/app/hooks/useRoomLastMessage.ts | 4 +++- src/client/slidingSync.ts | 16 +++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 2e4b725a3..f8cc9d528 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -181,7 +181,8 @@ describe('getLastMessageText', () => { getLiveTimeline: () => ({ getEvents: () => events, }), - getMember: (id: string) => (members?.[id] ? { name: members[id] } : null), + 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', () => { @@ -201,6 +202,12 @@ describe('getLastMessageText', () => { 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: {} }); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index e40daf938..92b4c3128 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -8,6 +8,7 @@ import { 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 @@ -90,7 +91,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}`; } diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index d7acfb35f..a55bdedff 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -97,8 +97,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 @@ -116,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, ''], @@ -125,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', ''], @@ -153,7 +159,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 @@ -728,7 +734,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 e1adb09616caea6c4c0e7d92378bca4645eaa983 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:47:17 -0400 Subject: [PATCH 22/22] fix(preview): remove timeline spillover --- src/app/features/room/RoomTimeline.tsx | 5 ++--- src/app/hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 10 +++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 0630dcc9b..d026ec1fa 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -201,7 +201,7 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(!eventId); + const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -408,9 +408,8 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId, setAtBottom]); + }, [eventId, room.roomId]); useEffect(() => { if (eventId) return; diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index e5e7c4cfd..d53d74143 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -129,7 +129,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 dda1e0207..51c85dda8 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -351,7 +351,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; @@ -460,7 +460,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom(); + scrollToBottom('instant'); }, [alive, room, scrollToBottom]) ); @@ -490,7 +490,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(); + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -528,7 +528,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom(); + scrollToBottom('instant'); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -565,7 +565,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom(); + scrollToBottom('instant'); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => {