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