cynarAI 55df4a6439
feat(avatar): let Oos react to what their bots do (#141)
A bot's Oo now cheers for a moment when its run finishes, the stage Oo
follows the pointer and squishes when tapped, and chat headers show the
speaking bot's own Oo instead of the generic mascot. Small avatars in
lists only animate while working or waiting so long lists stay calm.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 01:51:37 +02:00

119 lines
3.5 KiB
TypeScript

import type { OoMoment } from "@rakazo/core";
import {
ACTIVE_RUN_STATUSES,
DEFAULT_OO_AVATAR,
decodeOoAvatar,
OO_SUCCESS_SECONDS,
ooIdentityShapeIndex,
ooMomentAfterStatus,
ooMomentForStatus,
resolveOoColor,
} from "@rakazo/core";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Image, View } from "react-native";
import { useI18n } from "../lib/i18n";
import { NativeSymbol } from "./native-symbol";
import { OoFigure } from "./oo-figure";
/** A short moment after a status change, such as joy when a run finishes. */
function useOoStatusMoment(status?: string): OoMoment | null {
const previous = useRef(status);
const [moment, setMoment] = useState<OoMoment | null>(null);
useEffect(() => {
const next = ooMomentAfterStatus(previous.current, status);
previous.current = status;
setMoment(next);
if (!next) return;
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
return () => clearTimeout(timer);
}, [status]);
return moment;
}
export const BotAvatar = memo(function BotAvatar({
color,
size = 54,
status,
identity,
moment,
still,
muted = false,
}: {
color: string;
size?: number;
status?: string;
identity?: string;
/** Explicit moment; wins over `status`. */
moment?: OoMoment;
still?: boolean;
muted?: boolean;
}) {
const { t } = useI18n();
const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status);
const picture = color.startsWith("data:image/") ? color : null;
const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]);
const seed = identity || (picture ? "agent" : color) || "agent";
const body = resolveOoColor(seed, look.color).hex;
const statusMoment = useOoStatusMoment(status);
return (
<View style={{ width: size, height: size }}>
{picture ? (
<Image
source={{ uri: picture }}
style={{ width: size, height: size, borderRadius: size / 2 }}
/>
) : (
<OoFigure
avatar={look}
color={body}
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
moment={moment ?? statusMoment ?? ooMomentForStatus(status)}
size={size}
still={still}
/>
)}
{isWorking ? (
<View
accessibilityLabel={t("Working")}
style={{
position: "absolute",
right: muted ? undefined : 0,
left: muted ? 0 : undefined,
bottom: 0,
width: Math.max(6, Math.round(size * 0.18)),
height: Math.max(6, Math.round(size * 0.18)),
borderRadius: size,
backgroundColor: "#F5A03C",
}}
/>
) : null}
{muted ? (
<View
accessible
accessibilityLabel={t("Notifications silenced")}
style={{
position: "absolute",
right: -2,
bottom: -2,
width: Math.max(14, Math.round(size * 0.34)),
height: Math.max(14, Math.round(size * 0.34)),
borderRadius: size,
borderWidth: 2,
borderColor: "#000",
backgroundColor: "#242428",
alignItems: "center",
justifyContent: "center",
}}
>
<NativeSymbol
ios="bell.slash.fill"
android="notifications-off"
size={Math.max(8, Math.round(size * 0.17))}
color="#ECECEE"
/>
</View>
) : null}
</View>
);
});