Skip to content
282 linesCodeBlameRaw
1import { ArrowUpRight, CircleAlert, CircleCheck, LoaderCircle, X } from "lucide-react";
2import { type FormEvent, useEffect, useId, useRef, useState } from "react";
3
4import type { CardAction, CardActionResult, FeedNotification, Result } from "@g1t/contracts";
5
6import { Hint } from "../ui/hint";
7import { InputAddon, InputGroup } from "../ui/input";
8import { cn } from "../../lib/cn";
9import { actionMode, inputValue, moneyInitial } from "../../lib/card-actions";
10import { currentSink, dismiss, settle, useWaitingCards } from "../../lib/notify-client";
11import { cardActionRequest, notificationActions } from "../../lib/notify-store";
12
13// A chat card's actions on a notification about it (docs/WORKSPACE.md,
14// "Cards"): a session at its cap (Approve more with the amount inline,
15// Stop, Open), a draft issue (File issue, Discard). Pressing one sends the
16// same `card_action` as the card in the conversation; the card there
17// changes for everyone, and this notification is put away.
18
19const BASE =
20 "inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md px-2.5 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:pointer-events-none disabled:opacity-50 max-sm:h-9 max-sm:px-3";
21
22const STYLES: Record<NonNullable<CardAction["style"]>, string> = {
23 primary: "bg-accent text-bg hover:bg-accent-hover",
24 danger: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10",
25 default: "border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg",
26};
27
28const buttonClass = (style: CardAction["style"], active = false) => cn(BASE, STYLES[style ?? "default"], active && "border-accent/60 bg-raised text-fg");
29
30type Said = { ok: boolean; message: string };
31
32export function NotificationCardActions({
33 notification,
34 onNavigate,
35 className,
36}: {
37 notification: FeedNotification;
38 /** After a link is followed: closes the panel it is in. */
39 onNavigate?: () => void;
40 className?: string;
41}) {
42 const actions = notificationActions(notification);
43 const [busy, setBusy] = useState<string | null>(null);
44 const [open, setOpen] = useState<string | null>(null);
45 const [said, setSaid] = useState<Said | null>(null);
46 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
47 useEffect(() => () => {
48 if (timer.current) clearTimeout(timer.current);
49 }, []);
50 if (!actions.length) return null;
51 const opened = actions.find((a) => a.id === open) ?? null;
52
53 const run = async (action: CardAction, input: string | null) => {
54 const request = cardActionRequest(notification, action, input);
55 if (!request || busy) return;
56 setBusy(action.id);
57 setSaid(null);
58 let answer: Said;
59 try {
60 const response = await fetch(request.url, {
61 method: "POST",
62 headers: { "content-type": "application/json" },
63 body: JSON.stringify(request.body),
64 });
65 const result = (await response.json()) as Result<CardActionResult>;
66 answer = result.ok
67 ? { ok: result.value.ok, message: result.value.message ?? (result.value.ok ? `${action.label}: done.` : `${action.label} didn't work.`) }
68 : { ok: false, message: result.error.message };
69 } catch {
70 answer = { ok: false, message: `${action.label} didn't go through. Try again in a moment.` };
71 }
72 setBusy(null);
73 setSaid(answer);
74 if (answer.ok) {
75 setOpen(null);
76 // Long enough to read what happened, then it is put away.
77 timer.current = setTimeout(() => settle(notification.id), 1_400);
78 }
79 };
80
81 const press = (action: CardAction) => {
82 const mode = actionMode(action);
83 if (mode === "link") {
84 dismiss(notification.id);
85 onNavigate?.();
86 currentSink().open(action.href!);
87 return;
88 }
89 if (mode === "input" || mode === "confirm") {
90 setOpen((now) => (now === action.id ? null : action.id));
91 return;
92 }
93 void run(action, null);
94 };
95
96 return (
97 <div className={cn("space-y-2", className)}>
98 <div role="group" aria-label={`${notification.title}: actions`} className="flex flex-wrap items-center gap-1.5" aria-busy={busy ? true : undefined}>
99 {actions.map((action) => {
100 const mode = actionMode(action);
101 const asks = mode === "input" || mode === "confirm";
102 return (
103 <button
104 key={action.id}
105 type="button"
106 disabled={!!busy || !!said?.ok}
107 aria-expanded={asks ? open === action.id : undefined}
108 onClick={() => press(action)}
109 className={buttonClass(action.style, asks && open === action.id)}
110 >
111 {busy === action.id && <LoaderCircle size={12} aria-hidden="true" className="-ml-0.5 animate-spin" />}
112 {action.label}
113 {mode === "link" && <ArrowUpRight size={12} aria-hidden="true" className="-mr-0.5 text-faint" />}
114 </button>
115 );
116 })}
117 </div>
118 {opened && !said?.ok && (
119 <Ask
120 key={opened.id}
121 action={opened}
122 busy={busy === opened.id}
123 onCancel={() => setOpen(null)}
124 onSubmit={(value) => void run(opened, value)}
125 />
126 )}
127 {said && (
128 <p role={said.ok ? "status" : "alert"} className={cn("flex items-start gap-1.5 text-xs leading-snug", said.ok ? "text-success" : "text-danger")}>
129 <span className="mt-px shrink-0" aria-hidden="true">
130 {said.ok ? <CircleCheck size={13} /> : <CircleAlert size={13} />}
131 </span>
132 {said.message}
133 </p>
134 )}
135 </div>
136 );
137}
138
139/** What an action needs first, right there: an amount, a line of text, or a yes. */
140function Ask({ action, busy, onSubmit, onCancel }: { action: CardAction; busy: boolean; onSubmit: (value: string | null) => void; onCancel: () => void }) {
141 const input = action.input ?? null;
142 const money = input?.kind === "money";
143 const [value, setValue] = useState(() => (money ? moneyInitial(input?.initial) : (input?.initial ?? "")));
144 const [tried, setTried] = useState(false);
145 const field = useRef<HTMLInputElement>(null);
146 const id = useId();
147 const ready = input ? inputValue(action, value) : null;
148 const invalid = !!input && tried && !ready;
149
150 useEffect(() => {
151 field.current?.focus();
152 if (money) field.current?.select();
153 }, [money]);
154
155 const submit = (event: FormEvent) => {
156 event.preventDefault();
157 if (busy) return;
158 if (input && !ready) {
159 setTried(true);
160 field.current?.focus();
161 return;
162 }
163 onSubmit(input ? ready : null);
164 };
165
166 return (
167 <form
168 onSubmit={submit}
169 onKeyDown={(event) => {
170 if (event.key === "Escape") {
171 event.stopPropagation();
172 onCancel();
173 }
174 }}
175 className="rounded-lg bg-bg/70 p-2 ring-1 ring-line"
176 >
177 {input ? (
178 <label htmlFor={id} className="mb-1.5 block text-[0.6875rem] font-medium text-muted">
179 {input.label || action.label}
180 </label>
181 ) : (
182 <p className="mb-2 text-xs leading-snug text-fg-soft">{action.confirm}</p>
183 )}
184 <div className="flex items-center gap-1.5">
185 {input &&
186 (money ? (
187 <InputGroup className={cn("h-7 grow max-sm:h-9", invalid && "border-danger/70")}>
188 <InputAddon className="pr-0 font-mono text-muted">$</InputAddon>
189 <input
190 ref={field}
191 id={id}
192 value={value}
193 onChange={(event) => {
194 setValue(event.target.value);
195 setTried(false);
196 }}
197 inputMode="decimal"
198 autoComplete="off"
199 data-1p-ignore
200 placeholder={input.placeholder ?? "0.00"}
201 aria-invalid={invalid || undefined}
202 className="w-full min-w-0 bg-transparent pr-2 pl-1 font-mono text-xs text-fg tabular-nums outline-none placeholder:text-faint max-sm:text-base"
203 />
204 </InputGroup>
205 ) : (
206 <input
207 ref={field}
208 id={id}
209 value={value}
210 onChange={(event) => {
211 setValue(event.target.value);
212 setTried(false);
213 }}
214 autoComplete="off"
215 data-1p-ignore
216 placeholder={input.placeholder ?? undefined}
217 aria-invalid={invalid || undefined}
218 className="h-7 min-w-0 grow rounded-md border border-line bg-bg px-2 text-xs text-fg outline-none placeholder:text-faint focus-visible:border-accent-dim aria-invalid:border-danger/70 max-sm:h-9 max-sm:text-base"
219 />
220 ))}
221 <div className={cn("flex shrink-0 gap-1.5", !input && "w-full justify-end")}>
222 <button type="button" onClick={onCancel} className={buttonClass("default")}>
223 Cancel
224 </button>
225 <button type="submit" disabled={busy} aria-busy={busy || undefined} className={buttonClass(action.style === "danger" ? "danger" : "primary")}>
226 {busy && <LoaderCircle size={12} aria-hidden="true" className="-ml-0.5 animate-spin" />}
227 {action.label}
228 </button>
229 </div>
230 </div>
231 {invalid && <p className="mt-1.5 text-[0.6875rem] text-danger">{money ? "Enter an amount in dollars, like 5.00." : "Write something first."}</p>}
232 </form>
233 );
234}
235
236/**
237 * The chat cards waiting on the person, at the top of the notifications
238 * panel: each one's title and preview, a link to where it is, and its
239 * actions. Gone once acted on here, or after a day.
240 */
241export function WaitingCards({ onNavigate }: { onNavigate?: () => void }) {
242 const waiting = useWaitingCards();
243 if (!waiting.length) return null;
244 return (
245 <section aria-labelledby="waiting-cards" className="mb-4">
246 <h3 id="waiting-cards" className="mb-2 text-xs font-medium text-muted">
247 Waiting on you in chat
248 </h3>
249 <ul className="space-y-2">
250 {waiting.map((notification) => (
251 <li key={notification.id} className="relative rounded-lg border border-line bg-surface p-3">
252 <span aria-hidden="true" className="absolute inset-y-2 left-0 w-[3px] rounded-r bg-accent" />
253 <div className="flex items-start gap-2">
254 <button
255 type="button"
256 onClick={() => {
257 onNavigate?.();
258 currentSink().open(notification.href);
259 }}
260 className="min-w-0 grow rounded text-left outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
261 >
262 <span className="block truncate text-sm font-medium text-fg hover:underline">{notification.title}</span>
263 {notification.body && <span className="mt-0.5 line-clamp-2 block text-xs text-muted">{notification.body}</span>}
264 </button>
265 <Hint label="Put away">
266 <button
267 type="button"
268 aria-label="Put away"
269 onClick={() => settle(notification.id)}
270 className="flex size-6 shrink-0 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
271 >
272 <X size={14} />
273 </button>
274 </Hint>
275 </div>
276 <NotificationCardActions notification={notification} onNavigate={onNavigate} className="mt-2.5" />
277 </li>
278 ))}
279 </ul>
280 </section>
281 );
282}