From e61e1aa40bed5d8a5c164b544a3221a0e3f38d21 Mon Sep 17 00:00:00 2001 From: nicepopo86-lang Date: Mon, 16 Feb 2026 03:22:36 +0000 Subject: [PATCH] Fix multi-attachment comments posting as separate messages --- .../AddCommentOrAttachmentParams.ts | 2 +- src/libs/ReportUtils.ts | 10 +++-- src/libs/actions/Report/index.ts | 18 ++------ .../prepareRequestPayload/index.native.ts | 42 ++++++++++++------- src/libs/prepareRequestPayload/index.ts | 8 ++++ tests/actions/ReportTest.ts | 21 ++++++---- 6 files changed, 58 insertions(+), 43 deletions(-) diff --git a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts index de70f600204a..30dfad31d1e7 100644 --- a/src/libs/API/parameters/AddCommentOrAttachmentParams.ts +++ b/src/libs/API/parameters/AddCommentOrAttachmentParams.ts @@ -5,7 +5,7 @@ type AddCommentOrAttachmentParams = { reportActionID?: string; commentReportActionID?: string | null; reportComment?: string; - file?: FileObject; + file?: FileObject | FileObject[]; timezone?: string; clientCreatedTime?: string; isOldDotConciergeChat?: boolean; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1f9dc76c6a4c..bbdf368607b5 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6296,20 +6296,22 @@ function getPolicyDescriptionText(policy: OnyxEntry): string { function buildOptimisticAddCommentReportAction( text?: string, - file?: FileObject, + file?: FileObject | FileObject[], actorAccountID?: number, createdOffset = 0, reportID?: string, reportActionID: string = rand64(), ): OptimisticReportAction { const commentText = getParsedComment(text ?? '', {reportID}); - const attachmentHtml = getUploadingAttachmentHtml(file); + const files = Array.isArray(file) ? file : file ? [file] : []; + const attachmentHtml = files.map((singleFile) => getUploadingAttachmentHtml(singleFile)).join('

'); const htmlForNewComment = `${commentText}${commentText && attachmentHtml ? '

' : ''}${attachmentHtml}`; const textForNewComment = Parser.htmlToText(htmlForNewComment); - const isAttachmentOnly = file && !text; - const isAttachmentWithText = !!text && file !== undefined; + const hasAttachment = files.length > 0; + const isAttachmentOnly = hasAttachment && !text; + const isAttachmentWithText = !!text && hasAttachment; const accountID = actorAccountID ?? currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID; const delegateAccountDetails = getPersonalDetailByEmail(delegateEmail); diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index dd0ad4dd74c8..6166148ddf4c 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -289,7 +289,7 @@ type AddActionsParams = { timezoneParam: Timezone; currentUserAccountID: number; text?: string; - file?: FileObject; + file?: FileObject | FileObject[]; isInSidePanel?: boolean; pregeneratedResponseParams?: PregeneratedResponseParams; }; @@ -828,20 +828,8 @@ function addAttachmentWithComment({ playSound(SOUNDS.DONE); }; - // Single attachment - if (!Array.isArray(attachments)) { - addActions({report, notifyReportID, ancestors, timezoneParam: timezone, currentUserAccountID, text, file: attachments, isInSidePanel}); - handlePlaySound(); - return; - } - - // Multiple attachments - first: combine text + first attachment as a single action - addActions({report, notifyReportID, ancestors, timezoneParam: timezone, currentUserAccountID, text, file: attachments?.at(0), isInSidePanel}); - - // Remaining: attachment-only actions (no text duplication) - for (let i = 1; i < attachments?.length; i += 1) { - addActions({report, notifyReportID, ancestors, timezoneParam: timezone, currentUserAccountID, text: '', file: attachments?.at(i), isInSidePanel}); - } + // Send single or multiple attachments in one action + addActions({report, notifyReportID, ancestors, timezoneParam: timezone, currentUserAccountID, text, file: attachments, isInSidePanel}); // Play sound once handlePlaySound(); diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 5232f8d3365e..630a2d913459 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -38,23 +38,37 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf } if (key === 'file' && initiatedOffline) { - const {uri: path = '', source, name, type} = value as File; - if (!source) { - validateFormDataParameter(command, key, value); - formData.append(key, value as string | Blob); + const files = Array.isArray(value) ? value : [value]; + return files.reduce>((chain, fileValue) => { + return chain.then(() => { + const {uri: path = '', source, name, type} = fileValue as File; + if (!source) { + validateFormDataParameter(command, key, fileValue); + formData.append(key, fileValue as string | Blob); + return Promise.resolve(); + } - return Promise.resolve(); - } - // Use the actual file name if available, otherwise fall back to extracting from path/uri - const fileName = name || (path ? (path.split('/').pop() ?? '') : '') || ''; - return readFileAsync(source, fileName, () => {}, undefined, type).then((file) => { - if (!file) { - return; - } + // Use the actual file name if available, otherwise fall back to extracting from path/uri + const fileName = name || (path ? (path.split('/').pop() ?? '') : '') || ''; + return readFileAsync(source, fileName, () => {}, undefined, type).then((file) => { + if (!file) { + return; + } - validateFormDataParameter(command, key, file); - formData.append(key, file); + validateFormDataParameter(command, key, file); + formData.append(key, file); + }); + }); + }, Promise.resolve()); + } + + if (Array.isArray(value)) { + value.forEach((singleValue) => { + validateFormDataParameter(command, key, singleValue); + formData.append(key, singleValue as string | Blob); }); + + return Promise.resolve(); } validateFormDataParameter(command, key, value); diff --git a/src/libs/prepareRequestPayload/index.ts b/src/libs/prepareRequestPayload/index.ts index 1dda836375d6..06e8daadb92b 100644 --- a/src/libs/prepareRequestPayload/index.ts +++ b/src/libs/prepareRequestPayload/index.ts @@ -14,6 +14,14 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data) => { continue; } + if (Array.isArray(value)) { + value.forEach((singleValue) => { + validateFormDataParameter(command, key, singleValue); + formData.append(key, singleValue as string | Blob); + }); + continue; + } + validateFormDataParameter(command, key, value); formData.append(key, value as string | Blob); } diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index ae4a16492cf2..881736f860f0 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -1493,7 +1493,7 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.DELETE_COMMENT, 0); }); - it('should post text + attachment as first action then attachment only for remaining attachments when adding multiple attachments with a comment', async () => { + it('should post multiple attachments with comment in one AddTextAndAttachment request', async () => { global.fetch = TestHelper.getGlobalFetchMock(); const playSoundMock = playSound as jest.MockedFunction; await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); @@ -1503,8 +1503,8 @@ describe('actions/Report', () => { const conn = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persisted) => { - const relevant = (persisted ?? []).filter((r) => r?.command === WRITE_COMMANDS.ADD_ATTACHMENT || r?.command === WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); - if (relevant.length >= 3) { + const relevant = (persisted ?? []).filter((r) => r?.command === WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); + if (relevant.length >= 1) { Onyx.disconnect(conn); resolve(relevant); } @@ -1533,11 +1533,13 @@ describe('actions/Report', () => { expect(playSoundMock).toHaveBeenCalledTimes(1); expect(playSoundMock).toHaveBeenCalledWith(SOUNDS.DONE); + expect(relevant).toHaveLength(1); expect(relevant.at(0)?.command).toBe(WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); - expect(relevant.slice(1).every((r) => r.command === WRITE_COMMANDS.ADD_ATTACHMENT)).toBe(true); + expect(Array.isArray(relevant.at(0)?.data?.file)).toBe(true); + expect((relevant.at(0)?.data?.file as File[]).length).toBe(3); }); - it('should create attachment only actions when adding multiple attachments without a comment', async () => { + it('should create one attachment-only action when adding multiple attachments without a comment', async () => { global.fetch = TestHelper.getGlobalFetchMock(); const playSoundMock = playSound as jest.MockedFunction; await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true}); @@ -1547,8 +1549,8 @@ describe('actions/Report', () => { const conn = Onyx.connect({ key: ONYXKEYS.PERSISTED_REQUESTS, callback: (persisted) => { - const relevant = (persisted ?? []).filter((r) => r?.command === WRITE_COMMANDS.ADD_ATTACHMENT || r?.command === WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT); - if (relevant.length >= 2) { + const relevant = (persisted ?? []).filter((r) => r?.command === WRITE_COMMANDS.ADD_ATTACHMENT); + if (relevant.length >= 1) { Onyx.disconnect(conn); resolve(relevant); } @@ -1575,9 +1577,10 @@ describe('actions/Report', () => { expect(playSoundMock).toHaveBeenCalledTimes(1); expect(playSoundMock).toHaveBeenCalledWith(SOUNDS.DONE); + expect(relevant).toHaveLength(1); expect(relevant.at(0)?.command).toBe(WRITE_COMMANDS.ADD_ATTACHMENT); - expect(relevant.slice(1).every((r) => r.command === WRITE_COMMANDS.ADD_ATTACHMENT)).toBe(true); - expect(relevant.some((r) => r.command === WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT)).toBe(false); + expect(Array.isArray(relevant.at(0)?.data?.file)).toBe(true); + expect((relevant.at(0)?.data?.file as File[]).length).toBe(2); }); it('should create attachment only action & not play sound when adding attachment without a comment & shouldPlaySound not passed', async () => {