Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/notifications/NotificationToasts.tsx
T

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;