import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Avatar, Box, Button, Checkbox, Chip, color, config, Header, Icon, IconButton, Icons, Input, Line, MenuItem, Modal, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Spinner, Text, toRem, } from 'folds'; import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; import { useAtomValue, useAtom } from 'jotai'; import { IThumbnailContent } from '../../../../types/matrix/common'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { stopPropagation } from '../../../utils/keyboard'; import { useModalStyle } from '../../../hooks/useModalStyle'; import { mDirectAtom } from '../../../state/mDirectList'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { mxcUrlToHttp } from '../../../utils/matrix'; 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, buildPlaintextAttachmentContent } from './forwardContent'; type RoomRowProps = { room: Room; dm: boolean; useAuthentication: boolean; selected: boolean; onToggle: () => void; sending: boolean; }; function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) { const mx = useMatrixClient(); const avatarMxc = room.getMxcAvatarUrl(); const avatarUrl = avatarMxc ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined) : undefined; return ( { evt.stopPropagation(); onToggle(); }} /> } before={ ( )} /> } > {room.name} {dm && ( Direct Message )} ); } // 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 ( {nameInitials(senderName)}} /> {showThumb && info && ( ( )} /> )} {senderName} {label} ); } // 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 ( ( )} /> } > {room.name} ); } type Props = { mEvent: MatrixEvent; onClose: () => void; }; export function ForwardMessageDialog({ mEvent, onClose }: Props) { const mx = useMatrixClient(); const modalStyle = useModalStyle(400); const directs = useAtomValue(mDirectAtom); const useAuthentication = useMediaAuthentication(); const searchInputRef = useRef(null); const [query, setQuery] = useState(''); const [comment, setComment] = useState(''); const [sending, setSending] = useState(false); const [sentTo, setSentTo] = useState(null); const [error, setError] = useState(null); const [recents, setRecents] = useAtom(recentForwardTargetsAtom); // Rooms whose comment message already delivered this session — so a retry after // a forward failure doesn't re-post the comment (only the missing forward). Kept // for the dialog's lifetime: a room that already got the comment won't get it // again even if the text is later edited, which is the safe (no-duplicate) choice. const commentSentRef = useRef>(new Set()); // Selection persists across query changes: a room selected then filtered out // of the rendered slice stays selected. const [selectedRoomIds, setSelectedRoomIds] = useState>(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( () => mx .getRooms() .filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom()) .sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)), [mx], ); const filtered = useMemo(() => { if (!query) return allRooms; const q = query.toLowerCase(); return allRooms.filter((r) => r.name.toLowerCase().includes(q)); }, [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 () => { 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); const ids = [...selectedRoomIds]; // `fwdContent.file` (present on encrypted attachments) carries the AES key/iv // needed to decrypt it. Sending it as-is into a room that isn't encrypted would // publish that key in plaintext (Gitea #63), so any unencrypted destination gets // a decrypted-and-re-uploaded plaintext version instead. Built once (not per // room) since every unencrypted destination gets the same re-upload. let plaintextContent: Record | undefined; let plaintextContentError: string | undefined; if (fwdContent.file) { const needsPlaintext = ids.some((id) => !mx.getRoom(id)?.hasEncryptionStateEvent()); if (needsPlaintext) { try { plaintextContent = await buildPlaintextAttachmentContent( mx, fwdContent, useAuthentication, ); } catch { plaintextContentError = 'Could not prepare this attachment for an unencrypted room.'; } } } const commentBody = comment.trim(); // Rooms are sent ONE AT A TIME. matrix-js-sdk queues message sends, and // when one queued send fails permanently (e.g. 403 in a room you cannot // post to) the scheduler rejects every send still waiting in the queue — // so firing all rooms concurrently turned one forbidden target into // "Failed to forward" for all of them, with half-sent comments (Gitea #194). const sendToRoom = (id: string): Promise => { const destEncrypted = !!mx.getRoom(id)?.hasEncryptionStateEvent(); // Encrypted destinations keep the original (possibly encrypted-attachment) // content; unencrypted ones get the plaintext version, or fail outright if // that couldn't be built — never fall back to sending the encrypted `file`. const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent; if (fwdContent.file && !destEncrypted && !contentToSend) { return Promise.reject(new Error(plaintextContentError)); } // threadId-aware overload (P3-8): explicit null = send to the main timeline. // eslint-disable-next-line @typescript-eslint/no-explicit-any const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend); // Send the optional comment first so it reads as a note above the // forwarded content. The room counts as failed if either send rejects. // Track rooms whose comment already landed so a retry (after the FORWARD // failed) doesn't post the comment twice — only the missing forward. const needsComment = commentBody && !commentSentRef.current.has(id); const step = needsComment ? mx.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody }).then(() => { commentSentRef.current.add(id); }) : Promise.resolve(); return step.then(sendForward); }; const results: PromiseSettledResult[] = []; for (const id of ids) { // eslint-disable-next-line no-await-in-loop results.push(...(await Promise.allSettled([sendToRoom(id)]))); } const failedIds: string[] = []; const failedNames: string[] = []; const succeededIds: string[] = []; results.forEach((result, i) => { if (result.status === 'rejected') { failedIds.push(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 failed = failedNames.length; const succeeded = total - failed; if (failed === 0) { setSentTo(`Forwarded to ${total} ${total === 1 ? 'room' : 'rooms'}`); setTimeout(onClose, 1400); return; } 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; } setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`); }, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents, useAuthentication]); return ( }> searchInputRef.current ?? false, onDeactivate: onClose, clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} >
Forward message
{!sentTo && } {!sentTo && ( ) => setQuery(e.target.value)} /> ) => setComment(e.target.value)} /> {error && ( {error} )} )} {sentTo ? ( ✓ {sentTo} ) : ( <> {/* Recent targets — quick access, hidden while searching */} {!query && recentRooms.length > 0 && ( Recent {recentRooms.map((room) => ( toggleRoom(room.roomId)} sending={sending} /> ))} )} {filtered.slice(0, 60).map((room) => ( toggleRoom(room.roomId)} sending={sending} /> ))} {filtered.length === 0 && ( No rooms found )} {sending && ( )} )}
); }