Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/__mocks__/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export const mockedCapabilities: Capabilities = {
attachments: [
'allowed',
'folder',
'conversation-subfolders',
],
call: [
'predefined-backgrounds',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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')
},

Expand Down Expand Up @@ -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')
Expand Down
40 changes: 0 additions & 40 deletions src/services/__tests__/filesSharingServices.spec.js

This file was deleted.

55 changes: 55 additions & 0 deletions src/services/filesSharingServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type {
createFileShareParams,
createFileShareResponse,
getFileTemplatesListResponse,
PostAttachmentFolderParams,
PostAttachmentFolderResponse,
ProbeAttachmentFolderParams,
ProbeAttachmentFolderResponse,
} from '../types/index.ts'

/**
Expand Down Expand Up @@ -56,8 +60,59 @@ 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 }: { token: string } & ProbeAttachmentFolderParams): ProbeAttachmentFolderResponse {
return axios.post(generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment/folder', { token }), { fileNames })
}

/**
* 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 }: { token: string } & PostAttachmentFolderParams): PostAttachmentFolderResponse {
return axios.post(generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment', { token }), {
filePath,
fileName,
referenceId,
talkMetaData,
})
}

export {
createNewFile,
getFileTemplates,
postAttachment,
probeAttachmentFolder,
shareFile,
}
170 changes: 168 additions & 2 deletions src/stores/__tests__/upload.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@ 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 { generateOCSResponse } from '../../test-helpers.js'
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,
})),
}))
Expand All @@ -33,8 +38,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
Expand Down Expand Up @@ -63,13 +77,16 @@ describe('fileUploadStore', () => {
describe('uploading', () => {
const uploadMock = vi.fn()
const client = {
createDirectory: vi.fn().mockResolvedValue(undefined),
exists: vi.fn(),
}

beforeEach(() => {
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(() => {
Expand Down Expand Up @@ -369,6 +386,155 @@ 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(generateOCSResponse({ payload: { 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('updates temporary message file names when probe predicts renames', async () => {
conversationGetter.mockReturnValue({ type: 2, displayName: 'Room' })
probeAttachmentFolder.mockResolvedValue(generateOCSResponse({ payload: {
folder: DRAFT_PATH,
renames: [
{ 'photo.jpg': 'photo (1).jpg' },
{ 'doc.pdf': 'doc.pdf' },
],
}}))

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 })

// Only the renamed file should trigger a dispatch
const updateCalls = vuexStoreDispatch.mock.calls
.filter(([action]) => action === 'addTemporaryMessage')

const expectedFileNameMatch = (name) => expect.objectContaining({
messageParameters: expect.objectContaining({
file: expect.objectContaining({
name,
}),
}),
})

expect(updateCalls).toHaveLength(3)
// Initial name assignment
expect(updateCalls[0][1]).toMatchObject({
token: TOKEN,
message: expectedFileNameMatch('photo.jpg'),
})
expect(updateCalls[1][1]).toMatchObject({
token: TOKEN,
message: expectedFileNameMatch('doc.pdf'),
})
// Rename from probeAttachment
expect(updateCalls[2][1]).toMatchObject({
token: TOKEN,
message: expectedFileNameMatch('photo (1).jpg'),
})
})

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 = [
{
Expand Down
Loading
Loading