diff --git a/__tests__/component/ViewerSettings.spec.ts b/__tests__/component/ViewerSettings.spec.ts index 882f23b..b4f51d8 100644 --- a/__tests__/component/ViewerSettings.spec.ts +++ b/__tests__/component/ViewerSettings.spec.ts @@ -66,3 +66,18 @@ describe('the slideshow delay dropdown', () => { expect(select.props('modelValue')).toEqual({ id: 30, label: '30 seconds' }) }) }) + +describe('the original images switch', () => { + afterEach(async () => { + await useViewerSettings().setOriginalImages(false) + }) + + it('saves the choice', async () => { + const toggle = mountSettings().findComponent({ name: 'NcFormBoxSwitch' }) + + toggle.vm.$emit('update:modelValue', true) + await toggle.vm.$nextTick() + + expect(useViewerSettings().originalImages.value).toBe(true) + }) +}) diff --git a/__tests__/utils.spec.ts b/__tests__/utils.spec.ts index 9cb3cb1..b9c4fcc 100644 --- a/__tests__/utils.spec.ts +++ b/__tests__/utils.spec.ts @@ -8,6 +8,7 @@ import { getCurrentUser } from '@nextcloud/auth' import { File } from '@nextcloud/files' import { isPublicShare } from '@nextcloud/sharing/public' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useViewerSettings } from '../lib/composables/useViewerSettings.ts' import { findLivePhotoPeerFromFileId, findLivePhotoPeerFromName, @@ -247,6 +248,34 @@ describe('previewUtils.getPreviewIfAny', () => { }) }) + describe('for a user who chose original images', () => { + beforeEach(async () => { + await useViewerSettings().setOriginalImages(true) + }) + + afterEach(async () => { + await useViewerSettings().setOriginalImages(false) + }) + + it('shows the file itself', () => { + const file = makeFileWithAttributes({ hasPreview: true }) + expect(getPreviewIfAny(file)).toBe(file.encodedSource) + }) + + it('keeps the preview of a file the browser cannot show', () => { + const file = makeFile({ mime: 'image/heic', attributes: { hasPreview: true } }) + expect(getPreviewIfAny(file)).toContain('/core/preview?') + }) + + it('keeps the preview of a share that forbids downloading', () => { + const file = makeFileWithAttributes({ + hasPreview: true, + 'share-attributes': JSON.stringify([{ scope: 'permissions', key: 'download', value: false }]), + }) + expect(getPreviewIfAny(file)).toContain('/core/preview?') + }) + }) + // The server caps it at the original and at its own preview limit, so // this asks for whatever most it will give, whatever the screen is it('asks for the largest preview past the size of the display', () => { diff --git a/__tests__/viewerSettings.spec.ts b/__tests__/viewerSettings.spec.ts index 02b6acc..de3c8f1 100644 --- a/__tests__/viewerSettings.spec.ts +++ b/__tests__/viewerSettings.spec.ts @@ -4,7 +4,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const stored = vi.hoisted(() => ({ value: null as { slideshow_delay: number, volume: number, muted: boolean } | null })) +const stored = vi.hoisted(() => ({ value: null as Record | null })) const put = vi.hoisted(() => vi.fn()) const showError = vi.hoisted(() => vi.fn()) vi.mock('@nextcloud/initial-state', () => ({ loadState: (app: string, key: string, fallback: unknown) => (app === 'viewer' && key === 'config' ? stored.value : null) ?? fallback })) @@ -16,7 +16,7 @@ vi.mock('@nextcloud/dialogs', () => ({ showError })) * * @param settings - What the server handed the page, nothing for a guest */ -async function settingsWith(settings: { slideshow_delay: number, volume: number, muted: boolean } | null) { +async function settingsWith(settings: Record | null) { stored.value = settings vi.resetModules() const { useViewerSettings } = await import('../lib/composables/useViewerSettings.ts') @@ -24,7 +24,7 @@ async function settingsWith(settings: { slideshow_delay: number, volume: number, } /** What the server hands a user who never changed anything */ -const STORED = { slideshow_delay: 5, volume: 100, muted: false } +const STORED = { slideshow_delay: 5, volume: 100, muted: false, original_images: false } beforeEach(() => { put.mockReset() @@ -93,3 +93,25 @@ describe('the volume', () => { expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/muted'), { value: true }) }) }) + +describe('original images', () => { + it('are off unless the user chose them', async () => { + expect((await settingsWith(null)).originalImages.value).toBe(false) + expect((await settingsWith({ ...STORED, original_images: true })).originalImages.value).toBe(true) + }) + + it('are kept on the server', async () => { + const settings = await settingsWith(STORED) + + await settings.setOriginalImages(true) + + expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/original_images'), { value: true }) + }) + + // It would refuse to save them + it('are not offered by a server that does not know them', async () => { + expect((await settingsWith({ slideshow_delay: 5, volume: 100, muted: false })).offersOriginalImages).toBe(false) + expect((await settingsWith(STORED)).offersOriginalImages).toBe(true) + expect((await settingsWith(null)).offersOriginalImages).toBe(true) + }) +}) diff --git a/lib/components/ViewerSettings.vue b/lib/components/ViewerSettings.vue index 5818e6f..b916f65 100644 --- a/lib/components/ViewerSettings.vue +++ b/lib/components/ViewerSettings.vue @@ -20,6 +20,16 @@ @update:modelValue="onDelaySelected" /> + + + + + + @@ -47,6 +57,8 @@ import { computed } from 'vue' import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection' import NcAppSettingsShortcutsSection from '@nextcloud/vue/components/NcAppSettingsShortcutsSection' +import NcFormBox from '@nextcloud/vue/components/NcFormBox' +import NcFormBoxSwitch from '@nextcloud/vue/components/NcFormBoxSwitch' import NcHotkey from '@nextcloud/vue/components/NcHotkey' import NcHotkeyList from '@nextcloud/vue/components/NcHotkeyList' import NcSelect from '@nextcloud/vue/components/NcSelect' @@ -72,7 +84,7 @@ interface DelayOption { } const rtl = isRTL() -const { slideshowDelay, setSlideshowDelay } = useViewerSettings() +const { slideshowDelay, setSlideshowDelay, originalImages, offersOriginalImages, setOriginalImages } = useViewerSettings() // The current delay is listed too when it is none of the offered ones: // the server accepts any from 1 to 60 seconds diff --git a/lib/composables/useViewerSettings.ts b/lib/composables/useViewerSettings.ts index a67f11f..afccdab 100644 --- a/lib/composables/useViewerSettings.ts +++ b/lib/composables/useViewerSettings.ts @@ -18,6 +18,8 @@ interface StoredConfig { volume: number /** Whether videos and audio start muted */ muted: boolean + /** Whether images are shown from the file itself rather than a preview */ + original_images: boolean } /** The settings of a user who never changed them, as the server has them */ @@ -25,11 +27,12 @@ export const DEFAULT_CONFIG: Readonly = { slideshow_delay: 5, volume: 100, muted: false, + original_images: false, } // No settings for a guest, or from a server that keeps none: they are then // only kept until the page is left -const stored = loadState('viewer', 'config', null) +const stored = loadState | null>('viewer', 'config', null) const config = reactive({ ...DEFAULT_CONFIG, ...stored }) /** @@ -54,6 +57,9 @@ async function setConfig(key: K, value: StoredConf } } +// A server older than the setting would refuse to save it +const offersOriginalImages = stored === null || 'original_images' in stored + /** * The user's viewer settings, the same for every viewer on the page. */ @@ -62,8 +68,11 @@ export function useViewerSettings() { slideshowDelay: toRef(readonly(config), 'slideshow_delay'), volume: toRef(readonly(config), 'volume'), muted: toRef(readonly(config), 'muted'), + originalImages: toRef(readonly(config), 'original_images'), + offersOriginalImages, setSlideshowDelay: (seconds: number) => setConfig('slideshow_delay', seconds), setVolume: (percent: number) => setConfig('volume', percent), setMuted: (muted: boolean) => setConfig('muted', muted), + setOriginalImages: (original: boolean) => setConfig('original_images', original), } } diff --git a/lib/utils/previewUtils.ts b/lib/utils/previewUtils.ts index 2d82354..c124f6a 100644 --- a/lib/utils/previewUtils.ts +++ b/lib/utils/previewUtils.ts @@ -10,6 +10,7 @@ import { getCapabilities } from '@nextcloud/capabilities' import { encodePath } from '@nextcloud/paths' import { generateUrl } from '@nextcloud/router' import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public' +import { useViewerSettings } from '../composables/useViewerSettings.ts' import { browserSupportedMimes } from '../models/images.ts' import { canDownload } from './canDownload.ts' import { publicShareOf } from './publicShare.ts' @@ -90,35 +91,41 @@ export function getPreviewIfAny(file: IFile, available?: AvailableSpace): string const { x, y } = previewSize(available) // Encoded: this is handed to a media element as its `src`, and a name // holding a `#` or a `?` would otherwise cut the URL short. - if (previewTooSmall(file, x, y)) { + if (showsOriginal(file, x, y)) { return file.encodedSource } return serverPreview(file, x, y) ?? file.encodedSource } /** - * Whether the server would render the file smaller than the space it fills, - * when the browser could show the file itself instead. + * Whether to show the file itself rather than its preview, when the browser + * can show it and the share lets it be downloaded. * - * An admin can cap previews well below the screen (`preview_max_x`, - * `preview_max_y`), and the server then answers that small preview however - * large it is asked for, so the picture shows blurred. Its capabilities say - * where the cap is, without fetching a preview to find out. Older servers - * do not say, and keep their previews. + * Either the user chose originals in the viewer settings, or the server + * would render the file smaller than the space it fills: an admin can cap + * previews well below the screen (`preview_max_x`, `preview_max_y`), and the + * server then answers that small preview however large it is asked for, so + * the picture shows blurred. Its capabilities say where the cap is, without + * fetching a preview to find out. Older servers do not say. * * @param file - The file to show * @param x - The pixels wide it fills * @param y - The pixels high it fills */ -function previewTooSmall(file: IFile, x: number, y: number): boolean { +function showsOriginal(file: IFile, x: number, y: number): boolean { + if (!browserSupportedMimes.includes(file.mime ?? '') + // A share that forbids downloading serves the preview, never the file + || !canDownload(file)) { + return false + } + if (useViewerSettings().originalImages.value) { + return true + } const limit = (getCapabilities() as PreviewCapabilities).core?.previews if (!limit?.max_width || !limit.max_height) { return false } - return (x > limit.max_width || y > limit.max_height) - && browserSupportedMimes.includes(file.mime ?? '') - // A share that forbids downloading serves the preview, never the file - && canDownload(file) + return x > limit.max_width || y > limit.max_height } /**