feat(ux): forward to multiple rooms + live bookmark previews (P6-3)

Forward: checkbox multi-select room picker + "Send to N rooms" batch send
(Promise.allSettled). Full success auto-closes; partial failure keeps the dialog
open with a "Forwarded to X/N — failed: …" summary and prunes the selection to
only the failures (retry won't duplicate to already-sent rooms). Content builder
extracted to a unit-tested forwardContent.ts (edit-forwarding, reply-strip,
undecryptable-refused; 4 tests).

Bookmarks: BookmarksPanel resolves each saved message's live event (useRoomEvent)
so previews reflect edits and show a deleted indicator for redactions; the stored
snapshot stays as the fallback while loading, on fetch failure, or after leaving
the room. Stored bookmark shape unchanged.

Gates: tsc/eslint/prettier clean, build OK, 665 tests. Reviewed (dup-resend on
retry + Checkbox readOnly fixed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:30:33 -04:00
parent 4ff07ea2bd
commit ebcd8ec926
7 changed files with 391 additions and 108 deletions
+61 -11
View File
@@ -1,4 +1,5 @@
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
@@ -16,6 +17,8 @@ import {
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
bookmark: Bookmark;
onJump: (roomId: string, eventId: string) => void;
onRemove: (eventId: string) => void;
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
preview?: ReactNode;
};
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
@@ -104,18 +109,50 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{bookmark.previewText || '(no preview)'}
{preview ?? (bookmark.previewText || '(no preview)')}
</Text>
</Button>
</Box>
);
}
type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
// Renders the same layout as BookmarkItem, but resolves the message body live so
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
room.findEventById(bookmark.eventId),
);
const snapshot = bookmark.previewText || '(no preview)';
let preview: ReactNode = snapshot;
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
if (liveEvent) {
if (liveEvent.isRedacted()) {
preview = (
<MessageDeletedContent
reason={liveEvent.getUnsigned().redacted_because?.content?.reason as string | undefined}
/>
);
} else {
// body is already the edited text since useRoomEvent applied m.replace.
const { body } = liveEvent.getContent();
preview = typeof body === 'string' && body ? body : snapshot;
}
}
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
}
type BookmarksPanelProps = {
onClose: () => void;
};
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const mx = useMatrixClient();
const { bookmarks, removeBookmark } = useBookmarks();
const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState('');
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
{filtered.map((bk) => {
// Live render when the room is joined (useRoomEvent needs a non-null Room);
// otherwise fall back to the stored snapshot for rooms we've left.
const room = mx.getRoom(bk.roomId);
return room ? (
<LiveBookmarkItem
key={bk.eventId}
room={room}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
) : (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
);
})}
</Box>
)}
</Scroll>