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);
+};