From 74485fb5b5aea09b8270fbda28eb4651c8cd5913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:09:31 +0200 Subject: [PATCH 1/2] chore(deps): bump @nextcloud/viewer to 2.0.0-beta.19 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e690832d2830a..480f71899930d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.2.0", "@nextcloud/sharing": "^1.0.0-beta.4", - "@nextcloud/viewer": "^2.0.0-beta.18", + "@nextcloud/viewer": "^2.0.0-beta.19", "@nextcloud/vue": "^9.12.0", "@simplewebauthn/browser": "^14.0.0", "@vueuse/core": "^15.0.0", @@ -2195,12 +2195,13 @@ } }, "node_modules/@nextcloud/viewer": { - "version": "2.0.0-beta.18", - "resolved": "https://registry.npmjs.org/@nextcloud/viewer/-/viewer-2.0.0-beta.18.tgz", - "integrity": "sha512-keeVIGu1NgfhWwheA2EBvQqwU2ur61lOfg99GYjWNfJIgc+YKQKedh3kF6IK9DCyYRarwp4KD9wzmi6LEKlIpg==", + "version": "2.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@nextcloud/viewer/-/viewer-2.0.0-beta.19.tgz", + "integrity": "sha512-qP9Ca3VChDz/J2KWVTUig4kcd/iLOdO+VWOXLxNCW8xAWwTxhc7ZJINt2G0wRhCTGc1ux/523Y7PBYn2JOHCug==", "license": "AGPL-3.0-or-later", "dependencies": { "@mdi/svg": "^7.4.47", + "@nextcloud/auth": "^2.6.0", "@nextcloud/image-editor": "^1.0.0-beta.6", "@nextcloud/logger": "^3.0.3", "@nextcloud/vue": "^9.13.0", diff --git a/package.json b/package.json index 0c02e333f6ca8..7bce91c72bddf 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.2.0", "@nextcloud/sharing": "^1.0.0-beta.4", - "@nextcloud/viewer": "^2.0.0-beta.18", + "@nextcloud/viewer": "^2.0.0-beta.19", "@nextcloud/vue": "^9.12.0", "@simplewebauthn/browser": "^14.0.0", "@vueuse/core": "^15.0.0", From aa6adad57ef1a2369722b298d1ab064cb86c236b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:09:32 +0200 Subject: [PATCH 2/2] feat(files): show a linked file inline again in its reference widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget looked for the handlers of the viewer app, which are gone since the viewer moved into @nextcloud/viewer: every file link fell back to its static card. Put a there instead, which shows the file read-only with a menu to open it in the viewer or download it, and falls back to the card when it cannot. A click on the card opens the viewer through the library. The file is handed the permissions the reference carries, and what its share allows: no download offered where a share forbids it. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- .../src/views/ReferenceFileWidget.spec.ts | 137 ++++++++++++++++++ apps/files/src/views/ReferenceFileWidget.vue | 117 +++++++-------- 2 files changed, 191 insertions(+), 63 deletions(-) create mode 100644 apps/files/src/views/ReferenceFileWidget.spec.ts diff --git a/apps/files/src/views/ReferenceFileWidget.spec.ts b/apps/files/src/views/ReferenceFileWidget.spec.ts new file mode 100644 index 0000000000000..306f608d51c8f --- /dev/null +++ b/apps/files/src/views/ReferenceFileWidget.spec.ts @@ -0,0 +1,137 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFile } from '@nextcloud/files' + +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const viewer = vi.hoisted(() => ({ canView: vi.fn(), open: vi.fn() })) +vi.mock('@nextcloud/viewer', () => ({ + canView: viewer.canView, + getViewer: () => ({ open: viewer.open }), +})) +const user = vi.hoisted(() => ({ current: { uid: 'alice' } as { uid: string } | null })) +vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => user.current })) + +const { default: ReferenceFileWidget } = await import('./ReferenceFileWidget.vue') + +// What the viewer's element keeps of what it is given +class ViewerFileStub extends HTMLElement { + file?: IFile +} +if (!customElements.get('nc-viewer-file')) { + customElements.define('nc-viewer-file', ViewerFileStub) +} + +const richObject = { + id: 42, + name: 'beach.jpg', + size: 2048, + path: 'Photos/beach.jpg', + link: 'https://cloud.example.com/f/42', + mimetype: 'image/jpeg', + mtime: 1760000000, + 'preview-available': true, +} + +/** + * Show the widget for a file. + * + * @param overrides - What differs from the picture above + */ +function mountWidget(overrides: Record = {}) { + return mount(ReferenceFileWidget, { + props: { richObject: { ...richObject, ...overrides } }, + global: { mocks: { t: (_app: string, text: string) => text } }, + attachTo: document.body, + }) +} + +beforeEach(() => { + user.current = { uid: 'alice' } + viewer.canView.mockReturnValue(true) + window.OC = { ...window.OC, Util: { humanFileSize: () => '2 KB', relativeModifiedDate: () => 'a week ago' } } as typeof window.OC +}) + +afterEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' +}) + +describe('ReferenceFileWidget', () => { + it('shows the file with the viewer\'s own element', () => { + const wrapper = mountWidget() + + const element = wrapper.find('nc-viewer-file').element as ViewerFileStub + expect(element.file?.fileid).toBe(42) + expect(element.file?.displayname).toBe('beach.jpg') + expect(element.file?.source).toContain('/remote.php/dav/files/alice/Photos/beach.jpg') + }) + + it('hands the viewer the permissions the reference carries', () => { + const wrapper = mountWidget({ permissions: 27 }) + + expect((wrapper.find('nc-viewer-file').element as ViewerFileStub).file?.permissions).toBe(27) + }) + + // The viewer then offers no download, and asks for the preview by hand + it('hands the viewer what a share forbids', () => { + const wrapper = mountWidget({ 'hide-download': 'yes', 'can-download': 'no' }) + + const { attributes } = (wrapper.find('nc-viewer-file').element as ViewerFileStub).file! + expect(attributes['hide-download']).toBe(true) + expect(JSON.parse(attributes['share-attributes'])).toEqual([{ scope: 'permissions', key: 'download', value: false }]) + }) + + it('lets the viewer download a file of a share that allows it', () => { + const wrapper = mountWidget({ 'hide-download': 'no', 'can-download': 'yes' }) + + const { attributes } = (wrapper.find('nc-viewer-file').element as ViewerFileStub).file! + expect(attributes['hide-download']).toBe(false) + expect(attributes['share-attributes']).toBeUndefined() + }) + + it('falls back to its link when the viewer cannot show the file', async () => { + const wrapper = mountWidget() + + wrapper.find('nc-viewer-file').element.dispatchEvent(new CustomEvent('errored')) + await wrapper.vm.$nextTick() + + expect(wrapper.find('nc-viewer-file').exists()).toBe(false) + expect(wrapper.find('a.widget-file--link').exists()).toBe(true) + }) + + it('is a link to a file the viewer has nothing for', () => { + viewer.canView.mockReturnValue(false) + const wrapper = mountWidget({ name: 'report.zip', mimetype: 'application/zip' }) + + expect(wrapper.find('nc-viewer-file').exists()).toBe(false) + expect(wrapper.find('a.widget-file--link').exists()).toBe(true) + }) + + // A guest has no files of their own the path would be in + it('is a link for a guest', () => { + user.current = null + const wrapper = mountWidget() + + expect(wrapper.find('nc-viewer-file').exists()).toBe(false) + expect(viewer.canView).not.toHaveBeenCalled() + }) + + it('opens the file in the viewer from its link when it is not shown inline', async () => { + const wrapper = mount(ReferenceFileWidget, { + props: { richObject, interactive: false }, + global: { mocks: { t: (_app: string, text: string) => text } }, + }) + + await wrapper.find('a.widget-file--link').trigger('click') + + expect(viewer.open).toHaveBeenCalledOnce() + const [files, file] = viewer.open.mock.calls[0]! + expect(files).toHaveLength(1) + expect(file.fileid).toBe(42) + }) +}) diff --git a/apps/files/src/views/ReferenceFileWidget.vue b/apps/files/src/views/ReferenceFileWidget.vue index f8ce689b5d59f..d50b562858cd8 100644 --- a/apps/files/src/views/ReferenceFileWidget.vue +++ b/apps/files/src/views/ReferenceFileWidget.vue @@ -19,20 +19,17 @@ - + + @errored="failedViewer = true" /> import type { Node } from '@nextcloud/files' -import type { Component, PropType } from 'vue' +import type { PropType } from 'vue' import { getCurrentUser } from '@nextcloud/auth' import { getFilePickerBuilder } from '@nextcloud/dialogs' +import { File, Permission } from '@nextcloud/files' import { t } from '@nextcloud/l10n' import { generateRemoteUrl, generateUrl } from '@nextcloud/router' +import { canView, getViewer } from '@nextcloud/viewer' import path from 'path' import { defineComponent } from 'vue' import FileIcon from 'vue-material-design-icons/File.vue' @@ -80,31 +79,9 @@ type Ressource = { mimetype: string mtime: number // as unix timestamp 'preview-available': boolean -} - -type ViewerHandler = { - id: string - group: string - mimes: string[] - component: Component -} - -/** - * Minimal mock of the legacy Viewer FileInfo - * TODO: replace by Node object - */ -type ViewerFile = { - filename: string // the path to the root folder - basename: string // the file name - lastmod: Date // the last modification date - size: number // the file size in bytes - type: string - mime: string - fileid: number - failed: boolean - loaded: boolean - davPath: string - source: string + permissions?: number + 'hide-download'?: 'yes' | 'no' + 'can-download'?: 'yes' | 'no' } export default defineComponent({ @@ -133,37 +110,51 @@ export default defineComponent({ data() { return { + // The element @nextcloud/viewer defines to show a file anywhere + viewerFileTag: 'nc-viewer-file', previewUrl: null as string | null, failedViewer: false, } }, computed: { - availableViewerHandlers(): ViewerHandler[] { - return (window?.OCA?.Viewer?.availableHandlers || []) as ViewerHandler[] - }, - - viewerHandler(): ViewerHandler | undefined { - return this.availableViewerHandlers - .find((handler) => handler.mimes.includes(this.richObject.mimetype)) + /** + * The file, as the viewer takes it. The server resolved the reference + * for this user: the path is in their folder, a file shared with them + * included. None for a guest, who has no folder + */ + node(): File | null { + const uid = getCurrentUser()?.uid + if (!uid || this.isFolder) { + return null + } + return new File({ + id: this.richObject.id, + source: generateRemoteUrl(`dav/files/${uid}/${this.richObject.path}`).replace(/\/\/$/, '/'), + root: `/files/${uid}`, + displayname: this.richObject.name, + mime: this.richObject.mimetype, + size: this.richObject.size, + mtime: new Date(this.richObject.mtime * 1000), + // Not known here, and not needed to show it + owner: null, + // Readable at least, or this widget would say it is not accessible + permissions: this.richObject.permissions ?? Permission.READ, + attributes: { + hasPreview: this.richObject['preview-available'], + // What a share allows, which the viewer reads as DAV gives it: + // no download offered, and the preview asked for by hand + 'hide-download': this.richObject['hide-download'] === 'yes', + ...(this.richObject['can-download'] === 'no' + ? { 'share-attributes': JSON.stringify([{ scope: 'permissions', key: 'download', value: false }]) } + : {}), + }, + }) }, - viewerFile(): ViewerFile { - const davSource = generateRemoteUrl(`dav/files/${getCurrentUser()?.uid}/${this.richObject.path}`) - .replace(/\/\/$/, '/') - return { - filename: this.richObject.path, - basename: this.richObject.name, - lastmod: new Date(this.richObject.mtime * 1000), - size: this.richObject.size, - type: 'file', - mime: this.richObject.mimetype, - fileid: this.richObject.id, - failed: false, - loaded: true, - davPath: davSource, - source: davSource, - } + /** Whether the viewer has something to show the file with */ + viewable(): boolean { + return this.node !== null && canView(this.node) }, fileSize() { @@ -221,10 +212,10 @@ export default defineComponent({ event.stopPropagation() event.preventDefault() this.openFilePicker() - } else if (window?.OCA?.Viewer?.mimetypes.indexOf(this.richObject.mimetype) !== -1 && !window?.OCA?.Viewer?.file) { + } else if (this.viewable) { event.stopPropagation() event.preventDefault() - window?.OCA?.Viewer?.open({ path: this.richObject.path }) + getViewer().open([this.node!], this.node!) } },