| 1 | import { ArrowUpRight, CircleAlert, CircleCheck, LoaderCircle, X } from "lucide-react"; |
| 2 | import { type FormEvent, useEffect, useId, useRef, useState } from "react"; |
| 3 | |
| 4 | import type { CardAction, CardActionResult, FeedNotification, Result } from "@g1t/contracts"; |
| 5 | |
| 6 | import { Hint } from "../ui/hint"; |
| 7 | import { InputAddon, InputGroup } from "../ui/input"; |
| 8 | import { cn } from "../../lib/cn"; |
| 9 | import { actionMode, inputValue, moneyInitial } from "../../lib/card-actions"; |
| 10 | import { currentSink, dismiss, settle, useWaitingCards } from "../../lib/notify-client"; |
| 11 | import { 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 | |
| 19 | const 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 | |
| 22 | const 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 | |
| 28 | const buttonClass = (style: CardAction["style"], active = false) => cn(BASE, STYLES[style ?? "default"], active && "border-accent/60 bg-raised text-fg"); |
| 29 | |
| 30 | type Said = { ok: boolean; message: string }; |
| 31 | |
| 32 | export 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. */ |
| 140 | function 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 | */ |
| 241 | export 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 | } |