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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 19:49:33 -04:00
co-authored by Claude Opus 5
parent 1a8fa5cd4c
commit b38621861b
@@ -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<unknown> => {
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<unknown>[] = [];
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[] = [];