diff --git a/apps/files/src/actions/openLocallyAction.spec.ts b/apps/files/src/actions/openLocallyAction.spec.ts index 6202edda9fcbc..dd1e9680ae728 100644 --- a/apps/files/src/actions/openLocallyAction.spec.ts +++ b/apps/files/src/actions/openLocallyAction.spec.ts @@ -18,6 +18,7 @@ vi.mock('@nextcloud/dialogs', async (importOriginal) => ({ ...await importOriginal(), showError: vi.fn(), })) +vi.mock('@nextcloud/viewer', () => ({ getViewer: () => ({ open: vi.fn() }) })) const view = { id: 'files', @@ -155,8 +156,31 @@ describe('Open locally action enabled tests', () => { }) describe('Open locally action execute tests', () => { + const file = new File({ + id: 1, + source: 'http://nextcloud.local/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.UPDATE, + root: '/files/admin', + }) + + /** + * Answer the confirmation dialog with one of its buttons + * + * @param label - The label of the button to click + */ + function clickDialogButton(label: string) { + const setButtons = nextcloudDialogs.DialogBuilder.prototype.setButtons + vi.spyOn(nextcloudDialogs.DialogBuilder.prototype, 'setButtons').mockImplementation(function(this: nextcloudDialogs.DialogBuilder, buttons) { + buttons.find((button) => button.label === label)!.callback() + return setButtons.call(this, buttons) + }) + } + let spyShowDialog beforeEach(() => { + vi.restoreAllMocks() vi.resetAllMocks() spyShowDialog = vi.spyOn(nextcloudDialogs.Dialog.prototype, 'show') .mockImplementation(() => Promise.resolve()) @@ -189,12 +213,49 @@ describe('Open locally action execute tests', () => { // Silent action expect(exec).toBe(null) - expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledTimes(2) expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/openlocaleditor?format=json', { path: '/foobar.txt' }) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/opened', { fileId: 1 }) expect(showError).toBeCalledTimes(0) expect(windowOpenSpy).toBeCalledWith('nc://open/test@nextcloud.local/foobar.txt?token=foobar', '_self') }) + test('Open locally reports the file opened once, even after a retry', async () => { + vi.spyOn(axios, 'post').mockImplementation(async () => ({ + data: { ocs: { data: { token: 'foobar' } } }, + })) + vi.spyOn(window, 'open').mockImplementation(() => null) + clickDialogButton('Retry and close') + + await action.exec({ + nodes: [file], + view, + folder: {} as IFolder, + contents: [], + }) + + expect(axios.post).toBeCalledTimes(3) + expect(vi.mocked(axios.post).mock.calls.filter(([url]) => url.endsWith('/opened'))).toHaveLength(1) + }) + + test('Open online leaves reporting the file opened to the viewer', async () => { + vi.spyOn(axios, 'post').mockImplementation(async () => ({ + data: { ocs: { data: { token: 'foobar' } } }, + })) + vi.spyOn(window, 'open').mockImplementation(() => null) + clickDialogButton('Open online') + + await action.exec({ + nodes: [file], + view, + folder: {} as IFolder, + contents: [], + }) + + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).not.toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files/api/v1/opened', expect.anything()) + }) + test('Open locally fails and shows error', async () => { vi.spyOn(axios, 'post').mockImplementation(async () => ({})) const showError = vi.spyOn(nextcloudDialogs, 'showError') diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index d93f06ed4cf34..2f9ecfccf0cee 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -66,15 +66,20 @@ export const action: IFileAction = { * @param contents - The folder the file is in, so the viewer can navigate */ async function attemptOpenLocalClient(node: INode, contents: ActionContextSingle['contents']) { - await openLocalClient(node.path) + let redirected = await openLocalClient(node.path) const result = await confirmLocalEditDialog() if (result === 'local') { - await openLocalClient(node.path) + redirected = await openLocalClient(node.path) || redirected } else if (result === 'online') { // The listing carries the folders too, and the viewer pages through - // files alone + // files alone. The viewer reports the open itself. const files = contents.filter((entry): entry is IFile => entry.type === FileType.File) await getViewer().open(files, node as IFile) + return + } + + if (redirected) { + await reportOpened(node) } } @@ -83,8 +88,9 @@ async function attemptOpenLocalClient(node: INode, contents: ActionContextSingle * There is no way to get notified if this action was successful. * * @param path - Path to open + * @return Whether the client was asked to open it */ -async function openLocalClient(path: string): Promise { +async function openLocalClient(path: string): Promise { const link = generateOcsUrl('apps/files/api/v1') + '/openlocaleditor?format=json' try { @@ -94,9 +100,25 @@ async function openLocalClient(path: string): Promise { url += '?token=' + result.data.ocs.data.token window.open(url, '_self') + return true } catch (error) { showError(t('files', 'Failed to redirect to client')) logger.error('Failed to redirect to client', { error }) + return false + } +} + +/** + * Tell the server the user opened the file, so apps know what they work with. + * We cannot know whether the client did open it, only that it was asked to. + * + * @param node - The file opened + */ +async function reportOpened(node: INode): Promise { + try { + await axios.post(generateOcsUrl('apps/files/api/v1/opened'), { fileId: node.fileid }) + } catch (error) { + logger.error('Failed to report the file as opened', { error }) } }