diff --git a/__tests__/opened.spec.ts b/__tests__/opened.spec.ts new file mode 100644 index 0000000..bdbb364 --- /dev/null +++ b/__tests__/opened.spec.ts @@ -0,0 +1,87 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const post = vi.fn() +const isPublicShare = vi.fn(() => false) +const getCurrentUser = vi.fn<() => { uid: string } | null>(() => ({ uid: 'alice' })) +const logger = { debug: vi.fn(), warn: vi.fn() } + +vi.mock('@nextcloud/axios', () => ({ default: { post } })) +vi.mock('@nextcloud/router', () => ({ generateOcsUrl: (url: string) => `/ocs/v2.php/${url}` })) +vi.mock('@nextcloud/sharing/public', () => ({ isPublicShare: () => isPublicShare() })) +vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => getCurrentUser() })) +vi.mock('../lib/services/logger.ts', () => ({ logger })) + +const { reportOpened } = await import('../lib/services/opened.ts') + +const file = new File({ + source: 'https://cloud.example.com/remote.php/dav/files/alice/a.jpg', + root: '/files/alice', + owner: 'alice', + mime: 'image/jpeg', + id: 42, +}) + +describe('reportOpened', () => { + beforeEach(() => { + post.mockReset().mockResolvedValue({}) + isPublicShare.mockReturnValue(false) + getCurrentUser.mockReturnValue({ uid: 'alice' }) + logger.debug.mockReset() + logger.warn.mockReset() + }) + + it('reports the file by its id', async () => { + await reportOpened(file) + + expect(post).toHaveBeenCalledWith('/ocs/v2.php/apps/files/api/v1/opened', { fileId: 42 }) + }) + + it('reports nothing for a guest', async () => { + getCurrentUser.mockReturnValue(null) + + await reportOpened(file) + expect(post).not.toHaveBeenCalled() + }) + + it('reports nothing on a public share page', async () => { + isPublicShare.mockReturnValue(true) + + await reportOpened(file) + expect(post).not.toHaveBeenCalled() + }) + + // Talk hands a logged-in user files it took from a share + it('reports nothing for a file reached through a public share', async () => { + const shared = new File({ + source: 'https://cloud.example.com/public.php/dav/files/abc/a.jpg', + root: '/files/abc', + owner: null, + mime: 'image/jpeg', + id: 42, + }) + + await reportOpened(shared) + expect(post).not.toHaveBeenCalled() + }) + + // A server before 36, or a file the user cannot see: nothing to warn about + it('only logs for debugging when the server answers 404', async () => { + post.mockRejectedValue(Object.assign(new Error('Not found'), { response: { status: 404 } })) + + await expect(reportOpened(file)).resolves.toBeUndefined() + expect(logger.debug).toHaveBeenCalledOnce() + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('warns, without failing, when the request fails otherwise', async () => { + post.mockRejectedValue(new Error('offline')) + + await expect(reportOpened(file)).resolves.toBeUndefined() + expect(logger.warn).toHaveBeenCalledOnce() + }) +}) diff --git a/lib/services/opened.ts b/lib/services/opened.ts new file mode 100644 index 0000000..72dcaa0 --- /dev/null +++ b/lib/services/opened.ts @@ -0,0 +1,39 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFile } from '@nextcloud/files' + +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { isPublicShare } from '@nextcloud/sharing/public' +import { publicShareOf } from '../utils/publicShare.ts' +import { logger } from './logger.ts' + +/** + * Tell the server the user opened a file, so apps like recommendations + * know what they work with. Reading a file says nothing about that: sync + * clients and previews read files all the time. + * + * Only a user's own files: a guest or a file reached through a public + * share has no file id the user could open. Opening is never held up by + * it, and a server before 36 has no such endpoint. + * + * @param file - The file the viewer was asked to open + */ +export async function reportOpened(file: IFile): Promise { + if (getCurrentUser() === null || isPublicShare() || publicShareOf(file) || file.fileid === undefined) { + return + } + + try { + await axios.post(generateOcsUrl('apps/files/api/v1/opened'), { fileId: file.fileid }) + } catch (error) { + if ((error as { response?: { status?: number } }).response?.status === 404) { + logger.debug('The server does not know this file or how to report opening it', { file, error }) + return + } + logger.warn('Could not report the file as opened', { file, error }) + } +} diff --git a/lib/views/Viewer.vue b/lib/views/Viewer.vue index 256f5c3..aab0bb3 100644 --- a/lib/views/Viewer.vue +++ b/lib/views/Viewer.vue @@ -366,6 +366,7 @@ import { getHandlers, isHandlerEnabled } from '../handlers.ts' import { getHandlerForFile } from '../helpers/handlerHelper.ts' import { fetchFolderContent } from '../services/dav.ts' import { logger } from '../services/logger.ts' +import { reportOpened } from '../services/opened.ts' import { dispatchViewerEvent, ViewerSession } from '../session.ts' import { prepareDownload } from '../utils/beforeDownload.ts' import { canDownload } from '../utils/canDownload.ts' @@ -1356,6 +1357,8 @@ async function showFiles(files: IFile[], file?: IFile, options?: ViewerOptions, if (!isSameFile) { loading.value = true pendingLoads.value = 1 + // Only what was asked for: stepping through the others is browsing + reportOpened(file) } // A failure to open something else earlier is not this file's problem errorString.value = null