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>
119 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
});
|