Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
6.3 kB · 194 lines
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195<script lang="ts"> import { page } from "$app/state"; import { resolve } from "$app/paths"; import ArrowRight from "$icon/arrow-right"; import Bell from "$icon/bell"; import Button from "$lib/components/ui/Button.svelte"; import Dropdown from "$lib/components/ui/Dropdown.svelte"; import { createBobbinClient } from "$lib/api/client"; import { createDeliberiClient } from "$lib/api/deliberi"; import { getUnreadCount, hydrateNotifications, listNotifications, markAllNotificationsRead, updateNotificationSeen } from "$lib/api/notifications"; import { getAuth } from "$lib/auth.svelte"; import { createLoad } from "$lib/action.svelte"; import { notificationCategory } from "./notification"; import NotificationList from "./NotificationList.svelte"; import NotificationToolbar from "./NotificationToolbar.svelte"; import type { CategoryFilter, NotificationSummary, ReadFilter } from "./types";
interface Props { /** optional fixture override for storybook; omitted → loads from deliberi */ notifications?: NotificationSummary[]; }
let { notifications: fixtures }: Props = $props();
const auth = getAuth();
// the appview preview serves five rows and sends the rest to the full page const PREVIEW_LIMIT = 5;
let readFilter = $state<ReadFilter>("unread"); let categoryFilter = $state<CategoryFilter>("all");
let overrides = $state<Record<string, boolean>>({});
const previewLoad = createLoad(async () => { if (fixtures) { const unread = fixtures.filter((n) => !n.read).length; return { summaries: fixtures, totalUnread: unread }; } const agent = auth.agent; const config = page.data.publicConfig; if (!agent || !config?.deliberiUrl || !config?.bobbinUrl) throw new Error("Not ready");
const deliberi = createDeliberiClient({ deliberiUrl: config.deliberiUrl, agent }); const bobbin = createBobbinClient({ serviceUrl: config.bobbinUrl });
const [countResult, listResult] = await Promise.all([ getUnreadCount(deliberi), listNotifications(deliberi, { read: readFilter, limit: PREVIEW_LIMIT }).then((r) => hydrateNotifications(bobbin, r.notifications) ) ]); return { summaries: listResult, totalUnread: countResult.count }; });
const notifications = $derived(previewLoad.data?.summaries ?? []); const totalUnread = $derived(previewLoad.data?.totalUnread ?? 0);
const all = $derived( notifications.map((n) => (n.uri in overrides ? { ...n, read: overrides[n.uri] } : n)) );
// the badge counts everything unread, not the rows this filter shows const unreadCount = $derived(totalUnread); const badge = $derived(unreadCount > 99 ? "99+" : String(unreadCount));
// the unread tab filters on the data as it loaded, never on the local read // state, so toggling a row restyles it in place instead of dropping it out of // the list under the cursor. const unreadOnLoad = $derived(new Set(notifications.filter((n) => !n.read).map((n) => n.uri)));
const filtered = $derived( all .filter((n) => readFilter !== "unread" || unreadOnLoad.has(n.uri)) .filter( (n) => categoryFilter === "all" || notificationCategory(n.type) === categoryFilter ) );
const preview = $derived(filtered.slice(0, PREVIEW_LIMIT));
const toggleRead = async (uri: string, read: boolean) => { overrides = { ...overrides, [uri]: read }; if (fixtures) return; const agent = auth.agent; const config = page.data.publicConfig; if (!agent || !config?.deliberiUrl) return; const deliberi = createDeliberiClient({ deliberiUrl: config.deliberiUrl, agent }); await updateNotificationSeen(deliberi, { uri, read }).catch(() => {}); };
const markAllRead = async () => { const next: Record<string, boolean> = {}; for (const n of filtered) next[n.uri] = true; overrides = { ...overrides, ...next }; if (fixtures) return; const agent = auth.agent; const config = page.data.publicConfig; if (!agent || !config?.deliberiUrl) return; const deliberi = createDeliberiClient({ deliberiUrl: config.deliberiUrl, agent }); await markAllNotificationsRead(deliberi).catch(() => {}); };
// the popover survives a client-side navigation unless something closes it const dismiss = (event: MouseEvent & { currentTarget: HTMLAnchorElement }) => { const popover = event.currentTarget.closest("[popover]"); if (popover instanceof HTMLElement) popover.hidePopover(); };
const viewAll = $derived( resolve( (categoryFilter === "all" ? `/notifications?read=${readFilter}` : `/notifications?read=${readFilter}&category=${categoryFilter}`) as "/" ) );</script>
{#snippet count()} {#if unreadCount > 0} <span class="pointer-events-none absolute top-0.5 right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-background-info-emphasis px-1 typography-paragraph-mini text-foreground-on-emphasis" aria-hidden="true" > {badge} </span> {/if}{/snippet}
<!-- the popover needs the room a phone does not have, so mobile goes to the page --><div class="relative flex md:hidden"> <Button variant="ghost" size="small" href={resolve("/notifications")} icon={Bell} aria-label="Notifications, {unreadCount} unread" /> {@render count()}</div>
<div class="relative hidden md:flex"> <Dropdown id="notifications-menu" group="topbar" variant="button" buttonVariant="ghost" size="small" align="right" label="Notifications, {unreadCount} unread" menuClass="w-[36rem]" > {#snippet trigger()} <Bell class="size-4" aria-hidden="true" /> {/snippet}
<div class="p-2"> <NotificationToolbar {readFilter} {categoryFilter} hasNotifications={filtered.length > 0} preferences={false} onReadFilter={(filter) => (readFilter = filter)} onCategoryFilter={(filter) => (categoryFilter = filter)} onMarkAllRead={markAllRead} /> </div>
<NotificationList notifications={preview} {readFilter} border={false} grouped={false} onToggleRead={toggleRead} />
<a href={viewAll} onclick={dismiss} class="flex items-center justify-end gap-1 p-2 typography-paragraph-small text-foreground-muted no-underline transition-colors hover:bg-background-subtle hover:text-foreground-default hover:no-underline md:px-4" > View all <ArrowRight class="size-4" aria-hidden="true" /> </a> </Dropdown>
{@render count()}</div>