From b38621861b91b84f9219e468e002ba64d05a3035 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 18 Sep 2026 19:49:33 -0400 Subject: [PATCH] fix(forward): one forbidden target no longer fails the whole multi-room forward (#194 P6-3) All targets were sent concurrently through matrix-js-sdk's message queue; when the send to a room you cannot post to failed with 403 the scheduler clearQueue()'d every send still waiting, so 'Send to 3 rooms' with one read-only room reported 'Failed to forward' for all three and left a half-sent comment in the first. Rooms are now sent one at a time. Verified: 'Forwarded to 2/3. Failed: Read Only Room.' and both good rooms receive comment + forwarded message. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../room/message/ForwardMessageDialog.tsx | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) 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[] = [];