update_bot takes a look (oo1: value), validated and normalized in core. A suggested look renders as a "New look" card with the avatar preview on web and mobile; accepting applies it to the bot. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com>
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
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 { BotAvatar } from "./bot-avatar";
|
|
import { CookingMode, useNow } from "./cooking-mode";
|
|
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: stored,
|
|
createdAt,
|
|
onAct,
|
|
}: {
|
|
block: CardBlock;
|
|
/** When the card was posted; an open suggestion expires after a while. */
|
|
createdAt?: string;
|
|
onAct?: (act: CardAct) => Promise<void>;
|
|
}) {
|
|
const tokens = mobileTokens();
|
|
const { t, locale } = useI18n();
|
|
const [pending, setPending] = useState<CardAct | null>(null);
|
|
const [expanded, setExpanded] = useState<string[]>([]);
|
|
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<T>(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 (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={() => setExpanded((ids) => [...ids, id])}
|
|
style={{ paddingVertical: 6 }}
|
|
>
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{t("Show {count} more", { count: hidden })}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const roundButton = (
|
|
label: string,
|
|
symbol: { ios: string; android: "remove" | "add" },
|
|
disabled: boolean,
|
|
onPress: () => void,
|
|
) => (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={label}
|
|
accessibilityState={{ disabled }}
|
|
disabled={disabled}
|
|
onPress={onPress}
|
|
style={[styles.round, { borderColor: tokens.border, opacity: disabled ? 0.4 : 1 }]}
|
|
>
|
|
<NativeSymbol ios={symbol.ios} android={symbol.android} size={16} />
|
|
</Pressable>
|
|
);
|
|
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={styles.header}>
|
|
{part.avatar ? <BotAvatar color={part.avatar} size={56} /> : null}
|
|
<View style={styles.headerText}>
|
|
<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>
|
|
</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 === "stepper") {
|
|
const label = part.label;
|
|
return (
|
|
<View key={index} style={styles.stepperRow}>
|
|
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{label}</Text>
|
|
<View style={styles.stepperControls}>
|
|
{roundButton(
|
|
t("Fewer {label}", { label }),
|
|
{ ios: "minus", android: "remove" },
|
|
busy || part.value <= 1,
|
|
() => void act({ type: "stepper", sectionId: part.id, value: part.value - 1 }),
|
|
)}
|
|
<Text
|
|
testID="card-stepper-value"
|
|
style={[styles.body, styles.stepperValue, { color: tokens.foreground }]}
|
|
>
|
|
{part.value}
|
|
</Text>
|
|
{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 }),
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "steps") {
|
|
const { shown, hidden } = visible(part.id, part.steps);
|
|
return (
|
|
<View key={index} style={{ gap: 10 }}>
|
|
{shown.map((step, position) => {
|
|
const remaining = cardTimerRemaining(step.timerEndsAt, now);
|
|
return (
|
|
<View key={step.id} style={{ flexDirection: "row", gap: 10 }}>
|
|
<Text
|
|
style={[styles.body, styles.stepNumber, { color: tokens.mutedForeground }]}
|
|
>
|
|
{position + 1}
|
|
</Text>
|
|
<View style={{ flex: 1, gap: 2 }}>
|
|
<Text style={[styles.body, { color: tokens.foreground }]}>{step.text}</Text>
|
|
{step.timerSeconds ? (
|
|
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
|
|
{remaining !== null
|
|
? formatCardClock(remaining)
|
|
: formatCardTimerLength(step.timerSeconds, dateLocaleForUi(locale))}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
{showMore(part.id, hidden)}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "checklist") {
|
|
const { shown, hidden } = visible(part.id, part.items);
|
|
return (
|
|
<View key={index}>
|
|
{shown.map((item) => {
|
|
const checked = Boolean(item.checked);
|
|
const display = checklistItemDisplay(item, factor);
|
|
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" },
|
|
]}
|
|
>
|
|
{display.amount ? (
|
|
<Text style={{ fontWeight: "600" }}>{display.amount} </Text>
|
|
) : null}
|
|
{display.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
{showMore(part.id, hidden)}
|
|
</View>
|
|
);
|
|
}
|
|
if (part.type === "select") {
|
|
return (
|
|
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
|
|
{part.options.map((option) => {
|
|
const chosen = part.selected?.includes(option.id) ?? false;
|
|
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>
|
|
);
|
|
}
|
|
const actions = part.actions.filter((action) => action.kind === "steps" || !answered);
|
|
if (!actions.length) return null;
|
|
return (
|
|
<View key={index} style={styles.chips}>
|
|
{actions.map((action, position) => {
|
|
const primary = position === 0;
|
|
const opensSteps = action.kind === "steps";
|
|
const disabled = opensSteps ? !steps : busy || nothingChosen;
|
|
return (
|
|
<Pressable
|
|
key={action.id}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ disabled }}
|
|
disabled={disabled}
|
|
onPress={() =>
|
|
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,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.body,
|
|
{
|
|
fontWeight: "600",
|
|
color: primary ? tokens.primaryForeground : tokens.foreground,
|
|
},
|
|
]}
|
|
>
|
|
{action.kind === "accept"
|
|
? t("Accept")
|
|
: action.kind === "dismiss"
|
|
? t("Dismiss")
|
|
: action.label}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
})}
|
|
{suggestion && suggestion !== "proposed" ? (
|
|
<Text
|
|
style={[
|
|
styles.small,
|
|
{
|
|
color:
|
|
suggestion === "failed"
|
|
? tokens.destructive
|
|
: suggestion === "done"
|
|
? tokens.success
|
|
: tokens.mutedForeground,
|
|
},
|
|
]}
|
|
>
|
|
{suggestion === "failed"
|
|
? (block.suggestion?.error ?? t("Failed"))
|
|
: suggestion === "running"
|
|
? t("Running")
|
|
: suggestion === "done"
|
|
? t("Done")
|
|
: suggestion === "dismissed"
|
|
? t("Dismissed")
|
|
: t("Expired")}
|
|
</Text>
|
|
) : null}
|
|
{cooking && steps ? (
|
|
<CookingMode
|
|
title={header?.title ?? ""}
|
|
steps={steps}
|
|
now={now}
|
|
busy={busy}
|
|
onAct={act}
|
|
onClose={() => setCooking(false)}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
card: {
|
|
width: "90%",
|
|
gap: 14,
|
|
borderRadius: 18,
|
|
borderWidth: 1,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 14,
|
|
},
|
|
header: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
headerText: { flex: 1, gap: 2 },
|
|
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" },
|
|
});
|