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:
@@ -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