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
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,23 @@ const authorName = computed(() => {
return p ? getDisplayName(p.author) : ''
})

const avatarUrl = ref('')
const avatarUrl = ref<string>()

// Bumped on every parent change so stale avatar fetches (older parent)
// don't overwrite a newer one if the user navigates between comments
// while fetches are still in flight.
let avatarFetchToken = 0

watch(parent, (p) => {
avatarUrl.value = ''
avatarUrl.value = undefined
const token = ++avatarFetchToken
if (!p?.author) {
return
}
fetchAvatarBlobUrl(p.author, 20)
.then((url) => {
if (token === avatarFetchToken) {
avatarUrl.value = (url as string) ?? ''
avatarUrl.value = url
}
})
.catch(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ interface MentionItem extends MentionNodeAttrs {
id: string
label: string
username: string
avatarUrl: string
avatarUrl: string | undefined
}

export default {
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/input/editor/mention/MentionUser.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<template>
<NodeViewWrapper class="mention-user">
<img :src="avatarUrl">
<img
:src="avatarUrl"
alt=""
>
<span class="mention__label">
{{ node.attrs.label ?? node.attrs.id }}
</span>
Expand All @@ -15,14 +18,13 @@ import type { IUser } from '@/modelTypes/IUser'

const props = defineProps(nodeViewProps)

const avatarUrl = ref('')
const avatarUrl = ref<string>()

watch(
() => props.node.attrs.id,
async () => {
const username = props.node.attrs.id as string
const url = await fetchAvatarBlobUrl({username} as IUser, 32)
avatarUrl.value = url as string
avatarUrl.value = await fetchAvatarBlobUrl({username} as IUser, 32)
},
{immediate: true},
)
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/components/input/editor/mention/mentionSuggestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface MentionItem extends MentionNodeAttrs {
id: string
label: string
username: string
avatarUrl: string
avatarUrl: string | undefined
}

async function searchUsersForProject(projectId: number, query: string): Promise<MentionItem[]> {
Expand All @@ -25,15 +25,12 @@ async function searchUsersForProject(projectId: number, query: string): Promise<

// Fetch avatar URLs for all users
const usersWithAvatars = await Promise.all(
users.map(async (user) => {
const avatarUrl = await fetchAvatarBlobUrl(user, 32)
return {
id: user.username,
label: getDisplayName(user),
username: user.username,
avatarUrl: avatarUrl as string,
}
}),
users.map(async (user) => ({
id: user.username,
label: getDisplayName(user),
username: user.username,
avatarUrl: await fetchAvatarBlobUrl(user, 32),
})),
)

return usersWithAvatars
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/misc/User.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const {t} = useI18n({useScope: 'global'})

const displayName = computed(() => getDisplayName(props.user))
const isBot = computed(() => ((props.user as IUser & {botOwnerId?: number}).botOwnerId ?? 0) > 0)
const avatarSrc = ref('')
const avatarSrc = ref<string>()

async function loadAvatar() {
avatarSrc.value = await fetchAvatarBlobUrl(props.user, props.avatarSize)
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/components/tasks/partials/Comments.vue
Original file line number Diff line number Diff line change
Expand Up @@ -281,17 +281,21 @@ const newCommentText = ref('')
const saved = ref<ITask['id'] | null>(null)
const saving = ref<ITask['id'] | null>(null)

const userAvatar = ref('')
const userAvatar = ref<string>()
const avatarCache = reactive(new Map<string, string>())

function avatarFor(u: IUser, size: number) {
function avatarFor(u: IUser, size: number): string | undefined {
const key = `${u.id}-${size}`
const cached = avatarCache.get(key)
if (!cached) {
fetchAvatarBlobUrl(u, size).then(url => avatarCache.set(key, url))
fetchAvatarBlobUrl(u, size).then(url => {
if (url) {
avatarCache.set(key, url)
}
})
}

return avatarCache.get(key) || ''
return cached
}

watch(() => authStore.info, async (nu) => {
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/models/user.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {describe, it, expect} from 'vitest'
import {getDisplayName} from './user'
import {fetchAvatarBlobUrl, getDisplayName} from './user'
import type {IUser} from '@/modelTypes/IUser'

function makeUser(overrides: Partial<IUser> = {}): IUser {
Expand Down Expand Up @@ -31,3 +31,9 @@ describe('getDisplayName', () => {
expect(getDisplayName(user)).toBe('janedoe')
})
})

describe('fetchAvatarBlobUrl', () => {
it('should resolve to undefined for a user without a username', async () => {
await expect(fetchAvatarBlobUrl({} as IUser)).resolves.toBeUndefined()
})
})
23 changes: 12 additions & 11 deletions frontend/src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,24 @@ const avatarService = new AvatarService()
const avatarCache = new Map<string, string>()
const pendingRequests = new Map<string, Promise<string>>()

export async function fetchAvatarBlobUrl(user: IUser, size = 50) {
// Returns undefined, never '': Vue renders src="" which the browser resolves to the page
// URL and reports as a failed image load.
export async function fetchAvatarBlobUrl(user: IUser, size = 50): Promise<string | undefined> {
if (!user || !user.username) {
return ''
return undefined
}
const key = `${user.username}-${size}`
// Return cached URL if available
if (avatarCache.has(key)) {
return avatarCache.get(key) as string

const cached = avatarCache.get(key)
if (cached) {
return cached
}
// If there's already a pending request for this avatar, wait for it
if (pendingRequests.has(key)) {
return await pendingRequests.get(key) as string

const pending = pendingRequests.get(key)
if (pending) {
return await pending
}

// Create a new request
const requestPromise = avatarService.getBlobUrl(`/avatar/${user.username}?size=${size}`)
.then(url => {
avatarCache.set(key, url)
Expand Down
17 changes: 11 additions & 6 deletions frontend/src/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,21 @@ export default async function setupSentry(app: App, router: Router) {
document.body.addEventListener(
'error',
(event) => {
if (!event.target) return

if (event.target.tagName === 'IMG') {
const target = event.target

if (target instanceof HTMLImageElement) {
// An empty or placeholder src resolves to the page URL and fires an error event
// without ever requesting anything, so there's no failed load to report.
const src = target.getAttribute('src')
if (!src || src === '#') return

Sentry.captureMessage(
`Failed to load image: ${event.target.src}`,
`Failed to load image: ${target.src}`,
'warning',
)
} else if (event.target.tagName === 'LINK') {
} else if (target instanceof HTMLLinkElement) {
Sentry.captureMessage(
`Failed to load css: ${event.target.href}`,
`Failed to load css: ${target.href}`,
'warning',
)
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/stores/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export const useAuthStore = defineStore('auth', () => {
const needsTotpPasscode = ref(false)

const info = ref<IUser | null>(null)
const avatarUrl = ref('')
const avatarUrl = ref<string>()
const settings = ref<IUserSettings>(new UserSettingsModel())

const currentSessionId = ref<string | null>(null)
Expand Down
Loading