diff --git a/package-lock.json b/package-lock.json index dc676162b7..42b9e35133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", - "@nextcloud/router": "^3.1.0", + "@nextcloud/router": "^3.2.0", "@nextcloud/sharing": "^0.4.0", "@nextcloud/vue-select": "^4.1.0", "@vuepic/vue-datepicker": "^11.0.3", @@ -3909,9 +3909,9 @@ } }, "node_modules/@nextcloud/router": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.1.0.tgz", - "integrity": "sha512-e4dkIaxRSwdZJlZFpn9x03QgBn/Sa2hN1hp/BA7+AbzykmSAlKuWfdmX8j/8ewrLpQwYmZR23IZO9XwpJXq2Uw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@nextcloud/router/-/router-3.2.0.tgz", + "integrity": "sha512-an4gJ7mlDZGtYq8zTxp4Sg+iLlKKGvQ9ZH3Dp6ga/IlDNAP7VeMVGphsY798ORjncAp9+sEf370L5zpsnuxgng==", "license": "GPL-3.0-or-later", "dependencies": { "@nextcloud/typings": "^1.10.0" diff --git a/package.json b/package.json index abe1f9c641..31537945ba 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.3", - "@nextcloud/router": "^3.1.0", + "@nextcloud/router": "^3.2.0", "@nextcloud/sharing": "^0.4.0", "@nextcloud/vue-select": "^4.1.0", "@vuepic/vue-datepicker": "^11.0.3", diff --git a/src/components/NcAvatar/NcAvatar.vue b/src/components/NcAvatar/NcAvatar.vue index 010fe1fdeb..62ec1dc5f5 100644 --- a/src/components/NcAvatar/NcAvatar.vue +++ b/src/components/NcAvatar/NcAvatar.vue @@ -454,6 +454,15 @@ export default { default: false, }, + /** + * The user's avatar version, as returned alongside user lists. + * Passing it earns the avatar a much longer cache lifetime. + */ + version: { + type: [String, Number], + default: undefined, + }, + /** * Set a display name that will be rendered as a tooltip * either the url, user or displayName property must be defined @@ -845,6 +854,8 @@ export default { this.loadAvatarUrl() }, + version: 'loadAvatarUrl', + preloadedUserStatus(status) { if (status) { this.setUserStatus(status) @@ -989,18 +1000,17 @@ export default { * @return {string} */ avatarUrlGenerator(user, size) { - let avatarUrl = getAvatarUrl(user, { + // Only the current user's version is on the page. Anyone else's has to + // come from whoever fetched the user list. + const version = this.version + ?? (user === getCurrentUser()?.uid ? window.oc_userconfig?.avatar?.version : undefined) + + return getAvatarUrl(user, { size, isDarkTheme: this.isDarkTheme, isGuest: this.isGuest, + version, }) - - // eslint-disable-next-line camelcase - if (user === getCurrentUser()?.uid && typeof oc_userconfig !== 'undefined') { - avatarUrl += '?v=' + window.oc_userconfig.avatar.version - } - - return avatarUrl }, /** diff --git a/src/globals.d.ts b/src/globals.d.ts index f9fc0f3f4e..1cceec46f6 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -21,6 +21,7 @@ declare global { } // eslint-disable-next-line @typescript-eslint/no-explicit-any OCP: any + oc_userconfig?: { avatar?: { version?: number } } _nc_vue_element_id?: number _nc_contacts_menu_hooks: { [id: string]: ContactsMenuAction } } diff --git a/src/utils/getAvatarUrl.ts b/src/utils/getAvatarUrl.ts index 83ee8ee865..7b8fbe70aa 100644 --- a/src/utils/getAvatarUrl.ts +++ b/src/utils/getAvatarUrl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { generateUrl } from '@nextcloud/router' +import { generateAvatarUrl } from '@nextcloud/router' import { checkIfDarkTheme } from '../functions/isDarkTheme/index.ts' interface AvatarUrlOptions { @@ -23,6 +23,11 @@ interface AvatarUrlOptions { * @default 64 */ size?: 64 | 512 + + /** + * The user's avatar version. Including it earns a much longer cache lifetime. + */ + version?: string | number } /** @@ -32,21 +37,11 @@ interface AvatarUrlOptions { * @param options - Adjustments for the avatar format */ export function getAvatarUrl(user: string, options?: AvatarUrlOptions): string { - // backend only supports 64 and 512px - // so we only request the needed size for better caching of the request. - const size = (options?.size || 64) <= 64 - ? 64 - : 512 - - const guestUrl = options?.isGuest - ? '/guest' - : '' - const themeUrl = options?.isDarkTheme ?? checkIfDarkTheme(document.body) - ? '/dark' - : '' - - return generateUrl(`/avatar${guestUrl}/{user}/{size}${themeUrl}?guestFallback=true`, { - user, - size, + return generateAvatarUrl(user, { + size: options?.size, + isGuestUser: options?.isGuest, + isDarkTheme: options?.isDarkTheme ?? checkIfDarkTheme(document.body), + guestFallback: true, + version: options?.version, }) } diff --git a/tests/unit/components/NcAvatar/NcAvatar.spec.ts b/tests/unit/components/NcAvatar/NcAvatar.spec.ts index 7320b0c451..b80d5f2519 100644 --- a/tests/unit/components/NcAvatar/NcAvatar.spec.ts +++ b/tests/unit/components/NcAvatar/NcAvatar.spec.ts @@ -3,11 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type * as NextcloudAuth from '@nextcloud/auth' + import { mount, shallowMount } from '@vue/test-utils' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { h, nextTick } from 'vue' import NcAvatar from '../../../../src/components/NcAvatar/NcAvatar.vue' +vi.mock('@nextcloud/auth', async (importOriginal) => ({ + ...await importOriginal(), + getCurrentUser: () => ({ uid: 'me', displayName: 'Me', isAdmin: false }), +})) + describe('NcAvatar.vue', () => { it('aria label is set to include status if status is shown visually', async () => { const status = { @@ -250,5 +257,55 @@ describe('NcAvatar.vue', () => { expect(wrapper.find('img').attributes('srcset')).toBeUndefined() }) }) + + describe('Cache-busting version', () => { + afterEach(() => { + delete window.oc_userconfig + }) + + it('versions another user from the passed prop', async () => { + const wrapper = mount(NcAvatar, { + props: { displayName: 'Alice', user: 'alice', version: 7 }, + }) + await nextTick() + + expect(wrapper.find('img').attributes('src')) + .toBe('//index.php/avatar/alice/64?guestFallback=true&v=7') + }) + + it('leaves another user unversioned when the page config has one', async () => { + window.oc_userconfig = { avatar: { version: 3 } } + + const wrapper = mount(NcAvatar, { + props: { displayName: 'Alice', user: 'alice' }, + }) + await nextTick() + + expect(wrapper.find('img').attributes('src')).not.toContain('v=') + }) + + it('versions the current user from the page config', async () => { + window.oc_userconfig = { avatar: { version: 3 } } + + const wrapper = mount(NcAvatar, { + props: { displayName: 'Me', user: 'me' }, + }) + await nextTick() + + expect(wrapper.find('img').attributes('src')) + .toBe('//index.php/avatar/me/64?guestFallback=true&v=3') + }) + + it('prefers an explicitly passed version over the page config', async () => { + window.oc_userconfig = { avatar: { version: 3 } } + + const wrapper = mount(NcAvatar, { + props: { displayName: 'Me', user: 'me', version: 9 }, + }) + await nextTick() + + expect(wrapper.find('img').attributes('src')).toContain('v=9') + }) + }) }) }) diff --git a/tests/unit/utils/getAvatarUrl.spec.ts b/tests/unit/utils/getAvatarUrl.spec.ts index 028492225c..12badd4b5d 100644 --- a/tests/unit/utils/getAvatarUrl.spec.ts +++ b/tests/unit/utils/getAvatarUrl.spec.ts @@ -34,6 +34,16 @@ describe('getAvatarUrl', () => { expect(getAvatarUrl('john', { size: 512 })).toBe('//index.php/avatar/john/512/dark?guestFallback=true') }) + it('should append the version when one is given', () => { + expect(getAvatarUrl('alice', { version: 7 })).toBe('//index.php/avatar/alice/64?guestFallback=true&v=7') + expect(getAvatarUrl('alice', { version: 0 })).toBe('//index.php/avatar/alice/64?guestFallback=true&v=0') + }) + + it('should omit the version when there is none', () => { + expect(getAvatarUrl('alice')).toBe('//index.php/avatar/alice/64?guestFallback=true') + expect(getAvatarUrl('alice', { version: undefined })).toBe('//index.php/avatar/alice/64?guestFallback=true') + }) + it('should return correct relative URL for user avatar in dark mode if enforced', () => { expect(getAvatarUrl('alice', { isDarkTheme: true })).toBe('//index.php/avatar/alice/64/dark?guestFallback=true') expect(getAvatarUrl('john', { isDarkTheme: true, size: 512 })).toBe('//index.php/avatar/john/512/dark?guestFallback=true')