storytellurx-v1/app/components/chat/chat-interface.tsx
Elias Rhouzlane cfe3526f6b fix: localize
2025-07-14 14:08:57 +02:00

142 lines
5.3 KiB
TypeScript

"use client";
import { useBackground } from "../../game-engine/background-provider.tsx";
import { useNarrative } from "../../game-engine/narrative-provider.tsx";
import ChatInput from "./chat-input.tsx";
import ChatMessageDisplay from "./chat-message.tsx";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import ChevronDownIcon from "@bureaudouble/icons/radix/ChevronDownIcon";
import { useObserveTurnVisibility } from "../../hooks/use-observe-turn-visibility.ts";
import TurnProvider from "../../game-engine/turn-provider.tsx";
import { getImageDataFromMessage } from "./chat-image.tsx";
import { personaChatContainerVariants } from "../../game-engine/persona-styles.ts";
import { gameStateToVariantState } from "../../game-engine/persona-styles.ts";
import ScrollProgressTracker from "../utils/scroll-progress-tracker.tsx";
import { useClientLocale } from "../../game-engine/locale-provider.tsx";
export default function ChatInterface() {
const { messages, status, gameState } = useNarrative();
const { t } = useClientLocale();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { visibleTurn } = useObserveTurnVisibility(scrollableContainerRef);
const { setBackground } = useBackground();
useEffect(() => {
const imageGeneration = getImageDataFromMessage(visibleTurn);
if (imageGeneration?.imageUrl) {
setBackground(
imageGeneration.imageUrl,
Math.max(0.6 * (gameState.arousalPeakPotential ?? 0.1), 1),
);
} else if (!visibleTurn) {
setBackground(null, 0);
}
}, [setBackground, visibleTurn, gameState.arousalPeakPotential]);
const typingRef = useRef<HTMLDivElement>(null);
const prevMessagesLength = useRef<number>(0);
const scrolledToBottomRef = useRef<boolean | "force">(undefined);
const [isAtBottom, setIsAtBottom] = useState(true);
useLayoutEffect(() => {
const onScrollEnd = () => {
if (scrolledToBottomRef.current !== "force") {
scrolledToBottomRef.current = (document.documentElement.scrollTop +
document.documentElement.clientHeight) >=
(document.documentElement.scrollHeight - 70);
}
setIsAtBottom(!!scrolledToBottomRef.current);
};
const onScroll = () => {
if (scrolledToBottomRef.current !== "force") {
scrolledToBottomRef.current = false;
}
};
globalThis.addEventListener("scrollend", onScrollEnd);
globalThis.addEventListener("scroll", onScroll);
return () => {
globalThis.removeEventListener("scrollend", onScrollEnd);
globalThis.removeEventListener("scroll", onScroll);
};
}, [scrolledToBottomRef]);
useLayoutEffect(() => {
const enableScroll = scrolledToBottomRef.current ||
prevMessagesLength.current !== messages.length;
if (enableScroll || status === "ready" || status === "submitted") {
scrolledToBottomRef.current = "force";
typingRef.current?.scrollIntoView({ behavior: "instant" });
setTimeout(() => scrolledToBottomRef.current = true, 0);
}
prevMessagesLength.current = messages.length;
}, [
JSON.stringify(messages),
status,
]);
return (
<>
<ScrollProgressTracker
ref={scrollableContainerRef}
applyOnBody
className={personaChatContainerVariants(
gameStateToVariantState(gameState),
)}
>
{messages.filter((v) => v.id !== "initial-00").map(
(message, index, arr) => (
<TurnProvider
key={message.id}
messages={arr.slice(0, index + 1)}
nextAssistantMessage={arr.at(index + 1)?.role === "assistant"
? arr.at(index + 1)
: arr.at(index + 2)?.role === "assistant"
? arr.at(index + 2)
: null}
>
<ChatMessageDisplay />
</TurnProvider>
),
)}
{status === "streaming" &&
(
<div className="pl-1 text-sm text-foreground animate-pulse">
{t("aiIsTyping", { personaId: gameState?.personaId ?? "..." })}
<span className="inline-block w-1 h-3 bg-white ml-1 animate-ping">
</span>
</div>
)}
<div
ref={typingRef}
data-atbottom={isAtBottom}
className="size-1 mb-4"
/>
<AnimatePresence>
{!isAtBottom && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="fixed bottom-15 left-1/2 -translate-x-1/2 z-20"
>
<button
type="button"
onClick={() => {
typingRef.current?.scrollIntoView({ behavior: "instant" });
}}
className="p-1.5 rounded-full bg-background/70 border border-border text-foreground hover:bg-white hover:text-black transition-colors"
aria-label={t("scrollToBottom")}
>
<ChevronDownIcon className="size-4" />
</button>
</motion.div>
)}
</AnimatePresence>
</ScrollProgressTracker>
<ChatInput />
</>
);
}