Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions __tests__/component/ViewerSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
29 changes: 29 additions & 0 deletions __tests__/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 25 additions & 3 deletions __tests__/viewerSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number | boolean> | 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 }))
Expand All @@ -16,15 +16,15 @@ 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<string, number | boolean> | null) {
stored.value = settings
vi.resetModules()
const { useViewerSettings } = await import('../lib/composables/useViewerSettings.ts')
return useViewerSettings()
}

/** 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()
Expand Down Expand Up @@ -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)
})
})
14 changes: 13 additions & 1 deletion lib/components/ViewerSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
@update:modelValue="onDelaySelected" />
</NcAppSettingsSection>

<NcAppSettingsSection v-if="offersOriginalImages" id="viewer-settings-images" :name="t('Images')">
<NcFormBox>
<NcFormBoxSwitch
:label="t('Show original images instead of previews')"
:description="t('Sharper, but slower to load and uses more data.')"
:modelValue="originalImages"
@update:modelValue="setOriginalImages" />
</NcFormBox>
</NcAppSettingsSection>

<NcAppSettingsShortcutsSection>
<NcHotkeyList :label="t('Navigation')">
<!-- Previous and next follow the reading direction -->
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion lib/composables/useViewerSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ 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 */
export const DEFAULT_CONFIG: Readonly<StoredConfig> = {
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<StoredConfig | null>('viewer', 'config', null)
const stored = loadState<Partial<StoredConfig> | null>('viewer', 'config', null)
const config = reactive<StoredConfig>({ ...DEFAULT_CONFIG, ...stored })

/**
Expand All @@ -54,6 +57,9 @@ async function setConfig<K extends keyof StoredConfig>(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.
*/
Expand All @@ -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),
}
}
33 changes: 20 additions & 13 deletions lib/utils/previewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}

/**
Expand Down
Loading