@@ -8,8 +8,10 @@ import {
88 TooltipContent ,
99 TooltipTrigger ,
1010} from "@/components/ui/tooltip" ;
11+ import type { IServerFile } from "@/backend/models/domain-models" ;
1112import { useTranslation } from "@/i18n/use-translation" ;
1213import { cn , formattedTime , getAvatarPlaceholder } from "@/lib/utils" ;
14+ import getFileUrl from "@/utils/getFileUrl" ;
1315import {
1416 useInfiniteQuery ,
1517 useMutation ,
@@ -299,10 +301,7 @@ const NotificationPage = () => {
299301
300302 { ! hasItems && ! feedQuery . isFetching && ! feedQuery . isError && (
301303 < div className = "mt-10 flex min-h-[200px] flex-col items-center justify-center gap-3 border-b border-dashed border-border pb-10 text-center" >
302- < Bell
303- className = "size-9 text-muted-foreground"
304- strokeWidth = { 1.25 }
305- />
304+ < Bell className = "size-9 text-muted-foreground" strokeWidth = { 1.25 } />
306305 < div className = "space-y-1" >
307306 < p className = "font-medium text-foreground" >
308307 { _t ( "No notifications yet" ) }
@@ -335,100 +334,115 @@ const NotificationPage = () => {
335334 { feedQuery . data ?. pages . flatMap ( ( page ) => {
336335 if ( ! isNotificationSuccess ( page ) ) return [ ] ;
337336 return page . nodes . map ( ( notification ) => {
338- const isUnread = ! notification . read_at ;
339- const link = notificationLink (
340- notification . type ,
341- notification . payload ,
342- ) ;
343- const profileHref = actorProfileHref ( notification ) ;
344- const actorLabel = actorDisplayName ( notification , _t ) ;
345- const { Icon : TypeIcon , labelKey : typeLabelKey } =
346- notificationTypeIcon ( notification . type ) ;
347-
348- const created = new Date ( notification . created_at ) ;
349- const timeTitle = created . toLocaleString ( ) ;
350-
351- return (
352- < li key = { notification . id } >
353- < div
354- className = { cn (
355- "flex gap-3 border-l-2 py-3 pl-3 transition-colors" ,
356- isUnread ? "border-l-primary" : "border-l-transparent" ,
357- ! isUnread && "hover:bg-muted/40" ,
358- ) }
359- >
360- < div className = "relative shrink-0" >
361- { profileHref ? (
362- < Link
363- href = { profileHref }
364- className = "block rounded-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
365- aria-label = { _t ( "View profile of $" , [ actorLabel ] ) }
366- >
367- < NotificationActorAvatar label = { actorLabel } />
368- </ Link >
369- ) : (
370- < NotificationActorAvatar label = { actorLabel } />
337+ const isUnread = ! notification . read_at ;
338+ const link = notificationLink (
339+ notification . type ,
340+ notification . payload ,
341+ ) ;
342+ const profileHref = actorProfileHref ( notification ) ;
343+ const actorLabel = actorDisplayName ( notification , _t ) ;
344+ const { Icon : TypeIcon , labelKey : typeLabelKey } =
345+ notificationTypeIcon ( notification . type ) ;
346+
347+ const created = new Date ( notification . created_at ) ;
348+ const timeTitle = created . toLocaleString ( ) ;
349+
350+ return (
351+ < li key = { notification . id } >
352+ < div
353+ className = { cn (
354+ "flex gap-3 border-l-2 py-3 pl-3 transition-colors" ,
355+ isUnread ? "border-l-primary" : "border-l-transparent" ,
356+ ! isUnread && "hover:bg-muted/40" ,
371357 ) }
372- < span
373- className = "absolute -bottom-0.5 -right-0.5 flex size-5 items-center justify-center border border-border bg-background text-muted-foreground"
374- title = { _t ( typeLabelKey ) }
375- aria-hidden
376- >
377- < TypeIcon className = "size-3" strokeWidth = { 2 } />
378- </ span >
379- </ div >
380-
381- < Link
382- href = { link }
383- className = "group/link flex min-w-0 flex-1 flex-col gap-1 py-0.5 pr-1 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
384358 >
385- < p className = "text-sm leading-snug text-foreground" >
386- < span className = "font-semibold" > { actorLabel } </ span > { " " }
359+ < div className = "relative shrink-0" >
360+ { profileHref ? (
361+ < Link
362+ href = { profileHref }
363+ className = "block rounded-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
364+ aria-label = { _t ( "View profile of $" , [ actorLabel ] ) }
365+ >
366+ < NotificationActorAvatar
367+ label = { actorLabel }
368+ profilePhoto = { notification . actor ?. profile_photo }
369+ profilePhotoUrl = {
370+ notification . actor ?. profile_photo_url
371+ }
372+ />
373+ </ Link >
374+ ) : (
375+ < NotificationActorAvatar
376+ label = { actorLabel }
377+ profilePhoto = { notification . actor ?. profile_photo }
378+ profilePhotoUrl = {
379+ notification . actor ?. profile_photo_url
380+ }
381+ />
382+ ) }
387383 < span
388- className = { cn (
389- "font-normal" ,
390- isUnread
391- ? "text-foreground/90"
392- : "text-muted-foreground" ,
393- ) }
384+ className = "absolute -bottom-0.5 -right-0.5 flex size-5 items-center justify-center border border-border bg-background text-muted-foreground"
385+ title = { _t ( typeLabelKey ) }
386+ aria-hidden
394387 >
395- { notificationBodyAfterActor ( notification , _t ) }
388+ < TypeIcon className = "size-3" strokeWidth = { 2 } />
396389 </ span >
397- </ p >
398- < div className = "flex items-center gap-1.5 text-xs text-muted-foreground" >
399- < time dateTime = { created . toISOString ( ) } title = { timeTitle } >
400- { formattedTime ( created ) }
401- </ time >
402- < ChevronRight className = "size-3.5 opacity-0 transition-opacity group-hover/link:opacity-70" />
403390 </ div >
404- </ Link >
405-
406- { isUnread && (
407- < Tooltip >
408- < TooltipTrigger asChild >
409- < Button
410- type = "button"
411- variant = "ghost"
412- size = "icon"
413- className = "size-9 shrink-0 text-muted-foreground hover:text-foreground"
414- disabled = { markReadMutation . isPending }
415- aria-label = { _t ( "Mark as read" ) }
416- onClick = { ( e ) => {
417- e . preventDefault ( ) ;
418- markReadMutation . mutate ( notification . id ) ;
419- } }
391+
392+ < Link
393+ href = { link }
394+ className = "group/link flex min-w-0 flex-1 flex-col gap-1 py-0.5 pr-1 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
395+ >
396+ < p className = "text-sm leading-snug text-foreground" >
397+ < span className = "font-semibold" > { actorLabel } </ span > { " " }
398+ < span
399+ className = { cn (
400+ "font-normal" ,
401+ isUnread
402+ ? "text-foreground/90"
403+ : "text-muted-foreground" ,
404+ ) }
420405 >
421- < Check className = "size-4" />
422- </ Button >
423- </ TooltipTrigger >
424- < TooltipContent side = "left" >
425- { _t ( "Mark as read" ) }
426- </ TooltipContent >
427- </ Tooltip >
428- ) }
429- </ div >
430- </ li >
431- ) ;
406+ { notificationBodyAfterActor ( notification , _t ) }
407+ </ span >
408+ </ p >
409+ < div className = "flex items-center gap-1.5 text-xs text-muted-foreground" >
410+ < time
411+ dateTime = { created . toISOString ( ) }
412+ title = { timeTitle }
413+ >
414+ { formattedTime ( created ) }
415+ </ time >
416+ < ChevronRight className = "size-3.5 opacity-0 transition-opacity group-hover/link:opacity-70" />
417+ </ div >
418+ </ Link >
419+
420+ { isUnread && (
421+ < Tooltip >
422+ < TooltipTrigger asChild >
423+ < Button
424+ type = "button"
425+ variant = "ghost"
426+ size = "icon"
427+ className = "size-9 shrink-0 text-muted-foreground hover:text-foreground"
428+ disabled = { markReadMutation . isPending }
429+ aria-label = { _t ( "Mark as read" ) }
430+ onClick = { ( e ) => {
431+ e . preventDefault ( ) ;
432+ markReadMutation . mutate ( notification . id ) ;
433+ } }
434+ >
435+ < Check className = "size-4" />
436+ </ Button >
437+ </ TooltipTrigger >
438+ < TooltipContent side = "left" >
439+ { _t ( "Mark as read" ) }
440+ </ TooltipContent >
441+ </ Tooltip >
442+ ) }
443+ </ div >
444+ </ li >
445+ ) ;
432446 } ) ;
433447 } ) }
434448 </ ul >
@@ -449,14 +463,22 @@ const NotificationPage = () => {
449463 ) ;
450464} ;
451465
452- function NotificationActorAvatar ( { label } : { label : string } ) {
466+ function NotificationActorAvatar ( {
467+ label,
468+ profilePhoto,
469+ profilePhotoUrl,
470+ } : {
471+ label : string ;
472+ profilePhoto ?: IServerFile | null ;
473+ profilePhotoUrl ?: string | null ;
474+ } ) {
475+ const fromStructured = profilePhoto ? getFileUrl ( profilePhoto ) : "" ;
476+ const fromLegacyUrl = profilePhotoUrl ?. trim ( ) ?? "" ;
477+ const src = fromStructured || fromLegacyUrl || getAvatarPlaceholder ( label ) ;
478+
453479 return (
454480 < Avatar className = "size-10 border border-border bg-background" >
455- < AvatarImage
456- src = { getAvatarPlaceholder ( label ) }
457- alt = ""
458- className = "object-cover"
459- />
481+ < AvatarImage src = { src } alt = "" className = "object-cover" />
460482 < AvatarFallback className = "text-xs font-medium" >
461483 { label . replace ( / ^ @ / , "" ) . slice ( 0 , 2 ) . toUpperCase ( ) }
462484 </ AvatarFallback >
0 commit comments