import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts"; import { CARD_LIMITS } from "@rakazo/contracts"; import { applyCardAct, cardScale, cardTimerRemaining, checklistItemDisplay, formatCardClock, formatCardTimerLength, suggestionStatus, } from "@rakazo/core"; import { useState } from "react"; import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; import { mobileTokens } from "../lib/appearance"; import { dateLocaleForUi, useI18n } from "../lib/i18n"; import { CookingMode, useNow } from "./cooking-mode"; import { NativeSymbol } from "./native-symbol"; type Section = Extract; /** A bot's interactive card. Taps go to the server, which owns the card's state. */ export function ThreadCard({ block: stored, createdAt, onAct, }: { block: CardBlock; /** When the card was posted; an open suggestion expires after a while. */ createdAt?: string; onAct?: (act: CardAct) => Promise; }) { const tokens = mobileTokens(); const { t, locale } = useI18n(); const [pending, setPending] = useState(null); const [expanded, setExpanded] = useState([]); const [cooking, setCooking] = useState(false); // Show the tap in flight on top of the stored card, so the card answers at once. const preview = pending ? applyCardAct(stored, pending) : null; const block = preview?.ok ? preview.block : stored; const suggestion = block.suggestion ? suggestionStatus(block, createdAt ?? new Date()) : undefined; // A suggestion takes one tap; once it moved on, only its outcome shows. const answered = Boolean(block.answer) || (suggestion !== undefined && suggestion !== "proposed"); const busy = !onAct || pending !== null; const factor = cardScale(block); const header = block.sections?.find((part): part is Section<"header"> => part.type === "header"); const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps"); const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt))); async function act(next: CardAct) { if (!onAct || pending) return; setPending(next); try { await onAct(next); } catch (error) { Alert.alert( t("Could not submit answer"), error instanceof Error ? error.message : t("Please try again."), ); } finally { setPending(null); } } function visible(id: string, entries: T[]): { shown: T[]; hidden: number } { if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) { return { shown: entries, hidden: 0 }; } return { shown: entries.slice(0, CARD_LIMITS.collapsedEntries), hidden: entries.length - CARD_LIMITS.collapsedEntries, }; } function showMore(id: string, hidden: number) { if (!hidden) return null; return ( setExpanded((ids) => [...ids, id])} style={{ paddingVertical: 6 }} > {t("Show {count} more", { count: hidden })} ); } const roundButton = ( label: string, symbol: { ios: string; android: "remove" | "add" }, disabled: boolean, onPress: () => void, ) => ( ); const select = block.sections?.find((part): part is Section<"select"> => part.type === "select"); const nothingChosen = Boolean(select?.multiple && !select.selected?.length); return ( {block.sections?.map((part, index) => { if (part.type === "header") { return ( {part.title} {part.subtitle ? ( {part.subtitle} ) : null} {part.meta?.length ? ( {part.meta.join(" ยท ")} ) : null} ); } if (part.type === "facts") { return ( {part.rows.map((row) => ( {row.label} {row.value} ))} ); } if (part.type === "stepper") { const label = part.label; return ( {label} {roundButton( t("Fewer {label}", { label }), { ios: "minus", android: "remove" }, busy || part.value <= 1, () => void act({ type: "stepper", sectionId: part.id, value: part.value - 1 }), )} {part.value} {roundButton( t("More {label}", { label }), { ios: "plus", android: "add" }, busy || part.value >= CARD_LIMITS.stepperMax, () => void act({ type: "stepper", sectionId: part.id, value: part.value + 1 }), )} ); } if (part.type === "steps") { const { shown, hidden } = visible(part.id, part.steps); return ( {shown.map((step, position) => { const remaining = cardTimerRemaining(step.timerEndsAt, now); return ( {position + 1} {step.text} {step.timerSeconds ? ( {remaining !== null ? formatCardClock(remaining) : formatCardTimerLength(step.timerSeconds, dateLocaleForUi(locale))} ) : null} ); })} {showMore(part.id, hidden)} ); } if (part.type === "checklist") { const { shown, hidden } = visible(part.id, part.items); return ( {shown.map((item) => { const checked = Boolean(item.checked); const display = checklistItemDisplay(item, factor); return ( void act({ type: "toggle", sectionId: part.id, itemId: item.id, checked: !checked, }) } style={styles.checkRow} > {display.amount ? ( {display.amount} ) : null} {display.label} ); })} {showMore(part.id, hidden)} ); } if (part.type === "select") { return ( {part.options.map((option) => { const chosen = part.selected?.includes(option.id) ?? false; return ( void act({ type: "select", sectionId: part.id, optionId: option.id, ...(part.multiple ? { selected: !chosen } : {}), }) } style={[ styles.option, part.multiple ? null : { alignSelf: "stretch" }, { borderColor: chosen ? tokens.primary : tokens.border, backgroundColor: chosen ? tokens.primary : "transparent", opacity: answered && !chosen ? 0.5 : 1, }, ]} > {option.label} ); })} ); } const actions = part.actions.filter((action) => action.kind === "steps" || !answered); if (!actions.length) return null; return ( {actions.map((action, position) => { const primary = position === 0; const opensSteps = action.kind === "steps"; const disabled = opensSteps ? !steps : busy || nothingChosen; return ( opensSteps ? setCooking(true) : void act({ type: "action", actionId: action.id }) } style={[ styles.option, { borderColor: primary ? tokens.primary : tokens.border, backgroundColor: primary ? tokens.primary : "transparent", opacity: disabled ? 0.5 : 1, }, ]} > {action.kind === "accept" ? t("Accept") : action.kind === "dismiss" ? t("Dismiss") : action.label} ); })} ); })} {suggestion && suggestion !== "proposed" ? ( {suggestion === "failed" ? (block.suggestion?.error ?? t("Failed")) : suggestion === "running" ? t("Running") : suggestion === "done" ? t("Done") : suggestion === "dismissed" ? t("Dismissed") : t("Expired")} ) : null} {cooking && steps ? ( setCooking(false)} /> ) : null} ); } const styles = StyleSheet.create({ card: { width: "90%", gap: 14, borderRadius: 18, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 14, }, title: { fontSize: 16, fontWeight: "600" }, body: { fontSize: 15.5, lineHeight: 22 }, small: { fontSize: 13.5 }, factRow: { flexDirection: "row", gap: 12 }, factValue: { flex: 1 }, checkRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 }, chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, option: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 14, paddingVertical: 10 }, stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, stepperControls: { flexDirection: "row", alignItems: "center", gap: 8 }, stepperValue: { minWidth: 28, textAlign: "center", fontWeight: "600" }, round: { width: 34, height: 34, borderRadius: 17, borderWidth: 1, alignItems: "center", justifyContent: "center", }, stepNumber: { width: 16, textAlign: "right" }, });