import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts"; import { useState } from "react"; import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; import { mobileTokens } from "../lib/appearance"; import { useI18n } from "../lib/i18n"; 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, onAct, }: { block: CardBlock; onAct?: (act: CardAct) => Promise; }) { const tokens = mobileTokens(); const { t } = useI18n(); const [pending, setPending] = useState(null); const answered = Boolean(block.answer); const busy = !onAct || pending !== null; 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); } } const checkedNow = (list: Section<"checklist">, itemId: string, stored: boolean) => pending?.type === "toggle" && pending.sectionId === list.id && pending.itemId === itemId ? pending.checked : stored; const selectedNow = (select: Section<"select">, optionId: string) => pending?.type === "select" && pending.sectionId === select.id && pending.optionId === optionId ? (pending.selected ?? true) : (select.selected?.includes(optionId) ?? false); 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 === "checklist") { return ( {part.items.map((item) => { const checked = checkedNow(part, item.id, Boolean(item.checked)); return ( void act({ type: "toggle", sectionId: part.id, itemId: item.id, checked: !checked, }) } style={styles.checkRow} > {item.label} ); })} ); } if (part.type === "select") { return ( {part.options.map((option) => { const chosen = selectedNow(part, option.id); 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} ); })} ); } if (answered) return null; return ( {part.actions.map((action, position) => { const primary = position === 0; const disabled = busy || nothingChosen; return ( 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.label} ); })} ); })} ); } 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 }, });