From f352a0df4301ea5652cab57a7a1bc33f790a9abe Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Mon, 14 Sep 2026 10:02:48 +0200 Subject: [PATCH 1/2] feat(NcAvatar): add a version prop for longer avatar caching Signed-off-by: Peter Ringelmann --- src/components/NcAvatar/NcAvatar.vue | 26 +++++++++---- src/globals.d.ts | 1 + src/utils/getAvatarUrl.ts | 15 +++++++- .../unit/components/NcAvatar/NcAvatar.spec.ts | 38 ++++++++++++++++++- tests/unit/utils/getAvatarUrl.spec.ts | 5 +++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/components/NcAvatar/NcAvatar.vue b/src/components/NcAvatar/NcAvatar.vue index 010fe1fdeb..4758b9b31f 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: null, + }, + /** * 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 : null) + + 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..8bcade9e1a 100644 --- a/src/utils/getAvatarUrl.ts +++ b/src/utils/getAvatarUrl.ts @@ -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 | null } /** @@ -45,8 +50,16 @@ export function getAvatarUrl(user: string, options?: AvatarUrlOptions): string { ? '/dark' : '' - return generateUrl(`/avatar${guestUrl}/{user}/{size}${themeUrl}?guestFallback=true`, { + // Coalesced rather than checked for truthiness: 0 is a real version, held by + // everyone who never changed their avatar. + const version = options?.version ?? null + const versionUrl = version === null + ? '' + : '&v={version}' + + return generateUrl(`/avatar${guestUrl}/{user}/{size}${themeUrl}?guestFallback=true${versionUrl}`, { user, size, + version, }) } diff --git a/tests/unit/components/NcAvatar/NcAvatar.spec.ts b/tests/unit/components/NcAvatar/NcAvatar.spec.ts index 7320b0c451..ea580fabfd 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,34 @@ describe('NcAvatar.vue', () => { expect(wrapper.find('img').attributes('srcset')).toBeUndefined() }) }) + + describe('Cache-busting version', () => { + afterEach(() => { + delete window.oc_userconfig + }) + + 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..8ad88a4287 100644 --- a/tests/unit/utils/getAvatarUrl.spec.ts +++ b/tests/unit/utils/getAvatarUrl.spec.ts @@ -34,6 +34,11 @@ 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 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') From f08cc2a89c5042b39ecfae94103b72d2d94d8747 Mon Sep 17 00:00:00 2001 From: Peter Ringelmann Date: Thu, 17 Sep 2026 14:52:00 +0200 Subject: [PATCH 2/2] refactor(getAvatarUrl): build the URL via generateAvatarUrl Drops the duplicated URL construction now that @nextcloud/router 3.2.0 supports guestFallback and version. Signed-off-by: Peter Ringelmann --- package-lock.json | 8 ++--- package.json | 2 +- src/components/NcAvatar/NcAvatar.vue | 4 +-- src/utils/getAvatarUrl.ts | 34 +++++-------------- .../unit/components/NcAvatar/NcAvatar.spec.ts | 21 ++++++++++++ tests/unit/utils/getAvatarUrl.spec.ts | 5 +++ 6 files changed, 41 insertions(+), 33 deletions(-) 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 4758b9b31f..62ec1dc5f5 100644 --- a/src/components/NcAvatar/NcAvatar.vue +++ b/src/components/NcAvatar/NcAvatar.vue @@ -460,7 +460,7 @@ export default { */ version: { type: [String, Number], - default: null, + default: undefined, }, /** @@ -1003,7 +1003,7 @@ export default { // 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 : null) + ?? (user === getCurrentUser()?.uid ? window.oc_userconfig?.avatar?.version : undefined) return getAvatarUrl(user, { size, diff --git a/src/utils/getAvatarUrl.ts b/src/utils/getAvatarUrl.ts index 8bcade9e1a..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 { @@ -27,7 +27,7 @@ interface AvatarUrlOptions { /** * The user's avatar version. Including it earns a much longer cache lifetime. */ - version?: string | number | null + version?: string | number } /** @@ -37,29 +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' - : '' - - // Coalesced rather than checked for truthiness: 0 is a real version, held by - // everyone who never changed their avatar. - const version = options?.version ?? null - const versionUrl = version === null - ? '' - : '&v={version}' - - return generateUrl(`/avatar${guestUrl}/{user}/{size}${themeUrl}?guestFallback=true${versionUrl}`, { - user, - size, - version, + 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 ea580fabfd..b80d5f2519 100644 --- a/tests/unit/components/NcAvatar/NcAvatar.spec.ts +++ b/tests/unit/components/NcAvatar/NcAvatar.spec.ts @@ -263,6 +263,27 @@ describe('NcAvatar.vue', () => { 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 } } diff --git a/tests/unit/utils/getAvatarUrl.spec.ts b/tests/unit/utils/getAvatarUrl.spec.ts index 8ad88a4287..12badd4b5d 100644 --- a/tests/unit/utils/getAvatarUrl.spec.ts +++ b/tests/unit/utils/getAvatarUrl.spec.ts @@ -39,6 +39,11 @@ describe('getAvatarUrl', () => { 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')