From ca19a73cef788c6c6bf16385a223f5d41733323c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 22:17:21 -0400 Subject: [PATCH 1/6] feat(dm-list): show latest message preview below room name --- src/app/features/room-nav/RoomNavItem.tsx | 4 +- src/app/hooks/useRoomLastMessage.ts | 64 +++++++++++++++++++++++ 2 files changed, 67 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 15e615720..0202c602c 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -74,6 +74,7 @@ import { CallControlState } from '$plugins/call/CallControlState'; import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { useRoomLastMessage } from '$hooks/useRoomLastMessage'; import { StateEvent } from '$types/matrix/room'; import { AvatarPresence, PresenceBadge } from '$components/presence'; import { RoomNavUser } from './RoomNavUser'; @@ -293,12 +294,13 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); + const lastMessage = useRoomLastMessage(direct ? room : undefined); const [topicEvent, setTopicEvent] = useState(getStateEvent(room, StateEvent.RoomTopic)); // Ensures that the description does not stick to the position the room is in the row useEffect(() => setTopicEvent(getStateEvent(room, StateEvent.RoomTopic)), [room, setTopicEvent]); const roomDescription = direct - ? (customDMCards && (topicEvent?.getContent().topic as string)) || presence?.status + ? (customDMCards && (topicEvent?.getContent().topic as string)) || lastMessage : undefined; const { navigateRoom } = useRoomNavigate(); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts new file mode 100644 index 000000000..b3a2418a5 --- /dev/null +++ b/src/app/hooks/useRoomLastMessage.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { MatrixEvent, MsgType, Room, RoomEvent as RoomEventEnum } from '$types/matrix-sdk'; +import { MessageEvent } from '$types/matrix/room'; + +function eventToPreviewText(ev: MatrixEvent): string | undefined { + if (ev.isRedacted()) return undefined; + + const type = ev.getType(); + + 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; + } + if (msgtype === MsgType.Image) return '📷 Image'; + if (msgtype === MsgType.Video) return '📹 Video'; + if (msgtype === MsgType.Audio) return '🎵 Audio'; + if (msgtype === MsgType.File) return '📎 File'; + } + + if (type === MessageEvent.Sticker) { + return `🎉 ${ev.getContent().body ?? 'Sticker'}`; + } + + return undefined; +} + +function getLastMessageText(room: Room): string | undefined { + const events = room.getLiveTimeline().getEvents(); + const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined); + return match ? eventToPreviewText(match) : undefined; +} + +/** + * Reactively returns a human-readable preview of the last message in a room's + * live timeline. Listens to Timeline events so the preview updates as messages + * arrive. Pass `undefined` to disable (returns `undefined`). + */ +export function useRoomLastMessage(room: Room | undefined): string | undefined { + const [text, setText] = useState(() => + room ? getLastMessageText(room) : undefined + ); + + useEffect(() => { + if (!room) { + setText(undefined); + return undefined; + } + setText(getLastMessageText(room)); + + const update = () => setText(getLastMessageText(room)); + room.on(RoomEventEnum.Timeline, update); + room.on(RoomEventEnum.LocalEchoUpdated, update); + return () => { + room.off(RoomEventEnum.Timeline, update); + room.off(RoomEventEnum.LocalEchoUpdated, update); + }; + }, [room]); + + return text; +} From 5a14a89e190009a861ce47cdac4c65177a2c1725 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 23:13:41 -0400 Subject: [PATCH 2/6] 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 1c340f031a28f67a84f176961c0f7946f877843a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 00:18:31 -0400 Subject: [PATCH 3/6] feat(dm-list): add toggle to hide DM message preview --- src/app/features/room-nav/RoomNavItem.tsx | 4 +++- 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(+), 1 deletion(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 0202c602c..d34d4e6fb 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -265,6 +265,7 @@ type RoomNavItemProps = { showAvatar?: boolean; direct?: boolean; customDMCards?: boolean; + dmMessagePreview?: boolean; }; export function RoomNavItem({ @@ -273,6 +274,7 @@ export function RoomNavItem({ showAvatar, direct, customDMCards, + dmMessagePreview = true, notificationMode, linkPath, }: RoomNavItemProps) { @@ -294,7 +296,7 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); - const lastMessage = useRoomLastMessage(direct ? room : undefined); + const lastMessage = useRoomLastMessage(direct && dmMessagePreview ? room : undefined); const [topicEvent, setTopicEvent] = useState(getStateEvent(room, StateEvent.RoomTopic)); // Ensures that the description does not stick to the position the room is in the row diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index f543a19ea..962c44b78 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, @@ -527,6 +528,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 05bb8e0fb..aeda68b45 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -118,6 +118,7 @@ export interface Settings { mentionInReplies: boolean; showPersonaSetting: boolean; closeFoldersByDefault: boolean; + dmMessagePreview: boolean; // furry stuff renderAnimals: boolean; @@ -219,6 +220,7 @@ const defaultSettings: Settings = { mentionInReplies: true, showPersonaSetting: false, closeFoldersByDefault: false, + dmMessagePreview: true, // furry stuff renderAnimals: true, From e4dff72f037244a067b686b9ed393603944636d8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 00:22:42 -0400 Subject: [PATCH 4/6] feat(dm-list): prefix message preview with sender display name --- src/app/features/room-nav/RoomNavItem.tsx | 2 +- src/app/hooks/useRoomLastMessage.ts | 43 +++++++++++++++++------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index d34d4e6fb..2093cfd2d 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -296,7 +296,7 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); - const lastMessage = useRoomLastMessage(direct && dmMessagePreview ? room : undefined); + const lastMessage = useRoomLastMessage(direct && dmMessagePreview ? room : undefined, mx); const [topicEvent, setTopicEvent] = useState(getStateEvent(room, StateEvent.RoomTopic)); // Ensures that the description does not stick to the position the room is in the row diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index b3a2418a5..741d2f1c6 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -1,5 +1,11 @@ import { useEffect, useState } from 'react'; -import { MatrixEvent, MsgType, Room, RoomEvent as RoomEventEnum } from '$types/matrix-sdk'; +import { + MatrixClient, + MatrixEvent, + MsgType, + Room, + RoomEvent as RoomEventEnum, +} from '$types/matrix-sdk'; import { MessageEvent } from '$types/matrix/room'; function eventToPreviewText(ev: MatrixEvent): string | undefined { @@ -28,37 +34,52 @@ function eventToPreviewText(ev: MatrixEvent): string | undefined { return undefined; } -function getLastMessageText(room: Room): string | undefined { +function getLastMessageText(room: Room, mx: MatrixClient): string | undefined { const events = room.getLiveTimeline().getEvents(); const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined); - return match ? eventToPreviewText(match) : 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. Listens to Timeline events so the preview updates as messages - * arrive. Pass `undefined` to disable (returns `undefined`). + * live timeline, prefixed with "You:" or the sender's display name. + * Listens to Timeline events so the preview updates as messages arrive. + * Pass `undefined` for room to disable (returns `undefined`). */ -export function useRoomLastMessage(room: Room | undefined): string | undefined { +export function useRoomLastMessage( + room: Room | undefined, + mx: MatrixClient | undefined +): string | undefined { const [text, setText] = useState(() => - room ? getLastMessageText(room) : undefined + room && mx ? getLastMessageText(room, mx) : undefined ); useEffect(() => { - if (!room) { + if (!room || !mx) { setText(undefined); return undefined; } - setText(getLastMessageText(room)); + setText(getLastMessageText(room, mx)); - const update = () => setText(getLastMessageText(room)); + const update = () => setText(getLastMessageText(room, mx)); room.on(RoomEventEnum.Timeline, update); room.on(RoomEventEnum.LocalEchoUpdated, update); return () => { room.off(RoomEventEnum.Timeline, update); room.off(RoomEventEnum.LocalEchoUpdated, update); }; - }, [room]); + }, [room, mx]); return text; } From 391d0a83e8e2b52bc514baf957e8ac5b44bff26f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 09:32:44 -0400 Subject: [PATCH 5/6] 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 962c44b78..c48896651 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -528,6 +528,9 @@ export function Appearance() { description="Show a custom DM card instead of the DM-ed's details" after={} /> + + + Date: Sun, 12 Apr 2026 09:37:30 -0400 Subject: [PATCH 6/6] fix(dm-list): update message preview immediately on event decryption --- src/app/hooks/useRoomLastMessage.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 741d2f1c6..145ff1d64 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { MatrixClient, MatrixEvent, + MatrixEventEvent, MsgType, Room, RoomEvent as RoomEventEnum, @@ -75,9 +76,17 @@ export function useRoomLastMessage( 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]);