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)"
+ >
{{ person.name.charAt(0).toUpperCase() }}
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 () => {