Skip to content
Open
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
82 changes: 81 additions & 1 deletion src/services/__tests__/filesSharingServices.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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' }],
})
})
})
77 changes: 77 additions & 0 deletions src/services/filesSharingServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>[]
}

/**
* Appends a file as a message to the messages list
*
Expand Down Expand Up @@ -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<ProbeAttachmentFolderData> {
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<Record<string, string>[]> {
const response = await axios.post<{ ocs: { data: { renames: Record<string, string>[] } } }>(
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,
}
123 changes: 121 additions & 2 deletions src/stores/__tests__/upload.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
}))
Expand All @@ -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
Expand Down Expand Up @@ -63,13 +76,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 +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 = [
{
Expand Down
Loading
Loading