Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

420 linesCodeBlameRaw
1import { AlarmClock, Bell, Bookmark, Check, CheckCheck, CircleCheck, CircleX, Ellipsis, Hand, Info, Mail, MailOpen, Undo2 } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
3import { Link, useFetcher } from "react-router";
4
5import type { InboxCounts, InboxItem, InboxSeverity } from "@g1t/contracts";
6
7import { WaitingCards } from "./notifications/card-actions";
8import { SubmitButton } from "./ui";
9import { Badge, type BadgeTone } from "./ui/badge";
10import { Button } from "./ui/button";
11import { Card } from "./ui/card";
12import { Hint } from "./ui/hint";
13import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
14import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
15import { Skeleton } from "./ui/skeleton";
16import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
17import { cn } from "../lib/cn";
18import { isPending } from "../lib/pending";
19import {
20 INBOX_TABS,
21 type InboxTab,
22 REASON_LABEL,
23 SEVERITY_LABEL,
24 SNOOZES,
25 bellCount,
26 emptyFor,
27 isUnread,
28 settledLine,
29 tabCount,
30 updatesLabel,
31 whenShort,
32} from "../lib/inbox";
33import { useOwnPresence } from "../lib/notify-client";
34import { dndOn } from "../lib/presence";
35import type { InboxPanelData } from "../routes/inbox-json";
36
37/** Where every notifications form posts (routes/notifications.tsx). */
38const ACTION = "/notifications";
39/** How often the bell asks for its count while the page is in view. */
40const COUNT_EVERY_MS = 60_000;
41
42const TONE: Record<InboxSeverity, BadgeTone> = {
43 error: "danger",
44 warning: "warn",
45 success: "accent",
46 info: "info",
47};
48
49const ICON: Record<InboxSeverity, { icon: ReactNode; ring: string }> = {
50 error: { icon: <CircleX size={15} />, ring: "bg-danger/10 text-danger ring-danger/30" },
51 warning: { icon: <Hand size={15} />, ring: "bg-warn/10 text-warn ring-warn/30" },
52 success: { icon: <CircleCheck size={15} />, ring: "bg-success/10 text-success ring-success/30" },
53 info: { icon: <Info size={15} />, ring: "bg-info/10 text-info ring-info/30" },
54};
55
56/** The round mark that says what kind of item it is. */
57function SeverityMark({ severity }: { severity: InboxSeverity }) {
58 const { icon, ring } = ICON[severity];
59 return (
60 <span aria-hidden="true" className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full ring-1", ring)}>
61 {icon}
62 </span>
63 );
64}
65
66/** Hidden fields naming one item's action. */
67function Fields({ intent, id, extra }: { intent: string; id: string; extra?: Record<string, string> }) {
68 return (
69 <>
70 <input type="hidden" name="intent" value={intent} />
71 <input type="hidden" name="id" value={id} />
72 {Object.entries(extra ?? {}).map(([name, value]) => (
73 <input key={name} type="hidden" name={name} value={value} />
74 ))}
75 </>
76 );
77}
78
79/**
80 * One item: what happened, to what, when, and how much it matters. The
81 * whole card opens what it is about and marks it read; Done is at hand,
82 * and the menu has the rest. Read items are dimmed. While Done or Snooze
83 * is on its way the card steps out of the list.
84 */
85export function InboxCard({ item, onOpen }: { item: InboxItem; onOpen?: () => void }) {
86 const fetcher = useFetcher<{ error?: string }>();
87 const unread = isUnread(item);
88 const leaving = isPending(fetcher, { id: item.id }) && ["done", "snooze", "undone"].includes(String(fetcher.formData?.get("intent")));
89 // Once out, it stays out: the list reloads after its request settles, and
90 // dismissing several quickly keeps putting that reload off. It comes back
91 // only if the request failed or the item itself changed.
92 const [left, setLeft] = useState<string | null>(null);
93 useEffect(() => {
94 if (leaving) setLeft(item.updatedAt);
95 }, [leaving, item.updatedAt]);
96 useEffect(() => {
97 if (fetcher.state === "idle" && fetcher.data?.error) setLeft(null);
98 }, [fetcher.state, fetcher.data]);
99 const [now, setNow] = useState(() => Date.now());
100 useEffect(() => setNow(Date.now()), [item.updatedAt]);
101 if (leaving || left === item.updatedAt) return null;
102
103 const submit = (intent: string, extra?: Record<string, string>) => fetcher.submit({ intent, id: item.id, ...extra }, { method: "post", action: ACTION });
104 const open = () => {
105 if (unread) submit("read");
106 onOpen?.();
107 };
108
109 return (
110 <li
111 className={cn(
112 "group relative flex gap-3 rounded-lg border border-line bg-bg/40 p-3 transition-colors hover:border-line-strong hover:bg-raised/50",
113 !unread && "opacity-60 hover:opacity-100",
114 )}
115 >
116 <SeverityMark severity={item.severity} />
117 <div className="min-w-0 flex-1">
118 <Link
119 to={item.url}
120 onClick={open}
121 className="block truncate pr-14 text-sm font-semibold text-fg outline-none [@media(hover:none)]:pr-24 after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-accent"
122 >
123 {item.title}
124 </Link>
125 {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>}
126 {/* Done by itself: what ended it, and who. */}
127 {settledLine(item) && (
128 <p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-fg-soft">
129 <CheckCheck size={12} aria-hidden="true" className="shrink-0 text-success" />
130 <span className="truncate">{settledLine(item)}</span>
131 </p>
132 )}
133 <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint [@media(hover:none)]:pr-24">
134 <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap">
135 {whenShort(item.updatedAt, now)}
136 </time>
137 <Badge tone={TONE[item.severity]} className="shrink-0">
138 {SEVERITY_LABEL[item.severity]}
139 </Badge>
140 {/* Why they were told, and how much has happened, said quietly. */}
141 <span className="truncate">{REASON_LABEL[item.reason] ?? item.reason}</span>
142 {updatesLabel(item.count) && (
143 <>
144 <span aria-hidden="true">·</span>
145 <span className="shrink-0 tabular-nums">{updatesLabel(item.count)}</span>
146 </>
147 )}
148 {item.saved && (
149 <span className="inline-flex items-center gap-1 text-faint">
150 <Bookmark size={12} aria-hidden="true" />
151 Saved
152 </span>
153 )}
154 </div>
155 {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>}
156 </div>
157 {unread && (
158 <span
159 aria-label="Unread"
160 className="absolute top-3.5 right-3 size-2 rounded-full bg-accent group-focus-within:hidden group-hover:hidden [@media(hover:none)]:hidden"
161 />
162 )}
163 {/* Above the card's link, so they act instead of opening it. On hover or focus, and always on a touch screen. */}
164 <div className="absolute top-2 right-2 z-10 hidden items-center gap-0.5 group-focus-within:flex group-hover:flex [@media(hover:none)]:flex">
165 {item.doneAt ? (
166 <fetcher.Form method="post" action={ACTION}>
167 <Fields intent="undone" id={item.id} />
168 <Hint label="Move back to Notifications">
169 <SubmitButton fetcher={fetcher} icon aria-label="Move back to Notifications" variant="outline" size="icon-xs" className={ICON_BUTTON}>
170 <Undo2 size={14} />
171 </SubmitButton>
172 </Hint>
173 </fetcher.Form>
174 ) : (
175 <fetcher.Form method="post" action={ACTION}>
176 <Fields intent="done" id={item.id} />
177 <Hint label="Done">
178 <SubmitButton fetcher={fetcher} icon aria-label="Done" variant="outline" size="icon-xs" className={ICON_BUTTON}>
179 <Check size={14} />
180 </SubmitButton>
181 </Hint>
182 </fetcher.Form>
183 )}
184 <DropdownMenu>
185 <DropdownMenuTrigger asChild>
186 <Button variant="outline" size="icon-xs" aria-label="More actions" className={ICON_BUTTON}>
187 <Ellipsis size={14} />
188 </Button>
189 </DropdownMenuTrigger>
190 <DropdownMenuContent align="end">
191 <DropdownMenuItem onSelect={() => submit(unread ? "read" : "unread")}>
192 {unread ? <MailOpen /> : <Mail />}
193 {unread ? "Mark as read" : "Mark as unread"}
194 </DropdownMenuItem>
195 <DropdownMenuItem onSelect={() => submit(item.saved ? "unsave" : "save")}>
196 <Bookmark />
197 {item.saved ? "Unsave" : "Save"}
198 </DropdownMenuItem>
199 {!item.doneAt && (
200 <>
201 <DropdownMenuSeparator />
202 <DropdownMenuLabel>Snooze until</DropdownMenuLabel>
203 {SNOOZES.map((snooze) => (
204 <DropdownMenuItem key={snooze.key} onSelect={() => submit("snooze", { for: snooze.key })}>
205 <AlarmClock />
206 {snooze.label}
207 </DropdownMenuItem>
208 ))}
209 </>
210 )}
211 </DropdownMenuContent>
212 </DropdownMenu>
213 </div>
214 </li>
215 );
216}
217
218/** A row's small action: an outline icon button on the surface, a finger wide on a phone. */
219const ICON_BUTTON = "bg-surface text-muted max-sm:size-10 disabled:opacity-60";
220
221/** What an empty list says, for its tab. */
222export function InboxEmpty({ tab, view }: { tab: InboxTab; view?: "inbox" | "saved" | "done" }) {
223 const { title, detail } = emptyFor(tab, view);
224 return (
225 <Card tone="plain" radius="lg" className="flex flex-col items-center gap-2 border-dashed px-6 py-12 text-center">
226 <Bell size={20} className="text-faint" aria-hidden="true" />
227 <p className="text-sm font-medium">{title}</p>
228 <p className="max-w-64 text-xs text-muted">{detail}</p>
229 </Card>
230 );
231}
232
233/** The tab row, with what is unread under each. */
234export function InboxTabs({ tab, counts, onChange }: { tab: InboxTab; counts: InboxCounts | null; onChange: (tab: InboxTab) => void }) {
235 return (
236 <Tabs value={tab} onValueChange={(value) => onChange(value as InboxTab)}>
237 <TabsList className="flex w-full overflow-x-auto scrollbar-none">
238 {INBOX_TABS.map((entry) => {
239 const count = tabCount(counts, entry.tab);
240 return (
241 <TabsTrigger key={entry.tab} value={entry.tab} className="flex shrink-0 grow items-center justify-center gap-1.5">
242 {entry.label}
243 {count > 0 && (
244 <span
245 className={cn("rounded-full px-1.5 text-[0.6875rem] tabular-nums", entry.tab === "needs" ? "bg-warn/15 text-warn" : "bg-line text-muted")}
246 >
247 {count}
248 </span>
249 )}
250 </TabsTrigger>
251 );
252 })}
253 </TabsList>
254 </Tabs>
255 );
256}
257
258/** Mark all read, for the tab being looked at. */
259export function MarkAllRead({ tab, disabled }: { tab: InboxTab; disabled: boolean }) {
260 const fetcher = useFetcher();
261 return (
262 <fetcher.Form method="post" action={ACTION}>
263 <input type="hidden" name="intent" value="read_all" />
264 <input type="hidden" name="tab" value={tab} />
265 <SubmitButton fetcher={fetcher} disabled={disabled} pending="Marking…" variant="ghost" size="inline" className="px-2 py-1 text-xs">
266 <CheckCheck size={14} />
267 Mark all read
268 </SubmitButton>
269 </fetcher.Form>
270 );
271}
272
273/**
274 * The bell in the top bar, and the panel it opens from the right: the
275 * notifications's tabs and newest items, to work through without leaving the page.
276 * Its count comes with the page (root.tsx), and is asked for again every
277 * minute while the page is in view, and after anything is marked.
278 */
279export function NotificationsBell({ counts: loaded }: { counts: InboxCounts | null }) {
280 const [open, setOpen] = useState(false);
281 const [tab, setTab] = useState<InboxTab>("all");
282 const list = useFetcher<InboxPanelData>({ key: "inbox-panel" });
283 const poll = useFetcher<InboxPanelData>({ key: "inbox-count" });
284
285 useEffect(() => {
286 if (open) list.load(`/inbox.json?tab=${tab}`);
287 // eslint-disable-next-line react-hooks/exhaustive-deps
288 }, [open, tab]);
289 useEffect(() => {
290 const ask = () => {
291 if (document.visibilityState === "visible") poll.load("/inbox.json?only=counts");
292 };
293 const timer = setInterval(ask, COUNT_EVERY_MS);
294 window.addEventListener("focus", ask);
295 return () => {
296 clearInterval(timer);
297 window.removeEventListener("focus", ask);
298 };
299 // eslint-disable-next-line react-hooks/exhaustive-deps
300 }, []);
301
302 const counts = (open ? list.data?.counts : null) ?? poll.data?.counts ?? loaded;
303 const unread = counts?.unread ?? 0;
304 const items = list.data?.tab === tab ? list.data.items : null;
305 const shown = bellCount(unread);
306 // Do not disturb shows on the bell: an amber dot while it holds (the feed says; nothing on the server).
307 const dnd = dndOn(useOwnPresence(), Date.now());
308
309 return (
310 <Sheet open={open} onOpenChange={setOpen}>
311 <SheetTrigger asChild>
312 <Button
313 variant="ghost"
314 size="icon-sm"
315 aria-label={`${unread ? `Notifications, ${unread} unread` : "Notifications"}${dnd ? ", do not disturb is on" : ""}`}
316 className="relative"
317 >
318 <Bell size={17} />
319 {dnd && <span aria-hidden="true" className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full bg-warn ring-2 ring-bg" />}
320 {shown && (
321 // Amber while something waits on them, red for a failure, else the accent.
322 <span
323 className={cn(
324 "absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[0.625rem] font-semibold tabular-nums text-bg ring-2 ring-bg",
325 (counts?.warning ?? 0) > 0 ? "bg-warn" : (counts?.error ?? 0) > 0 ? "bg-danger" : "bg-accent",
326 )}
327 >
328 {shown}
329 </span>
330 )}
331 </Button>
332 </SheetTrigger>
333 <SheetContent
334 // Focus starts on the tab being looked at, not on Mark all read.
335 onOpenAutoFocus={(event) => {
336 event.preventDefault();
337 (event.currentTarget as HTMLElement | null)?.querySelector<HTMLElement>('[role="tab"][data-state="active"]')?.focus();
338 }}
339 >
340 <SheetHeader className="flex-row items-center justify-between gap-3">
341 <SheetTitle>Notifications</SheetTitle>
342 <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />
343 </SheetHeader>
344 <div className="border-b border-line px-5 py-3">
345 <InboxTabs tab={tab} counts={counts} onChange={setTab} />
346 </div>
347 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
348 {/* Chat cards waiting on you (live notifications), to act on from here. */}
349 <WaitingCards onNavigate={() => setOpen(false)} />
350 {items == null ? (
351 list.state === "idle" && list.data?.tab === tab ? (
352 <p className="text-sm text-muted">Notifications could not be loaded. Try again in a moment.</p>
353 ) : (
354 <ul aria-busy="true" className="space-y-2">
355 {Array.from({ length: 4 }, (_, index) => (
356 <Card asChild key={index} tone="plain" radius="lg" className="flex gap-3 p-3">
357 <li>
358 <Skeleton className="size-8 rounded-full" />
359 <div className="flex-1 space-y-2">
360 <Skeleton className="h-3.5 w-3/4" />
361 <Skeleton className="h-3 w-1/2" />
362 </div>
363 </li>
364 </Card>
365 ))}
366 </ul>
367 )
368 ) : items.length === 0 ? (
369 <InboxEmpty tab={tab} />
370 ) : (
371 <ul className="space-y-2">
372 {items.map((item) => (
373 <InboxCard key={item.id} item={item} onOpen={() => setOpen(false)} />
374 ))}
375 </ul>
376 )}
377 </div>
378 <SheetFooter className="justify-between">
379 <SheetDescription className="text-xs">Done, saved and snoozed items are on the Notifications page.</SheetDescription>
380 <SheetClose asChild>
381 <Link to={tab === "all" ? "/notifications" : `/notifications?tab=${tab}`} className="shrink-0 text-sm font-medium text-accent hover:underline">
382 Open Notifications
383 </Link>
384 </SheetClose>
385 </SheetFooter>
386 </SheetContent>
387 </Sheet>
388 );
389}
390
391/**
392 * Mission control's card of what is unread in the person's notifications and
393 * worth a look: items waiting on them, then failures. Titled for
394 * notifications, not "Needs you", which on mission control is the work list's
395 * own count. Not shown when there are none.
396 */
397export function InboxNeedsCard({ items, total }: { items: InboxItem[]; total: number }) {
398 if (items.length === 0) return null;
399 return (
400 <Card asChild className="p-5">
401 <section>
402 <div className="flex items-center justify-between gap-3">
403 <h2 className="flex items-center gap-2 text-sm font-semibold">
404 <Bell size={14} className="text-warn" aria-hidden="true" />
405 From your notifications
406 <span className="rounded-full bg-warn/15 px-1.5 text-[0.6875rem] font-medium tabular-nums text-warn">{total}</span>
407 </h2>
408 <Link to="/notifications" className="text-xs font-medium text-accent hover:underline">
409 Open Notifications
410 </Link>
411 </div>
412 <ul className="mt-3 space-y-2">
413 {items.map((item) => (
414 <InboxCard key={item.id} item={item} />
415 ))}
416 </ul>
417 </section>
418 </Card>
419 );
420}