diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md
new file mode 100644
index 000000000..46cbcff81
--- /dev/null
+++ b/.changeset/feat-dm-message-preview.md
@@ -0,0 +1,5 @@
+---
+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
new file mode 100644
index 000000000..3f8587b85
--- /dev/null
+++ b/.changeset/room-message-preview.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+feat(room-nav): show topic and last-message preview for rooms in the sidebar, fetching enough timeline events to handle reactions and edits correctly
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 7262e8fbe..d92266451 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -72,6 +72,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '$hooks/useLivekitSupport';
import { Presence, useUserPresence } from '$hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '$components/presence';
+import { useRoomLastMessage } from '$hooks/useRoomLastMessage';
import { RoomNavUser } from './RoomNavUser';
import { SidebarUnreadBadge } from '$components/sidebar';
@@ -266,6 +267,9 @@ type RoomNavItemProps = {
customDMCards?: boolean;
hideText?: boolean;
joinCallOnSingleClick?: boolean;
+ roomTopicPreview?: boolean;
+ roomMessagePreview?: boolean;
+ dmMessagePreview?: boolean;
};
export function RoomNavItem({
@@ -274,6 +278,9 @@ export function RoomNavItem({
showAvatar,
direct,
customDMCards,
+ roomTopicPreview = false,
+ roomMessagePreview = false,
+ dmMessagePreview = true,
notificationMode,
linkPath,
hideText,
@@ -297,8 +304,12 @@ export function RoomNavItem({
const matrixRoomName = useRoomName(room);
const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName;
const presence = useUserPresence(dmUserId ?? '');
+ const showPreview = direct ? dmMessagePreview : roomMessagePreview;
+ const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx);
const getRoomTopic = useRoomTopic(room);
- const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined;
+ const roomTopic = direct
+ ? (customDMCards && getRoomTopic) || lastMessage || presence?.status
+ : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined);
const { navigateRoom } = useRoomNavigate();
const navigate = useNavigate();
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index e00e5f425..ea308a0a1 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -784,12 +784,18 @@ export function Appearance({
const [sidebarSelector, setSidebarSelector] = useState('roomSidebarWidth');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [customDMCards, setCustomDMCards] = useSetting(settingsAtom, 'customDMCards');
+ const [dmMessagePreview, setDmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview');
const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs');
const [themeBrowserOpen, setThemeBrowserOpen] = useState(false);
const [closeFoldersByDefault, setCloseFoldersByDefault] = useSetting(
settingsAtom,
'closeFoldersByDefault'
);
+ const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview');
+ const [roomMessagePreview, setRoomMessagePreview] = useSetting(
+ settingsAtom,
+ 'roomMessagePreview'
+ );
return (
@@ -842,6 +848,51 @@ export function Appearance({
/>
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
;
+ sender?: string;
+ roomId?: string;
+ redacted?: boolean;
+ effectiveType?: string;
+ encrypted?: boolean;
+}) {
+ const type = overrides.type ?? 'm.room.message';
+ const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' };
+ return {
+ getType: () => type,
+ getContent: () => content,
+ getSender: () => overrides.sender ?? '@alice:test',
+ getRoomId: () => overrides.roomId ?? '!room:test',
+ isRedacted: () => overrides.redacted ?? false,
+ isEncrypted: () => overrides.encrypted ?? false,
+ getEffectiveEvent: () => ({ type: overrides.effectiveType ?? type, content }),
+ } as never;
+}
+
+const makeLastMessageMx = (userId = '@alice:test') => ({ getUserId: () => userId }) as never;
+
+const makeLastMessageRoom = (
+ events: ReturnType[],
+ members?: Record
+) =>
+ ({
+ roomId: '!room:test',
+ getLiveTimeline: () => ({
+ getEvents: () => events,
+ }),
+ getMember: (id: string) =>
+ members?.[id] ? { name: members[id], rawDisplayName: members[id] } : null,
+ }) as never;
+
+// -------- stripReplyFallback --------
+
+describe('stripReplyFallback', () => {
+ it('returns the body unchanged when there is no fallback', () => {
+ expect(stripReplyFallback('hello world')).toBe('hello world');
+ });
+
+ it('strips lines starting with > and the blank separator', () => {
+ const body = '> reply line 1\n> reply line 2\n\nactual message';
+ expect(stripReplyFallback(body)).toBe('actual message');
+ });
+
+ it('strips fallback with no separator line', () => {
+ const body = '> quoted\nrest';
+ expect(stripReplyFallback(body)).toBe('rest');
+ });
+
+ it('returns empty string when the entire body is a fallback', () => {
+ expect(stripReplyFallback('> only quote\n')).toBe('');
+ });
+
+ it('handles multi-line actual message after fallback', () => {
+ const body = '> quote\n\nline 1\nline 2';
+ expect(stripReplyFallback(body)).toBe('line 1\nline 2');
+ });
+});
+
+// -------- eventToPreviewText --------
+
+describe('eventToPreviewText', () => {
+ it('returns body for m.text message', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.text', body: 'hi' } });
+ expect(eventToPreviewText(ev)).toBe('hi');
+ });
+
+ it('returns body for m.emote message', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.emote', body: 'waves' } });
+ expect(eventToPreviewText(ev)).toBe('waves');
+ });
+
+ it('returns body for m.notice message', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.notice', body: 'notice' } });
+ expect(eventToPreviewText(ev)).toBe('notice');
+ });
+
+ it('returns image icon for m.image', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.image', body: 'photo.png' } });
+ expect(eventToPreviewText(ev)).toBe('π· Image');
+ });
+
+ it('returns video icon for m.video', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.video', body: 'clip.mp4' } });
+ expect(eventToPreviewText(ev)).toBe('πΉ Video');
+ });
+
+ it('returns audio icon for m.audio', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.audio', body: 'song.mp3' } });
+ expect(eventToPreviewText(ev)).toBe('π΅ Audio');
+ });
+
+ it('returns file icon for m.file', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.file', body: 'doc.pdf' } });
+ expect(eventToPreviewText(ev)).toBe('π File');
+ });
+
+ it('returns encrypted placeholder for encrypted events', () => {
+ const ev = makeEvent({ type: 'm.room.encrypted', content: {} });
+ expect(eventToPreviewText(ev)).toBe('π Encrypted message');
+ });
+
+ it('returns decrypted content when event has been decrypted', () => {
+ const ev = makeEvent({
+ type: 'm.room.encrypted',
+ content: { msgtype: 'm.text', body: 'decrypted text' },
+ effectiveType: 'm.room.message',
+ });
+ expect(eventToPreviewText(ev)).toBe('decrypted text');
+ });
+
+ it('returns sticker text', () => {
+ const ev = makeEvent({ type: 'm.sticker', content: { body: 'party' } });
+ expect(eventToPreviewText(ev)).toBe('π party');
+ });
+
+ it('returns undefined for redacted events', () => {
+ const ev = makeEvent({ redacted: true });
+ expect(eventToPreviewText(ev)).toBeUndefined();
+ });
+
+ it('returns undefined for reaction events', () => {
+ const ev = makeEvent({ type: 'm.reaction', content: {} });
+ expect(eventToPreviewText(ev)).toBeUndefined();
+ });
+
+ it('returns undefined for edit events (m.replace)', () => {
+ const ev = makeEvent({
+ content: {
+ msgtype: 'm.text',
+ body: 'edited',
+ 'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
+ },
+ });
+ expect(eventToPreviewText(ev)).toBeUndefined();
+ });
+
+ it('strips reply fallback from text body', () => {
+ const ev = makeEvent({
+ content: { msgtype: 'm.text', body: '> quoted\n\nreal message' },
+ });
+ expect(eventToPreviewText(ev)).toBe('real message');
+ });
+
+ it('returns 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();
+ });
+});
+
+// -------- getLastMessageText --------
+
+describe('getLastMessageText', () => {
+ 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(makeLastMessageRoom([ev]), makeLastMessageMx())).toBe('You: hi');
+ });
+
+ it('returns "DisplayName: text" for another user', () => {
+ const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } });
+ const room = makeLastMessageRoom([ev], { '@bob:test': 'Bob' });
+ expect(getLastMessageText(room, makeLastMessageMx())).toBe('Bob: hey');
+ });
+
+ it('falls back to localpart when no display name is available', () => {
+ const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } });
+ const room = makeLastMessageRoom([ev]);
+ expect(getLastMessageText(room, makeLastMessageMx())).toBe('bob: hey');
+ });
+
+ it('falls back to localpart when member is loaded but has no display name', () => {
+ const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } });
+ const room = makeLastMessageRoom([ev], { '@bob:test': '@bob:test' });
+ expect(getLastMessageText(room, makeLastMessageMx())).toBe('bob: hey');
+ });
+
+ it('skips reactions and picks the last real message', () => {
+ const msg = makeEvent({ content: { msgtype: 'm.text', body: 'real' } });
+ const reaction = makeEvent({ type: 'm.reaction', content: {} });
+ expect(getLastMessageText(makeLastMessageRoom([msg, reaction]), makeLastMessageMx())).toBe(
+ 'You: real'
+ );
+ });
+
+ it('returns undefined when there are no displayable events', () => {
+ const reaction = makeEvent({ type: 'm.reaction', content: {} });
+ expect(
+ getLastMessageText(makeLastMessageRoom([reaction]), makeLastMessageMx())
+ ).toBeUndefined();
+ });
+
+ it('returns undefined for an empty timeline', () => {
+ expect(getLastMessageText(makeLastMessageRoom([]), makeLastMessageMx())).toBeUndefined();
+ });
+});
+
+// -------- useRoomLastMessage hook --------
+
+describe('useRoomLastMessage', () => {
+ const makeMx = (userId = '@alice:test') => ({
+ getUserId: () => userId,
+ on: vi.fn<(event: string, handler: (...args: unknown[]) => void) => void>(),
+ off: vi.fn<(event: string, handler: (...args: unknown[]) => void) => void>(),
+ });
+
+ const roomListeners = new Map void)[]>();
+
+ const makeRoom = (events: ReturnType[]) => ({
+ roomId: '!room:test',
+ getLiveTimeline: () => ({ getEvents: () => events }),
+ getMember: () => null,
+ on: vi
+ .fn<(event: string, handler: (...args: unknown[]) => void) => void>()
+ .mockImplementation((event, handler) => {
+ const list = roomListeners.get(event) ?? [];
+ list.push(handler);
+ roomListeners.set(event, list);
+ }),
+ off: vi.fn<(event: string, handler: (...args: unknown[]) => void) => void>(),
+ });
+
+ beforeEach(() => {
+ 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', () => {
+ 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));
+
+ // 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());
+ });
+
+ // 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
new file mode 100644
index 000000000..2a36204d3
--- /dev/null
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -0,0 +1,187 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ EventType,
+ MatrixEventEvent,
+ MsgType,
+ RoomEvent as RoomEventEnum,
+} from '$types/matrix-sdk';
+import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk';
+import { getMemberDisplayName } from '$utils/room';
+
+const REACTION_EVENT_TYPE: string = EventType.Reaction;
+const ENCRYPTED_EVENT_TYPE: string = EventType.RoomMessageEncrypted;
+const ROOM_MESSAGE_EVENT_TYPE: string = EventType.RoomMessage;
+const STICKER_EVENT_TYPE: string = EventType.Sticker;
+
+/**
+ * Strip the legacy reply fallback (lines starting with `> `) that some
+ * clients prepend when replying to a message.
+ */
+export function stripReplyFallback(body: string): string {
+ const lines = body.split('\n');
+ let i = 0;
+ 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');
+}
+
+export function eventToPreviewText(ev: MatrixEvent): string | undefined {
+ if (ev.isRedacted()) return undefined;
+
+ // 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 === REACTION_EVENT_TYPE) return undefined;
+ const relType = content?.['m.relates_to']?.rel_type;
+ if (relType === 'm.replace') return undefined;
+
+ // Only show encrypted placeholder if the event is still encrypted (not yet decrypted).
+ if (type === ENCRYPTED_EVENT_TYPE) return 'π Encrypted message';
+
+ if (type === ROOM_MESSAGE_EVENT_TYPE) {
+ const { msgtype } = content;
+ if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) {
+ return stripReplyFallback(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 (msgtype === 'm.location') return 'π Location';
+ }
+
+ if (type === STICKER_EVENT_TYPE) {
+ return `π ${content.body ?? 'Sticker'}`;
+ }
+
+ // Polls β show the question text when available.
+ if (type === 'org.matrix.msc3381.poll.start' || type === 'm.poll.start') {
+ const pollContent = content?.['org.matrix.msc3381.poll.start'] ?? content?.['m.poll.start'];
+ const question =
+ typeof pollContent === 'object' && pollContent !== null
+ ? (pollContent as { question?: Record }).question
+ : undefined;
+ const textParts = question?.['m.text'];
+ const pollBodyCandidate =
+ (Array.isArray(textParts)
+ ? (textParts[0] as { body?: unknown } | undefined)?.body
+ : undefined) ??
+ question?.['org.matrix.msc1767.text'] ??
+ question?.body;
+ const pollBody =
+ typeof pollBodyCandidate === 'string' && pollBodyCandidate.trim()
+ ? pollBodyCandidate.trim()
+ : 'Poll';
+ return `π ${pollBody}`;
+ }
+
+ 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;
+}
+
+function findLastDisplayableEvent(events: MatrixEvent[]): MatrixEvent | undefined {
+ for (let i = events.length - 1; i >= 0; i -= 1) {
+ const event = events[i];
+ if (event && eventToPreviewText(event) !== undefined) return event;
+ }
+ return undefined;
+}
+
+export function getLastMessageText(room: Room, mx: MatrixClient): string | undefined {
+ const events = room.getLiveTimeline().getEvents();
+ const match = findLastDisplayableEvent(events);
+ 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 =
+ getMemberDisplayName(room, senderId ?? '') ?? displayNameFromMxid(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
+ );
+
+ // Debounce timer ref β cleared on unmount and room change.
+ const debounceRef = useRef | undefined>(undefined);
+
+ useEffect(() => {
+ if (!room || !mx) {
+ setText(undefined);
+ return undefined;
+ }
+
+ 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.
+ room.on(RoomEventEnum.Timeline, update);
+ room.on(RoomEventEnum.LocalEchoUpdated, update);
+
+ 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 = findLastDisplayableEvent(events);
+ if (lastDisplayable && lastDisplayable.isEncrypted()) {
+ mx.decryptEventIfNeeded(lastDisplayable).catch(() => undefined);
+ }
+
+ return () => {
+ clearTimeout(debounceRef.current);
+ 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/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 70ba9221e..934e3afcb 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 ? 3 : undefined,
+ },
sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn,
- }),
+ });
+ },
[activeSession?.baseUrl, activeSession?.slidingSyncOptIn, clientConfig.slidingSync]
)
);
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index dce68f104..a9f4cbcdf 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -197,6 +197,7 @@ export function Direct() {
}, [roomSidebarWidth]);
const [joinCallOnSingleClick] = useSetting(settingsAtom, 'joinCallOnSingleClick');
+ const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview');
const createDirectSelected = useDirectCreateSelected();
@@ -205,26 +206,28 @@ 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);
- directsSetRef.current = directs;
+ const activityTimerRef = useRef | undefined>(undefined);
useEffect(() => {
+ const directRoomIds = Array.from(directs);
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
- directsSetRef.current.forEach((roomId) => {
+ directRoomIds.forEach((roomId) => {
const room = mx.getRoom(roomId);
room?.on(RoomEvent.Timeline, handleTimeline);
});
return () => {
- directsSetRef.current.forEach((roomId) => {
+ clearTimeout(activityTimerRef.current);
+ directRoomIds.forEach((roomId) => {
const room = mx.getRoom(roomId);
room?.off(RoomEvent.Timeline, handleTimeline);
});
@@ -355,6 +358,7 @@ export function Direct() {
room.roomId
)}
joinCallOnSingleClick={joinCallOnSingleClick}
+ dmMessagePreview={dmMessagePreview}
/>
@@ -364,8 +368,8 @@ export function Direct() {
- )}
-
+ )}
+
{!mobileOrTablet() && (
@@ -447,8 +451,8 @@ export function Home() {
- )}
-
+ )}
+
{!mobileOrTablet() && (
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index b1b744c1f..dc0186a7d 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -169,6 +169,9 @@ export interface Settings {
threadRootHeight: number;
vcmsgSidebarWidth: number;
widgetSidebarWidth: number;
+ roomTopicPreview: boolean;
+ roomMessagePreview: boolean;
+ dmMessagePreview: boolean;
// furry stuff
renderAnimals: boolean;
@@ -301,6 +304,9 @@ export const defaultSettings: Settings = {
threadRootHeight: 220,
vcmsgSidebarWidth: 399,
widgetSidebarWidth: 420,
+ roomTopicPreview: false,
+ roomMessagePreview: false,
+ dmMessagePreview: true,
// furry stuff
renderAnimals: true,
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index cea5b1f78..e170983e0 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -43,9 +43,11 @@ 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.
+// 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;
@@ -59,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 configurable timeline limit (default 1; raised to 5 when message previews are enabled).
const ACTIVE_ROOM_TIMELINE_LIMIT = 50;
export type PartialSlidingSyncRequest = {
@@ -73,6 +75,7 @@ export type SlidingSyncConfig = {
proxyBaseUrl?: string;
bootstrapClassicOnColdCache?: boolean;
listPageSize?: number;
+ listTimelineLimit?: number;
timelineLimit?: number;
pollTimeoutMs?: number;
maxRooms?: number;
@@ -103,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
@@ -122,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, ''],
@@ -131,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', ''],
@@ -153,9 +162,13 @@ 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();
+ 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
@@ -165,7 +178,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map void;
@@ -307,12 +322,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);
@@ -743,8 +759,8 @@ export class SlidingSyncManager {
list = {
ranges: [[0, 20]],
sort: LIST_SORT_ORDER,
- timeline_limit: LIST_TIMELINE_LIMIT,
- required_state: buildListRequiredState(),
+ timeline_limit: this.listTimelineLimit,
+ required_state: buildListRequiredState(this.listTimelineLimit > 0),
...updateArgs,
};
} else {