orchai/src/components/layout/NotificationCenter.tsx

301 lines
8.6 KiB
TypeScript
Raw Normal View History

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 {
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 } = useParams();
const containerRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState<OrchaiNotification[]>([]);
const [filter, setFilter] = useState<"all" | "unread" | "errors" | "fixes">(
"all"
);
const loadNotifications = useCallback(async () => {
if (!projectId) {
setNotifications([]);
return;
}
try {
const items = await listNotifications(projectId, false);
setNotifications(items);
} catch {
// Ignore load errors in layout chrome
}
}, [projectId]);
useEffect(() => {
void loadNotifications();
}, [loadNotifications]);
useLiveRefresh({
enabled: Boolean(projectId),
projectId,
refresh: loadNotifications,
fallbackIntervalMs: 15_000,
});
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | null = null;
const setup = async () => {
try {
const cleanup = await listen<NewNotificationEvent>("new-notification", (event) => {
const incoming = event.payload.notification;
if (projectId && incoming.project_id !== projectId) {
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();
}
};
}, [projectId]);
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 (!projectId) {
return;
}
try {
await markAllNotificationsRead(projectId);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
} catch {
// ignore
}
}
return (
<div className="relative" ref={containerRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`${buttonClass({ variant: "secondary", size: "sm" })} relative border border-gray-300`}
>
Notifications
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-semibold text-white">
{unreadCount}
</span>
)}
</button>
{open && (
<div className={`absolute right-0 z-20 mt-2 w-[360px] shadow-lg ${cardClass}`}>
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<h3 className="text-sm font-semibold text-gray-800">Notifications</h3>
<button
type="button"
onClick={handleMarkAllRead}
className={buttonClass({ variant: "ghost", size: "xs" })}
disabled={!projectId || unreadCount === 0}
>
Mark all read
</button>
</div>
<div className="flex gap-1 border-b border-gray-100 px-2 py-2">
{[
{ id: "all", label: "All" },
{ id: "unread", label: "Unread" },
{ id: "errors", label: "Errors" },
{ id: "fixes", label: "Fixes" },
].map((item) => (
<button
key={item.id}
type="button"
onClick={() =>
setFilter(item.id as "all" | "unread" | "errors" | "fixes")
}
className={`${pillClass(filter === item.id)} text-xs`}
>
{item.label}
</button>
))}
</div>
<div className="max-h-[420px] overflow-y-auto">
{filteredNotifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-gray-400">
No notifications.
</div>
) : (
filteredNotifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => void handleOpenNotification(notification)}
className={`block w-full border-b border-gray-100 px-3 py-2 text-left hover:bg-gray-50 ${
notification.read ? "bg-white" : "bg-blue-50/40"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-gray-900">
{notification.title}
</span>
{!notification.read && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-600" />
)}
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-gray-600">
{notification.message}
</p>
<p className="mt-1 text-[11px] text-gray-400">
{new Date(notification.created_at).toLocaleString()}
</p>
</button>
))
)}
</div>
</div>
)}
</div>
);
}