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