diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md
new file mode 100644
index 000000000..ab8e37801
--- /dev/null
+++ b/.changeset/feat-dm-message-preview.md
@@ -0,0 +1,5 @@
+---
+'@sable/client': minor
+---
+
+feat(dm-list): show last-message preview below DM room name
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 15e615720..2093cfd2d 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';
@@ -264,6 +265,7 @@ type RoomNavItemProps = {
showAvatar?: boolean;
direct?: boolean;
customDMCards?: boolean;
+ dmMessagePreview?: boolean;
};
export function RoomNavItem({
@@ -272,6 +274,7 @@ export function RoomNavItem({
showAvatar,
direct,
customDMCards,
+ dmMessagePreview = true,
notificationMode,
linkPath,
}: RoomNavItemProps) {
@@ -293,12 +296,13 @@ export function RoomNavItem({
const matrixRoomName = useRoomName(room);
const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName;
const presence = useUserPresence(dmUserId ?? '');
+ 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
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/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index f543a19ea..c48896651 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,
@@ -529,6 +530,17 @@ 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 events so the preview updates as messages arrive.
+ * 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/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,