Skip to content

Commit b053d17

Browse files
committed
fix(utils): ensure only 64px or 512px avatars are loaded
The backend only supports those values, so loading any other size will result in one of them but will be cached under a different URL by the browser. This should reduce the avatar queries of the browser to only max. 2 per user. Signed-off-by: Ferdinand Thiessen <[email protected]>
1 parent 30a542d commit b053d17

File tree

6 files changed

+143
-47
lines changed

6 files changed

+143
-47
lines changed

src/components/NcAvatar/NcAvatar.vue

+33-16
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,24 @@ export default {
234234
</template>
235235

236236
<script>
237+
import { getCurrentUser } from '@nextcloud/auth'
238+
import { getBuilder } from '@nextcloud/browser-storage'
239+
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
240+
import { generateUrl } from '@nextcloud/router'
241+
import { vOnClickOutside as ClickOutside } from '@vueuse/components'
242+
import { useTemplateRef } from 'vue'
243+
244+
import { getRoute } from '../../components/NcRichText/autolink.ts'
245+
import { useIsDarkThemeElement } from '../../composables/index.ts'
246+
import { userStatus } from '../../mixins/index.js'
247+
import { getAvatarUrl } from '../../utils/getAvatarUrl.ts'
248+
import { getUserStatusText } from '../../utils/UserStatus.ts'
249+
import { t } from '../../l10n.js'
250+
251+
import axios from '@nextcloud/axios'
252+
import usernameToColor from '../../functions/usernameToColor/index.js'
253+
254+
import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
237255
import NcActions from '../NcActions/index.js'
238256
import NcActionLink from '../NcActionLink/index.js'
239257
import NcActionRouter from '../NcActionRouter/index.js'
@@ -242,21 +260,6 @@ import NcButton from '../NcButton/index.ts'
242260
import NcIconSvgWrapper from '../NcIconSvgWrapper/index.js'
243261
import NcLoadingIcon from '../NcLoadingIcon/index.js'
244262
import NcUserStatusIcon from '../NcUserStatusIcon/index.js'
245-
import usernameToColor from '../../functions/usernameToColor/index.js'
246-
import { getAvatarUrl } from '../../utils/getAvatarUrl.ts'
247-
import { getUserStatusText } from '../../utils/UserStatus.ts'
248-
import { userStatus } from '../../mixins/index.js'
249-
import { t } from '../../l10n.js'
250-
import { getRoute } from '../../components/NcRichText/autolink.ts'
251-
252-
import axios from '@nextcloud/axios'
253-
import DotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
254-
255-
import { getCurrentUser } from '@nextcloud/auth'
256-
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
257-
import { getBuilder } from '@nextcloud/browser-storage'
258-
import { generateUrl } from '@nextcloud/router'
259-
import { vOnClickOutside as ClickOutside } from '@vueuse/components'
260263

261264
const browserStorage = getBuilder('nextcloud').persist().build()
262265

@@ -417,6 +420,16 @@ export default {
417420
default: 'body',
418421
},
419422
},
423+
424+
setup() {
425+
const root = useTemplateRef('main')
426+
const isDarkTheme = useIsDarkThemeElement(root)
427+
428+
return {
429+
isDarkTheme,
430+
}
431+
},
432+
420433
data() {
421434
return {
422435
avatarUrlLoaded: null,
@@ -717,7 +730,11 @@ export default {
717730
* @return {string}
718731
*/
719732
avatarUrlGenerator(user, size) {
720-
let avatarUrl = getAvatarUrl(user, size, this.isGuest)
733+
let avatarUrl = getAvatarUrl(user, {
734+
size,
735+
isDarkTheme: this.isDarkTheme,
736+
isGuest: this.isGuest,
737+
})
721738

722739
// eslint-disable-next-line camelcase
723740
if (user === getCurrentUser()?.uid && typeof oc_userconfig !== 'undefined') {

src/components/NcRichContenteditable/NcAutoCompleteResult.vue

+13-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
-->
55

66
<template>
7-
<div class="autocomplete-result">
7+
<div ref="root" class="autocomplete-result">
88
<!-- Avatar or icon -->
99
<div :class="[icon, `autocomplete-result__icon--${avatarUrl ? 'with-avatar' : ''}`]"
1010
:style="avatarUrl ? { backgroundImage: `url(${avatarUrl})` } : null "
@@ -31,6 +31,8 @@
3131
</template>
3232

3333
<script>
34+
import { useTemplateRef } from 'vue'
35+
import { useIsDarkThemeElement } from '../../composables/useIsDarkTheme/index.ts'
3436
import { getAvatarUrl } from '../../utils/getAvatarUrl.ts'
3537

3638
import NcUserStatusIcon from '../NcUserStatusIcon/index.js'
@@ -81,25 +83,30 @@ export default {
8183
default: () => ({}),
8284
},
8385
},
86+
87+
setup() {
88+
const root = useTemplateRef('root')
89+
const isDarkTheme = useIsDarkThemeElement(root)
90+
return {
91+
isDarkTheme,
92+
}
93+
},
94+
8495
computed: {
8596
avatarUrl() {
8697
if (this.iconUrl) {
8798
return this.iconUrl
8899
}
89100

90101
return this.id && this.source === 'users'
91-
? this.getAvatarUrl(this.id, 44)
102+
? getAvatarUrl(this.id, { isDarkTheme: this.isDarkTheme })
92103
: null
93104
},
94105
// For backwards compatibility
95106
labelWithFallback() {
96107
return this.label || this.title
97108
},
98109
},
99-
100-
methods: {
101-
getAvatarUrl,
102-
},
103110
}
104111
</script>
105112

src/components/NcRichContenteditable/NcMentionBubble.vue

+15-7
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
-->
55

66
<template>
7-
<span :class="{'mention-bubble--primary': primary}"
7+
<span ref="root"
8+
:class="{'mention-bubble--primary': primary}"
89
class="mention-bubble"
910
contenteditable="false">
1011
<span class="mention-bubble__wrapper">
@@ -25,7 +26,8 @@
2526
</template>
2627

2728
<script>
28-
import { getAvatarUrl } from '../../utils/getAvatarUrl.ts'
29+
import { useTemplateRef } from 'vue'
30+
import { useIsDarkThemeElement } from '../../composables/index.ts'
2931

3032
export default {
3133
name: 'NcMentionBubble',
@@ -65,14 +67,24 @@ export default {
6567
default: false,
6668
},
6769
},
70+
71+
setup() {
72+
const root = useTemplateRef('root')
73+
const isDarkTheme = useIsDarkThemeElement(root)
74+
75+
return {
76+
isDarkTheme,
77+
}
78+
},
79+
6880
computed: {
6981
avatarUrl() {
7082
if (this.iconUrl) {
7183
return this.iconUrl
7284
}
7385

7486
return this.id && this.source === 'users'
75-
? this.getAvatarUrl(this.id, 44)
87+
? this.getAvatarUrl(this.id, { isDarkTheme: this.isDarkTheme })
7688
: null
7789
},
7890
mentionText() {
@@ -85,10 +97,6 @@ export default {
8597
return this.label || this.title
8698
},
8799
},
88-
89-
methods: {
90-
getAvatarUrl,
91-
},
92100
}
93101
</script>
94102

src/composables/useIsDarkTheme/index.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import type { DeepReadonly, Ref } from 'vue'
7-
import { ref, readonly, watch } from 'vue'
6+
import type { DeepReadonly, MaybeRef, Ref } from 'vue'
7+
import { ref, readonly, watch, toValue, computed } from 'vue'
88
import { createSharedComposable, usePreferredDark, useMutationObserver } from '@vueuse/core'
99
import { checkIfDarkTheme } from '../../functions/isDarkTheme/index.ts'
1010

@@ -15,19 +15,22 @@ import { checkIfDarkTheme } from '../../functions/isDarkTheme/index.ts'
1515
* @param el - The element to check for the dark theme enabled on (default is `document.body`)
1616
* @return {DeepReadonly<Ref<boolean>>} - computed boolean whether the dark theme is enabled
1717
*/
18-
export function useIsDarkThemeElement(el: HTMLElement = document.body): DeepReadonly<Ref<boolean>> {
19-
const isDarkTheme = ref(checkIfDarkTheme(el))
18+
export function useIsDarkThemeElement(el: MaybeRef<HTMLElement> = document.body): DeepReadonly<Ref<boolean>> {
19+
const element = computed(() => toValue(el))
20+
const isDarkTheme = ref(checkIfDarkTheme(toValue(el)))
2021
const isDarkSystemTheme = usePreferredDark()
2122

2223
/** Update the isDarkTheme */
2324
function updateIsDarkTheme() {
24-
isDarkTheme.value = checkIfDarkTheme(el)
25+
isDarkTheme.value = checkIfDarkTheme(element.value)
2526
}
2627

2728
// Watch for element change to handle data-theme* attributes change
28-
useMutationObserver(el, updateIsDarkTheme, { attributes: true })
29+
useMutationObserver(element, updateIsDarkTheme, { attributes: true })
2930
// Watch for system theme change for the default theme
3031
watch(isDarkSystemTheme, updateIsDarkTheme, { immediate: true })
32+
// Watch for element changes
33+
watch(element, updateIsDarkTheme)
3134

3235
return readonly(isDarkTheme)
3336
}

src/utils/getAvatarUrl.ts

+39-4
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,47 @@
44
*/
55

66
import { generateUrl } from '@nextcloud/router'
7+
import { checkIfDarkTheme } from '../functions/isDarkTheme/index.ts'
78

8-
export const getAvatarUrl = (user: string, size: number | string, isGuest?: boolean): string => {
9-
const darkTheme = window.getComputedStyle(document.body)
10-
.getPropertyValue('--background-invert-if-dark') === 'invert(100%)'
9+
interface AvatarUrlOptions {
10+
/**
11+
* Should the dark theme variant be used.
12+
*/
13+
isDarkTheme?: boolean
1114

12-
return generateUrl('/avatar' + (isGuest ? '/guest' : '') + '/{user}/{size}' + (darkTheme ? '/dark' : ''), {
15+
/**
16+
* Is the user a guest user.
17+
*/
18+
isGuest?: boolean
19+
20+
/**
21+
* Size of the avatar.
22+
* @default 64
23+
*/
24+
size?: 64 | 512
25+
}
26+
27+
/**
28+
* Get the avatar URL for a given user.
29+
*
30+
* @param user - The user id
31+
* @param options - Adjustments for the avatar format
32+
*/
33+
export function getAvatarUrl(user: string, options?: AvatarUrlOptions): string {
34+
// backend only supports 64 and 512px
35+
// so we only requrest the needed size for better caching of the request.
36+
const size = (options?.size || 64) <= 64
37+
? 64
38+
: 512
39+
40+
const guestUrl = options?.isGuest
41+
? '/guest'
42+
: ''
43+
const themeUrl = options?.isDarkTheme ?? checkIfDarkTheme(document.body)
44+
? '/dark'
45+
: ''
46+
47+
return generateUrl(`/avatar${guestUrl}/{user}/{size}${themeUrl}`, {
1348
user,
1449
size,
1550
})

tests/unit/utils/getAvatarUrl.spec.ts

+34-8
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,52 @@ describe('getAvatarUrl', () => {
1111
})
1212

1313
it('should return correct relative URL for user avatar', () => {
14-
expect(getAvatarUrl('john', 44)).toBe('//index.php/avatar/john/44')
15-
expect(getAvatarUrl('alice', '64', false)).toBe('//index.php/avatar/alice/64')
14+
expect(getAvatarUrl('alice')).toBe('//index.php/avatar/alice/64')
15+
expect(getAvatarUrl('john', { size: 44 })).toBe('//index.php/avatar/john/64')
16+
})
17+
18+
it('should return correct relative URL with fixed sizes', () => {
19+
expect(getAvatarUrl('alice', { size: 0 })).toBe('//index.php/avatar/alice/64')
20+
expect(getAvatarUrl('alice', { size: -1 })).toBe('//index.php/avatar/alice/64')
21+
expect(getAvatarUrl('john', { size: 64 })).toBe('//index.php/avatar/john/64')
22+
expect(getAvatarUrl('john', { size: 65 })).toBe('//index.php/avatar/john/512')
23+
})
24+
25+
it('should return correct relative URL for user avatar in dark mode', () => {
26+
document.body.style.setProperty('--background-invert-if-dark', 'invert(100%)')
27+
28+
expect(getAvatarUrl('alice')).toBe('//index.php/avatar/alice/64/dark')
29+
expect(getAvatarUrl('john', { size: 44 })).toBe('//index.php/avatar/john/64/dark')
30+
})
31+
32+
it('should return correct relative URL for user avatar in dark mode if enforced', () => {
33+
expect(getAvatarUrl('alice', { isDarkTheme: true })).toBe('//index.php/avatar/alice/64/dark')
34+
expect(getAvatarUrl('john', { isDarkTheme: true, size: 128 })).toBe('//index.php/avatar/john/512/dark')
35+
})
36+
37+
it('should return correct relative URL for user avatar in bright mode if enforced but body is darkmode', () => {
38+
document.body.style.setProperty('--background-invert-if-dark', 'invert(100%)')
39+
40+
expect(getAvatarUrl('alice', { isDarkTheme: false })).toBe('//index.php/avatar/alice/64')
41+
expect(getAvatarUrl('john', { isDarkTheme: false, size: 128 })).toBe('//index.php/avatar/john/512')
1642
})
1743

1844
it('should return correct relative URL for user avatar in dark mode', () => {
1945
document.body.style.setProperty('--background-invert-if-dark', 'invert(100%)')
2046

21-
expect(getAvatarUrl('john', 44)).toBe('//index.php/avatar/john/44/dark')
22-
expect(getAvatarUrl('alice', '64', false)).toBe('//index.php/avatar/alice/64/dark')
47+
expect(getAvatarUrl('alice')).toBe('//index.php/avatar/alice/64/dark')
48+
expect(getAvatarUrl('john', { size: 44 })).toBe('//index.php/avatar/john/64/dark')
2349
})
2450

2551
it('should return correct relative URL for guest avatar', () => {
26-
expect(getAvatarUrl('john', 44, true)).toBe('//index.php/avatar/guest/john/44')
27-
expect(getAvatarUrl('alice', '64', true)).toBe('//index.php/avatar/guest/alice/64')
52+
expect(getAvatarUrl('alice', { isGuest: true })).toBe('//index.php/avatar/guest/alice/64')
53+
expect(getAvatarUrl('john', { size: 44, isGuest: true })).toBe('//index.php/avatar/guest/john/64')
2854
})
2955

3056
it('should return correct relative URL for guest avatar in dark mode', () => {
3157
document.body.style.setProperty('--background-invert-if-dark', 'invert(100%)')
3258

33-
expect(getAvatarUrl('john', 44, true)).toBe('//index.php/avatar/guest/john/44/dark')
34-
expect(getAvatarUrl('alice', '64', true)).toBe('//index.php/avatar/guest/alice/64/dark')
59+
expect(getAvatarUrl('alice', { isGuest: true })).toBe('//index.php/avatar/guest/alice/64/dark')
60+
expect(getAvatarUrl('john', { size: 44, isGuest: true })).toBe('//index.php/avatar/guest/john/64/dark')
3561
})
3662
})

0 commit comments

Comments
 (0)