From 37bf33df8111c1718de7c8fe2cfaa096d6d1edcd Mon Sep 17 00:00:00 2001 From: Samy Date: Thu, 13 Aug 2026 02:28:32 +0200 Subject: [PATCH] fix: prevent duplicate attachment sends --- .../composition/CompositionBox.tsx | 13 ++- .../composition/messageSendGuard.ts | 16 ++++ .../conversation/messageSendGuard_test.ts | 80 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 ts/components/conversation/composition/messageSendGuard.ts create mode 100644 ts/test/session/unit/components/conversation/messageSendGuard_test.ts diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index d5b3237db..ef61ea4de 100644 --- a/ts/components/conversation/composition/CompositionBox.tsx +++ b/ts/components/conversation/composition/CompositionBox.tsx @@ -67,6 +67,7 @@ import { updateOutgoingLightBoxOptions } from '../../../state/ducks/modalDialog' import { isEnterKey, isEscapeKey } from '../../../util/keyboardShortcuts'; import type { CommunityInvitation } from '../../../session/messages/outgoing/visibleMessage/VisibleMessage'; import { SessionGifPanel } from './gif/SessionGifPanel'; +import { createMessageSendGuard } from './messageSendGuard'; export interface ReplyingToMessageProps { convoId: string; @@ -243,6 +244,7 @@ class CompositionBoxInner extends Component { private readonly emojiPanel: RefObject; private readonly emojiPanelButton: any; private readonly showGifsButtonRef: RefObject; + private readonly messageSendGuard = createMessageSendGuard(); private linkPreviewAbortController?: AbortController; constructor(props: Props) { @@ -640,7 +642,16 @@ class CompositionBoxInner extends Component { return text.length > 0; } - private async onSendMessage() { + private onSendMessage() { + const { selectedConversationKey } = this.props; + if (!selectedConversationKey) { + return this.sendCurrentMessage(); + } + + return this.messageSendGuard(selectedConversationKey, () => this.sendCurrentMessage()); + } + + private async sendCurrentMessage() { const { selectedConversationKey, selectedConversation, diff --git a/ts/components/conversation/composition/messageSendGuard.ts b/ts/components/conversation/composition/messageSendGuard.ts new file mode 100644 index 000000000..7f3e9ad40 --- /dev/null +++ b/ts/components/conversation/composition/messageSendGuard.ts @@ -0,0 +1,16 @@ +export function createMessageSendGuard() { + const conversationsSending = new Set(); + + return async (conversationKey: string, sendMessage: () => Promise) => { + if (conversationsSending.has(conversationKey)) { + return; + } + + conversationsSending.add(conversationKey); + try { + await sendMessage(); + } finally { + conversationsSending.delete(conversationKey); + } + }; +} diff --git a/ts/test/session/unit/components/conversation/messageSendGuard_test.ts b/ts/test/session/unit/components/conversation/messageSendGuard_test.ts new file mode 100644 index 000000000..5c0404e6f --- /dev/null +++ b/ts/test/session/unit/components/conversation/messageSendGuard_test.ts @@ -0,0 +1,80 @@ +import chai from 'chai'; + +import { createMessageSendGuard } from '../../../../../components/conversation/composition/messageSendGuard'; + +const { expect } = chai; + +describe('createMessageSendGuard', () => { + it('ignores another send to the same conversation while one is in progress', async () => { + const sendMessage = createMessageSendGuard(); + let resolveFirstSend!: () => void; + let sendCount = 0; + + const firstSend = sendMessage( + 'conversation-a', + () => + new Promise(resolve => { + sendCount += 1; + resolveFirstSend = resolve; + }) + ); + await sendMessage('conversation-a', async () => { + sendCount += 1; + }); + + expect(sendCount).to.equal(1); + + resolveFirstSend(); + await firstSend; + await sendMessage('conversation-a', async () => { + sendCount += 1; + }); + + expect(sendCount).to.equal(2); + }); + + it('allows sends to different conversations at the same time', async () => { + const sendMessage = createMessageSendGuard(); + let resolveFirstSend!: () => void; + let secondSendCompleted = false; + + const firstSend = sendMessage( + 'conversation-a', + () => + new Promise(resolve => { + resolveFirstSend = resolve; + }) + ); + await sendMessage('conversation-b', async () => { + secondSendCompleted = true; + }); + + expect(secondSendCompleted).to.equal(true); + + resolveFirstSend(); + await firstSend; + }); + + it('allows another send after a failure', async () => { + const sendMessage = createMessageSendGuard(); + const failure = new Error('send failed'); + let caught: unknown; + + try { + await sendMessage('conversation-a', async () => { + throw failure; + }); + } catch (error) { + caught = error; + } + + expect(caught).to.equal(failure); + + let didRetry = false; + await sendMessage('conversation-a', async () => { + didRetry = true; + }); + + expect(didRetry).to.equal(true); + }); +});