From c087eefe745e4d53cd8e3bc19664784f8c0bd60a Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Mon, 30 Mar 2026 12:17:27 +0200 Subject: [PATCH] feat(conversationfolder): add frontend support for per-share conversation subfolders Adds the API call layer and upload store integration to use the new POST /api/v1/chat/{token}/attachment endpoint when conversation subfolders are enabled. AI-assisted-by: Claude Sonnet 4.6 --- src/__mocks__/capabilities.ts | 2 + .../__tests__/filesSharingServices.spec.js | 55 +++++- src/services/filesSharingServices.ts | 39 ++++ src/stores/__tests__/upload.spec.js | 144 ++++++++++++++- src/stores/upload.ts | 166 +++++++++++++++++- 5 files changed, 395 insertions(+), 11 deletions(-) diff --git a/src/__mocks__/capabilities.ts b/src/__mocks__/capabilities.ts index 2f888170513..2074cd27066 100644 --- a/src/__mocks__/capabilities.ts +++ b/src/__mocks__/capabilities.ts @@ -134,6 +134,7 @@ export const mockedCapabilities: Capabilities = { attachments: { allowed: true, folder: '/Talk', + 'conversation-subfolders': true, }, call: { enabled: true, @@ -211,6 +212,7 @@ export const mockedCapabilities: Capabilities = { attachments: [ 'allowed', 'folder', + 'conversation-subfolders', ], call: [ 'predefined-backgrounds', diff --git a/src/services/__tests__/filesSharingServices.spec.js b/src/services/__tests__/filesSharingServices.spec.js index 74f605fac40..2acc713a1ec 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, shareFile } from '../filesSharingServices.ts' vi.mock('@nextcloud/axios', () => ({ default: { @@ -37,4 +37,57 @@ 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([]) + }) }) diff --git a/src/services/filesSharingServices.ts b/src/services/filesSharingServices.ts index ac9050b1b66..83529c4d776 100644 --- a/src/services/filesSharingServices.ts +++ b/src/services/filesSharingServices.ts @@ -14,6 +14,14 @@ 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 +} + /** * Appends a file as a message to the messages list * @@ -56,8 +64,39 @@ async function createNewFile({ filePath, templatePath, templateType }: createFil } as createFileFromTemplateParams) } +/** + * Post a file from a conversation attachment subfolder as a chat message. + * + * Unlike shareFile(), this does not create a per-file TYPE_ROOM share. + * Access is controlled by the folder-level share that was automatically + * created when the conversation subfolder was first created via WebDAV MKCOL. + * + * @param payload The function payload + * @param payload.token The conversation token + * @param payload.filePath File path relative to the user's home root + * @param payload.fileName Desired final file name (used for server-side 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, shareFile, } diff --git a/src/stores/__tests__/upload.spec.js b/src/stores/__tests__/upload.spec.js index 828a9e79660..ce567ff3483 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, 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,16 @@ vi.mock('../../utils/fileUpload.ts', async () => { } }) vi.mock('../../services/filesSharingServices.ts', () => ({ + postAttachment: 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 +75,7 @@ describe('fileUploadStore', () => { describe('uploading', () => { const uploadMock = vi.fn() const client = { + createDirectory: vi.fn().mockResolvedValue(undefined), exists: vi.fn(), } @@ -70,6 +83,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 +384,131 @@ describe('fileUploadStore', () => { expect(uploadStore.currentUploadId).not.toBeDefined() }) + describe('conversation folder (group/public rooms)', () => { + const TOKEN = 'XXTOKENXX' + + beforeEach(() => { + uploadMock.mockResolvedValue() + postAttachment.mockResolvedValue() + }) + + test('creates conversation subfolder 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] }) + + // uid = 'current-user' (len 12), prefixLen = min(16, 63-12) = 16 + // subPrefix = 'Current User' (12 chars < 16), subfolderName = 'Current User-current-user' + const convFolderName = 'My Room-' + TOKEN + const subfolderName = 'Current User-current-user' + const subfolderPath = 'Talk/' + convFolderName + '/' + subfolderName + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } }) + + // Conversation subfolders must be created via DAV MKCOL + expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk') + expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName) + expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName + '/' + subfolderName) + + // No PROPFIND round-trip — backend handles conflict resolution + expect(findUniquePath).not.toHaveBeenCalled() + + // File is uploaded to a temp name inside the conversation subfolder + expect(uploadMock).toHaveBeenCalledTimes(1) + const uploadedPath = uploadMock.mock.calls[0][0] + expect(uploadedPath).toMatch(new RegExp('^/' + subfolderPath + '/upload-id1-.*-' + file.name + '$')) + + // File is posted via Talk attachment endpoint with original name for rename-on-conflict + expect(postAttachment).toHaveBeenCalledTimes(1) + expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ + token: TOKEN, + fileName: file.name, + filePath: expect.stringMatching(new RegExp('^' + subfolderPath + '/upload-id1-.*-' + file.name + '$')), + })) + expect(shareFile).not.toHaveBeenCalled() + }) + + test('passes original file name to postAttachment for 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(findUniquePath).not.toHaveBeenCalled() + expect(postAttachment).toHaveBeenCalledTimes(2) + expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'photo.jpg' })) + expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'doc.pdf' })) + }) + + test('sanitizes slash in room name to space for conversation folder', async () => { + conversationGetter.mockReturnValue({ type: 2, displayName: 'Team/Chat' }) + + const file = { name: 'test.txt', type: 'text/plain', size: 10, lastModified: 0 } + uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] }) + + // '/' is replaced by ' ', trimmed → 'Team Chat' + const convFolderName = 'Team Chat-' + TOKEN + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } }) + + expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName) + }) + + test('hyphen in room name does not confuse token extraction in folder name', async () => { + conversationGetter.mockReturnValue({ type: 3, displayName: 'My-Group' }) + + const file = { name: 'test.txt', type: 'text/plain', size: 10, lastModified: 0 } + uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] }) + + // Hyphen in room name is preserved; the token is simply appended with '-' + const convFolderName = 'My-Group-' + TOKEN + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } }) + + expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName) + }) + + test('truncates long room name to 64 Unicode code points in folder name', async () => { + const longName = 'A'.repeat(70) + conversationGetter.mockReturnValue({ type: 2, displayName: longName }) + + const file = { name: 'test.txt', type: 'text/plain', size: 10, lastModified: 0 } + uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] }) + + const convFolderName = 'A'.repeat(64) + '-' + TOKEN + + await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } }) + + expect(client.createDirectory).toHaveBeenCalledWith('/files/current-user/Talk/' + convFolderName) + }) + + 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(client.createDirectory).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..17f9c7a9c31 100644 --- a/src/stores/upload.ts +++ b/src/stores/upload.ts @@ -18,10 +18,12 @@ 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 as postAttachmentApi, shareFile as shareFileApi, } from '../services/filesSharingServices.ts' import { isAxiosErrorResponse } from '../types/guards.ts' @@ -340,6 +342,63 @@ export const useUploadStore = defineStore('upload', () => { delete uploads[uploadId] } + /** + * Lazily create the per-conversation attachment subfolder hierarchy via + * WebDAV MKCOL and return the folder path (relative to the user's home root). + * + * Structure: /-/-/ + * + * The MKCOL of the innermost subfolder triggers a NodeCreatedEvent on the + * backend, which automatically creates a TYPE_ROOM share on that folder so + * all room members can access it. + * + * @param token The conversation token + * @returns The subfolder path (relative to user home root, no leading slash) + */ + async function ensureConversationFolder(token: string): Promise { + const conversation = vuexStore.getters.conversation(token) + const displayName: string = conversation?.displayName ?? '' + + // Sanitize display name: replace /, \, and ASCII control chars with space. + // eslint-disable-next-line no-control-regex + let cleanName = displayName.replace(/[/\\\x00-\x1f]/g, ' ').trim() + // Trim to 64 Unicode code points (matching Config::sanitizeDisplayName). + cleanName = [...cleanName].slice(0, 64).join('').trimEnd() + const convFolderName = cleanName + '-' + token + + // Compute subfolder name: "-" or just "". + const uid = actorStore.userId ?? '' + const prefixLen = Math.min(16, Math.max(0, 63 - uid.length)) + let subfolderName = uid + if (prefixLen > 0) { + // eslint-disable-next-line no-control-regex + let subPrefix = actorStore.displayName.replace(/[/\\\x00-\x1f]/g, ' ').trim() + subPrefix = [...subPrefix].slice(0, prefixLen).join('').trimEnd() + if (subPrefix) { + subfolderName = subPrefix + '-' + uid + } + } + + const baseFolder = settingsStore.attachmentFolder + const convPath = baseFolder + '/' + convFolderName + const subPath = convPath + '/' + subfolderName + + const client = getDavClient() + const userRoot = '/files/' + uid + + for (const segment of [baseFolder, convPath, subPath]) { + try { + await client.createDirectory(userRoot + segment) + } catch { + // Directory already exists — ignore. + } + } + + // Return the path relative to the user root (no leading slash), which + // is the format used by prepareUploadPaths. + return subPath.replace(/^\//, '') + } + /** * Uploads the files to the root directory of the user * @@ -372,23 +431,58 @@ export const useUploadStore = defineStore('upload', () => { EventBus.emit('scroll-chat-to-bottom', { smooth: true, force: true }) } - await prepareUploadPaths({ token, uploadId }) + // For group and public rooms, lazily create the per-conversation subfolder + // and use it as the upload target. The MKCOL triggers the backend to + // create a folder-level TYPE_ROOM share automatically. + const conversation = vuexStore.getters.conversation(token) + const useConversationFolder = conversation + && [CONVERSATION.TYPE.GROUP, CONVERSATION.TYPE.PUBLIC].includes(conversation.type) + && getTalkConfig(token, 'attachments', 'conversation-subfolders') === true + const conversationFolderPath = useConversationFolder + ? await ensureConversationFolder(token) + : null + + await prepareUploadPaths({ token, uploadId, conversationFolderPath }) await processUpload({ token, uploadId }) - await shareFiles({ token, uploadId, lastIndex, caption, options }) + await shareFiles({ token, uploadId, lastIndex, caption, options, conversationFolderPath }) EventBus.emit('upload-finished') } /** - * Prepare unique paths to upload for each file + * Prepare unique paths to upload for each file. + * + * For conversation-folder uploads the backend handles rename-on-conflict, + * so we skip the PROPFIND round-trip and assign a guaranteed-unique temp + * name (`uploadId-index-originalName`) instead. The original file name is + * passed separately to `postAttachment` so the backend can rename the temp + * file to the desired name (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 + * @param payload.conversationFolderPath Optional per-conversation subfolder path + * (relative to user root, no leading slash). When provided, files are + * uploaded there instead of directly into the attachment folder. */ - async function prepareUploadPaths({ token, uploadId }: { token: string, uploadId: string }) { + async function prepareUploadPaths({ token, uploadId, conversationFolderPath }: { token: string, uploadId: string, conversationFolderPath?: string | null }) { + if (conversationFolderPath) { + // 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: '/' + conversationFolderPath + '/' + tempName }) + } + return + } + + // Regular attachment-folder upload: use PROPFIND to find unique paths. const client = getDavClient() const userRoot = '/files/' + actorStore.userId @@ -398,7 +492,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 { @@ -497,8 +590,10 @@ export const useUploadStore = defineStore('upload', () => { * @param payload.lastIndex The index of last uploaded file * @param payload.caption The text caption to the media * @param payload.options The share options + * @param payload.conversationFolderPath Optional per-conversation subfolder path. + * When provided, files are posted via postAttachment (no per-file share). */ - async function shareFiles({ token, uploadId, lastIndex, caption, options }: UploadFilesPayload & { lastIndex: string }) { + async function shareFiles({ token, uploadId, lastIndex, caption, options, conversationFolderPath }: UploadFilesPayload & { lastIndex: string, conversationFolderPath?: string | null }) { const shares = getShareableFiles(uploadId) for await (const share of shares) { if (!share) { @@ -516,7 +611,60 @@ export const useUploadStore = defineStore('upload', () => { options?.parent ? { replyTo: options.parent.id } : {}, )) - await shareFile({ token, path: shareableFile.sharePath!, index, uploadId, id, referenceId, talkMetaData }) + if (conversationFolderPath) { + // File is in a conversation subfolder covered by a folder-level + // TYPE_ROOM share — post it as a chat message without a per-file share. + // Pass the original file name so the backend can rename the temp + // file and return the conflict-resolved final name in the response. + const fileName = shareableFile.file.newName || shareableFile.file.name + await postFile({ token, path: shareableFile.sharePath!, index, uploadId, id, referenceId, talkMetaData, fileName }) + } else { + await shareFile({ token, path: shareableFile.sharePath!, index, uploadId, id, referenceId, talkMetaData }) + } + } + } + + /** + * Posts a file from a conversation subfolder as a chat message via the + * dedicated Talk endpoint (no per-file TYPE_ROOM share is created). + * + * @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.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 used by the backend for rename-on-conflict + */ + async function postFile({ 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) { + markFileAsSharing({ uploadId, index }) + } + + // The path from prepareUploadPaths has a leading slash; strip it. + const filePath = path.replace(/^\//, '') + await postAttachmentApi({ token, filePath, fileName, referenceId, talkMetaData }) + + if (uploadId) { + markFileAsShared({ uploadId, index }) + } + } catch (error) { + console.error('Error while posting conversation folder file: ', error) + + if (isAxiosErrorResponse(error) && error.response?.status === 403) { + showError(t('spreed', 'You are not allowed to share files')) + } else if (isAxiosErrorResponse(error) && error.response?.data?.ocs?.meta?.message) { + showError(error.response.data.ocs.meta.message) + } else { + showError(t('spreed', 'Error while sharing file')) + } + + if (uploadId) { + vuexStore.dispatch('markTemporaryMessageAsFailed', { token, id, uploadId, reason: 'failed-share' }) + } } } @@ -609,10 +757,12 @@ export const useUploadStore = defineStore('upload', () => { initialiseUpload, discardUpload, uploadFiles, + ensureConversationFolder, prepareUploadPaths, processUpload, shareFiles, shareFile, + postFile, retryUploadFiles, } })