From 629db9724fbb77cdafb3f90914ce4411ef75a88c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 00:37:49 -0400 Subject: [PATCH] feat(forward): message preview, optional comment, recent targets The Forward dialog forwarded blind. Add three things (design-system cleanup included): - Preview: a compact read-only preview at the top of the dialog shows the sender + body, with a thumbnail for image/video (reuses ThumbnailContent and the getMemberName/getMemberAvatarMxc/trimReplyFromBody helpers). We already hold mEvent, so nothing is fetched. - Comment: an optional "Add a comment" field sends a short m.text note to each target room, sequenced BEFORE the forwarded message per room so the note reads above the quoted content. The existing per-room failure / retry logic is preserved (a room fails if either send rejects). - Recent targets: a "Recent" chip row (hidden while searching) offers one-tap selection of rooms you last forwarded to. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage via the pure, unit-tested addRecentForwardTarget (state/recentForwardTargets.ts). Rooms you've since left are filtered out of the row. Also replaces the hardcoded rgba(0,0,0,0.35) sending scrim with a token-free opacity dim of the list (design-system rule: no hardcoded colors). Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 4 + .../room/message/ForwardMessageDialog.tsx | 250 ++++++++++++++++-- src/app/state/recentForwardTargets.test.ts | 47 ++++ src/app/state/recentForwardTargets.ts | 41 +++ 4 files changed, 325 insertions(+), 17 deletions(-) create mode 100644 src/app/state/recentForwardTargets.test.ts create mode 100644 src/app/state/recentForwardTargets.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index fffb573ed..d5dbf62d7 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -983,6 +983,10 @@ Hook: `src/app/hooks/useUserNotes.ts` The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`. +- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding. +- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room *before* the forwarded message (sequenced per room; a room counts as failed if either send fails). +- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row. + ### Live Bookmark Previews (P6-3) `BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**. diff --git a/src/app/features/room/message/ForwardMessageDialog.tsx b/src/app/features/room/message/ForwardMessageDialog.tsx index 3845bf1a5..01f8eddc0 100644 --- a/src/app/features/room/message/ForwardMessageDialog.tsx +++ b/src/app/features/room/message/ForwardMessageDialog.tsx @@ -5,6 +5,7 @@ import { Box, Button, Checkbox, + Chip, color, config, Header, @@ -21,9 +22,11 @@ import { Scroll, Spinner, Text, + toRem, } from 'folds'; -import { MatrixEvent, Room } from 'matrix-js-sdk'; -import { useAtomValue } from 'jotai'; +import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; +import { useAtomValue, useAtom } from 'jotai'; +import { IThumbnailContent } from '../../../../types/matrix/common'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { stopPropagation } from '../../../utils/keyboard'; import { useModalStyle } from '../../../hooks/useModalStyle'; @@ -31,6 +34,14 @@ import { mDirectAtom } from '../../../state/mDirectList'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; +import { UserAvatar } from '../../../components/user-avatar'; +import { ThumbnailContent } from '../../../components/message/content/ThumbnailContent'; +import { getMemberAvatarMxc, getMemberName, trimReplyFromBody } from '../../../utils/room'; +import { nameInitials } from '../../../utils/common'; +import { + recentForwardTargetsAtom, + addRecentForwardTarget, +} from '../../../state/recentForwardTargets'; import { buildForwardContent } from './forwardContent'; type RoomRowProps = { @@ -93,6 +104,139 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R ); } +// Compact, read-only preview of the message being forwarded — sender + body, +// plus a small thumbnail for image/video. We already hold mEvent, so no fetch. +function ForwardPreview({ + mEvent, + useAuthentication, +}: { + mEvent: MatrixEvent; + useAuthentication: boolean; +}) { + const mx = useMatrixClient(); + const room = mx.getRoom(mEvent.getRoomId() ?? '') ?? undefined; + const senderId = mEvent.getSender() ?? ''; + const senderName = room ? getMemberName(room, senderId) : senderId.split(':')[0]?.slice(1) || senderId; + const senderMxc = room ? getMemberAvatarMxc(room, senderId) : undefined; + const senderAvatarUrl = senderMxc + ? (mxcUrlToHttp(mx, senderMxc, useAuthentication, 48, 48, 'crop') ?? undefined) + : undefined; + + const content = mEvent.getContent(); + const msgtype = content.msgtype; + const isMedia = + msgtype === MsgType.Image || + msgtype === MsgType.Video || + msgtype === MsgType.File || + msgtype === MsgType.Audio; + const bodyStr = typeof content.body === 'string' ? content.body : ''; + const label = isMedia + ? ((content.filename as string | undefined) ?? bodyStr) || '(media)' + : trimReplyFromBody(bodyStr) || '(message)'; + const info = content.info as IThumbnailContent | undefined; + const showThumb = + (msgtype === MsgType.Image || msgtype === MsgType.Video) && + !!info && + (!!info.thumbnail_url || !!info.thumbnail_file); + + return ( + + + {nameInitials(senderName)}} + /> + + {showThumb && info && ( + ( + + )} + /> + )} + + + {senderName} + + + {label} + + + + ); +} + +// A compact selectable chip for a recently-forwarded-to room. +function RecentChip({ + room, + useAuthentication, + selected, + onToggle, + sending, +}: { + room: Room; + useAuthentication: boolean; + selected: boolean; + onToggle: () => void; + sending: boolean; +}) { + const mx = useMatrixClient(); + const avatarMxc = room.getMxcAvatarUrl(); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined) + : undefined; + + return ( + + ( + + )} + /> + + } + > + + {room.name} + + + ); +} + type Props = { mEvent: MatrixEvent; onClose: () => void; @@ -105,9 +249,11 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { const useAuthentication = useMediaAuthentication(); const searchInputRef = useRef(null); const [query, setQuery] = useState(''); + const [comment, setComment] = useState(''); const [sending, setSending] = useState(false); const [sentTo, setSentTo] = useState(null); const [error, setError] = useState(null); + const [recents, setRecents] = useAtom(recentForwardTargetsAtom); // Selection persists across query changes: a room selected then filtered out // of the rendered slice stays selected. const [selectedRoomIds, setSelectedRoomIds] = useState>(new Set()); @@ -139,6 +285,17 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { return allRooms.filter((r) => r.name.toLowerCase().includes(q)); }, [allRooms, query]); + // Resolve recent target ids to still-joined rooms (drop ones left/gone). + const recentRooms = useMemo( + () => + recents + .map((id) => mx.getRoom(id)) + .filter( + (r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom(), + ), + [recents, mx], + ); + const sendToSelected = useCallback(async () => { if (sending || selectedRoomIds.size === 0) return; const fwdContent = buildForwardContent(mx, mEvent); @@ -150,20 +307,36 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { setError(null); const ids = [...selectedRoomIds]; + const commentBody = comment.trim(); const results = await Promise.allSettled( - // threadId-aware overload (P3-8): explicit null = send to the main timeline. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)), + ids.map((id) => { + // threadId-aware overload (P3-8): explicit null = send to the main timeline. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent); + // Send the optional comment first so it reads as a note above the + // forwarded content. The room counts as failed if either send rejects. + return commentBody + ? mx + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any) + .then(sendForward) + : sendForward(); + }), ); const failedIds: string[] = []; const failedNames: string[] = []; + const succeededIds: string[] = []; results.forEach((result, i) => { if (result.status === 'rejected') { failedIds.push(ids[i]); failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]); + } else { + succeededIds.push(ids[i]); } }); + // Remember successful targets (most-recent first) for the Recent row. + succeededIds.forEach((id) => setRecents((prev) => addRecentForwardTarget(prev, id))); const total = ids.length; const failed = failedNames.length; @@ -184,7 +357,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { return; } setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`); - }, [mx, mEvent, onClose, sending, selectedRoomIds]); + }, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]); return ( }> @@ -221,10 +394,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { + {!sentTo && } {!sentTo && ( ) => setQuery(e.target.value)} /> + ) => setComment(e.target.value)} + /> {error && ( - + {error} )} @@ -262,7 +444,46 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { <> - + + {/* Recent targets — quick access, hidden while searching */} + {!query && recentRooms.length > 0 && ( + + + Recent + + + {recentRooms.map((room) => ( + toggleRoom(room.roomId)} + sending={sending} + /> + ))} + + + + )} {filtered.slice(0, 60).map((room) => ( diff --git a/src/app/state/recentForwardTargets.test.ts b/src/app/state/recentForwardTargets.test.ts new file mode 100644 index 000000000..634e7fd24 --- /dev/null +++ b/src/app/state/recentForwardTargets.test.ts @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// This module uses atomWithStorage(..., { getOnInit: true }), so it reads +// `localStorage` the moment it's evaluated. node has none, so install a no-op +// mock, then import dynamically (a static import would be hoisted above the +// mock assignment and evaluate the module too early). +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, +}; + +const { addRecentForwardTarget } = await import('./recentForwardTargets'); + +test('addRecentForwardTarget prepends a new roomId', () => { + assert.deepEqual(addRecentForwardTarget(['!a', '!b'], '!c'), ['!c', '!a', '!b']); +}); + +test('addRecentForwardTarget dedupes and moves the roomId to the front', () => { + assert.deepEqual(addRecentForwardTarget(['!a', '!b', '!c'], '!b'), ['!b', '!a', '!c']); +}); + +test('addRecentForwardTarget ignores an empty roomId', () => { + assert.deepEqual(addRecentForwardTarget(['!a', '!b'], ''), ['!a', '!b']); +}); + +test('addRecentForwardTarget caps the list at 8 entries, dropping the oldest', () => { + const eight = Array.from({ length: 8 }, (_, i) => `!r${i}`); + const result = addRecentForwardTarget(eight, '!new'); + assert.equal(result.length, 8); + assert.equal(result[0], '!new'); + // the oldest entry (last) is dropped + assert.equal(result.includes('!r7'), false); + assert.deepEqual(result.slice(1), eight.slice(0, 7)); +}); + +test('addRecentForwardTarget does not mutate its input', () => { + const input = ['!a', '!b']; + const before = [...input]; + addRecentForwardTarget(input, '!c'); + assert.deepEqual(input, before); +}); + +test('addRecentForwardTarget on an empty history returns a single-element list', () => { + assert.deepEqual(addRecentForwardTarget([], '!first'), ['!first']); +}); diff --git a/src/app/state/recentForwardTargets.ts b/src/app/state/recentForwardTargets.ts new file mode 100644 index 000000000..cf75b6b17 --- /dev/null +++ b/src/app/state/recentForwardTargets.ts @@ -0,0 +1,41 @@ +import { atom } from 'jotai'; +import { atomWithStorage, createJSONStorage } from 'jotai/utils'; + +const STORAGE_KEY = 'cinny_recent_forward_targets_v1'; +const MAX_RECENT_FORWARD_TARGETS = 8; + +// Internal atom persists as a plain string[] of roomIds (JSON-serializable). +// getOnInit reads localStorage synchronously so the Recent row is present on the +// first render of the Forward dialog (no flash of the empty default). +const internalAtom = atomWithStorage( + STORAGE_KEY, + [], + createJSONStorage(() => localStorage), + { getOnInit: true }, +); + +/** + * Global atom: string[] of the most recent distinct roomIds forwarded to. + * Most-recent first, deduped, capped at MAX_RECENT_FORWARD_TARGETS. + * Backed by localStorage (device-local convenience — not synced across devices). + */ +export const recentForwardTargetsAtom = atom( + (get): string[] => get(internalAtom), + (_get, set, updater: string[] | ((prev: string[]) => string[])) => { + set(internalAtom, (prev) => { + const prevList = Array.isArray(prev) ? prev : []; + const next = typeof updater === 'function' ? updater(prevList) : updater; + return next; + }); + }, +); + +/** + * Prepend a roomId: dedupes, drops empties, moves an existing id to the front, + * caps at MAX_RECENT_FORWARD_TARGETS. + */ +export const addRecentForwardTarget = (prev: string[], roomId: string): string[] => { + if (!roomId) return prev; + const withoutDupe = prev.filter((id) => id !== roomId); + return [roomId, ...withoutDupe].slice(0, MAX_RECENT_FORWARD_TARGETS); +};