import { listen } from "@tauri-apps/api/event"; import { isPermissionGranted, requestPermission, sendNotification, } from "@tauri-apps/plugin-notification"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { getTicketResult, listNotifications, markAllNotificationsRead, markNotificationRead, } from "../../lib/api"; import type { OrchaiNotification } from "../../lib/types"; import { useLiveRefresh } from "../../lib/useLiveRefresh"; import { buttonClass, cardClass, pillClass } from "../ui/primitives"; type NewNotificationEvent = { notification: OrchaiNotification; }; function shouldSkipSystemNotification(): boolean { if (typeof navigator === "undefined") { return true; } // Workaround: tauri-plugin-notification on Linux can panic in Tokio runtime // (notify-rust/zbus `block_on` inside async runtime). Keep in-app notifications only. return navigator.userAgent.toLowerCase().includes("linux"); } async function showSystemNotification(notification: OrchaiNotification) { if (shouldSkipSystemNotification()) { return; } try { let permissionGranted = await isPermissionGranted(); if (!permissionGranted) { const permission = await requestPermission(); permissionGranted = permission === "granted"; } if (permissionGranted) { sendNotification({ title: notification.title, body: notification.message, }); } } catch { // Best effort only } } export default function NotificationCenter() { const navigate = useNavigate(); const { projectId, ticketId } = useParams(); const containerRef = useRef(null); const [open, setOpen] = useState(false); const [notifications, setNotifications] = useState([]); const [ticketProjectId, setTicketProjectId] = useState(null); const [filter, setFilter] = useState<"all" | "unread" | "errors" | "fixes">( "all" ); const scopedProjectId = projectId ?? ticketProjectId; useEffect(() => { if (projectId) { setTicketProjectId(null); return; } if (!ticketId) { setTicketProjectId(null); return; } setTicketProjectId(null); let cancelled = false; void getTicketResult(ticketId) .then((result) => { if (!cancelled) { setTicketProjectId(result.ticket.project_id); } }) .catch(() => { if (!cancelled) { setTicketProjectId(null); } }); return () => { cancelled = true; }; }, [projectId, ticketId]); const loadNotifications = useCallback(async () => { if (!scopedProjectId) { setNotifications([]); return; } try { const items = await listNotifications(scopedProjectId, false); setNotifications(items); } catch { // Ignore load errors in layout chrome } }, [scopedProjectId]); useEffect(() => { void loadNotifications(); }, [loadNotifications]); useLiveRefresh({ enabled: Boolean(scopedProjectId), projectId: scopedProjectId, refresh: loadNotifications, fallbackIntervalMs: 15_000, }); useEffect(() => { let cancelled = false; let unlisten: (() => void) | null = null; const setup = async () => { try { const cleanup = await listen("new-notification", (event) => { const incoming = event.payload.notification; if (scopedProjectId && incoming.project_id !== scopedProjectId) { return; } setNotifications((prev) => { const withoutDuplicate = prev.filter((n) => n.id !== incoming.id); return [incoming, ...withoutDuplicate]; }); void showSystemNotification(incoming); }); if (cancelled) { cleanup(); return; } unlisten = cleanup; } catch (error: unknown) { console.error("Failed to subscribe to notifications", error); } }; void setup(); return () => { cancelled = true; if (unlisten) { unlisten(); } }; }, [scopedProjectId]); useEffect(() => { if (!open) { return; } function handleOutsideClick(event: MouseEvent) { const target = event.target as Node | null; if (!containerRef.current || !target) { return; } if (!containerRef.current.contains(target)) { setOpen(false); } } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { setOpen(false); } } document.addEventListener("mousedown", handleOutsideClick); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleOutsideClick); document.removeEventListener("keydown", handleEscape); }; }, [open]); const unreadCount = useMemo( () => notifications.filter((n) => !n.read).length, [notifications] ); const filteredNotifications = useMemo(() => { switch (filter) { case "unread": return notifications.filter((n) => !n.read); case "errors": return notifications.filter((n) => n.notification_type === "Error"); case "fixes": return notifications.filter((n) => n.notification_type === "FixReady"); default: return notifications; } }, [filter, notifications]); async function handleOpenNotification(notification: OrchaiNotification) { setOpen(false); if (!notification.read) { setNotifications((prev) => prev.map((n) => (n.id === notification.id ? { ...n, read: true } : n)) ); // Do not block navigation on read acknowledgement. void markNotificationRead(notification.id).catch(() => { // ignore }); } if (notification.ticket_id) { navigate(`/tickets/${notification.ticket_id}`); return; } navigate(`/projects/${notification.project_id}`); } async function handleMarkAllRead() { if (!scopedProjectId) { return; } try { await markAllNotificationsRead(scopedProjectId); setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); } catch { // ignore } } return (
{open && (

Notifications

{[ { id: "all", label: "All" }, { id: "unread", label: "Unread" }, { id: "errors", label: "Errors" }, { id: "fixes", label: "Fixes" }, ].map((item) => ( ))}
{filteredNotifications.length === 0 ? (
No notifications.
) : ( filteredNotifications.map((notification) => ( )) )}
)}
); }