Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
236 lines
8.4 KiB
TypeScript
236 lines
8.4 KiB
TypeScript
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<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
|
|
|
|
/** 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<void>;
|
|
}) {
|
|
const tokens = mobileTokens();
|
|
const { t } = useI18n();
|
|
const [pending, setPending] = useState<CardAct | null>(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 (
|
|
<View
|
|
testID="thread-card"
|
|
style={[styles.card, { borderColor: tokens.border, backgroundColor: tokens.card }]}
|
|
>
|
|
{block.sections?.map((part, index) => {
|
|
if (part.type === "header") {
|
|
return (
|
|
<View key={index} style={{ gap: 2 }}>
|
|
<Text style={[styles.title, { color: tokens.foreground }]}>{part.title}</Text>
|
|
{part.subtitle ? (
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{part.subtitle}
|
|
</Text>
|
|
) : null}
|
|
{part.meta?.length ? (
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{part.meta.join(" · ")}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "facts") {
|
|
return (
|
|
<View key={index} style={{ gap: 6 }}>
|
|
{part.rows.map((row) => (
|
|
<View key={row.label} style={styles.factRow}>
|
|
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{row.label}</Text>
|
|
<Text style={[styles.body, styles.factValue, { color: tokens.foreground }]}>
|
|
{row.value}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "checklist") {
|
|
return (
|
|
<View key={index}>
|
|
{part.items.map((item) => {
|
|
const checked = checkedNow(part, item.id, Boolean(item.checked));
|
|
return (
|
|
<Pressable
|
|
key={item.id}
|
|
accessibilityRole="checkbox"
|
|
accessibilityState={{ checked, disabled: busy }}
|
|
disabled={busy}
|
|
onPress={() =>
|
|
void act({
|
|
type: "toggle",
|
|
sectionId: part.id,
|
|
itemId: item.id,
|
|
checked: !checked,
|
|
})
|
|
}
|
|
style={styles.checkRow}
|
|
>
|
|
<NativeSymbol
|
|
ios={checked ? "checkmark.circle.fill" : "circle"}
|
|
android={checked ? "checkmark-circle" : "ellipse-outline"}
|
|
size={20}
|
|
color={checked ? tokens.primary : tokens.mutedForeground}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.body,
|
|
{
|
|
flexShrink: 1,
|
|
color: checked ? tokens.mutedForeground : tokens.foreground,
|
|
},
|
|
checked && { textDecorationLine: "line-through" },
|
|
]}
|
|
>
|
|
{item.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "select") {
|
|
return (
|
|
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
|
|
{part.options.map((option) => {
|
|
const chosen = selectedNow(part, option.id);
|
|
return (
|
|
<Pressable
|
|
key={option.id}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: chosen, disabled: busy || answered }}
|
|
disabled={busy || answered}
|
|
onPress={() =>
|
|
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,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.body,
|
|
{ color: chosen ? tokens.primaryForeground : tokens.foreground },
|
|
]}
|
|
>
|
|
{option.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
if (answered) return null;
|
|
return (
|
|
<View key={index} style={styles.chips}>
|
|
{part.actions.map((action, position) => {
|
|
const primary = position === 0;
|
|
const disabled = busy || nothingChosen;
|
|
return (
|
|
<Pressable
|
|
key={action.id}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ disabled }}
|
|
disabled={disabled}
|
|
onPress={() => 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,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.body,
|
|
{
|
|
fontWeight: "600",
|
|
color: primary ? tokens.primaryForeground : tokens.foreground,
|
|
},
|
|
]}
|
|
>
|
|
{action.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 },
|
|
});
|