Skip to content

Commit 204c095

Browse files
committed
feat(attachments): implement the new upload endpoint
Signed-off-by: Dorra Jaouad <dorra.jaoued7@gmail.com>
1 parent 7fb9f2a commit 204c095

7 files changed

Lines changed: 401 additions & 17 deletions

File tree

src/__mocks__/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ export const mockedCapabilities: Capabilities = {
212212
attachments: [
213213
'allowed',
214214
'folder',
215+
'conversation-subfolders',
215216
],
216217
call: [
217218
'predefined-backgrounds',

src/components/MessagesList/MessagesGroup/Message/MessagePart/MessageBody.vue

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ export default {
464464
},
465465
466466
sendingErrorCanRetry() {
467-
return ['timeout', 'other', 'failed-upload'].includes(this.message.sendingFailure)
467+
return ['timeout', 'other', 'failed-upload', 'failed-share'].includes(this.message.sendingFailure)
468468
},
469469
470470
sendingErrorIconTitle() {
@@ -477,9 +477,6 @@ export default {
477477
if (this.message.sendingFailure === 'quota') {
478478
return t('spreed', 'Not enough free space to upload file')
479479
}
480-
if (this.message.sendingFailure === 'failed-share') {
481-
return t('spreed', 'You are not allowed to share files')
482-
}
483480
return t('spreed', 'You cannot send messages to this conversation at the moment')
484481
},
485482
@@ -530,6 +527,11 @@ export default {
530527
uploadId: this.$store.getters.message(this.message.token, this.message.id)?.uploadId,
531528
caption: this.renderedMessage !== this.message.message ? this.message.message : undefined,
532529
})
530+
} else if (this.message.sendingFailure === 'failed-share') {
531+
this.uploadStore.retryShareFiles({
532+
token: this.message.token,
533+
uploadId: this.$store.getters.message(this.message.token, this.message.id)?.uploadId,
534+
})
533535
} else {
534536
EventBus.emit('retry-message', this.message.id)
535537
EventBus.emit('focus-chat-input')

src/services/__tests__/filesSharingServices.spec.js

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import axios from '@nextcloud/axios'
77
import { generateOcsUrl } from '@nextcloud/router'
88
import { afterEach, describe, expect, test, vi } from 'vitest'
9-
import { shareFile } from '../filesSharingServices.ts'
9+
import { postAttachment, probeAttachmentFolder, shareFile } from '../filesSharingServices.ts'
1010

1111
vi.mock('@nextcloud/axios', () => ({
1212
default: {
@@ -37,4 +37,84 @@ describe('filesSharingServices', () => {
3737
},
3838
)
3939
})
40+
41+
test('postAttachment calls the Talk chat attachment API endpoint', async () => {
42+
axios.post.mockResolvedValue({ data: { ocs: { data: { renames: [{ 'test.txt': 'test.txt' }] } } } })
43+
44+
const renames = await postAttachment({
45+
token: 'XXTOKENXX',
46+
filePath: 'Talk/My Room-XXTOKENXX/Current User-current-user/upload-id1-0-test.txt',
47+
fileName: 'test.txt',
48+
referenceId: 'the-reference-id',
49+
talkMetaData: '{"caption":"hello"}',
50+
})
51+
52+
expect(axios.post).toHaveBeenCalledWith(
53+
generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment', { token: 'XXTOKENXX' }),
54+
{
55+
filePath: 'Talk/My Room-XXTOKENXX/Current User-current-user/upload-id1-0-test.txt',
56+
fileName: 'test.txt',
57+
referenceId: 'the-reference-id',
58+
talkMetaData: '{"caption":"hello"}',
59+
},
60+
)
61+
expect(renames).toEqual([{ 'test.txt': 'test.txt' }])
62+
})
63+
64+
test('postAttachment returns conflict-resolved renames when backend renames the file', async () => {
65+
axios.post.mockResolvedValue({
66+
data: { ocs: { data: { renames: [{ 'photo.jpg': 'photo (1).jpg' }] } } },
67+
})
68+
69+
const renames = await postAttachment({
70+
token: 'XXTOKENXX',
71+
filePath: 'Talk/Room-XXTOKENXX/Alice-alice/upload-id1-0-photo.jpg',
72+
fileName: 'photo.jpg',
73+
referenceId: 'ref-1',
74+
talkMetaData: '{}',
75+
})
76+
77+
expect(renames).toEqual([{ 'photo.jpg': 'photo (1).jpg' }])
78+
})
79+
80+
test('postAttachment returns empty array when response has no renames field', async () => {
81+
axios.post.mockResolvedValue({})
82+
83+
const renames = await postAttachment({
84+
token: 'XXTOKENXX',
85+
filePath: 'Talk/Room-XXTOKENXX/Alice-alice/upload-id1-0-doc.pdf',
86+
fileName: 'doc.pdf',
87+
referenceId: 'ref-2',
88+
talkMetaData: '{}',
89+
})
90+
91+
expect(renames).toEqual([])
92+
})
93+
94+
test('probeAttachmentFolder calls the Talk attachment-folder probe endpoint', async () => {
95+
axios.post.mockResolvedValue({
96+
data: {
97+
ocs: {
98+
data: {
99+
folder: 'Talk/My Room-XXTOKENXX/Draft',
100+
renames: [{ 'photo.jpg': 'photo.jpg' }, { 'photo.jpg': 'photo (1).jpg' }],
101+
},
102+
},
103+
},
104+
})
105+
106+
const probe = await probeAttachmentFolder({
107+
token: 'XXTOKENXX',
108+
fileNames: ['photo.jpg', 'photo.jpg'],
109+
})
110+
111+
expect(axios.post).toHaveBeenCalledWith(
112+
generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment/folder', { token: 'XXTOKENXX' }),
113+
{ fileNames: ['photo.jpg', 'photo.jpg'] },
114+
)
115+
expect(probe).toEqual({
116+
folder: 'Talk/My Room-XXTOKENXX/Draft',
117+
renames: [{ 'photo.jpg': 'photo.jpg' }, { 'photo.jpg': 'photo (1).jpg' }],
118+
})
119+
})
40120
})

src/services/filesSharingServices.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ import axios from '@nextcloud/axios'
1414
import { generateOcsUrl } from '@nextcloud/router'
1515
import { SHARE } from '../constants.ts'
1616

17+
type PostAttachmentParams = {
18+
token: string
19+
filePath: string
20+
fileName: string
21+
referenceId: string
22+
talkMetaData: string
23+
}
24+
25+
type ProbeAttachmentFolderParams = {
26+
token: string
27+
fileNames: string[]
28+
}
29+
30+
type ProbeAttachmentFolderData = {
31+
folder: string
32+
renames: Record<string, string>[]
33+
}
34+
1735
/**
1836
* Appends a file as a message to the messages list
1937
*
@@ -56,8 +74,67 @@ async function createNewFile({ filePath, templatePath, templateType }: createFil
5674
} as createFileFromTemplateParams)
5775
}
5876

77+
/**
78+
* Probe the conversation attachment folder for the given conversation.
79+
*
80+
* Creates the caller's conversation subfolder hierarchy (and the folder-level
81+
* TYPE_ROOM share that grants all room members access) server-side if not yet
82+
* present, and returns the path of the Draft staging folder where files must
83+
* be uploaded before being posted via {@link postAttachment}.
84+
*
85+
* @param payload The function payload
86+
* @param payload.token The conversation token
87+
* @param payload.fileNames Desired file names — used only for server-side
88+
* rename-on-conflict probing; the authoritative final names are
89+
* returned by {@link postAttachment}.
90+
* @return Draft folder path (relative to user home root, no leading slash)
91+
* and a rename simulation for the requested file names.
92+
*/
93+
async function probeAttachmentFolder({ token, fileNames }: ProbeAttachmentFolderParams): Promise<ProbeAttachmentFolderData> {
94+
const response = await axios.post<{ ocs: { data: ProbeAttachmentFolderData } }>(
95+
generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment/folder', { token }),
96+
{ fileNames },
97+
)
98+
return response.data.ocs.data
99+
}
100+
101+
/**
102+
* Post a file staged in the conversation Draft folder as a chat message.
103+
*
104+
* Unlike {@link shareFile}, this does not create a per-file TYPE_ROOM share —
105+
* access is controlled by the folder-level share created by
106+
* {@link probeAttachmentFolder}. The backend moves the file from Draft into
107+
* the shared conversation subfolder, resolving name conflicts by appending
108+
* " (1)", " (2)", … to the desired file name.
109+
*
110+
* @param payload The function payload
111+
* @param payload.token The conversation token
112+
* @param payload.filePath Draft file path relative to the user's home root
113+
* (must be inside the Draft folder returned by probeAttachmentFolder)
114+
* @param payload.fileName Desired final file name (for rename-on-conflict)
115+
* @param payload.referenceId Client reference ID for the chat message
116+
* @param payload.talkMetaData JSON-encoded metadata (caption, messageType, silent, …)
117+
* @return An array of `{ originalName: finalName }` entries — one per posted
118+
* file. When the backend had to rename due to a conflict the two
119+
* names differ; otherwise they are identical.
120+
*/
121+
async function postAttachment({ token, filePath, fileName, referenceId, talkMetaData }: PostAttachmentParams): Promise<Record<string, string>[]> {
122+
const response = await axios.post<{ ocs: { data: { renames: Record<string, string>[] } } }>(
123+
generateOcsUrl('apps/spreed/api/v1/chat/{token}/attachment', { token }),
124+
{
125+
filePath,
126+
fileName,
127+
referenceId,
128+
talkMetaData,
129+
},
130+
)
131+
return response.data?.ocs?.data?.renames ?? []
132+
}
133+
59134
export {
60135
createNewFile,
61136
getFileTemplates,
137+
postAttachment,
138+
probeAttachmentFolder,
62139
shareFile,
63140
}

src/stores/__tests__/upload.spec.js

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,21 @@ import { showError } from '@nextcloud/dialogs'
77
import { getUploader } from '@nextcloud/upload'
88
import { createPinia, setActivePinia } from 'pinia'
99
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
10+
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
1011
import { getDavClient } from '../../services/DavClient.ts'
11-
import { shareFile } from '../../services/filesSharingServices.ts'
12+
import { postAttachment, probeAttachmentFolder, shareFile } from '../../services/filesSharingServices.ts'
1213
import { findUniquePath } from '../../utils/fileUpload.ts'
1314
import { useActorStore } from '../actor.ts'
1415
import { useSettingsStore } from '../settings.ts'
1516
import { useUploadStore } from '../upload.ts'
1617

18+
// conversationGetter must be defined before vi.mock so the factory can close over it.
19+
// Vitest evaluates the factory lazily (after module-level init), so this works.
20+
const conversationGetter = vi.fn().mockReturnValue(null)
1721
const vuexStoreDispatch = vi.fn()
1822
vi.mock('vuex', () => ({
1923
useStore: vi.fn(() => ({
20-
getters: {},
24+
getters: { conversation: conversationGetter },
2125
dispatch: vuexStoreDispatch,
2226
})),
2327
}))
@@ -33,8 +37,17 @@ vi.mock('../../utils/fileUpload.ts', async () => {
3337
}
3438
})
3539
vi.mock('../../services/filesSharingServices.ts', () => ({
40+
postAttachment: vi.fn(),
41+
probeAttachmentFolder: vi.fn(),
3642
shareFile: vi.fn(),
3743
}))
44+
vi.mock('../../services/CapabilitiesManager.ts', async (importOriginal) => {
45+
const actual = await importOriginal()
46+
return {
47+
...actual,
48+
getTalkConfig: vi.fn().mockReturnValue(true),
49+
}
50+
})
3851

3952
describe('fileUploadStore', () => {
4053
let actorStore
@@ -63,13 +76,16 @@ describe('fileUploadStore', () => {
6376
describe('uploading', () => {
6477
const uploadMock = vi.fn()
6578
const client = {
79+
createDirectory: vi.fn().mockResolvedValue(undefined),
6680
exists: vi.fn(),
6781
}
6882

6983
beforeEach(() => {
7084
getDavClient.mockReturnValue(client)
7185
getUploader.mockReturnValue({ upload: uploadMock })
7286
console.error = vi.fn()
87+
// Default: ONE_TO_ONE room — no conversation folder used
88+
conversationGetter.mockReturnValue({ type: 1, displayName: 'Direct message' })
7389
})
7490

7591
afterEach(() => {
@@ -369,6 +385,109 @@ describe('fileUploadStore', () => {
369385
expect(uploadStore.currentUploadId).not.toBeDefined()
370386
})
371387

388+
describe('conversation folder (group/public rooms)', () => {
389+
const TOKEN = 'XXTOKENXX'
390+
const DRAFT_PATH = 'Talk/My Room-XXTOKENXX/Draft'
391+
392+
beforeEach(() => {
393+
uploadMock.mockResolvedValue()
394+
postAttachment.mockResolvedValue()
395+
probeAttachmentFolder.mockResolvedValue({ folder: DRAFT_PATH, renames: [] })
396+
})
397+
398+
test('probes the attachment folder and posts via postAttachment for a group room', async () => {
399+
conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' })
400+
401+
const file = {
402+
name: 'pngimage.png',
403+
type: 'image/png',
404+
size: 123,
405+
lastModified: Date.UTC(2021, 3, 27, 15, 30, 0),
406+
}
407+
408+
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })
409+
410+
await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: { silent: false } })
411+
412+
// Probe endpoint is called with the desired file names;
413+
// folder creation and the TYPE_ROOM share happen server-side.
414+
expect(probeAttachmentFolder).toHaveBeenCalledTimes(1)
415+
expect(probeAttachmentFolder).toHaveBeenCalledWith({
416+
token: TOKEN,
417+
fileNames: [file.name],
418+
})
419+
420+
// No client-side MKCOL and no PROPFIND round-trip.
421+
expect(client.createDirectory).not.toHaveBeenCalled()
422+
expect(findUniquePath).not.toHaveBeenCalled()
423+
424+
// File is uploaded under a temp name inside the Draft folder.
425+
expect(uploadMock).toHaveBeenCalledTimes(1)
426+
const uploadedPath = uploadMock.mock.calls[0][0]
427+
expect(uploadedPath).toMatch(new RegExp('^/' + DRAFT_PATH + '/upload-id1-.*-' + file.name + '$'))
428+
429+
// File is posted via the Talk attachment endpoint with the
430+
// original name for rename-on-conflict on the backend.
431+
expect(postAttachment).toHaveBeenCalledTimes(1)
432+
expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({
433+
token: TOKEN,
434+
fileName: file.name,
435+
filePath: expect.stringMatching(new RegExp('^' + DRAFT_PATH + '/upload-id1-.*-' + file.name + '$')),
436+
}))
437+
expect(shareFile).not.toHaveBeenCalled()
438+
})
439+
440+
test('probes with all file names and posts each file in a multi-file upload', async () => {
441+
conversationGetter.mockReturnValue({ type: 2, displayName: 'Room' })
442+
443+
const file1 = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 }
444+
const file2 = { name: 'doc.pdf', type: 'application/pdf', size: 200, lastModified: 0 }
445+
446+
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file1, file2] })
447+
448+
await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null })
449+
450+
expect(probeAttachmentFolder).toHaveBeenCalledWith({
451+
token: TOKEN,
452+
fileNames: ['photo.jpg', 'doc.pdf'],
453+
})
454+
expect(findUniquePath).not.toHaveBeenCalled()
455+
expect(postAttachment).toHaveBeenCalledTimes(2)
456+
expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'photo.jpg' }))
457+
expect(postAttachment).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'doc.pdf' }))
458+
})
459+
460+
test('falls back to shareFile when the probe endpoint fails', async () => {
461+
conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' })
462+
probeAttachmentFolder.mockRejectedValueOnce(new Error('boom'))
463+
findUniquePath.mockResolvedValueOnce({ path: '/Talk/photo.jpg', name: 'photo.jpg' })
464+
465+
const file = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 }
466+
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })
467+
468+
await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null })
469+
470+
expect(probeAttachmentFolder).toHaveBeenCalledTimes(1)
471+
expect(postAttachment).not.toHaveBeenCalled()
472+
expect(shareFile).toHaveBeenCalledTimes(1)
473+
})
474+
475+
test('falls back to shareFile when conversation-subfolders capability is false', async () => {
476+
getTalkConfig.mockReturnValueOnce(false)
477+
conversationGetter.mockReturnValue({ type: 2, displayName: 'My Room' })
478+
findUniquePath.mockResolvedValueOnce({ path: '/Talk/photo.jpg', name: 'photo.jpg' })
479+
480+
const file = { name: 'photo.jpg', type: 'image/jpeg', size: 100, lastModified: 0 }
481+
uploadStore.initialiseUpload({ uploadId: 'upload-id1', token: TOKEN, files: [file] })
482+
483+
await uploadStore.uploadFiles({ token: TOKEN, uploadId: 'upload-id1', options: null })
484+
485+
expect(probeAttachmentFolder).not.toHaveBeenCalled()
486+
expect(postAttachment).not.toHaveBeenCalled()
487+
expect(shareFile).toHaveBeenCalledTimes(1)
488+
})
489+
})
490+
372491
test('autorenames files using timestamps when requested', () => {
373492
const files = [
374493
{

0 commit comments

Comments
 (0)