From 204c095e9838d71ec4a0f73edd3452a4d46ebc63 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Mon, 13 Apr 2026 12:26:00 +0200 Subject: [PATCH] feat(attachments): implement the new upload endpoint Signed-off-by: Dorra Jaouad --- src/__mocks__/capabilities.ts | 1 + .../Message/MessagePart/MessageBody.vue | 10 +- .../__tests__/filesSharingServices.spec.js | 82 +++++++++++- src/services/filesSharingServices.ts | 77 +++++++++++ src/stores/__tests__/upload.spec.js | 123 ++++++++++++++++- src/stores/upload.ts | 124 ++++++++++++++++-- src/types/index.ts | 1 + 7 files changed, 401 insertions(+), 17 deletions(-) diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 8491ef4e7a3..2074cd27066 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -212,6 +212,7 @@ export const mockedCapabilities: Capabilities = { attachments: [ 'allowed', 'folder', + 'conversation-subfolders', ], call: [ 'predefined-backgrounds', diff --git a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue index ae71077d82d..c8f5e3da954 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue @@ -464,7 +464,7 @@ export default { }, sendingErrorCanRetry() { - return ['timeout', 'other', 'failed-upload'].includes(this.message.sendingFailure) + return ['timeout', 'other', 'failed-upload', 'failed-share'].includes(this.message.sendingFailure) }, sendingErrorIconTitle() { @@ -477,9 +477,6 @@ export default { if (this.message.sendingFailure === 'quota') { return t('spreed', 'Not enough free space to upload file') } - if (this.message.sendingFailure === 'failed-share') { - return t('spreed', 'You are not allowed to share files') - } return t('spreed', 'You cannot send messages to this conversation at the moment') }, @@ -530,6 +527,11 @@ export default { uploadId: this.$store.getters.message(this.message.token, this.message.id)?.uploadId, caption: this.renderedMessage !== this.message.message ? this.message.message : undefined, }) + } else if (this.message.sendingFailure === 'failed-share') { + this.uploadStore.retryShareFiles({ + token: this.message.token, + uploadId: this.$store.getters.message(this.message.token, this.message.id)?.uploadId, + }) } else { EventBus.emit('retry-message', this.message.id) EventBus.emit('focus-chat-input') diff --git a/src/services/__tests__/filesSharingServices.spec.js b/src/services/__tests__/filesSharingServices.spec.js index 74f605fac40..c965168fbe0 100644 --- a/src/services/__tests__/filesSharingServices.spec.js +++ b/src/services/__tests__/filesSharingServices.spec.js @@ -6,7 +6,7 @@ import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' import { afterEach, describe, expect, test, vi } from 'vitest' -import { shareFile } from '../filesSharingServices.ts' +import { postAttachment, probeAttachmentFolder, shareFile } from '../filesSharingServices.ts' vi.mock('@nextcloud/axios', () => ({ default: { @@ -37,4 +37,84 @@ describe('filesSharingServices', () => { }, ) }) + + test('postAttachment calls the Talk chat attachment API endpoint', async () => { + axios.post.mockResolvedValue({ data: { ocs: { data: { renames: [{ 'test.txt': 'test.txt' }] } } } }) + + const renames = await postAttachment({ + token: 'XXTOKENXX', + filePath: 'Talk/My Room-XXTOKENXX/Current User-current-user/upload-id1-0-test.txt', + fileName: 'test.txt', + referenceId: 'the-reference-id', + talkMetaData: '{"caption":"hello"}', + }) + + expect(axios.post).toHaveBeenCalledWith( + generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment', { token: 'XXTOKENXX' }), + { + filePath: 'Talk/My Room-XXTOKENXX/Current User-current-user/upload-id1-0-test.txt', + fileName: 'test.txt', + referenceId: 'the-reference-id', + talkMetaData: '{"caption":"hello"}', + }, + ) + expect(renames).toEqual([{ 'test.txt': 'test.txt' }]) + }) + + test('postAttachment returns conflict-resolved renames when backend renames the file', async () => { + axios.post.mockResolvedValue({ + data: { ocs: { data: { renames: [{ 'photo.jpg': 'photo (1).jpg' }] } } }, + }) + + const renames = await postAttachment({ + token: 'XXTOKENXX', + filePath: 'Talk/Room-XXTOKENXX/Alice-alice/upload-id1-0-photo.jpg', + fileName: 'photo.jpg', + referenceId: 'ref-1', + talkMetaData: '{}', + }) + + expect(renames).toEqual([{ 'photo.jpg': 'photo (1).jpg' }]) + }) + + test('postAttachment returns empty array when response has no renames field', async () => { + axios.post.mockResolvedValue({}) + + const renames = await postAttachment({ + token: 'XXTOKENXX', + filePath: 'Talk/Room-XXTOKENXX/Alice-alice/upload-id1-0-doc.pdf', + fileName: 'doc.pdf', + referenceId: 'ref-2', + talkMetaData: '{}', + }) + + expect(renames).toEqual([]) + }) + + test('probeAttachmentFolder calls the Talk attachment-folder probe endpoint', async () => { + axios.post.mockResolvedValue({ + data: { + ocs: { + data: { + folder: 'Talk/My Room-XXTOKENXX/Draft', + renames: [{ 'photo.jpg': 'photo.jpg' }, { 'photo.jpg': 'photo (1).jpg' }], + }, + }, + }, + }) + + const probe = await probeAttachmentFolder({ + token: 'XXTOKENXX', + fileNames: ['photo.jpg', 'photo.jpg'], + }) + + expect(axios.post).toHaveBeenCalledWith( + generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment/folder', { token: 'XXTOKENXX' }), + { fileNames: ['photo.jpg', 'photo.jpg'] }, + ) + expect(probe).toEqual({ + folder: 'Talk/My Room-XXTOKENXX/Draft', + renames: [{ 'photo.jpg': 'photo.jpg' }, { 'photo.jpg': 'photo (1).jpg' }], + }) + }) }) diff --git a/src/services/filesSharingServices.ts b/src/services/filesSharingServices.ts index ac9050b1b66..837a4553bf1 100644 --- a/src/services/filesSharingServices.ts +++ b/src/services/filesSharingServices.ts @@ -14,6 +14,24 @@ import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' import { SHARE } from '../constants.ts' +type PostAttachmentParams = { + token: string + filePath: string + fileName: string + referenceId: string + talkMetaData: string +} + +type ProbeAttachmentFolderParams = { + token: string + fileNames: string[] +} + +type ProbeAttachmentFolderData = { + folder: string + renames: Record[] +} + /** * Appends a file as a message to the messages list * @@ -56,8 +74,67 @@ async function createNewFile({ filePath, templatePath, templateType }: createFil } as createFileFromTemplateParams) } +/** + * Probe the conversation attachment folder for the given conversation. + * + * Creates the caller's conversation subfolder hierarchy (and the folder-level + * TYPE_ROOM share that grants all room members access) server-side if not yet + * present, and returns the path of the Draft staging folder where files must + * be uploaded before being posted via {@link postAttachment}. + * + * @param payload The function payload + * @param payload.token The conversation token + * @param payload.fileNames Desired file names — used only for server-side + * rename-on-conflict probing; the authoritative final names are + * returned by {@link postAttachment}. + * @return Draft folder path (relative to user home root, no leading slash) + * and a rename simulation for the requested file names. + */ +async function probeAttachmentFolder({ token, fileNames }: ProbeAttachmentFolderParams): Promise { + const response = await axios.post<{ ocs: { data: ProbeAttachmentFolderData } }>( + generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment/folder', { token }), + { fileNames }, + ) + return response.data.ocs.data +} + +/** + * Post a file staged in the conversation Draft folder as a chat message. + * + * Unlike {@link shareFile}, this does not create a per-file TYPE_ROOM share — + * access is controlled by the folder-level share created by + * {@link probeAttachmentFolder}. The backend moves the file from Draft into + * the shared conversation subfolder, resolving name conflicts by appending + * " (1)", " (2)", … to the desired file name. + * + * @param payload The function payload + * @param payload.token The conversation token + * @param payload.filePath Draft file path relative to the user's home root + * (must be inside the Draft folder returned by probeAttachmentFolder) + * @param payload.fileName Desired final file name (for rename-on-conflict) + * @param payload.referenceId Client reference ID for the chat message + * @param payload.talkMetaData JSON-encoded metadata (caption, messageType, silent, …) + * @return An array of `{ originalName: finalName }` entries — one per posted + * file. When the backend had to rename due to a conflict the two + * names differ; otherwise they are identical. + */ +async function postAttachment({ token, filePath, fileName, referenceId, talkMetaData }: PostAttachmentParams): Promise[]> { + const response = await axios.post<{ ocs: { data: { renames: Record[] } } }>( + generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment', { token }), + { + filePath, + fileName, + referenceId, + talkMetaData, + }, + ) + return response.data?.ocs?.data?.renames ?? [] +} + export { createNewFile, getFileTemplates, + postAttachment, + probeAttachmentFolder, shareFile, } diff --git a/src/stores/__tests__/upload.spec.js b/src/stores/__tests__/upload.spec.js index 828a9e79660..abf956b2982 100644 --- a/src/stores/__tests__/upload.spec.js +++ b/src/stores/__tests__/upload.spec.js @@ -7,17 +7,21 @@ import { showError } from '@nextcloud/dialogs' import { getUploader } from '@nextcloud/upload' import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { getTalkConfig } from '../../services/CapabilitiesManager.ts' import { getDavClient } from '../../services/DavClient.ts' -import { shareFile } from '../../services/filesSharingServices.ts' +import { postAttachment, probeAttachmentFolder, shareFile } from '../../services/filesSharingServices.ts' import { findUniquePath } from '../../utils/fileUpload.ts' import { useActorStore } from '../actor.ts' import { useSettingsStore } from '../settings.ts' import { useUploadStore } from '../upload.ts' +// conversationGetter must be defined before vi.mock so the factory can close over it. +// Vitest evaluates the factory lazily (after module-level init), so this works. +const conversationGetter = vi.fn().mockReturnValue(null) const vuexStoreDispatch = vi.fn() vi.mock('vuex', () => ({ useStore: vi.fn(() => ({ - getters: {}, + getters: { conversation: conversationGetter }, dispatch: vuexStoreDispatch, })), })) @@ -33,8 +37,17 @@ vi.mock('../../utils/fileUpload.ts', async () => { } }) vi.mock('../../services/filesSharingServices.ts', () => ({ + postAttachment: vi.fn(), + probeAttachmentFolder: vi.fn(), shareFile: vi.fn(), })) +vi.mock('../../services/CapabilitiesManager.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getTalkConfig: vi.fn().mockReturnValue(true), + } +}) describe('fileUploadStore', () => { let actorStore @@ -63,6 +76,7 @@ describe('fileUploadStore', () => { describe('uploading', () => { const uploadMock = vi.fn() const client = { + createDirectory: vi.fn().mockResolvedValue(undefined), exists: vi.fn(), } @@ -70,6 +84,8 @@ describe('fileUploadStore', () => { getDavClient.mockReturnValue(client) getUploader.mockReturnValue({ upload: uploadMock }) console.error = vi.fn() + // Default: ONE_TO_ONE room — no conversation folder used + conversationGetter.mockReturnValue({ type: 1, displayName: 'Direct message' }) }) afterEach(() => { @@ -369,6 +385,109 @@ describe('fileUploadStore', () => { expect(uploadStore.currentUploadId).not.toBeDefined() }) + describe('conversation folder (group/public rooms)', () => { + const TOKEN = 'XXTOKENXX' + const DRAFT_PATH = 'Talk/My Room-XXTOKENXX/Draft' + + beforeEach(() => { + uploadMock.mockResolvedValue() + postAttachment.mockResolvedValue() + probeAttachmentFolder.mockResolvedValue({ folder: DRAFT_PATH, renames: [] }) + }) + + test('probes the attachment folder and posts via postAttachment for a group room', async () => { + conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' }) + + const file = { + name: 'pngimage.png', + type: 'image/png', + size: 123, + lastModified: Date.UTC(2021, 3, 27, 15, 30, 0), + } + + uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] }) + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } }) + + // Probe endpoint is called with the desired file names; + // folder creation and the TYPE_ROOM share happen server-side. + expect(probeAttachmentFolder).toHaveBeenCalledTimes(1) + expect(probeAttachmentFolder).toHaveBeenCalledWith({ + token: TOKEN, + fileNames: [file.name], + }) + + // No client-side MKCOL and no PROPFIND round-trip. + expect(client.createDirectory).not.toHaveBeenCalled() + expect(findUniquePath).not.toHaveBeenCalled() + + // File is uploaded under a temp name inside the Draft folder. + expect(uploadMock).toHaveBeenCalledTimes(1) + const uploadedPath = uploadMock.mock.calls[0][0] + expect(uploadedPath).toMatch(new RegExp('^/' + DRAFT_PATH + '/upload-id1-.*-' + file.name + '$')) + + // File is posted via the Talk attachment endpoint with the + // original name for rename-on-conflict on the backend. + expect(postAttachment).toHaveBeenCalledTimes(1) + expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ + token: TOKEN, + fileName: file.name, + filePath: expect.stringMatching(new RegExp('^' + DRAFT_PATH + '/upload-id1-.*-' + file.name + '$')), + })) + expect(shareFile).not.toHaveBeenCalled() + }) + + test('probes with all file names and posts each file in a multi-file upload', async () => { + conversationGetter.mockReturnValue({ type: 2, displayName: 'Room' }) + + const file1 = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 } + const file2 = { name: 'doc.pdf', type: 'application/pdf', size: 200, lastModified: 0 } + + uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file1, file2] }) + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null }) + + expect(probeAttachmentFolder).toHaveBeenCalledWith({ + token: TOKEN, + fileNames: ['photo.jpg', 'doc.pdf'], + }) + expect(findUniquePath).not.toHaveBeenCalled() + expect(postAttachment).toHaveBeenCalledTimes(2) + expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'photo.jpg' })) + expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'doc.pdf' })) + }) + + test('falls back to shareFile when the probe endpoint fails', async () => { + conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' }) + probeAttachmentFolder.mockRejectedValueOnce(new Error('boom')) + findUniquePath.mockResolvedValueOnce({ path: '/Talk/photo.jpg', name: 'photo.jpg' }) + + const file = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 } + uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] }) + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null }) + + expect(probeAttachmentFolder).toHaveBeenCalledTimes(1) + expect(postAttachment).not.toHaveBeenCalled() + expect(shareFile).toHaveBeenCalledTimes(1) + }) + + test('falls back to shareFile when conversation-subfolders capability is false', async () => { + getTalkConfig.mockReturnValueOnce(false) + conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' }) + findUniquePath.mockResolvedValueOnce({ path: '/Talk/photo.jpg', name: 'photo.jpg' }) + + const file = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 } + uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] }) + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null }) + + expect(probeAttachmentFolder).not.toHaveBeenCalled() + expect(postAttachment).not.toHaveBeenCalled() + expect(shareFile).toHaveBeenCalledTimes(1) + }) + }) + test('autorenames files using timestamps when requested', () => { const files = [ { diff --git a/src/stores/upload.ts b/src/stores/upload.ts index 9f123b086ac..d1ec748970d 100644 --- a/src/stores/upload.ts +++ b/src/stores/upload.ts @@ -18,10 +18,13 @@ import { defineStore } from 'pinia' import { reactive, ref } from 'vue' import { useStore } from 'vuex' import { useTemporaryMessage } from '../composables/useTemporaryMessage.ts' -import { MESSAGE, SHARED_ITEM } from '../constants.ts' +import { CONVERSATION, MESSAGE, SHARED_ITEM } from '../constants.ts' +import { getTalkConfig } from '../services/CapabilitiesManager.ts' import { getDavClient } from '../services/DavClient.ts' import { EventBus } from '../services/EventBus.ts' import { + postAttachment, + probeAttachmentFolder, shareFile as shareFileApi, } from '../services/filesSharingServices.ts' import { isAxiosErrorResponse } from '../types/guards.ts' @@ -40,6 +43,7 @@ import { useSettingsStore } from './settings.ts' type UploadsState = { [uploadId: string]: { token: string + draftFolderPath?: string | null files: { [index: string]: UploadFile } @@ -372,6 +376,26 @@ export const useUploadStore = defineStore('upload', () => { EventBus.emit('scroll-chat-to-bottom', { smooth: true, force: true }) } + // For group and public rooms with the conversation-subfolders feature + // enabled, stage uploads inside the backend-provided Draft folder and + // post them via the dedicated attachment endpoint. The probe call + // lazily creates the conversation subfolder hierarchy and the + // folder-level TYPE_ROOM share server-side. + const conversation = vuexStore.getters.conversation(token) + const useConversationFolder = conversation + && [CONVERSATION.TYPE.GROUP, CONVERSATION.TYPE.PUBLIC].includes(conversation.type) + && getTalkConfig(token, 'attachments', 'conversation-subfolders') === true + if (useConversationFolder) { + const fileNames = getInitialisedUploads(uploadId) + .map(([, uploadedFile]) => uploadedFile.file.newName || uploadedFile.file.name) + try { + const probe = await probeAttachmentFolder({ token, fileNames }) + uploads[uploadId].draftFolderPath = probe.folder + } catch (error) { + console.error('Error while probing conversation attachment folder, falling back to flat upload: ', error) + } + } + await prepareUploadPaths({ token, uploadId }) await processUpload({ token, uploadId }) @@ -382,13 +406,37 @@ export const useUploadStore = defineStore('upload', () => { } /** - * Prepare unique paths to upload for each file + * Prepare unique paths to upload for each file. + * + * For Draft-folder uploads the backend handles rename-on-conflict when + * {@link postAttachment} moves the file out of Draft, so we skip the + * PROPFIND round-trip and assign a guaranteed-unique temp name + * (`uploadId-index-originalName`) inside the Draft folder instead. The + * original file name is passed separately to `postAttachment` so the + * backend can name the final file correctly (with ` (1)` / ` (2)` + * suffixes if needed). + * + * For regular attachment-folder uploads the existing PROPFIND uniqueness + * logic is kept unchanged. * * @param payload the wrapping object * @param payload.token The conversation token * @param payload.uploadId unique identifier */ async function prepareUploadPaths({ token, uploadId }: { token: string, uploadId: string }) { + const draftFolderPath = uploads[uploadId]?.draftFolderPath + if (draftFolderPath) { + // Assign a guaranteed-unique temp name; the backend resolves the + // final name and any conflicts when postAttachment is called. + for (const [index, uploadedFile] of getInitialisedUploads(uploadId)) { + const fileName = uploadedFile.file.newName || uploadedFile.file.name + const tempName = `${uploadId}-${index}-${fileName}` + markFileAsPendingUpload({ uploadId, index, sharePath: '/' + draftFolderPath + '/' + tempName }) + } + return + } + + // Regular attachment-folder upload: use PROPFIND to find unique paths. const client = getDavClient() const userRoot = '/files/' + actorStore.userId @@ -398,7 +446,6 @@ export const useUploadStore = defineStore('upload', () => { const performPropFind = async (uploadEntry: UploadEntry) => { const [index, uploadedFile] = uploadEntry const fileName = (uploadedFile.file.newName || uploadedFile.file.name) - // Candidate rest of the path const path = settingsStore.attachmentFolder + '/' + fileName try { @@ -516,31 +563,49 @@ export const useUploadStore = defineStore('upload', () => { options?.parent ? { replyTo: options.parent.id } : {}, )) - await shareFile({ token, path: shareableFile.sharePath!, index, uploadId, id, referenceId, talkMetaData }) + // Persist talkMetaData on the file so retryShareFiles can reuse it + uploads[uploadId].files[index].talkMetaData = talkMetaData + + const fileName = shareableFile.file.newName || shareableFile.file.name + await performShare({ token, path: shareableFile.sharePath!, index, uploadId, id, referenceId, talkMetaData, fileName }) } } /** - * Shares the files to the conversation + * Share or post a single file to a conversation. + * + * When the upload has a draftFolderPath (conversation-subfolder flow) and + * a fileName is provided, the file is posted via the Talk attachment + * endpoint. Otherwise it falls back to the classic files_sharing API. * * @param payload the wrapping object * @param payload.token The conversation token - * @param payload.path The file path from the user's root directory + * @param payload.path The file path (with leading slash for draft files, + * relative to user root for classic shares) * @param [payload.index] The index of uploaded file * @param [payload.uploadId] unique identifier * @param [payload.id] Id of temporary message * @param [payload.referenceId] A reference id to recognize the message later * @param [payload.talkMetaData] The metadata JSON-encoded object + * @param [payload.fileName] Original file name — when present together + * with a stored draftFolderPath, the attachment endpoint is used */ - async function shareFile({ token, path, index, uploadId, id, referenceId, talkMetaData }: { token: string, path: string, index: string, uploadId: string, id: number, referenceId: string, talkMetaData: string }) { + async function performShare({ token, path, index, uploadId, id, referenceId, talkMetaData, fileName }: { token: string, path: string, index?: string, uploadId?: string, id?: number, referenceId?: string, talkMetaData?: string, fileName?: string }) { try { - if (uploadId) { + if (uploadId && index) { markFileAsSharing({ uploadId, index }) } - await shareFileApi({ path, shareWith: token, referenceId, talkMetaData }) + const draftFolderPath = uploadId ? uploads[uploadId]?.draftFolderPath : undefined + if (draftFolderPath && fileName) { + // Draft-folder flow: post via the Talk attachment endpoint + const filePath = path.replace(/^\//, '') + await postAttachment({ token, filePath, fileName, referenceId: referenceId!, talkMetaData: talkMetaData! }) + } else { + await shareFileApi({ path, shareWith: token, referenceId, talkMetaData }) + } - if (uploadId) { + if (uploadId && index) { markFileAsShared({ uploadId, index }) } } catch (error) { @@ -560,6 +625,15 @@ export const useUploadStore = defineStore('upload', () => { } } + /** + * Public wrapper — shares a file via the classic files_sharing API. + * Used by external callers (NewMessage, NewFileDialog) that don't + * participate in the upload-store lifecycle. + */ + async function shareFile(params: { token: string, path: string, index?: string, uploadId?: string, id?: number, referenceId?: string, talkMetaData?: string }) { + await performShare(params) + } + /** * Re-initialize failed uploads and open UploadEditor dialog * Insert caption if was provided @@ -582,6 +656,35 @@ export const useUploadStore = defineStore('upload', () => { currentUploadId.value = uploadId } + /** + * Retry sharing files that failed at the share/post step. + * The files are already uploaded; only the share API call is re-attempted. + * + * @param payload payload + * @param payload.token the conversation token + * @param payload.uploadId unique identifier + */ + async function retryShareFiles({ token, uploadId }: { token: string, uploadId: string }) { + if (!uploads[uploadId]) { + return + } + + // Find files stuck in 'sharing' status (share was attempted but failed) + const failedShares = getUploadsArray(uploadId) + .filter(([, file]) => file.status === 'sharing') + + for (const [index, shareableFile] of failedShares) { + // Reset status so markFileAsSharing (called inside performShare) can proceed + uploads[uploadId].files[index].status = 'successUpload' + + const { id, referenceId } = shareableFile.temporaryMessage || {} + const talkMetaData = shareableFile.talkMetaData || '{}' + const fileName = shareableFile.file.newName || shareableFile.file.name + + await performShare({ token, path: shareableFile.sharePath!, index, uploadId, id, referenceId, talkMetaData, fileName }) + } + } + return { uploads, currentUploadId, @@ -614,5 +717,6 @@ export const useUploadStore = defineStore('upload', () => { shareFiles, shareFile, retryUploadFiles, + retryShareFiles, } }) diff --git a/src/types/index.ts b/src/types/index.ts index ac7fe7780d1..52c09c73ba1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -642,6 +642,7 @@ export type UploadFile = { } sharePath?: string status?: string + talkMetaData?: string temporaryMessage: ChatMessage totalSize?: number }