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
19 changes: 11 additions & 8 deletions layers/community/components/CommunityWall.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Contribution, Contributor } from '../types'
import { supporterAvatarUrl } from '../utils/avatars'

defineProps<{
contributors: Contributor[]
Expand All @@ -14,6 +15,8 @@ const initialClass
= 'flex size-14 shrink-0 items-center justify-center rounded-medium bg-primary-container '
+ 'text-headline-small font-bold text-on-primary-container'

const failedAvatars = reactive(new Set<string>())

const badgeLabel = (contribution: Contribution) => {
if (contribution.kind === 'build') return t('community.wall.badge_build', { title: contribution.title })
if (contribution.kind === 'event') {
Expand All @@ -29,7 +32,10 @@ const badgeLink = (contribution: Contribution, name: string) => contribution.kin
/** Minecraft head with an `mcName`, else the supporter's avatar, else (supporters) an initial. */
const avatarOf = (person: Contributor): string | null => {
if (person.mcName) return teamAvatarUrl({ mcName: person.mcName }, 128)
if (person.avatarUrl) return person.avatarUrl
if (person.avatarUrl) {
const profile = person.contributions.find((c) => c.kind === 'supporter')?.path
return supporterAvatarUrl(person.avatarUrl, profile)
}
return person.contributions.every((c) => c.kind === 'supporter') ? null : teamAvatarUrl({}, 128)
}
</script>
Expand All @@ -49,20 +55,17 @@ const avatarOf = (person: Contributor): string | null => {
:key="person.key"
:class="personClass"
>
<NuxtImg
v-if="avatarOf(person)"
<img
v-if="avatarOf(person) && !failedAvatars.has(person.key)"
:src="avatarOf(person)!"
alt=""
width="56"
height="56"
fit="cover"
format="avif,webp"
quality="80"
densities="x1 x2"
class="size-14 shrink-0 rounded-medium bg-surface-container-highest object-cover"
loading="lazy"
decoding="async"
/>
@error="failedAvatars.add(person.key)"
>
<span v-else :class="initialClass" aria-hidden="true">
{{ person.name.charAt(0).toUpperCase() }}
</span>
Expand Down
24 changes: 24 additions & 0 deletions layers/community/utils/avatars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { smallAvatarUrl } from '#shared/utils/liteSupporters'

const OPEN_COLLECTIVE_AVATAR_HOSTS = new Set([
'opencollective-production.s3.us-west-1.amazonaws.com', 'images.opencollective.com'
])

/** 128px covers the 56px wall box at 2x. */
const WALL_AVATAR_SIZE = 128

/**
* A supporter's avatar as the wall loads it. Open Collective avatars become
* the sized render of the profile: the S3 original is too large, and the
* resizer rejects that origin. Any other host passes through unchanged.
*/
export function supporterAvatarUrl(avatarUrl: string, profile: string | undefined): string | null {
let host: string
try {
host = new URL(avatarUrl).hostname
} catch {
return null
}
if (!OPEN_COLLECTIVE_AVATAR_HOSTS.has(host)) return avatarUrl
return profile ? smallAvatarUrl(profile, WALL_AVATAR_SIZE) : null
}
4 changes: 2 additions & 2 deletions shared/utils/liteSupporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ const avatarOf = (value: unknown): string | null => {
}

/** Open Collective's resizer: the S3 original runs to 860 KB; 96px covers the 48px box at 2x. */
export function smallAvatarUrl(profile: string): string | null {
export function smallAvatarUrl(profile: string, size = 96): string | null {
const slug = new URL(profile).pathname.split('/').find(Boolean)
return slug ? `https://images.opencollective.com/${slug}/avatar/96.png` : null
return slug ? `https://images.opencollective.com/${slug}/avatar/${size}.png` : null
}

/**
Expand Down
40 changes: 40 additions & 0 deletions tests/community/avatars.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { smallAvatarUrl } from '../../shared/utils/liteSupporters'
import { supporterAvatarUrl } from '../../layers/community/utils/avatars'

const S3_ORIGINAL = 'https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/1/a.png'
const SIZED = 'https://images.opencollective.com/marc44/avatar/128.png'
const PROFILE = 'https://opencollective.com/marc44'

describe('supporterAvatarUrl', () => {
it('maps an Open Collective S3 original to the sized render of the profile', () => {
expect(supporterAvatarUrl(S3_ORIGINAL, PROFILE)).toBe(SIZED)
})

it('maps an images.opencollective.com avatar to the same sized render', () => {
expect(supporterAvatarUrl('https://images.opencollective.com/marc44/avatar/96.png', PROFILE)).toBe(SIZED)
})

it('returns null for an Open Collective avatar without a profile, so the original is never loaded', () => {
expect(supporterAvatarUrl(S3_ORIGINAL, undefined)).toBeNull()
})

it('returns null when the profile names no collective slug', () => {
expect(supporterAvatarUrl(S3_ORIGINAL, 'https://opencollective.com/')).toBeNull()
})

it('keeps an avatar from another host unchanged', () => {
const other = 'https://mc-heads.net/avatar/marc/128'
expect(supporterAvatarUrl(other, PROFILE)).toBe(other)
})
})

describe('smallAvatarUrl size', () => {
it('renders at the requested size', () => {
expect(smallAvatarUrl(PROFILE, 128)).toBe(SIZED)
})

it('renders at 96px unless asked otherwise', () => {
expect(smallAvatarUrl(PROFILE)).toBe('https://images.opencollective.com/marc44/avatar/96.png')
})
})
30 changes: 29 additions & 1 deletion tests/community/components.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment nuxt
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import CommunityStats from '../../layers/community/components/CommunityStats.vue'
import CommunityStrip from '../../layers/community/components/CommunityStrip.vue'
import CommunityWall from '../../layers/community/components/CommunityWall.vue'
Expand Down Expand Up @@ -136,7 +137,34 @@ describe('CommunityWall', () => {
contributions: [{ kind: 'supporter', path: 'https://opencollective.com/marc44' }]
}
const wrapper = await open(CommunityWall, { contributors: [marc] })
expect(wrapper.get('img').attributes('src')).toContain('opencollective-production')
expect(wrapper.get('img').attributes('src')).toBe('https://images.opencollective.com/marc44/avatar/128.png')
})

it('loads the supporter avatar without the image resizer', async () => {
const marc: Contributor = {
key: 'marc',
name: 'Marc',
anchor: 'person-marc',
avatarUrl: 'https://opencollective-production.s3.us-west-1.amazonaws.com/a.png',
contributions: [{ kind: 'supporter', path: 'https://opencollective.com/marc44' }]
}
const wrapper = await open(CommunityWall, { contributors: [marc] })
expect(wrapper.get('img').attributes('src')).not.toContain('cdn-cgi')
})

it('replaces a supporter avatar that fails to load with the initial', async () => {
const marc: Contributor = {
key: 'marc',
name: 'Marc',
anchor: 'person-marc',
avatarUrl: 'https://opencollective-production.s3.us-west-1.amazonaws.com/a.png',
contributions: [{ kind: 'supporter', path: 'https://opencollective.com/marc44' }]
}
const wrapper = await open(CommunityWall, { contributors: [marc] })
await wrapper.get('img').trigger('error')
await nextTick()
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.get('[aria-hidden="true"]').text()).toBe('M')
})

it('shows an initial instead of an image for a supporter without avatar', async () => {
Expand Down
Loading