Skip to content

Commit 73801b4

Browse files
committed
Add fallback to avatar and post images (#101)
* Add new Avatar component * Add image loading status to post images * Remove test code
1 parent 88a4852 commit 73801b4

9 files changed

Lines changed: 153 additions & 20 deletions

File tree

apps/web/app/user/[discordID]/page.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getCanonicalUserUrl } from '@/utils/urls'
66
import { Metadata } from 'next'
77
import { db, sql } from '@nextjs-forum/db'
88
import { notFound } from 'next/navigation'
9+
import { Avatar } from '@/components/avatar'
910

1011
export const revalidate = 60
1112
export const dynamic = 'error'
@@ -144,10 +145,10 @@ const UserInfo = async ({ params }: UserProps) => {
144145
<main className="w-full h-full flex flex-col items-center justify-center">
145146
<section className="w-full h-full flex flex-col xl:flex-row items-stretch justify-center max-w-7xl px-4 py-12 xl:py-16 gap-4 xl:gap-10">
146147
<div className="w-fit min-w-[20%] md:max-w-[50%] xl:max-w-[30%] flex flex-row items-stretch justify-start gap-4 shrink-0">
147-
<img
148-
className="size-16 rounded-full"
148+
<Avatar
149+
size={16}
149150
src={userData.avatarUrl}
150-
alt={`User Avatar of ${userData.username}`}
151+
username={userData.username}
151152
/>
152153
<div className="w-fit h-auto flex flex-col items-start justify-start gap-1 max-w-[200px]">
153154
<h1 className="w-full text-xl md:text-2xl font-semibold text-white line-clamp-1">

apps/web/components/avatar.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use client'
2+
3+
import { cn } from '@/utils/cn'
4+
import { useImageLoadingStatus } from '@/utils/hooks/useImageLoadingStatus'
5+
import { ComponentProps } from 'react'
6+
7+
const sizeToClassName = {
8+
4: 'w-4 h-4',
9+
5: 'w-5 h-5',
10+
10: 'w-10 h-10',
11+
16: 'w-16 h-16',
12+
}
13+
14+
const DEFAULT_AVATAR = 'https://cdn.discordapp.com/embed/avatars/1.png'
15+
16+
type AvatarProps = ComponentProps<'img'> & {
17+
size: keyof typeof sizeToClassName
18+
username?: string
19+
}
20+
21+
type LoadingStatus = 'loading' | 'loaded' | 'error'
22+
23+
export const Avatar = ({
24+
src,
25+
size,
26+
className,
27+
referrerPolicy,
28+
crossOrigin,
29+
username,
30+
...props
31+
}: AvatarProps) => {
32+
const loadingStatus = useImageLoadingStatus(src, {
33+
referrerPolicy,
34+
crossOrigin,
35+
})
36+
37+
if (loadingStatus === 'loaded') {
38+
return (
39+
<img
40+
src={src}
41+
alt={username ? `${username}'s avatar` : ''}
42+
className={cn('rounded-full', sizeToClassName[size], className)}
43+
{...props}
44+
/>
45+
)
46+
}
47+
48+
if (loadingStatus === 'error') {
49+
return (
50+
<img
51+
src={DEFAULT_AVATAR}
52+
alt={username ? `${username}'s avatar` : ''}
53+
className={cn('rounded-full', sizeToClassName[size], className)}
54+
{...props}
55+
/>
56+
)
57+
}
58+
59+
return (
60+
<span
61+
className={cn('rounded-full bg-neutral-800', sizeToClassName[size])}
62+
aria-busy="true"
63+
/>
64+
)
65+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client'
2+
3+
import { useImageLoadingStatus } from '@/utils/hooks/useImageLoadingStatus'
4+
import { ComponentProps } from 'react'
5+
6+
export const MessageContentImage = ({
7+
src,
8+
alt,
9+
...props
10+
}: ComponentProps<'img'>) => {
11+
const loadingStatus = useImageLoadingStatus(src)
12+
13+
if (loadingStatus === 'loaded') {
14+
return <img src={src} alt={alt} {...props} />
15+
}
16+
17+
const brokenMediaJsx = (
18+
<div className="border border-neutral-700 rounded-lg px-2 py-1 text-neutral-400 text-sm min-h-8 flex items-center">
19+
This media is unavailable, please check the original message on Discord
20+
</div>
21+
)
22+
23+
if (loadingStatus === 'error') {
24+
return brokenMediaJsx
25+
}
26+
27+
return (
28+
<div className="relative" aria-label="Loading media" aria-busy="true">
29+
{/* Most images will be broken so we will use the broken state to size this loading indicator,
30+
this will avoid most layout shifts */}
31+
<div aria-hidden="true">{brokenMediaJsx}</div>
32+
<div className="absolute inset-0 bg-neutral-800" />
33+
</div>
34+
)
35+
}

apps/web/components/message-content.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parseDiscordMessage } from '@/utils/discord-markdown'
22
import { isVideoLink } from '@/utils/video'
3+
import { MessageContentImage } from './message-content-media'
34

45
export type Attachment = {
56
id: string
@@ -39,7 +40,7 @@ export const MessageContent = async ({
3940
controls
4041
></video>
4142
) : (
42-
<img
43+
<MessageContentImage
4344
src={attachment.url}
4445
alt="Image"
4546
className="max-w-full h-auto object-cover"

apps/web/components/message.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DisplayLocalTime } from './local-time'
77
import { Attachment, MessageContent } from './message-content'
88
import { DeletedReply, MessageReply, Reply } from '@/components/reply'
99
import { MessageWrapper } from '@/components/message-wrapper'
10+
import { Avatar } from './avatar'
1011
type MessageProps = {
1112
snowflakeId: string
1213
content: string
@@ -48,10 +49,10 @@ export const Message = ({
4849
<div className="flex flex-row">
4950
<div className="flex w-[50px] shrink-0 items-start justify-start sm:w-[60px]">
5051
{isFirstRow ? (
51-
<img
52+
<Avatar
5253
src={author.avatarUrl}
53-
alt="Avatar"
54-
className="h-10 w-10 rounded-full"
54+
size={10}
55+
username={author.username}
5556
/>
5657
) : (
5758
<time

apps/web/components/most-helpful.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { db } from '@nextjs-forum/db'
22
import { CheckCircleSolidIcon } from '@/components/icons/check-circle-solid'
33
import Link from 'next/link'
4+
import { Avatar } from './avatar'
45

56
const getMostHelpfulUsers = async () => {
67
return db
@@ -32,11 +33,7 @@ export const MostHelpful = async () => {
3233
{users.map((user) => (
3334
<div key={user.id} className="flex justify-between py-2">
3435
<div className="flex space-x-2 items-center">
35-
<img
36-
src={user.avatarUrl}
37-
alt="Avatar"
38-
className="w-4 h-4 rounded-full"
39-
/>
36+
<Avatar src={user.avatarUrl} size={4} username={user.username} />
4037
{user.isPublic ? (
4138
<Link
4239
className="opacity-90 text-white line-clamp-1 max-w-[200px]"

apps/web/components/post.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from 'next/link'
22
import plur from 'plur'
33
import { buildPostTimeValues } from '@/utils/datetime'
4+
import { Avatar } from './avatar'
45

56
type PostProps = {
67
id: string
@@ -35,11 +36,7 @@ export const Post = ({
3536
</p>
3637

3738
<div className="mt-2 flex items-center space-x-2">
38-
<img
39-
src={author.avatar}
40-
alt={`${author.username}'s avatar`}
41-
className="rounded-full w-5 h-5"
42-
/>
39+
<Avatar src={author.avatar} size={5} username={author.username} />
4340
<div className="text-sm opacity-90 no-underline">
4441
{author.username} asked on{' '}
4542
<time dateTime={createdAtTimes.iso} title={createdAtTimes.tooltip}>

apps/web/components/reply.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ImageIcon } from '@/components/icons/image'
33
import { BadReplyIcon } from '@/components/icons/reply-bad'
44
import { ReplySplineIcon } from '@/components/icons/reply-spline'
55
import { Attachment } from '@/components/message-content'
6+
import { Avatar } from './avatar'
67
export type Reply = {
78
author: {
89
username: string
@@ -23,10 +24,10 @@ export const MessageReply = ({ reply }: { reply: Reply }) => {
2324
<ReplySplineIcon className="size-10 pt-2" />
2425
</div>
2526
<div className="flex w-0 flex-1 flex-row flex-nowrap items-center justify-start gap-2 text-xs md:text-base">
26-
<img
27+
<Avatar
2728
src={reply.author.avatarUrl}
28-
alt="Avatar"
29-
className="size-5 shrink-0 rounded-full"
29+
size={5}
30+
username={reply.author.username}
3031
/>
3132

3233
<div className="line-clamp-2 w-0 flex-1 text-left text-sm md:line-clamp-1">
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ComponentProps, useLayoutEffect } from 'react'
2+
import { useState } from 'react'
3+
4+
// Code based on https://github.com/radix-ui/primitives/blob/6e75e117977c9e6ffa939e6951a707f16ba0f95e/packages/react/avatar/src/avatar.tsx#L119
5+
6+
type LoadingStatus = 'loading' | 'loaded' | 'error'
7+
8+
export const useImageLoadingStatus = (
9+
src: string | undefined,
10+
{ referrerPolicy, crossOrigin }: ComponentProps<'img'> = {},
11+
) => {
12+
const [loadingStatus, setLoadingStatus] = useState<LoadingStatus>('loading')
13+
14+
useLayoutEffect(() => {
15+
if (!src) {
16+
setLoadingStatus('error')
17+
return
18+
}
19+
20+
const image = new window.Image()
21+
22+
image.onload = () => setLoadingStatus('loaded')
23+
image.onerror = () => setLoadingStatus('error')
24+
25+
if (referrerPolicy) {
26+
image.referrerPolicy = referrerPolicy
27+
}
28+
if (typeof crossOrigin === 'string') {
29+
image.crossOrigin = crossOrigin
30+
}
31+
image.src = src
32+
}, [src, referrerPolicy, crossOrigin])
33+
34+
return loadingStatus
35+
}

0 commit comments

Comments
 (0)