Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,116 @@ self.addEventListener("fetch", (event) => {
}
// Everything else: let the browser handle it normally
});

/* ============================================================
* Push notification router
* - Foreground client present → postMessage (in-app toast)
* - Background → system Notification, coalesced
* within a 60s window via a shared tag (the coalescence key).
* ============================================================ */

const COALESCENCE_BODY_PREFIX = 80;
const MAX_NOTIFICATION_ACTIONS = 2;

/* Mirror of src/utils/topicRouter.ts coalescenceKey (the SW cannot import it). */
function coalescenceKey(topic, body) {
return `${topic} ${(body || "").slice(0, COALESCENCE_BODY_PREFIX)}`;
}

async function handlePush(event) {
let payload = null;
try {
payload = event.data ? event.data.json() : null;
} catch {
payload = null;
}
if (!payload || typeof payload.topic !== "string") return;

const { topic, title = "Notification", body = "", data = {}, actions = [] } =
payload;
const key = coalescenceKey(topic, body);

const clientList = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});

// Foreground detection: route to the in-app toast queue instead of the OS.
const hasVisibleClient = clientList.some(
(c) => c.visibilityState === "visible" || c.focused
);
if (hasVisibleClient) {
for (const client of clientList) {
client.postMessage({ type: "push-notification", coalescenceKey: key, payload });
}
return;
}

// Background: coalesce identical events sharing the tag into one notification.
const existing = await self.registration.getNotifications({ tag: key });
const prevCount =
existing.length && existing[0].data ? existing[0].data.count || 1 : 0;
const count = prevCount + 1;

await self.registration.showNotification(
count > 1 ? `${title} (${count})` : title,
{
body,
tag: key, // identical tag replaces the prior notification (coalescence)
renotify: count > 1,
icon: "/icon-192.png",
badge: "/icon-192.png",
data: { ...data, topic, count, actions, coalescenceKey: key },
actions: (actions || [])
.slice(0, MAX_NOTIFICATION_ACTIONS)
.map((a) => ({ action: a.action, title: a.title, icon: a.icon })),
}
);
}

self.addEventListener("push", (event) => {
event.waitUntil(handlePush(event));
});

/* ---------- Notification click: route to URL or app route ---------- */
function resolveTarget(notificationData, actionId) {
const actions = (notificationData && notificationData.actions) || [];
if (actionId) {
const matched = actions.find((a) => a.action === actionId);
if (matched) return matched.url || matched.route || "/";
}
const first = actions[0];
return (first && (first.url || first.route)) || "/";
}

async function openOrFocus(target, payload) {
const clientList = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of clientList) {
if ("focus" in client) {
await client.focus();
client.postMessage({ type: "notification-action", target, payload });
return;
}
}
if (self.clients.openWindow) {
await self.clients.openWindow(target);
}
}

self.addEventListener("notificationclick", (event) => {
event.notification.close();
const data = event.notification.data || {};
const target = resolveTarget(data, event.action);
event.waitUntil(openOrFocus(target, data));
});

/* ---------- Control messages from clients ---------- */
self.addEventListener("message", (event) => {
const msg = event.data;
if (msg && msg.type === "skip-waiting") {
self.skipWaiting();
}
});
121 changes: 121 additions & 0 deletions src/components/panels/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"use client";

import { useNotifications, notificationStore } from "@/store/slices/notificationSlice";
import {
MAX_NOTIFICATION_ACTIONS,
type AppNotification,
type NotificationAction,
} from "@/types/notification";

/**
* In-app toast renderer. Reads the coalesced notification queue and shows one
* toast per coalescence key, suffixing "(N more)" when a key has coalesced more
* than once. Action buttons map to a URL or internal route (max 2).
*/

export interface ToastContainerProps {
/** Navigate to an internal app route (e.g. router.push). */
onNavigate?: (route: string) => void;
/** Called when an action button is pressed, before navigation. */
onAction?: (notification: AppNotification, action: NotificationAction) => void;
className?: string;
}

function topicAccent(topic: string): string {
const domain = topic.split(".")[0];
switch (domain) {
case "meter":
return "border-l-blue-500";
case "contract":
return "border-l-amber-500";
case "system":
return "border-l-red-500";
default:
return "border-l-border";
}
}

export function ToastContainer({
onNavigate,
onAction,
className,
}: ToastContainerProps) {
const notifications = useNotifications();

if (notifications.length === 0) return null;

const handleAction = (
notification: AppNotification,
action: NotificationAction
) => {
onAction?.(notification, action);
if (action.route) {
onNavigate?.(action.route);
} else if (action.url && typeof window !== "undefined") {
window.open(action.url, "_blank", "noopener,noreferrer");
}
notificationStore.dismiss(notification.id);
};

return (
<div
className={`fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2 ${
className ?? ""
}`}
role="region"
aria-label="Notifications"
>
{notifications.map((n) => (
<div
key={n.id}
role="alert"
className={`rounded-lg border border-l-4 ${topicAccent(
n.topic
)} border-border bg-background p-3 shadow-lg`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{n.title}
{n.count > 1 && (
<span className="ml-1 font-normal text-muted-foreground">
({n.count - 1} more)
</span>
)}
</p>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{n.body}
</p>
<p className="mt-1 font-mono text-[10px] uppercase tracking-wide text-muted-foreground">
{n.topic}
</p>
</div>
<button
onClick={() => notificationStore.dismiss(n.id)}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Dismiss notification"
>
</button>
</div>

{n.actions && n.actions.length > 0 && (
<div className="mt-2 flex gap-2">
{n.actions.slice(0, MAX_NOTIFICATION_ACTIONS).map((action) => (
<button
key={action.action}
onClick={() => handleAction(n, action)}
className="rounded-md border border-border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent"
>
{action.title}
</button>
))}
</div>
)}
</div>
))}
</div>
);
}

export default ToastContainer;
Loading
Loading