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
49 changes: 49 additions & 0 deletions src/app/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {useReducer} from 'react';

// Module-level cache shared by every Avatar instance for the life of the
// page. The same user often appears in many places at once (member stacks,
// team panels, admin lists), so once we know a URL is broken we never want
// to flash a fallback again.
const failedAvatarUrls = new Set<string>();

export interface AvatarProps {
displayName: string;
avatarUrl?: string | null;
className?: string;
}

export function Avatar({displayName, avatarUrl, className}: AvatarProps) {
const [, forceRender] = useReducer((count: number) => count + 1, 0);
const failed = Boolean(avatarUrl) && failedAvatarUrls.has(avatarUrl!);
const showImage = Boolean(avatarUrl) && !failed;

return (
<span className={className ? `avatar ${className}` : 'avatar'} title={displayName}>
{showImage ? (
<img
key={avatarUrl}
src={avatarUrl!}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={() => {
failedAvatarUrls.add(avatarUrl!);
forceRender();
}}
Comment on lines +30 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When an avatar image fails, only the specific component instance re-renders. Other Avatar components using the same failed URL will continue to show a broken image.
Severity: LOW

Suggested Fix

To ensure all Avatar components update when an image URL fails, implement a mechanism to notify all instances. For example, use a shared state or an event emitter. When an image fails, broadcast an event that all mounted Avatar components subscribe to, causing them to call forceRender() and display the fallback consistently.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/app/components/Avatar.tsx#L32-L35

Potential issue: When an avatar image fails to load, the `onError` handler adds the
failed URL to the module-level `failedAvatarUrls` set but only triggers a re-render for
the specific component instance where the error occurred. If other `Avatar` components
are mounted and displaying the same `avatarUrl`, they will not be notified of the
failure. Consequently, their rendering logic, which checks `failedAvatarUrls`, will not
be re-evaluated, and they will continue to display a broken image icon instead of the
intended fallback.

Did we get this right? 👍 / 👎 to inform future reviews.

/>
) : (
initials(displayName)
)}
</span>
);
}

