Skip to content
84 linesCodeBlameRaw
1/**
2 * Cards people can act on in chat: how a
3 * card's state reads, which of its actions are links and which run, and
4 * the money and text people type into them. Pure, so it is tested on its
5 * own; components/chat/card.tsx draws them.
6 */
7import type { CardAction, MessageCard } from "@g1t/contracts";
8
9import type { SessionChip } from "./session-card";
10import { microsOf, plainDollars } from "./money.ts";
11
12/** A card's state chip, as a session's is, outlined only when it is waiting quietly. */
13export type CardChip = { tone: SessionChip["tone"] | "merged"; live: boolean; outline?: boolean };
14
15const DRAFT_CHIPS: Record<string, CardChip> = {
16 // Waiting on someone to file it: lavender, outlined only.
17 draft: { tone: "accent", live: false, outline: true },
18 filed: { tone: "success", live: false },
19 discarded: { tone: "neutral", live: false },
20};
21
22/**
23 * How a draft issue's state reads; null for other kinds (a session's chip
24 * is lib/session-card.ts's, others are guessed from their words).
25 */
26export function cardChip(card: Pick<MessageCard, "kind" | "state">): CardChip | null {
27 if (!card.state) return null;
28 if (card.kind === "draft_issue") return DRAFT_CHIPS[card.state.trim().toLowerCase()] ?? { tone: "neutral", live: false };
29 return null;
30}
31
32/** What pressing an action does: opens a place, asks first, asks for a value first, or just runs. */
33export type ActionMode = "link" | "confirm" | "input" | "run";
34
35export function actionMode(action: CardAction): ActionMode {
36 if (action.href) return "link";
37 if (action.input) return "input";
38 if (action.confirm) return "confirm";
39 return "run";
40}
41
42/**
43 * The actions a card shows: links always; the rest only when the card has
44 * an owner to answer them (chat drops them otherwise, this is the same rule
45 * for a card that never went through it).
46 */
47export function shownActions(card: Pick<MessageCard, "actions" | "owner">): CardAction[] {
48 return (card.actions ?? []).filter((a) => !!a.href || !!card.owner);
49}
50
51/** Dollars as people type them ("5", "$12.5", "1,200") as "12.50", or null when it is not an amount. */
52export function moneyValue(input: string | null | undefined): string | null {
53 const text = String(input ?? "")
54 .trim()
55 .replace(/^\$\s*/, "")
56 .replace(/,/g, "");
57 if (!/^\d+(\.\d{0,2})?$|^\.\d{1,2}$/.test(text)) return null;
58 const amount = Number(text);
59 if (!Number.isFinite(amount) || amount <= 0) return null;
60 return plainDollars(microsOf(amount));
61}
62
63/** What a money field starts with: the suggestion as an amount, or empty. */
64export function moneyInitial(initial: string | null | undefined): string {
65 return moneyValue(initial) ?? "";
66}
67
68/** What is typed into a text action, trimmed, or null when there is nothing. */
69export function textValue(input: string | null | undefined): string | null {
70 const text = String(input ?? "").trim();
71 return text ? text : null;
72}
73
74/** The value an input action sends, or null when it is not ready to send. */
75export function inputValue(action: CardAction, typed: string): string | null {
76 if (!action.input) return null;
77 return action.input.kind === "money" ? moneyValue(typed) : textValue(typed);
78}
79
80/** Whether a body is long enough that its preview starts folded (over about eight lines). */
81export function foldsBody(body: string, lines = 8): boolean {
82 const rows = body.split("\n").reduce((n, line) => n + Math.max(1, Math.ceil(line.length / 80)), 0);
83 return rows > lines;
84}