152 lines
6.5 KiB
TypeScript
152 lines
6.5 KiB
TypeScript
// AK-P3 follow-up — macOS-style in-app notification toasts (top-right, below
|
|
// the shell header). Unread IN_APP deliveries pop up as dismissible toasts:
|
|
// X hides the toast (persisted per browser via localStorage, the delivery
|
|
// stays unread in the center), clicking the body marks it read and follows
|
|
// the deeplink. Data comes from the notifications route loader plus the
|
|
// shared SSE hook, so live arrivals toast too when the stream is available.
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useFetcher, useNavigate } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FeatherX } from "@subframe/core";
|
|
import type { NotificationCenterVM } from "@hivegrid/view-models";
|
|
import { Toast } from "~/ui/components/Toast";
|
|
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
|
|
import { useNotificationsStream, type NotificationsStreamState } from "~/components/notifications/useNotificationsStream";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import { useLocaleFormatters } from "~/locale/format";
|
|
import { appPath } from "~/utils/app/CanonicalUrlUtils";
|
|
|
|
const MAX_VISIBLE_TOASTS = 3;
|
|
const MAX_STORED_DISMISSED_IDS = 200;
|
|
|
|
function storageKeyFor(tenantSlug: string): string {
|
|
return `hg.notification-toasts.dismissed.${tenantSlug}`;
|
|
}
|
|
|
|
function readDismissedIds(tenantSlug: string): string[] {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
const raw = window.localStorage.getItem(storageKeyFor(tenantSlug));
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function persistDismissedIds(tenantSlug: string, ids: string[]): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(storageKeyFor(tenantSlug), JSON.stringify(ids.slice(-MAX_STORED_DISMISSED_IDS)));
|
|
} catch {
|
|
// Storage may be unavailable (private mode) — toasts then reappear on reload.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shared shell notification state: loads the notification-center data once per
|
|
* tenant and keeps it live via the SSE hook. Drives both the bell badge
|
|
* (unreadCount) and the toast stack (items).
|
|
*/
|
|
export function useShellNotifications(tenantSlug: string | null): { items: NotificationsStreamState["items"]; unreadCount: number } {
|
|
const loadFetcher = useFetcher<{ vm: NotificationCenterVM; streamUrl: string }>({ key: "notification-toasts" });
|
|
|
|
useEffect(() => {
|
|
if (tenantSlug) loadFetcher.load(appPath({ tenantSlug, path: "notifications", mode: "host-aware" }));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [tenantSlug]);
|
|
|
|
const initialItems = useMemo(
|
|
() => (loadFetcher.data?.vm.status === "ready" && loadFetcher.data.vm.data ? loadFetcher.data.vm.data.items : []),
|
|
[loadFetcher.data]
|
|
);
|
|
const stream = useNotificationsStream(tenantSlug && loadFetcher.data ? loadFetcher.data.streamUrl : null, initialItems);
|
|
return { items: stream.items, unreadCount: stream.unreadCount };
|
|
}
|
|
|
|
export function NotificationToasts({ tenantSlug, items }: { tenantSlug: string; items: NotificationsStreamState["items"] }) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const rootData = useRootData();
|
|
const formatters = useLocaleFormatters();
|
|
const markFetcher = useFetcher({ key: "notification-toasts-mark" });
|
|
const [dismissedIds, setDismissedIds] = useState<string[]>(() => readDismissedIds(tenantSlug));
|
|
|
|
const notificationsPath = appPath({ tenantSlug, path: "notifications", mode: "host-aware" });
|
|
|
|
useEffect(() => {
|
|
setDismissedIds(readDismissedIds(tenantSlug));
|
|
}, [tenantSlug]);
|
|
|
|
const visibleToasts = items
|
|
.filter((item) => !item.isRead && !dismissedIds.includes(item.id))
|
|
.slice(0, MAX_VISIBLE_TOASTS);
|
|
|
|
function dismiss(id: string) {
|
|
const next = [...dismissedIds, id];
|
|
setDismissedIds(next);
|
|
persistDismissedIds(tenantSlug, next);
|
|
}
|
|
|
|
function open(item: (typeof visibleToasts)[number]) {
|
|
dismiss(item.id);
|
|
const form = new FormData();
|
|
form.set("action", "mark-read");
|
|
form.set("deliveryId", item.id);
|
|
if (rootData?.csrf) form.set("csrf", rootData.csrf);
|
|
markFetcher.submit(form, { method: "post", action: notificationsPath });
|
|
navigate(item.deeplinkHref ?? notificationsPath);
|
|
}
|
|
|
|
if (visibleToasts.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className="pointer-events-none fixed right-4 top-16 z-50 flex w-full max-w-[336px] flex-col items-end gap-2"
|
|
data-ui-id="ui.notifications.toasts"
|
|
>
|
|
{visibleToasts.map((item) => (
|
|
<div key={item.id} className="pointer-events-auto relative w-full" data-ui-id={`ui.notifications.toast.${item.id}`}>
|
|
<button
|
|
type="button"
|
|
className="absolute right-2.5 top-2.5 z-10 flex h-5 w-5 items-center justify-center rounded-md text-icon-neutral-primary hover:bg-surface-neutral-tertiary"
|
|
aria-label={t("notifications.toasts.dismiss")}
|
|
onClick={() => dismiss(item.id)}
|
|
data-ui-id={`ui.notifications.toast.${item.id}.dismiss`}
|
|
>
|
|
<FeatherX />
|
|
</button>
|
|
<button type="button" className="w-full text-left" onClick={() => open(item)} data-ui-id={`ui.notifications.toast.${item.id}.open`}>
|
|
<Toast
|
|
variant="neutral"
|
|
hideProgress
|
|
hideUndo
|
|
className="w-full min-w-0 gap-0 px-4 py-2.5 pr-9"
|
|
title={
|
|
<span className="flex min-w-0 flex-col gap-1">
|
|
<span className="line-clamp-1 min-w-0 break-all" title={item.subjectLabel ? `${t(item.messageKey)}: ${item.subjectLabel}` : String(t(item.messageKey))}>
|
|
{item.subjectLabel ? `${t(item.messageKey)}: ${item.subjectLabel}` : t(item.messageKey)}
|
|
</span>
|
|
<span className="flex min-w-0 items-center gap-2 text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-secondary">
|
|
{item.createdAtIso ? (
|
|
<span className="whitespace-nowrap">{formatters.date(item.createdAtIso, { dateStyle: "short", timeStyle: "short" })}</span>
|
|
) : null}
|
|
{item.tagKey && item.tagTone ? (
|
|
<BadgeFeedback farbe={item.tagTone} size="small">
|
|
{t(item.tagKey)}
|
|
</BadgeFeedback>
|
|
) : null}
|
|
</span>
|
|
</span>
|
|
}
|
|
/>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default NotificationToasts;
|