diff --git a/src/app/features/room/message/ForwardMessageDialog.tsx b/src/app/features/room/message/ForwardMessageDialog.tsx index 3927ba9d5..abc6dc9ec 100644 --- a/src/app/features/room/message/ForwardMessageDialog.tsx +++ b/src/app/features/room/message/ForwardMessageDialog.tsx @@ -338,35 +338,43 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { } const commentBody = comment.trim(); - const results = await Promise.allSettled( - ids.map((id) => { - 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 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any) - .then(() => { - commentSentRef.current.add(id); - }) - : Promise.resolve(); - return step.then(sendForward); - }), - ); + // 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 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any) + .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[] = [];