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 22886c224..68ead11d8 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -70,6 +70,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useRoomLastMessage } from '$hooks/useRoomLastMessage'; import { RoomNavUser } from './RoomNavUser'; /** @@ -258,6 +259,9 @@ type RoomNavItemProps = { showAvatar?: boolean; direct?: boolean; customDMCards?: boolean; + roomTopicPreview?: boolean; + roomMessagePreview?: boolean; + dmMessagePreview?: boolean; }; export function RoomNavItem({ @@ -266,6 +270,9 @@ export function RoomNavItem({ showAvatar, direct, customDMCards, + roomTopicPreview = false, + roomMessagePreview = false, + dmMessagePreview = true, notificationMode, linkPath, }: RoomNavItemProps) { @@ -287,8 +294,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 f543a19ea..0df6e5ade 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -482,11 +482,17 @@ 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, 'closeFoldersByDefault' ); + const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview, setRoomMessagePreview] = useSetting( + settingsAtom, + 'roomMessagePreview' + ); return ( @@ -529,6 +535,43 @@ export function Appearance() { /> + + + } + /> + + + + + } + /> + + + + + } + /> + + ; + 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 new file mode 100644 index 000000000..7f773ec97 --- /dev/null +++ b/src/app/hooks/useRoomLastMessage.ts @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import { + MatrixClient, + MatrixEvent, + MatrixEventEvent, + MsgType, + Room, + RoomEvent as RoomEventEnum, +} 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. + */ +export 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'); +} + +export 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 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 (type === MessageEvent.Sticker) { + return `🎉 ${ev.getContent().body ?? 'Sticker'}`; + } + + return 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; + 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/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1a653e950..754c28bef 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -48,6 +48,7 @@ import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { getHomePath } from '$pages/pathUtils'; import { useClientConfig } from '$hooks/useClientConfig'; +import { getSettings } from '$state/settings'; import { pushSessionToSW } from '../../../sw-session'; import { SyncStatus } from './SyncStatus'; import { SpecVersions } from './SpecVersions'; @@ -212,12 +213,18 @@ export function ClientRoot({ children }: ClientRootProps) { const [startState, startMatrix] = useAsyncCallback( useCallback( - (m) => - startClient(m, { + (m) => { + const s = getSettings(); + const needsPreviewTimeline = s.dmMessagePreview || s.roomMessagePreview; + return startClient(m, { baseUrl: activeSession?.baseUrl, - slidingSync: clientConfig.slidingSync, + slidingSync: { + ...clientConfig.slidingSync, + listTimelineLimit: needsPreviewTimeline ? 5 : undefined, + }, sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn, - }), + }); + }, [activeSession?.baseUrl, activeSession?.slidingSyncOptIn, clientConfig.slidingSync] ) ); diff --git a/src/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/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index c25d99e30..5ceda0a1e 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -199,6 +199,8 @@ export function Home() { const notificationPreferences = useRoomsNotificationPreferencesContext(); const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); + const [roomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview] = useSetting(settingsAtom, 'roomMessagePreview'); const selectedRoomId = useSelectedRoom(); const createRoomSelected = useHomeCreateSelected(); @@ -344,6 +346,8 @@ export function Home() { => { +const buildLists = (pageSize: number, includeInviteList: boolean, listTimelineLimit: number): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); @@ -156,7 +159,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map void; @@ -300,12 +305,13 @@ export class SlidingSyncManager { this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS); this.listPageSize = listPageSize; const includeInviteList = config.includeInviteList !== false; + this.listTimelineLimit = clampPositive(config.listTimelineLimit, DEFAULT_LIST_TIMELINE_LIMIT); const roomTimelineLimit = clampPositive(config.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); - const lists = buildLists(listPageSize, includeInviteList); + const lists = buildLists(listPageSize, includeInviteList, this.listTimelineLimit); this.listKeys = Array.from(lists.keys()); this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs); @@ -717,7 +723,7 @@ export class SlidingSyncManager { list = { ranges: [[0, 20]], sort: LIST_SORT_ORDER, - timeline_limit: LIST_TIMELINE_LIMIT, + timeline_limit: this.listTimelineLimit, required_state: buildListRequiredState(), ...updateArgs, };