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:
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user