| 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 { Button, type ButtonVariant } from "../ui/button"; |
| 7 | import { Card } from "../ui/card"; |
| 8 | import { Hint } from "../ui/hint"; |
| 9 | import { InputAddon, InputGroup } from "../ui/input"; |
| 10 | import { cn } from "../../lib/cn"; |
| 11 | import { actionMode, inputValue, moneyInitial } from "../../lib/card-actions"; |
| 12 | import { currentSink, dismiss, settle, useWaitingCards } from "../../lib/notify-client"; |
| 13 | import { 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. */ |
| 22 | const VARIANT: Record<NonNullable<CardAction["style"]>, ButtonVariant> = { primary: "accent", danger: "destructive", default: "outline" }; |
| 23 | const 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 | |
| 29 | type Said = { ok: boolean; message: string }; |
| 30 | |
| 31 | export 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. */ |
| 139 | function 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 | */ |
| 240 | export 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 | } |