2026-04-14 08:09:19 +00:00
|
|
|
import { listen } from "@tauri-apps/api/event";
|
|
|
|
|
import {
|
|
|
|
|
isPermissionGranted,
|
|
|
|
|
requestPermission,
|
|
|
|
|
sendNotification,
|
|
|
|
|
} from "@tauri-apps/plugin-notification";
|
2026-04-21 16:01:04 +00:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2026-04-14 08:09:19 +00:00
|
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
|
|
|
import {
|
|
|
|
|
listNotifications,
|
|
|
|
|
markAllNotificationsRead,
|
|
|
|
|
markNotificationRead,
|
|
|
|
|
} from "../../lib/api";
|
|
|
|
|
import type { OrchaiNotification } from "../../lib/types";
|
2026-04-21 16:01:04 +00:00
|
|
|
import { useLiveRefresh } from "../../lib/useLiveRefresh";
|
2026-04-20 06:55:26 +00:00
|
|
|
import { buttonClass, cardClass, pillClass } from "../ui/primitives";
|
2026-04-14 08:09:19 +00:00
|
|
|
|
|
|
|
|
type NewNotificationEvent = {
|
|
|
|
|
notification: OrchaiNotification;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 15:37:49 +00:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
async function showSystemNotification(notification: OrchaiNotification) {
|
2026-04-21 15:37:49 +00:00
|
|
|
if (shouldSkipSystemNotification()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
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();
|
2026-04-14 08:35:06 +00:00
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
2026-04-14 08:09:19 +00:00
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [notifications, setNotifications] = useState<OrchaiNotification[]>([]);
|
2026-04-14 08:35:06 +00:00
|
|
|
const [filter, setFilter] = useState<"all" | "unread" | "errors" | "fixes">(
|
|
|
|
|
"all"
|
|
|
|
|
);
|
2026-04-14 08:09:19 +00:00
|
|
|
|
2026-04-21 16:01:04 +00:00
|
|
|
const loadNotifications = useCallback(async () => {
|
2026-04-14 08:09:19 +00:00
|
|
|
if (!projectId) {
|
|
|
|
|
setNotifications([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const items = await listNotifications(projectId, false);
|
|
|
|
|
setNotifications(items);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore load errors in layout chrome
|
|
|
|
|
}
|
2026-04-21 16:01:04 +00:00
|
|
|
}, [projectId]);
|
2026-04-14 08:09:19 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-21 16:01:04 +00:00
|
|
|
void loadNotifications();
|
|
|
|
|
}, [loadNotifications]);
|
|
|
|
|
|
|
|
|
|
useLiveRefresh({
|
|
|
|
|
enabled: Boolean(projectId),
|
|
|
|
|
projectId,
|
|
|
|
|
refresh: loadNotifications,
|
|
|
|
|
fallbackIntervalMs: 15_000,
|
|
|
|
|
});
|
2026-04-14 08:09:19 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-21 08:33:12 +00:00
|
|
|
let cancelled = false;
|
2026-04-14 08:09:19 +00:00
|
|
|
let unlisten: (() => void) | null = null;
|
|
|
|
|
|
|
|
|
|
const setup = async () => {
|
2026-04-21 08:33:12 +00:00
|
|
|
try {
|
|
|
|
|
const cleanup = await listen<NewNotificationEvent>("new-notification", (event) => {
|
|
|
|
|
const incoming = event.payload.notification;
|
2026-04-14 08:09:19 +00:00
|
|
|
|
2026-04-21 08:33:12 +00:00
|
|
|
if (projectId && incoming.project_id !== projectId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-14 08:09:19 +00:00
|
|
|
|
2026-04-21 08:33:12 +00:00
|
|
|
setNotifications((prev) => {
|
|
|
|
|
const withoutDuplicate = prev.filter((n) => n.id !== incoming.id);
|
|
|
|
|
return [incoming, ...withoutDuplicate];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void showSystemNotification(incoming);
|
2026-04-14 08:09:19 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-21 08:33:12 +00:00
|
|
|
if (cancelled) {
|
|
|
|
|
cleanup();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unlisten = cleanup;
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
console.error("Failed to subscribe to notifications", error);
|
|
|
|
|
}
|
2026-04-14 08:09:19 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void setup();
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-04-21 08:33:12 +00:00
|
|
|
cancelled = true;
|
2026-04-14 08:09:19 +00:00
|
|
|
if (unlisten) {
|
|
|
|
|
unlisten();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [projectId]);
|
|
|
|
|
|
2026-04-14 08:35:06 +00:00
|
|
|
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]);
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
const unreadCount = useMemo(
|
|
|
|
|
() => notifications.filter((n) => !n.read).length,
|
|
|
|
|
[notifications]
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-14 08:35:06 +00:00
|
|
|
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]);
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
async function handleOpenNotification(notification: OrchaiNotification) {
|
2026-04-20 07:25:31 +00:00
|
|
|
setOpen(false);
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
if (!notification.read) {
|
2026-04-20 07:25:31 +00:00
|
|
|
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(() => {
|
2026-04-14 08:09:19 +00:00
|
|
|
// ignore
|
2026-04-20 07:25:31 +00:00
|
|
|
});
|
2026-04-14 08:09:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-04-14 08:35:06 +00:00
|
|
|
<div className="relative" ref={containerRef}>
|
2026-04-14 08:09:19 +00:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen((v) => !v)}
|
2026-04-20 06:55:26 +00:00
|
|
|
className={`${buttonClass({ variant: "secondary", size: "sm" })} relative border border-gray-300`}
|
2026-04-14 08:09:19 +00:00
|
|
|
>
|
|
|
|
|
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 && (
|
2026-04-20 06:55:26 +00:00
|
|
|
<div className={`absolute right-0 z-20 mt-2 w-[360px] shadow-lg ${cardClass}`}>
|
2026-04-14 08:09:19 +00:00
|
|
|
<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}
|
2026-04-20 06:55:26 +00:00
|
|
|
className={buttonClass({ variant: "ghost", size: "xs" })}
|
2026-04-14 08:09:19 +00:00
|
|
|
disabled={!projectId || unreadCount === 0}
|
|
|
|
|
>
|
|
|
|
|
Mark all read
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-14 08:35:06 +00:00
|
|
|
<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")
|
|
|
|
|
}
|
2026-04-20 06:55:26 +00:00
|
|
|
className={`${pillClass(filter === item.id)} text-xs`}
|
2026-04-14 08:35:06 +00:00
|
|
|
>
|
|
|
|
|
{item.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-14 08:09:19 +00:00
|
|
|
<div className="max-h-[420px] overflow-y-auto">
|
2026-04-14 08:35:06 +00:00
|
|
|
{filteredNotifications.length === 0 ? (
|
2026-04-14 08:09:19 +00:00
|
|
|
<div className="px-3 py-6 text-center text-sm text-gray-400">
|
|
|
|
|
No notifications.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-04-14 08:35:06 +00:00
|
|
|
filteredNotifications.map((notification) => (
|
2026-04-14 08:09:19 +00:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|