<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const notificationItem = tv({
slots: {
// the row subscribes to NotificationList's columns, so every row shares one
// lead column width and the descriptions line up down the whole list. it
// only lays out correctly inside that grid.
root: "group/notification relative isolate col-span-full grid grid-cols-subgrid items-center gap-y-2xs rounded-(--notification-radius) px-xs py-sm hover:bg-background-canvas md:px-sm dark:hover:bg-background-inset",
lead: "relative col-start-1 row-start-1",
badge: "absolute -right-1.5 -bottom-1.5 flex size-5 items-center justify-center rounded-full bg-background-default group-hover/notification:bg-background-canvas dark:group-hover/notification:bg-background-inset",
icon: "size-3 shrink-0",
title: "col-start-2 row-start-1 flex min-w-0 items-center typography-paragraph-small text-inherit no-underline after:absolute after:inset-0 after:z-1 hover:no-underline",
slot: "flex shrink-0 transition-[width,margin-right] duration-200 ease-out motion-reduce:transition-none",
dot: "size-2 shrink-0 rounded-full bg-background-info-emphasis transition-transform motion-reduce:transition-none",
heading:
"flex min-w-0 items-center overflow-hidden *:not-first:before:whitespace-pre *:not-first:before:content-['_']",
number: "shrink-0",
text: "truncate",
subtitle:
"col-start-2 row-start-2 flex min-w-0 overflow-hidden typography-paragraph-small text-foreground-subtle *:not-first:before:whitespace-pre *:not-first:before:content-['_']",
actor: "min-w-0 truncate",
phrase: "min-w-0 truncate",
repo: "min-w-0 truncate",
action: "col-start-3 row-start-1 flex h-4 items-center justify-end",
toggle: "relative z-10 hidden items-center text-foreground-placeholder hover:cursor-pointer hover:text-foreground-muted md:group-hover/notification:flex",
time: "col-start-3 text-right typography-paragraph-small whitespace-nowrap text-foreground-subtle"
},
variants: {
single: {
true: { time: "row-start-1 md:group-hover/notification:invisible" },
false: { root: "grid-rows-[auto_auto]", lead: "row-span-2", time: "row-start-2" }
},
read: {
true: { title: "text-foreground-subtle", slot: "mr-0 w-0", dot: "scale-0" },
false: {
title: "font-medium text-foreground-default",
slot: "mr-xs w-2",
dot: "scale-100"
}
}
},
defaultVariants: {
single: false,
read: true
}
});
export type NotificationItemVariants = VariantProps<typeof notificationItem>;
</script>
<script lang="ts">
import { flushSync } from "svelte";
import type { Attachment } from "svelte/attachments";
import Mail from "$icon/mail";
import MailOpen from "$icon/mail-open";
import { buttonIcon } from "#lib/components/ui/Button.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import TimeAgo from "#lib/components/ui/TimeAgo.svelte";
import { EXIT_DURATION, EXIT_EASING, POP_DURATION, POP_EASING } from "#lib/motion/spring.ts";
import { profileIdentifier } from "#lib/api/identity.ts";
import {
notificationHref,
notificationIcon,
notificationPhrase,
notificationTarget,
notificationTone
} from "#lib/components/notifications/notification.ts";
import type { NotificationSummary } from "#lib/components/notifications/types.ts";
interface Props {
notification: NotificationSummary;
onToggleRead?: (uri: string, read: boolean) => void;
}
let { notification, onToggleRead }: Props = $props();
const single = $derived(!notification.subject);
const style = $derived(notificationItem({ single, read: notification.read }));
const Icon = $derived(notificationIcon(notification.type));
// resolve() would mangle the raw at:// uri here (collapses "//"), so it's used as-is
const href = $derived(notificationHref(notification));
const actor = $derived(profileIdentifier(notification.actor.handle, notification.actor.did));
let includingOwner = $state(true);
const target = $derived(notificationTarget(notification, includingOwner));
const truncates = (element: Element) =>
[...element.querySelectorAll("*")].some(
(child) =>
getComputedStyle(child).textOverflow === "ellipsis" &&
child.scrollWidth > child.clientWidth + 1
);
const fit: Attachment<HTMLElement> = (line) => {
const observer = new ResizeObserver(() => {
includingOwner = true;
flushSync();
if (!truncates(line)) return;
includingOwner = false;
flushSync();
});
observer.observe(line);
for (const child of line.children) observer.observe(child);
return () => observer.disconnect();
};
const toggle = () => onToggleRead?.(notification.uri, !notification.read);
const open = (event: MouseEvent & { currentTarget: HTMLAnchorElement }) => {
onToggleRead?.(notification.uri, true);
const popover = event.currentTarget.closest("[popover]");
if (popover instanceof HTMLElement) popover.hidePopover();
};
</script>
{#snippet summary()}
<span class={style.actor()} title={actor}>{actor}</span>
<span class={style.phrase()}>{notificationPhrase(notification, true)}</span>
{#if target}
<span class={style.repo()} title={notificationTarget(notification)}>{target}</span>
{/if}
{/snippet}
<div
data-testid="notificationRow"
data-uri={notification.uri}
data-type={notification.type}
data-notification
class={style.root()}
>
<div class={style.lead()}>
<Avatar
did={notification.actor.did}
handle={notification.actor.handle}
size="full"
pixels={32}
class="size-8"
/>
<span class={style.badge()} aria-hidden="true">
<Icon class={style.icon({ class: notificationTone(notification.type) })} />
</span>
</div>
<!-- the href embeds an at:// subject uri whose // resolve() would collapse -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a data-testid="notificationPhraseLink" {href} class={style.title()} onclick={open}>
<span class={style.slot()} aria-hidden="true">
<span
class={style.dot()}
style:transition-duration="{notification.read ? EXIT_DURATION : POP_DURATION}ms"
style:transition-timing-function={notification.read ? EXIT_EASING : POP_EASING}
></span>
</span>
{#if notification.subject}
<span class={style.heading()}>
<span class={style.number()}>#{notification.subject.number}</span>
{#if notification.subject.title}
<span class={style.text()} title={notification.subject.title}
>{notification.subject.title}</span
>
{/if}
</span>
{:else}
<span class={style.heading()} {@attach notification.repo && fit}>
{@render summary()}
</span>
{/if}
</a>
<div class={style.action()}>
{#if onToggleRead}
<button
type="button"
class={style.toggle()}
title={notification.read ? "Mark as unread" : "Mark as read"}
aria-label={notification.read ? "Mark as unread" : "Mark as read"}
onclick={toggle}
>
{#if notification.read}
<Mail class={buttonIcon({ size: "small" })} aria-hidden="true" />
{:else}
<MailOpen class={buttonIcon({ size: "small" })} aria-hidden="true" />
{/if}
</button>
{/if}
</div>
{#if !single}
<div class={style.subtitle()} {@attach notification.repo && fit}>
{@render summary()}
</div>
{/if}
<TimeAgo value={notification.createdAt} class={style.time()} />
</div>