Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196<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>