Skip to content

Commit c3db2ac

Browse files
committed
feat: enhance notification display with actor profile images
- Updated Notification model to include actor's profile photo and URL. - Modified notification rendering to display actor images alongside names. - Improved notification structure for better user experience and clarity. This update enriches the notification system by providing visual context for actors, enhancing user engagement.
1 parent b0dda97 commit c3db2ac

3 files changed

Lines changed: 126 additions & 99 deletions

File tree

src/app/dashboard/notifications/page.tsx

Lines changed: 119 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
TooltipContent,
99
TooltipTrigger,
1010
} from "@/components/ui/tooltip";
11+
import type { IServerFile } from "@/backend/models/domain-models";
1112
import { useTranslation } from "@/i18n/use-translation";
1213
import { cn, formattedTime, getAvatarPlaceholder } from "@/lib/utils";
14+
import getFileUrl from "@/utils/getFileUrl";
1315
import {
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>

src/backend/models/domain-models.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,5 +293,9 @@ export interface Notification {
293293
payload?: NotificationPayload | null;
294294
read_at?: Date | null;
295295
created_at: Date;
296-
actor?: Pick<User, "id" | "name" | "username"> | null;
296+
actor?:
297+
| (Pick<User, "id" | "name" | "username" | "profile_photo"> & {
298+
profile_photo_url?: string | null;
299+
})
300+
| null;
297301
}

src/backend/services/notifications.actions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ export async function listMyNotifications(
4949
json_build_object(
5050
'id', u.id,
5151
'name', u.name,
52-
'username', u.username
52+
'username', u.username,
53+
'profile_photo', u.profile_photo
5354
)
5455
ELSE NULL END AS actor
5556
FROM notifications n

0 commit comments

Comments
 (0)