File tree Expand file tree Collapse file tree
Expand file tree Collapse file tree Original file line number Diff line number Diff line change @@ -6,6 +6,7 @@ import { getCanonicalUserUrl } from '@/utils/urls'
66import { Metadata } from 'next'
77import { db , sql } from '@nextjs-forum/db'
88import { notFound } from 'next/navigation'
9+ import { Avatar } from '@/components/avatar'
910
1011export const revalidate = 60
1112export 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" >
Original file line number Diff line number Diff line change 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+ }
Original file line number Diff line number Diff line change 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+ }
Original file line number Diff line number Diff line change 11import { parseDiscordMessage } from '@/utils/discord-markdown'
22import { isVideoLink } from '@/utils/video'
3+ import { MessageContentImage } from './message-content-media'
34
45export 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"
Original file line number Diff line number Diff line change @@ -7,6 +7,7 @@ import { DisplayLocalTime } from './local-time'
77import { Attachment , MessageContent } from './message-content'
88import { DeletedReply , MessageReply , Reply } from '@/components/reply'
99import { MessageWrapper } from '@/components/message-wrapper'
10+ import { Avatar } from './avatar'
1011type 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
Original file line number Diff line number Diff line change 11import { db } from '@nextjs-forum/db'
22import { CheckCircleSolidIcon } from '@/components/icons/check-circle-solid'
33import Link from 'next/link'
4+ import { Avatar } from './avatar'
45
56const 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]"
Original file line number Diff line number Diff line change 11import Link from 'next/link'
22import plur from 'plur'
33import { buildPostTimeValues } from '@/utils/datetime'
4+ import { Avatar } from './avatar'
45
56type 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 } >
Original file line number Diff line number Diff line change @@ -3,6 +3,7 @@ import { ImageIcon } from '@/components/icons/image'
33import { BadReplyIcon } from '@/components/icons/reply-bad'
44import { ReplySplineIcon } from '@/components/icons/reply-spline'
55import { Attachment } from '@/components/message-content'
6+ import { Avatar } from './avatar'
67export 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" >
Original file line number Diff line number Diff line change 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+ }
You can’t perform that action at this time.
0 commit comments