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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user