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`.
|
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)
|
### 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**.
|
`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,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Chip,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
Header,
|
Header,
|
||||||
@@ -21,9 +22,11 @@ import {
|
|||||||
Scroll,
|
Scroll,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue, useAtom } from 'jotai';
|
||||||
|
import { IThumbnailContent } from '../../../../types/matrix/common';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||||
@@ -31,6 +34,14 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
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';
|
import { buildForwardContent } from './forwardContent';
|
||||||
|
|
||||||
type RoomRowProps = {
|
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 = {
|
type Props = {
|
||||||
mEvent: MatrixEvent;
|
mEvent: MatrixEvent;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -105,9 +249,11 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||||
const [error, setError] = 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
|
// Selection persists across query changes: a room selected then filtered out
|
||||||
// of the rendered slice stays selected.
|
// of the rendered slice stays selected.
|
||||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
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));
|
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
}, [allRooms, query]);
|
}, [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 () => {
|
const sendToSelected = useCallback(async () => {
|
||||||
if (sending || selectedRoomIds.size === 0) return;
|
if (sending || selectedRoomIds.size === 0) return;
|
||||||
const fwdContent = buildForwardContent(mx, mEvent);
|
const fwdContent = buildForwardContent(mx, mEvent);
|
||||||
@@ -150,20 +307,36 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const ids = [...selectedRoomIds];
|
const ids = [...selectedRoomIds];
|
||||||
|
const commentBody = comment.trim();
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
ids.map((id) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||||
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
// 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 failedIds: string[] = [];
|
||||||
const failedNames: string[] = [];
|
const failedNames: string[] = [];
|
||||||
|
const succeededIds: string[] = [];
|
||||||
results.forEach((result, i) => {
|
results.forEach((result, i) => {
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
failedIds.push(ids[i]);
|
failedIds.push(ids[i]);
|
||||||
failedNames.push(mx.getRoom(ids[i])?.name ?? 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 total = ids.length;
|
||||||
const failed = failedNames.length;
|
const failed = failedNames.length;
|
||||||
@@ -184,7 +357,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||||
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
|
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
@@ -221,10 +394,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
{!sentTo && <ForwardPreview mEvent={mEvent} useAuthentication={useAuthentication} />}
|
||||||
{!sentTo && (
|
{!sentTo && (
|
||||||
<Box
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -237,11 +412,18 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
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 && (
|
{error && (
|
||||||
<Text
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
size="T200"
|
|
||||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -262,7 +444,46 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
<>
|
<>
|
||||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||||
<Scroll size="300" hideTrack visibility="Hover">
|
<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) => (
|
{filtered.slice(0, 60).map((room) => (
|
||||||
<RoomRow
|
<RoomRow
|
||||||
key={room.roomId}
|
key={room.roomId}
|
||||||
@@ -291,12 +512,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
<Box
|
<Box
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
style={{
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(0,0,0,0.35)',
|
|
||||||
borderRadius: config.radii.R500,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Spinner variant="Secondary" size="400" />
|
<Spinner variant="Secondary" size="400" />
|
||||||
</Box>
|
</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