199 lines
5.6 KiB
TypeScript
199 lines
5.6 KiB
TypeScript
|
|
import { listen } from "@tauri-apps/api/event";
|
||
|
|
import {
|
||
|
|
isPermissionGranted,
|
||
|
|
requestPermission,
|
||
|
|
sendNotification,
|
||
|
|
} from "@tauri-apps/plugin-notification";
|
||
|
|
import { useEffect, useMemo, useState } from "react";
|
||
|
|
import { useNavigate, useParams } from "react-router-dom";
|
||
|
|
import {
|
||
|
|
listNotifications,
|
||
|
|
markAllNotificationsRead,
|
||
|
|
markNotificationRead,
|
||
|
|
} from "../../lib/api";
|
||
|
|
import type { OrchaiNotification } from "../../lib/types";
|
||
|
|
|
||
|
|
type NewNotificationEvent = {
|
||
|
|
notification: OrchaiNotification;
|
||
|
|
};
|
||
|
|
|
||
|
|
async function showSystemNotification(notification: OrchaiNotification) {
|
||
|
|
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 [open, setOpen] = useState(false);
|
||
|
|
const [notifications, setNotifications] = useState<OrchaiNotification[]>([]);
|
||
|
|
|
||
|
|
async function loadNotifications() {
|
||
|
|
if (!projectId) {
|
||
|
|
setNotifications([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const items = await listNotifications(projectId, false);
|
||
|
|
setNotifications(items);
|
||
|
|
} catch {
|
||
|
|
// Ignore load errors in layout chrome
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadNotifications();
|
||
|
|
}, [projectId]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let unlisten: (() => void) | null = null;
|
||
|
|
|
||
|
|
const setup = async () => {
|
||
|
|
unlisten = 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);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
void setup();
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
if (unlisten) {
|
||
|
|
unlisten();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}, [projectId]);
|
||
|
|
|
||
|
|
const unreadCount = useMemo(
|
||
|
|
() => notifications.filter((n) => !n.read).length,
|
||
|
|
[notifications]
|
||
|
|
);
|
||
|
|
|
||
|
|
async function handleOpenNotification(notification: OrchaiNotification) {
|
||
|
|
if (!notification.read) {
|
||
|
|
try {
|
||
|
|
await markNotificationRead(notification.id);
|
||
|
|
setNotifications((prev) =>
|
||
|
|
prev.map((n) => (n.id === notification.id ? { ...n, read: true } : n))
|
||
|
|
);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setOpen(false);
|
||
|
|
|
||
|
|
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">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setOpen((v) => !v)}
|
||
|
|
className="relative rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||
|
|
>
|
||
|
|
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] rounded-lg border border-gray-200 bg-white shadow-lg">
|
||
|
|
<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="text-xs text-blue-600 hover:underline"
|
||
|
|
disabled={!projectId || unreadCount === 0}
|
||
|
|
>
|
||
|
|
Mark all read
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="max-h-[420px] overflow-y-auto">
|
||
|
|
{notifications.length === 0 ? (
|
||
|
|
<div className="px-3 py-6 text-center text-sm text-gray-400">
|
||
|
|
No notifications.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
notifications.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>
|
||
|
|
);
|
||
|
|
}
|