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:
@@ -3,6 +3,8 @@ import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
@@ -29,16 +31,17 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
dm: boolean;
|
||||
useAuthentication: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
sending: boolean;
|
||||
};
|
||||
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) {
|
||||
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -49,8 +52,20 @@ function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={onClick}
|
||||
onClick={onToggle}
|
||||
disabled={sending}
|
||||
after={
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
readOnly
|
||||
variant="Primary"
|
||||
disabled={sending}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
@@ -93,6 +108,21 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Selection persists across query changes: a room selected then filtered out
|
||||
// of the rendered slice stays selected.
|
||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleRoom = useCallback((roomId: string) => {
|
||||
setSelectedRoomIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(roomId)) {
|
||||
next.delete(roomId);
|
||||
} else {
|
||||
next.add(roomId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allRooms = useMemo(
|
||||
() =>
|
||||
@@ -109,63 +139,52 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [allRooms, query]);
|
||||
|
||||
/**
|
||||
* Build the content to forward:
|
||||
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||
* original pre-edit body
|
||||
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||
* message stands alone in the target room
|
||||
*/
|
||||
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
|
||||
if (mEvent.isDecryptionFailure()) return undefined;
|
||||
const sendToSelected = useCallback(async () => {
|
||||
if (sending || selectedRoomIds.size === 0) return;
|
||||
const fwdContent = buildForwardContent(mx, mEvent);
|
||||
if (!fwdContent) {
|
||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
let content = { ...mEvent.getContent() };
|
||||
const ids = [...selectedRoomIds];
|
||||
const results = await Promise.allSettled(
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
||||
);
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const room = mx.getRoom(mEvent.getRoomId());
|
||||
if (eventId && room) {
|
||||
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||
if (newContent && typeof newContent === 'object') {
|
||||
content = { ...(newContent as Record<string, unknown>) };
|
||||
const failedIds: string[] = [];
|
||||
const failedNames: string[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
failedIds.push(ids[i]);
|
||||
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
|
||||
}
|
||||
});
|
||||
|
||||
const total = ids.length;
|
||||
const failed = failedNames.length;
|
||||
const succeeded = total - failed;
|
||||
|
||||
if (failed === 0) {
|
||||
setSentTo(`Forwarded to ${total} ${total === 1 ? 'room' : 'rooms'}`);
|
||||
setTimeout(onClose, 1400);
|
||||
return;
|
||||
}
|
||||
|
||||
delete content['m.relates_to'];
|
||||
if (typeof content.body === 'string') {
|
||||
content.body = trimReplyFromBody(content.body);
|
||||
setSending(false);
|
||||
// Prune to only the failures so a retry doesn't re-send to rooms that
|
||||
// already succeeded (duplicate messages).
|
||||
setSelectedRoomIds(new Set(failedIds));
|
||||
if (succeeded === 0) {
|
||||
setError('Failed to forward. Try again.');
|
||||
return;
|
||||
}
|
||||
if (typeof content.formatted_body === 'string') {
|
||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||
}
|
||||
return content;
|
||||
}, [mx, mEvent]);
|
||||
|
||||
const forward = useCallback(
|
||||
async (room: Room) => {
|
||||
if (sending) return;
|
||||
const fwdContent = buildForwardContent();
|
||||
if (!fwdContent) {
|
||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent);
|
||||
setSentTo(room.name);
|
||||
setTimeout(onClose, 1400);
|
||||
} catch {
|
||||
setSending(false);
|
||||
setError(`Failed to forward to ${room.name}. Try again.`);
|
||||
}
|
||||
},
|
||||
[mx, mEvent, onClose, sending, buildForwardContent],
|
||||
);
|
||||
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
@@ -237,50 +256,72 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
gap="300"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Text size="T300">✓ Forwarded to {sentTo}</Text>
|
||||
<Text size="T300">✓ {sentTo}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
{filtered.slice(0, 60).map((room) => (
|
||||
<RoomRow
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
dm={directs.has(room.roomId)}
|
||||
useAuthentication={useAuthentication}
|
||||
onClick={() => forward(room)}
|
||||
sending={sending}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Text size="T300" priority="300">
|
||||
No rooms found
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
{sending && (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
borderRadius: config.radii.R500,
|
||||
}}
|
||||
<>
|
||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
{filtered.slice(0, 60).map((room) => (
|
||||
<RoomRow
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
dm={directs.has(room.roomId)}
|
||||
useAuthentication={useAuthentication}
|
||||
selected={selectedRoomIds.has(room.roomId)}
|
||||
onToggle={() => toggleRoom(room.roomId)}
|
||||
sending={sending}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Text size="T300" priority="300">
|
||||
No rooms found
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
{sending && (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
borderRadius: config.radii.R500,
|
||||
}}
|
||||
>
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Line size="300" />
|
||||
<Box
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||
>
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
radii="400"
|
||||
disabled={selectedRoomIds.size === 0 || sending}
|
||||
before={sending && <Spinner variant="Primary" fill="Solid" size="200" />}
|
||||
onClick={sendToSelected}
|
||||
>
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="B400">
|
||||
Send to {selectedRoomIds.size} {selectedRoomIds.size === 1 ? 'room' : 'rooms'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
|
||||
Reference in New Issue
Block a user