From 26cd0bd604eabab0c77faa402777fdc1c63cfefc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 10:01:36 -0400 Subject: [PATCH 01/23] 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 a372b975a..51a0fc4ee 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'; /** @@ -260,6 +261,8 @@ type RoomNavItemProps = { showAvatar?: boolean; direct?: boolean; customDMCards?: boolean; + roomTopicPreview?: boolean; + roomMessagePreview?: boolean; }; export function RoomNavItem({ @@ -268,6 +271,8 @@ export function RoomNavItem({ showAvatar, direct, customDMCards, + roomTopicPreview = false, + roomMessagePreview = false, notificationMode, linkPath, }: RoomNavItemProps) { @@ -289,8 +294,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 0fe2d716f..713bfe519 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -373,6 +373,11 @@ export function Appearance({ settingsAtom, 'closeFoldersByDefault' ); + const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview, setRoomMessagePreview] = useSetting( + settingsAtom, + 'roomMessagePreview' + ); return ( @@ -425,6 +430,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 afd4de936..8a0bdd631 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(); @@ -345,6 +347,8 @@ export function Home() { Date: Sun, 12 Apr 2026 11:50:31 -0400 Subject: [PATCH 02/23] 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 cea5b1f78..3c0e250ad 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -43,9 +43,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 40b59a638f029528ca6c249e41b6b76c7e2d5328 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 12:10:58 -0400 Subject: [PATCH 03/23] 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 ecc39b0fcca38db6c4ec075a7146382412b57054 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 22:17:21 -0400 Subject: [PATCH 04/23] 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 51a0fc4ee..980dba94f 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -294,10 +294,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 4283a11a771640d42879e674fde05caa27dc4152 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 23:13:41 -0400 Subject: [PATCH 05/23] 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 7f909ac46f381379a05bdde7849eca34f5827dda Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 00:18:31 -0400 Subject: [PATCH 06/23] 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, 16 insertions(+), 2 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 980dba94f..33d2a9a26 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -263,6 +263,7 @@ type RoomNavItemProps = { customDMCards?: boolean; roomTopicPreview?: boolean; roomMessagePreview?: boolean; + dmMessagePreview?: boolean; }; export function RoomNavItem({ @@ -273,6 +274,7 @@ export function RoomNavItem({ customDMCards, roomTopicPreview = false, roomMessagePreview = false, + dmMessagePreview = true, notificationMode, linkPath, }: RoomNavItemProps) { @@ -294,7 +296,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 713bfe519..004b4fb25 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -367,6 +367,7 @@ 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 [themeBrowserOpen, setThemeBrowserOpen] = useState(false); const [closeFoldersByDefault, setCloseFoldersByDefault] = useSetting( @@ -424,8 +425,14 @@ 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/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index e84e04daa..134fefd1a 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -179,6 +179,7 @@ export function Direct() { const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); const [customDMCards] = useSetting(settingsAtom, 'customDMCards'); + const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); const createDirectSelected = useDirectCreateSelected(); @@ -298,6 +299,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 9290b3dbb..56d2c9ce5 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -151,6 +151,7 @@ export interface Settings { closeFoldersByDefault: boolean; roomTopicPreview: boolean; roomMessagePreview: boolean; + dmMessagePreview: boolean; // furry stuff renderAnimals: boolean; @@ -272,6 +273,7 @@ export const defaultSettings: Settings = { closeFoldersByDefault: false, roomTopicPreview: false, roomMessagePreview: false, + dmMessagePreview: true, // furry stuff renderAnimals: true, From 958ffb023b2be542dce010ac20f08cb625bf3346 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 09:32:44 -0400 Subject: [PATCH 07/23] 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 004b4fb25..719acf3ee 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -427,6 +427,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/23] 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 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/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 19649d900ba3860f140276f9dda46934020247b4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 15:20:39 -0400 Subject: [PATCH 09/23] 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 33d2a9a26..7b3bd56bc 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -300,7 +300,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 085af1e4ecdca767b9a280affbade04b36c45ae9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:02:46 -0400 Subject: [PATCH 10/23] 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 6acf85bde..7d8dc16fc 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -530,6 +530,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 @@ -836,6 +837,7 @@ export function Space() { direct={mDirects.has(roomId)} roomTopicPreview={roomTopicPreview} roomMessagePreview={roomMessagePreview} + dmMessagePreview={dmMessagePreview} linkPath={getToLink(roomId)} notificationMode={getRoomNotificationMode( notificationPreferences, From f423f3ca608580642f6c3673060e4fcf46cdc96f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:25:13 -0400 Subject: [PATCH 11/23] 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 33cce5be282de44259605c55d80c6fc584fb65c4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 13:34:33 -0400 Subject: [PATCH 12/23] 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 5bcc81a500893fcd85de4feda86d6fc78c24cf84 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:18:01 -0400 Subject: [PATCH 13/23] 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 015e0c56d..c1e043177 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -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 d6c0bac8f70a39f9dc79e10bf325518ce6221de2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:26:30 -0400 Subject: [PATCH 14/23] 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 c1e043177..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 = { From 3ef81f0896bc6550c7196d1759d6d5444533e565 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 23:37:30 -0400 Subject: [PATCH 15/23] 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 b090b0e1aedbc7728f626a0699c47df6ed6e9eda Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 00:42:28 -0400 Subject: [PATCH 16/23] 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 2bbba731f774a3208e394d64888c0f009dd09c00 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 17/23] 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 | 21 +++++++++------------ src/client/slidingSync.ts | 6 +++--- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 812f2043c..7f3792bb9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -208,7 +208,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); @@ -252,7 +259,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({ @@ -415,8 +429,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 c10b762b8..34e57d8ac 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react'; -import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffect } from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import type { @@ -467,9 +467,6 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); - const eventsLengthRef = useRef(eventsLength); - eventsLengthRef.current = eventsLength; - useLiveEventArrive( room, useCallback( @@ -489,8 +486,6 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth'); - lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); return; @@ -501,7 +496,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] ) ); @@ -526,10 +521,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( @@ -546,7 +541,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 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 03e2a9ea17a1e38dd8c81a5b0bf058044dfea242 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 19:33:47 -0400 Subject: [PATCH 18/23] 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 | 15 ++++++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 7f3792bb9..9bb010277 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -262,10 +262,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 34e57d8ac..3ad4f5b18 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -467,6 +467,9 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); + const eventsLengthRef = useRef(eventsLength); + eventsLengthRef.current = eventsLength; + useLiveEventArrive( room, useCallback( @@ -486,6 +489,8 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } + scrollToBottom(mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth'); + lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); return; @@ -496,7 +501,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] ) ); @@ -521,10 +526,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 189d446ef442723170fc0a21132135d5f8fbc13c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:26:19 -0400 Subject: [PATCH 19/23] 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 +-------------- src/app/hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++--------- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 9bb010277..43ec70806 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -208,13 +208,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) => { @@ -259,11 +252,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({ @@ -426,8 +415,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 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 3ad4f5b18..fb7976462 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react'; -import { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffect } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import type { @@ -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]) ); @@ -546,9 +546,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; @@ -566,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { From f5f7dac73b170863ad2e11efd241f38a0c2409d1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 23:43:28 -0400 Subject: [PATCH 20/23] 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 134fefd1a..179d32219 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -188,16 +188,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 @@ -207,6 +209,7 @@ export function Direct() { }); return () => { + clearTimeout(activityTimerRef.current); directsSetRef.current.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); From c87578eacf8486441b679616bb249341e74445f8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:41:03 -0400 Subject: [PATCH 21/23] 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 949c1c7d0..0c7905ba4 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -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 5fa43935bbf7b3299bc0c5fc74226a6d57602fba Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:47:17 -0400 Subject: [PATCH 22/23] 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 43ec70806..812f2043c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -208,7 +208,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); @@ -415,9 +415,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 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..0abfc8a91 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]) ); @@ -489,7 +489,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(); + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); 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(); + 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(() => { From 50d19efdf77c7c49f41f407c38a5c89677905c58 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 26 Apr 2026 18:58:00 -0400 Subject: [PATCH 23/23] perf(sliding-sync): reduce list timeline limit to match upstream baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT_LIST_TIMELINE_LIMIT was 3; upstream uses 1. With dmMessagePreview enabled (the default), ClientRoot was requesting 5 timeline events per room in the list subscription β€” 5x upstream's 1 event. Combined with the lazy m.room.member state that buildListRequiredState adds when listTimelineLimit > 0, this caused roughly 12x more data loaded into memory vs upstream for a 250-room list. iOS was killing our PWA process at ~10 minutes due to memory pressure while upstream stayed alive. Changes: - DEFAULT_LIST_TIMELINE_LIMIT: 3 β†’ 1 (matches upstream baseline for no-preview) - listTimelineLimit when previews on: 5 β†’ 3 (sufficient for skipping reactions/edits; useRoomLastMessage scans backwards through available events) Result: previews-off = 1 event/room (upstream parity), previews-on = 3 events/room (3x upstream instead of 5x + lazy member overhead per room). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/settings/cosmetics/Themes.tsx | 16 +++++++++++++--- src/app/hooks/timeline/useTimelineSync.ts | 2 +- src/app/hooks/useRoomLastMessage.ts | 12 ++++++------ src/app/pages/client/ClientRoot.tsx | 2 +- src/client/slidingSync.ts | 6 +++--- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 719acf3ee..7be63b0be 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -425,7 +425,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={ + + } /> @@ -435,7 +437,11 @@ export function Appearance({ focusId="dm-message-preview" description="Show a preview of the last message below DM room names." after={ - + } /> @@ -446,7 +452,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.ts b/src/app/hooks/timeline/useTimelineSync.ts index 0abfc8a91..5fa692995 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(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + scrollToBottom(); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 92b4c3128..2485bdc3d 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,6 @@ import { Room, RoomEvent as RoomEventEnum, } from '$types/matrix-sdk'; -import { MessageEvent } from '$types/matrix/room'; import { getMemberDisplayName } from '$utils/room'; /** @@ -17,7 +17,7 @@ import { getMemberDisplayName } from '$utils/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'); @@ -33,14 +33,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 === EventType.Reaction) return undefined; const relType = content?.['m.relates_to']?.rel_type; if (relType === 'm.replace') return undefined; // Only show encrypted placeholder if the event is still encrypted (not yet decrypted). - if (type === MessageEvent.RoomMessageEncrypted) return 'πŸ”’ Encrypted message'; + if (type === EventType.RoomMessageEncrypted) return 'πŸ”’ Encrypted message'; - if (type === MessageEvent.RoomMessage) { + if (type === EventType.RoomMessage) { const { msgtype } = content; if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) { return stripReplyFallback(content.body); @@ -52,7 +52,7 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined { if (msgtype === 'm.location') return 'πŸ“ Location'; } - if (type === MessageEvent.Sticker) { + if (type === EventType.Sticker) { return `πŸŽ‰ ${content.body ?? 'Sticker'}`; } 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 0c7905ba4..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;