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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 00:37:49 -04:00
co-authored by Claude Opus 4.8
parent a739c25f10
commit 629db9724f
4 changed files with 325 additions and 17 deletions
+4
View File
@@ -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**.
@@ -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 (
<Box
shrink="No"
gap="200"
alignItems="Center"
style={{
margin: `${config.space.S200} ${config.space.S400} 0`,
padding: config.space.S200,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Avatar size="200" radii="300">
<UserAvatar
userId={senderId}
src={senderAvatarUrl}
alt={senderName}
renderFallback={() => <Text size="H6">{nameInitials(senderName)}</Text>}
/>
</Avatar>
{showThumb && info && (
<ThumbnailContent
info={info}
renderImage={(src) => (
<img
src={src}
alt=""
style={{
width: toRem(40),
height: toRem(40),
borderRadius: config.radii.R300,
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
/>
)}
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{senderName}
</Text>
<Text size="T200" priority="300" truncate>
{label}
</Text>
</Box>
</Box>
);
}
// 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 (
<Chip
variant={selected ? 'Primary' : 'SurfaceVariant'}
fill="Soft"
radii="Pill"
disabled={sending}
onClick={onToggle}
aria-pressed={selected}
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<RoomIcon roomType={room.getType()} size="50" joinRule={room.getJoinRule()} filled />
)}
/>
</Avatar>
}
>
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
{room.name}
</Text>
</Chip>
);
}
type Props = {
mEvent: MatrixEvent;
onClose: () => void;
@@ -105,9 +249,11 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const useAuthentication = useMediaAuthentication();
const searchInputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [comment, setComment] = useState('');
const [sending, setSending] = useState(false);
const [sentTo, setSentTo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<Set<string>>(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 (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -221,10 +394,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{!sentTo && <ForwardPreview mEvent={mEvent} useAuthentication={useAuthentication} />}
{!sentTo && (
<Box
shrink="No"
direction="Column"
gap="200"
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
>
<Input
@@ -237,11 +412,18 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
<Input
variant="Background"
size="400"
radii="400"
outlined
placeholder="Add a comment (optional)…"
value={comment}
disabled={sending}
onChange={(e: ChangeEvent<HTMLInputElement>) => setComment(e.target.value)}
/>
{error && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
@@ -262,7 +444,46 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<>
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
<Scroll size="300" hideTrack visibility="Hover">
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S200, opacity: sending ? 0.5 : 1 }}
>
{/* Recent targets — quick access, hidden while searching */}
{!query && recentRooms.length > 0 && (
<Box
direction="Column"
gap="100"
style={{ paddingBottom: config.space.S100 }}
>
<Text
size="L400"
priority="300"
style={{ padding: `0 ${config.space.S200}` }}
>
Recent
</Text>
<Box
gap="100"
wrap="Wrap"
role="group"
aria-label="Recent forward targets"
style={{ padding: `0 ${config.space.S200}` }}
>
{recentRooms.map((room) => (
<RecentChip
key={room.roomId}
room={room}
useAuthentication={useAuthentication}
selected={selectedRoomIds.has(room.roomId)}
onToggle={() => toggleRoom(room.roomId)}
sending={sending}
/>
))}
</Box>
<Line size="300" style={{ marginTop: config.space.S100 }} />
</Box>
)}
{filtered.slice(0, 60).map((room) => (
<RoomRow
key={room.roomId}
@@ -291,12 +512,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Box
alignItems="Center"
justifyContent="Center"
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.35)',
borderRadius: config.radii.R500,
}}
style={{ position: 'absolute', inset: 0 }}
>
<Spinner variant="Secondary" size="400" />
</Box>
@@ -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']);
});
+41
View File
@@ -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<string[]>(
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);
};