diff --git a/layers/community/components/CommunityWall.vue b/layers/community/components/CommunityWall.vue index ca7c3a60..ba71c0f2 100644 --- a/layers/community/components/CommunityWall.vue +++ b/layers/community/components/CommunityWall.vue @@ -1,5 +1,6 @@ @@ -49,20 +55,17 @@ const avatarOf = (person: Contributor): string | null => { :key="person.key" :class="personClass" > - + @error="failedAvatars.add(person.key)" + > diff --git a/layers/community/utils/avatars.ts b/layers/community/utils/avatars.ts new file mode 100644 index 00000000..053a652a --- /dev/null +++ b/layers/community/utils/avatars.ts @@ -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 +} diff --git a/shared/utils/liteSupporters.ts b/shared/utils/liteSupporters.ts index d9db63c8..8937a191 100644 --- a/shared/utils/liteSupporters.ts +++ b/shared/utils/liteSupporters.ts @@ -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 } /** diff --git a/tests/community/avatars.spec.ts b/tests/community/avatars.spec.ts new file mode 100644 index 00000000..85e2dff7 --- /dev/null +++ b/tests/community/avatars.spec.ts @@ -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') + }) +}) diff --git a/tests/community/components.spec.ts b/tests/community/components.spec.ts index 9938a3c8..ee3c40b3 100644 --- a/tests/community/components.spec.ts +++ b/tests/community/components.spec.ts @@ -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' @@ -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 () => {