function initials(value: string) {
return value
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
19 changes: 7 additions & 12 deletions src/app/components/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {Link} from 'wouter';

import type {ProjectSummary} from '../../shared/projects';
import {Avatar} from './Avatar';
import {Markdown} from './Markdown';

interface ProjectListMember {
id: string;
displayName: string;
avatarUrl: string | null;
}

export function ProjectCard({
Expand Down Expand Up @@ -139,20 +141,13 @@ function MemberStack({
aria-label={members.map(({displayName}) => displayName).join(', ')}
>
{members.slice(0, 4).map((member) => (
<span key={member.id} title={member.displayName}>
{initials(member.displayName)}
</span>
<Avatar
key={member.id}
displayName={member.displayName}
avatarUrl={member.avatarUrl}
/>
))}
{members.length > 4 && <span>+{members.length - 4}</span>}
</span>
);
}

function initials(value: string) {
return value
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
15 changes: 5 additions & 10 deletions src/app/routes/ProjectDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {useQuery} from '@tanstack/react-query';
import {Link, useLocation, useParams} from 'wouter';

import {QueryState} from '../components/AppLayout';
import {Avatar} from '../components/Avatar';
import {Markdown} from '../components/Markdown';
import {ProjectVoting} from '../components/ProjectVoting';
import {useBallotStatus} from '../queries/administration';
Expand Down Expand Up @@ -138,7 +139,10 @@ export function ProjectDetailsPage() {
<ul>
{project.data.project.members.map((member) => (
<li key={member.id}>
<span>{initials(member.displayName)}</span>
<Avatar
displayName={member.displayName}
avatarUrl={member.avatarUrl}
/>
<a href={`mailto:${member.email}`}>
<strong>{member.displayName}</strong>
<small>{member.email}</small>
Expand Down Expand Up @@ -272,15 +276,6 @@ export function ProjectDetailsPage() {
);
}

function initials(value: string) {
return value
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}

function isImageMediaType(mediaType: string | null) {
return mediaType?.toLowerCase().startsWith('image/') ?? false;
}
Expand Down
12 changes: 12 additions & 0 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,18 @@ main {
color: var(--ink);
background: var(--pink);
}
.avatar {
display: grid;
place-items: center;
overflow: hidden;
flex: none;
}
.avatar img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.openSeat {
color: var(--blurple);
font-weight: 500;
Expand Down
2 changes: 1 addition & 1 deletion src/shared/videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export interface PlaylistItem {
projectName: string;
groupId: string | null;
groupName: string | null;
teamMembers: Array<{id: string; displayName: string}>;
teamMembers: Array<{id: string; displayName: string; avatarUrl: string | null}>;
durationSeconds: number;
gainDb: number;
position: number;
Expand Down
20 changes: 16 additions & 4 deletions src/worker/services/videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,13 @@ export async function listPlaylist(
}>();
if (!results.length) return [];

const membersByProject = new Map<string, Array<{id: string; displayName: string}>>();
const membersByProject = new Map<
string,
Array<{id: string; displayName: string; avatarUrl: string | null}>
>();
const members = await db
.prepare(
`SELECT pm.project_id, u.id user_id, u.display_name
`SELECT pm.project_id, u.id user_id, u.display_name, u.avatar_url
FROM project_members pm
JOIN users u ON u.id = pm.user_id
JOIN projects p ON p.id = pm.project_id
Expand All @@ -147,10 +150,19 @@ export async function listPlaylist(
ORDER BY pm.project_id, u.display_name COLLATE NOCASE, u.id`,
)
.bind(yearId)
.all<{project_id: string; user_id: string; display_name: string}>();
.all<{
project_id: string;
user_id: string;
display_name: string;
avatar_url: string | null;
}>();
for (const member of members.results) {
const projectMembers = membersByProject.get(member.project_id) ?? [];
projectMembers.push({id: member.user_id, displayName: member.display_name});
projectMembers.push({
id: member.user_id,
displayName: member.display_name,
avatarUrl: member.avatar_url,
});
membersByProject.set(member.project_id, projectMembers);
}

Expand Down
6 changes: 3 additions & 3 deletions test/player/controller.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,8 @@ const playlist: PlaylistItem[] = [
groupId: 'europe',
groupName: 'Europe',
teamMembers: [
{id: 'ada', displayName: 'Ada'},
{id: 'grace', displayName: 'Grace'},
{id: 'ada', displayName: 'Ada', avatarUrl: null},
{id: 'grace', displayName: 'Grace', avatarUrl: null},
],
durationSeconds: 10,
gainDb: 6,
Expand All @@ -173,7 +173,7 @@ const playlist: PlaylistItem[] = [
projectName: 'Second',
groupId: 'americas',
groupName: 'Americas',
teamMembers: [{id: 'linus', displayName: 'Linus'}],
teamMembers: [{id: 'linus', displayName: 'Linus', avatarUrl: null}],
durationSeconds: 20,
gainDb: -3,
position: 1,
Expand Down
6 changes: 3 additions & 3 deletions test/video-ui/video-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,8 @@ const playlist: PlaylistItem[] = [
groupId: 'europe',
groupName: 'Europe',
teamMembers: [
{id: 'ada', displayName: 'Ada Lovelace'},
{id: 'grace', displayName: 'Grace Hopper'},
{id: 'ada', displayName: 'Ada Lovelace', avatarUrl: null},
{id: 'grace', displayName: 'Grace Hopper', avatarUrl: null},
],
durationSeconds: 30,
gainDb: 0,
Expand All @@ -479,7 +479,7 @@ const playlist: PlaylistItem[] = [
projectName: 'Second project',
groupId: 'americas',
groupName: 'Americas',
teamMembers: [{id: 'linus', displayName: 'Linus Torvalds'}],
teamMembers: [{id: 'linus', displayName: 'Linus Torvalds', avatarUrl: null}],
durationSeconds: 45,
gainDb: -1,
position: 1,
Expand Down
Loading