refactor(characters): drive shared UI from active character

This commit is contained in:
2026-07-17 19:33:59 +08:00
parent 8bb1e21886
commit 7bd5defa5e
42 changed files with 335 additions and 118 deletions
@@ -48,7 +48,13 @@ describe("shared Tailwind components", () => {
it("renders CharacterAvatar with dynamic sizing and image utilities", () => { it("renders CharacterAvatar with dynamic sizing and image utilities", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<CharacterAvatar size={48} imageSize={96} className="custom-avatar" />, <CharacterAvatar
src="/images/avatar/maya.png"
alt="Maya Tan"
size={48}
imageSize={96}
className="custom-avatar"
/>,
); );
expect(html).toContain("inline-flex"); expect(html).toContain("inline-flex");
@@ -56,7 +62,7 @@ describe("shared Tailwind components", () => {
expect(html).toContain("width:48px"); expect(html).toContain("width:48px");
expect(html).toContain("height:48px"); expect(html).toContain("height:48px");
expect(html).toContain("size-full object-cover"); expect(html).toContain("size-full object-cover");
expect(html).toContain("%2Fimages%2Favatar%2Felio.png"); expect(html).toContain("%2Fimages%2Favatar%2Fmaya.png");
}); });
it("renders UserMessageAvatar custom and fallback images", () => { it("renders UserMessageAvatar custom and fallback images", () => {
+4 -4
View File
@@ -4,8 +4,8 @@ import Image from "next/image";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
export interface CharacterAvatarProps { export interface CharacterAvatarProps {
src?: string; src: string;
alt?: string; alt: string;
className?: string; className?: string;
size?: number | string; size?: number | string;
imageSize?: number; imageSize?: number;
@@ -13,8 +13,8 @@ export interface CharacterAvatarProps {
} }
export function CharacterAvatar({ export function CharacterAvatar({
src = "/images/avatar/elio.png", src,
alt = "Elio Silvestri", alt,
className, className,
size = 43, size = 43,
imageSize, imageSize,
@@ -79,6 +79,7 @@ describe("core Tailwind components", () => {
<AppBottomNav <AppBottomNav
activeItem="chat" activeItem="chat"
variant="dark" variant="dark"
privateRoomLabel="Maya Private room"
onChatClick={() => undefined} onChatClick={() => undefined}
onPrivateRoomClick={() => undefined} onPrivateRoomClick={() => undefined}
/>, />,
@@ -87,6 +88,7 @@ describe("core Tailwind components", () => {
<AppBottomNav <AppBottomNav
activeItem="privateRoom" activeItem="privateRoom"
variant="warm" variant="warm"
privateRoomLabel="Maya Private room"
onChatClick={() => undefined} onChatClick={() => undefined}
onPrivateRoomClick={() => undefined} onPrivateRoomClick={() => undefined}
/>, />,
@@ -99,7 +101,7 @@ describe("core Tailwind components", () => {
); );
expect(privateRoomHtml).toMatch(/class="[^"]*warm[^"]*"/); expect(privateRoomHtml).toMatch(/class="[^"]*warm[^"]*"/);
expect(privateRoomHtml).toMatch( expect(privateRoomHtml).toMatch(
/<button[^>]*aria-current="page"[^>]*>.*?<span>Elio Private room<\/span>/, /<button[^>]*aria-current="page"[^>]*>.*?<span>Maya Private room<\/span>/,
); );
expect(chatHtml).toContain('data-analytics-key="navigation.chat"'); expect(chatHtml).toContain('data-analytics-key="navigation.chat"');
expect(privateRoomHtml).toContain( expect(privateRoomHtml).toContain(
+2 -2
View File
@@ -10,7 +10,7 @@ export type AppBottomNavVariant = "warm" | "dark";
export interface AppBottomNavProps { export interface AppBottomNavProps {
activeItem?: AppBottomNavItem | null; activeItem?: AppBottomNavItem | null;
variant?: AppBottomNavVariant; variant?: AppBottomNavVariant;
privateRoomLabel?: string; privateRoomLabel: string;
onChatClick: () => void; onChatClick: () => void;
onPrivateRoomClick: () => void; onPrivateRoomClick: () => void;
} }
@@ -18,7 +18,7 @@ export interface AppBottomNavProps {
export function AppBottomNav({ export function AppBottomNav({
activeItem = null, activeItem = null,
variant = "warm", variant = "warm",
privateRoomLabel = "Elio Private room", privateRoomLabel,
onChatClick, onChatClick,
onPrivateRoomClick, onPrivateRoomClick,
}: AppBottomNavProps) { }: AppBottomNavProps) {
@@ -16,7 +16,10 @@ export default async function CharacterChatLayout({
if (!character?.capabilities.chat) notFound(); if (!character?.capabilities.chat) notFound();
return ( return (
<ChatRouteProviders characterId={character.id}> <ChatRouteProviders
characterId={character.id}
emptyChatGreeting={character.emptyChatGreeting}
>
{children} {children}
</ChatRouteProviders> </ChatRouteProviders>
); );
+6 -2
View File
@@ -9,7 +9,10 @@ import {
useChatDispatch, useChatDispatch,
useChatState, useChatState,
} from "@/stores/chat/chat-context"; } from "@/stores/chat/chat-context";
import { useActiveCharacterRoutes } from "@/providers/character-provider"; import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
@@ -46,6 +49,7 @@ const chatShellStyle = {
export function ChatScreen() { export function ChatScreen() {
const router = useRouter(); const router = useRouter();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const state = useChatState(); const state = useChatState();
@@ -167,7 +171,7 @@ export function ChatScreen() {
> >
<div className="pointer-events-none absolute inset-0 z-0"> <div className="pointer-events-none absolute inset-0 z-0">
<Image <Image
src="/images/chat/bg-chatpage.png" src={character.assets.chatBackground}
alt="" alt=""
fill fill
priority priority
@@ -235,6 +235,7 @@ function renderChatArea(
act(() => { act(() => {
root.render( root.render(
<ChatArea <ChatArea
characterId="character_elio"
messages={messages} messages={messages}
isReplyingAI={false} isReplyingAI={false}
initialScrollReady={initialScrollReady} initialScrollReady={initialScrollReady}
@@ -1,15 +1,22 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character";
import { CharacterProvider } from "@/providers/character-provider";
import { LockedImageMessageCard } from "../locked-image-message-card"; import { LockedImageMessageCard } from "../locked-image-message-card";
describe("LockedImageMessageCard", () => { describe("LockedImageMessageCard", () => {
it("renders the promotion hint and an enabled unlock action", () => { it("renders the promotion hint and an enabled unlock action", () => {
const character = getCharacterBySlug("maya");
if (!character) throw new Error("Missing Maya character fixture");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<CharacterProvider character={character}>
<LockedImageMessageCard <LockedImageMessageCard
hint="Unlock this private photo." hint="Unlock this private photo."
onUnlock={() => undefined} onUnlock={() => undefined}
/>, />
</CharacterProvider>,
); );
expect(html).toContain('aria-label="Locked private image"'); expect(html).toContain('aria-label="Locked private image"');
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -25,8 +26,8 @@ vi.mock("@/router/use-app-navigator", () => ({
describe("chat Tailwind components", () => { describe("chat Tailwind components", () => {
it("renders MessageAvatar AI and user branches with image utilities", () => { it("renders MessageAvatar AI and user branches with image utilities", () => {
const aiHtml = renderToStaticMarkup(<MessageAvatar isFromAI={true} />); const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />);
const userHtml = renderToStaticMarkup( const userHtml = renderWithCharacter(
<MessageAvatar isFromAI={false} userAvatarUrl="/user-avatar.png" />, <MessageAvatar isFromAI={false} userAvatarUrl="/user-avatar.png" />,
); );
@@ -71,8 +72,8 @@ describe("chat Tailwind components", () => {
}); });
it("renders PrivateMessageCard with fallback and loading states", () => { it("renders PrivateMessageCard with fallback and loading states", () => {
const fallbackHtml = renderToStaticMarkup(<PrivateMessageCard />); const fallbackHtml = renderWithCharacter(<PrivateMessageCard />);
const unlockingHtml = renderToStaticMarkup( const unlockingHtml = renderWithCharacter(
<PrivateMessageCard hint="Premium secret" isUnlocking onUnlock={() => undefined} />, <PrivateMessageCard hint="Premium secret" isUnlocking onUnlock={() => undefined} />,
); );
@@ -126,13 +127,18 @@ describe("chat Tailwind components", () => {
it("renders ImageBubble openable and paywalled states", () => { it("renders ImageBubble openable and paywalled states", () => {
const openableHtml = renderToStaticMarkup( const openableHtml = renderToStaticMarkup(
<ImageBubble <ImageBubble
characterId="character_elio"
messageId="message-1" messageId="message-1"
imageUrl="/chat-image.png" imageUrl="/chat-image.png"
onOpenImage={() => undefined} onOpenImage={() => undefined}
/>, />,
); );
const paywalledHtml = renderToStaticMarkup( const paywalledHtml = renderToStaticMarkup(
<ImageBubble imageUrl="/locked-image.png" imagePaywalled />, <ImageBubble
characterId="character_elio"
imageUrl="/locked-image.png"
imagePaywalled
/>,
); );
expect(openableHtml).toContain('role="button"'); expect(openableHtml).toContain('role="button"');
@@ -170,14 +176,14 @@ describe("chat Tailwind components", () => {
}); });
it("renders ChatHeader guest and member branches", () => { it("renders ChatHeader guest and member branches", () => {
const guestHtml = renderToStaticMarkup(<ChatHeader isGuest={true} />); const guestHtml = renderWithCharacter(<ChatHeader isGuest={true} />);
const memberHtml = renderToStaticMarkup( const memberHtml = renderWithCharacter(
<ChatHeader isGuest={false} offerBanner={<div>Offer</div>} />, <ChatHeader isGuest={false} offerBanner={<div>Offer</div>} />,
); );
const memberWithHintHtml = renderToStaticMarkup( const memberWithHintHtml = renderWithCharacter(
<ChatHeader isGuest={false} showBrowserHint />, <ChatHeader isGuest={false} showBrowserHint />,
); );
const guestWithHintHtml = renderToStaticMarkup( const guestWithHintHtml = renderWithCharacter(
<ChatHeader isGuest={true} showBrowserHint />, <ChatHeader isGuest={true} showBrowserHint />,
); );
@@ -369,3 +375,11 @@ describe("chat Tailwind components", () => {
}); });
}); });
function renderWithCharacter(node: ReactNode, slug = "elio"): string {
const character = getCharacterBySlug(slug);
if (!character) throw new Error(`Missing ${slug} character fixture`);
return renderToStaticMarkup(
<CharacterProvider character={character}>{node}</CharacterProvider>,
);
}
+2 -3
View File
@@ -24,7 +24,6 @@ import {
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import type { UiMessage } from "@/stores/chat/ui-message"; import type { UiMessage } from "@/stores/chat/ui-message";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { import {
@@ -46,7 +45,7 @@ const ReplyingAnimation = lazy(() =>
); );
export interface ChatAreaProps { export interface ChatAreaProps {
characterId?: string; characterId: string;
messages: readonly UiMessage[]; messages: readonly UiMessage[];
isReplyingAI: boolean; isReplyingAI: boolean;
scrollToBottomSignal?: number; scrollToBottomSignal?: number;
@@ -63,7 +62,7 @@ export interface ChatAreaProps {
} }
export function ChatArea({ export function ChatArea({
characterId = DEFAULT_CHARACTER_ID, characterId,
messages, messages,
isReplyingAI, isReplyingAI,
scrollToBottomSignal = 0, scrollToBottomSignal = 0,
+2 -3
View File
@@ -9,10 +9,9 @@
*/ */
import { ChatMediaImage } from "./chat-media-image"; import { ChatMediaImage } from "./chat-media-image";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
export interface ImageBubbleProps { export interface ImageBubbleProps {
characterId?: string; characterId: string;
messageId?: string; messageId?: string;
imageUrl: string; // base64 data URI 或 URL imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean; imagePaywalled?: boolean;
@@ -20,7 +19,7 @@ export interface ImageBubbleProps {
} }
export function ImageBubble({ export function ImageBubble({
characterId = DEFAULT_CHARACTER_ID, characterId,
messageId, messageId,
imageUrl, imageUrl,
imagePaywalled = false, imagePaywalled = false,
+19 -3
View File
@@ -1,5 +1,21 @@
import { CoinsRulesScreen } from "./coins-rules-screen"; import { redirect } from "next/navigation";
export default function CoinsRulesPage() { import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
return <CoinsRulesScreen />; import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function CoinsRulesPage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
redirect(
appendRouteSearchParams(
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).coinsRules,
await searchParams,
),
);
} }
@@ -2,6 +2,8 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character";
import { CharacterProvider } from "@/providers/character-provider";
import { Result } from "@/utils/result"; import { Result } from "@/utils/result";
import { FeedbackScreen } from "../feedback-screen"; import { FeedbackScreen } from "../feedback-screen";
@@ -48,7 +50,7 @@ describe("FeedbackScreen", () => {
}); });
it("renders all categories and defaults to Problem", () => { it("renders all categories and defaults to Problem", () => {
act(() => root.render(<FeedbackScreen />)); renderFeedbackScreen(root);
expect(container.textContent).toContain("Help us improve CozSweet"); expect(container.textContent).toContain("Help us improve CozSweet");
expect(container.textContent).toContain("Problem"); expect(container.textContent).toContain("Problem");
@@ -73,7 +75,7 @@ describe("FeedbackScreen", () => {
mocks.submitFeedback.mockResolvedValue( mocks.submitFeedback.mockResolvedValue(
Result.ok({ feedbackId: "feedback-123" }), Result.ok({ feedbackId: "feedback-123" }),
); );
act(() => root.render(<FeedbackScreen />)); renderFeedbackScreen(root);
const textarea = container.querySelector("textarea"); const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Expected feedback textarea"); if (!textarea) throw new Error("Expected feedback textarea");
@@ -104,6 +106,18 @@ describe("FeedbackScreen", () => {
}); });
}); });
function renderFeedbackScreen(root: Root): void {
const character = getCharacterBySlug("maya");
if (!character) throw new Error("Missing Maya character fixture");
act(() =>
root.render(
<CharacterProvider character={character}>
<FeedbackScreen />
</CharacterProvider>,
),
);
}
function setTextareaValue(element: HTMLTextAreaElement, value: string): void { function setTextareaValue(element: HTMLTextAreaElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor( const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype, HTMLTextAreaElement.prototype,
+19 -3
View File
@@ -1,5 +1,21 @@
import { FeedbackScreen } from "./feedback-screen"; import { redirect } from "next/navigation";
export default function FeedbackPage() { import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
return <FeedbackScreen />; import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function FeedbackPage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
redirect(
appendRouteSearchParams(
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).feedback,
await searchParams,
),
);
} }
@@ -2,7 +2,9 @@ import { act, type Dispatch } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character";
import type { LoginStatus } from "@/data/schemas/auth"; import type { LoginStatus } from "@/data/schemas/auth";
import { CharacterProvider } from "@/providers/character-provider";
import { getCharacterRoutes } from "@/router/routes"; import { getCharacterRoutes } from "@/router/routes";
import type { PrivateRoomEvent } from "@/stores/private-room"; import type { PrivateRoomEvent } from "@/stores/private-room";
import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state"; import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state";
@@ -80,7 +82,7 @@ describe("Private Room paywall navigation", () => {
renderHarness(root, "guest", roomDispatch); renderHarness(root, "guest", roomDispatch);
expect(mocks.openAuth).toHaveBeenCalledWith( expect(mocks.openAuth).toHaveBeenCalledWith(
getCharacterRoutes("elio").privateRoom, getCharacterRoutes("maya").privateRoom,
); );
expect(mocks.openSubscription).not.toHaveBeenCalled(); expect(mocks.openSubscription).not.toHaveBeenCalled();
}); });
@@ -91,9 +93,13 @@ function renderHarness(
loginStatus: LoginStatus, loginStatus: LoginStatus,
roomDispatch: Dispatch<PrivateRoomEvent>, roomDispatch: Dispatch<PrivateRoomEvent>,
): void { ): void {
const character = getCharacterBySlug("maya");
if (!character) throw new Error("Missing Maya character fixture");
act(() => { act(() => {
root.render( root.render(
<Harness loginStatus={loginStatus} roomDispatch={roomDispatch} />, <CharacterProvider character={character}>
<Harness loginStatus={loginStatus} roomDispatch={roomDispatch} />
</CharacterProvider>,
); );
}); });
} }
+19 -3
View File
@@ -1,5 +1,21 @@
import { SidebarScreen } from "@/app/sidebar/sidebar-screen"; import { redirect } from "next/navigation";
export default function SidebarPage() { import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
return <SidebarScreen />; import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function SidebarPage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
redirect(
appendRouteSearchParams(
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).sidebar,
await searchParams,
),
);
} }
@@ -17,21 +17,23 @@ describe("splash Tailwind components", () => {
}); });
it("renders SplashBackground with Tailwind fill image classes", () => { it("renders SplashBackground with Tailwind fill image classes", () => {
const html = renderToStaticMarkup(<SplashBackground />); const html = renderToStaticMarkup(
<SplashBackground src="/images/cover/maya.png" />,
);
expect(html).toContain("absolute"); expect(html).toContain("absolute");
expect(html).toContain("bg-sidebar-background"); expect(html).toContain("bg-sidebar-background");
expect(html).toContain("object-cover"); expect(html).toContain("object-cover");
expect(html).toContain("%2Fimages%2Fcover%2Felio.png"); expect(html).toContain("%2Fimages%2Fcover%2Fmaya.png");
}); });
it("renders SplashContent with Tailwind typography classes", () => { it("renders SplashContent with Tailwind typography classes", () => {
const html = renderToStaticMarkup(<SplashContent />); const html = renderToStaticMarkup(<SplashContent characterName="Maya" />);
expect(html).toContain("flex flex-col gap-md"); expect(html).toContain("flex flex-col gap-md");
expect(html).toContain("font-(family-name:--font-athelas)"); expect(html).toContain("font-(family-name:--font-athelas)");
expect(html).toContain("whitespace-pre-line"); expect(html).toContain("whitespace-pre-line");
expect(html).toContain("Welcome to my secret hideout"); expect(html).toContain("Welcome to Maya");
}); });
it("renders SplashButton as an always-ready action", () => { it("renders SplashButton as an always-ready action", () => {
@@ -11,9 +11,9 @@
import Image from "next/image"; import Image from "next/image";
export function SplashBackground({ export function SplashBackground({
src = "/images/cover/elio.png", src,
}: { }: {
src?: string; src: string;
}) { }) {
return ( return (
<div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background"> <div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background">
+2 -2
View File
@@ -4,11 +4,11 @@
const APOSTROPHE = "'"; const APOSTROPHE = "'";
export function SplashContent() { export function SplashContent({ characterName }: { characterName: string }) {
return ( return (
<div className="z-2 flex flex-col gap-md"> <div className="z-2 flex flex-col gap-md">
<h1 className="m-0 whitespace-pre-line text-left font-(family-name:--font-athelas) text-[clamp(28px,7.8vw,30px)] font-bold italic leading-[1.28] text-white"> <h1 className="m-0 whitespace-pre-line text-left font-(family-name:--font-athelas) text-[clamp(28px,7.8vw,30px)] font-bold italic leading-[1.28] text-white">
Welcome to my secret hideout~ Welcome to {characterName}{APOSTROPHE}s secret hideout~
{"\n"}It{APOSTROPHE}s just the two of us now. {"\n"}It{APOSTROPHE}s just the two of us now.
{"\n"}Feel free to whisper your {"\n"}Feel free to whisper your
{"\n"}little secrets. {"\n"}little secrets.
@@ -26,6 +26,7 @@ import { useSplashLatestMessage } from "../use-splash-latest-message";
function LatestMessageHarness() { function LatestMessageHarness() {
const state = useSplashLatestMessage({ const state = useSplashLatestMessage({
characterId: "character_maya",
hasInitialized: true, hasInitialized: true,
isAuthLoading: false, isAuthLoading: false,
loginStatus: "facebook", loginStatus: "facebook",
@@ -64,5 +65,9 @@ describe("useSplashLatestMessage hydration", () => {
expect(hydrationErrors).toEqual([]); expect(hydrationErrors).toEqual([]);
expect(container.textContent).toBe("Loading"); expect(container.textContent).toBe("Loading");
expect(mocks.loadLatestMessage).toHaveBeenCalledOnce(); expect(mocks.loadLatestMessage).toHaveBeenCalledOnce();
expect(mocks.loadLatestMessage).toHaveBeenCalledWith({
cache: mocks.cache,
characterId: "character_maya",
});
}); });
}); });
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { LoginStatus } from "@/data/schemas/auth"; import type { LoginStatus } from "@/data/schemas/auth";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { useHasHydrated } from "@/hooks/use-has-hydrated"; import { useHasHydrated } from "@/hooks/use-has-hydrated";
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider"; import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
@@ -13,7 +12,7 @@ import { Result } from "@/utils/result";
const log = new Logger("SplashLatestMessage"); const log = new Logger("SplashLatestMessage");
export interface UseSplashLatestMessageInput { export interface UseSplashLatestMessageInput {
characterId?: string; characterId: string;
hasInitialized: boolean; hasInitialized: boolean;
isAuthLoading: boolean; isAuthLoading: boolean;
loginStatus: LoginStatus; loginStatus: LoginStatus;
@@ -31,7 +30,7 @@ interface SplashLatestMessageState {
} }
export function useSplashLatestMessage({ export function useSplashLatestMessage({
characterId = DEFAULT_CHARACTER_ID, characterId,
hasInitialized, hasInitialized,
isAuthLoading, isAuthLoading,
loginStatus, loginStatus,
+2 -2
View File
@@ -65,12 +65,12 @@ export function SplashScreen() {
isLoading={latestMessage.isLoading} isLoading={latestMessage.isLoading}
onOpenChat={handleStartChat} onOpenChat={handleStartChat}
/> />
<SplashContent /> <SplashContent characterName={character.shortName} />
<div className={styles.buttonArea}> <div className={styles.buttonArea}>
<SplashButton onStartChat={handleStartChat} /> <SplashButton onStartChat={handleStartChat} />
</div> </div>
<p className={styles.bottom}> <p className={styles.bottom}>
{character.displayName}, {character.copy.splashRelationship} {character.displayName}, {character.tagline}
<br /> <br />
24/7 online | Chat | Companion | Heal | Sweet moments 24/7 online | Chat | Companion | Heal | Sweet moments
</p> </p>
@@ -145,7 +145,10 @@ describe("character actor route providers", () => {
function renderChat(characterId: string): void { function renderChat(characterId: string): void {
act(() => { act(() => {
root.render( root.render(
<ChatRouteProviders characterId={characterId}> <ChatRouteProviders
characterId={characterId}
emptyChatGreeting={`Hello from ${characterId}`}
>
<span>chat</span> <span>chat</span>
</ChatRouteProviders>, </ChatRouteProviders>,
); );
+7 -6
View File
@@ -2,13 +2,10 @@
import { createContext, type ReactNode, useContext } from "react"; import { createContext, type ReactNode, useContext } from "react";
import { import type { CharacterProfile } from "@/data/constants/character";
DEFAULT_CHARACTER,
type CharacterProfile,
} from "@/data/constants/character";
import { getCharacterRoutes, type CharacterRoutes } from "@/router/routes"; import { getCharacterRoutes, type CharacterRoutes } from "@/router/routes";
const CharacterContext = createContext<CharacterProfile>(DEFAULT_CHARACTER); const CharacterContext = createContext<CharacterProfile | null>(null);
export interface CharacterProviderProps { export interface CharacterProviderProps {
character: CharacterProfile; character: CharacterProfile;
@@ -27,7 +24,11 @@ export function CharacterProvider({
} }
export function useActiveCharacter(): CharacterProfile { export function useActiveCharacter(): CharacterProfile {
return useContext(CharacterContext); const character = useContext(CharacterContext);
if (!character) {
throw new Error("useActiveCharacter must be used within CharacterProvider.");
}
return character;
} }
export function useActiveCharacterRoutes(): CharacterRoutes { export function useActiveCharacterRoutes(): CharacterRoutes {
+9 -4
View File
@@ -2,7 +2,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { ChatProvider } from "@/stores/chat/chat-context"; import { ChatProvider } from "@/stores/chat/chat-context";
import { PaymentProvider } from "@/stores/payment/payment-context"; import { PaymentProvider } from "@/stores/payment/payment-context";
import { ChatAuthSync } from "@/stores/sync/chat-auth-sync"; import { ChatAuthSync } from "@/stores/sync/chat-auth-sync";
@@ -11,17 +10,23 @@ import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync";
export interface ChatRouteProvidersProps { export interface ChatRouteProvidersProps {
children: ReactNode; children: ReactNode;
characterId?: string; characterId: string;
emptyChatGreeting: string;
} }
export function ChatRouteProviders({ export function ChatRouteProviders({
children, children,
characterId = DEFAULT_CHARACTER_ID, characterId,
emptyChatGreeting,
}: ChatRouteProvidersProps) { }: ChatRouteProvidersProps) {
return ( return (
<PaymentProvider key={characterId}> <PaymentProvider key={characterId}>
<PaymentSuccessSync /> <PaymentSuccessSync />
<ChatProvider key={characterId} characterId={characterId}> <ChatProvider
key={characterId}
characterId={characterId}
emptyChatGreeting={emptyChatGreeting}
>
<ChatAuthSync /> <ChatAuthSync />
<ChatPaymentSuccessSync /> <ChatPaymentSuccessSync />
{children} {children}
@@ -2,17 +2,16 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { PrivateRoomProvider } from "@/stores/private-room"; import { PrivateRoomProvider } from "@/stores/private-room";
export interface PrivateRoomRouteProviderProps { export interface PrivateRoomRouteProviderProps {
children: ReactNode; children: ReactNode;
characterId?: string; characterId: string;
} }
export function PrivateRoomRouteProvider({ export function PrivateRoomRouteProvider({
children, children,
characterId = DEFAULT_CHARACTER_ID, characterId,
}: PrivateRoomRouteProviderProps) { }: PrivateRoomRouteProviderProps) {
return ( return (
<PrivateRoomProvider key={characterId} characterId={characterId}> <PrivateRoomProvider key={characterId} characterId={characterId}>
@@ -62,15 +62,18 @@ describe("chat actor request cancellation", () => {
() => new Promise(() => undefined), () => new Promise(() => undefined),
); );
const actor = createActor(loadHistoryActor, { const actor = createActor(loadHistoryActor, {
input: { characterId: "character_elio" }, input: {
characterId: "character_elio",
emptyChatGreeting: "Hello from Elio",
},
}).start(); }).start();
await vi.waitFor(() => await vi.waitFor(() =>
expect(historySync.syncNetworkHistory).toHaveBeenCalled(), expect(historySync.syncNetworkHistory).toHaveBeenCalled(),
); );
const signal = getSignal( const historyCall = historySync.syncNetworkHistory.mock.calls[0];
historySync.syncNetworkHistory.mock.calls[0]?.[3], expect(historyCall?.[3]).toBe("Hello from Elio");
); const signal = getSignal(historyCall?.[4]);
expect(signal.aborted).toBe(false); expect(signal.aborted).toBe(false);
actor.stop(); actor.stop();
expect(signal.aborted).toBe(true); expect(signal.aborted).toBe(true);
@@ -81,7 +84,10 @@ describe("chat actor request cancellation", () => {
() => new Promise(() => undefined), () => new Promise(() => undefined),
); );
const actor = createActor(unlockHistoryActor, { const actor = createActor(unlockHistoryActor, {
input: { characterId: "character_elio" }, input: {
characterId: "character_elio",
emptyChatGreeting: "Hello from Elio",
},
}).start(); }).start();
await vi.waitFor(() => expect(repository.unlockHistory).toHaveBeenCalled()); await vi.waitFor(() => expect(repository.unlockHistory).toHaveBeenCalled());
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_ID,
} from "@/data/constants/character";
import { import {
ChatSendResponseSchema, ChatSendResponseSchema,
type ChatSendResponse, type ChatSendResponse,
@@ -35,6 +38,7 @@ function makeResponse(
function makeChatState(overrides: Partial<ChatState> = {}): ChatState { function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
return { return {
characterId: DEFAULT_CHARACTER_ID, characterId: DEFAULT_CHARACTER_ID,
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
messages: [], messages: [],
promotion: null, promotion: null,
outgoingMessageRevision: 0, outgoingMessageRevision: 0,
@@ -4,7 +4,10 @@ import { createActor, fromCallback, waitFor } from "xstate";
import type { UiMessage } from "@/stores/chat/ui-message"; import type { UiMessage } from "@/stores/chat/ui-message";
import type { ChatEvent } from "@/stores/chat/chat-events"; import type { ChatEvent } from "@/stores/chat/chat-events";
import { chatMachine } from "@/stores/chat/chat-machine"; import { chatMachine } from "@/stores/chat/chat-machine";
import type { LoadMoreHistoryActorEvent } from "@/stores/chat/machine/actors/history"; import type {
ChatHistoryActorInput,
LoadMoreHistoryActorEvent,
} from "@/stores/chat/machine/actors/history";
import { import {
createLoadHistoryCallback, createLoadHistoryCallback,
@@ -50,7 +53,7 @@ describe("chat history flow", () => {
actors: { actors: {
loadHistory: fromCallback< loadHistory: fromCallback<
ChatEvent, ChatEvent,
{ characterId: string } ChatHistoryActorInput
>(({ sendBack }) => { >(({ sendBack }) => {
sendBack({ sendBack({
type: "ChatLocalHistoryLoaded", type: "ChatLocalHistoryLoaded",
@@ -119,7 +122,7 @@ describe("chat history flow", () => {
), ),
loadMoreHistory: fromCallback< loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent, LoadMoreHistoryActorEvent,
{ characterId: string } ChatHistoryActorInput
>( >(
({ receive, sendBack }) => { ({ receive, sendBack }) => {
receive((event) => { receive((event) => {
@@ -198,7 +201,7 @@ describe("chat history flow", () => {
}), }),
loadMoreHistory: fromCallback< loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent, LoadMoreHistoryActorEvent,
{ characterId: string } ChatHistoryActorInput
>( >(
({ receive, sendBack }) => { ({ receive, sendBack }) => {
receive((event) => { receive((event) => {
@@ -260,7 +263,7 @@ describe("chat history flow", () => {
}), }),
loadMoreHistory: fromCallback< loadMoreHistory: fromCallback<
LoadMoreHistoryActorEvent, LoadMoreHistoryActorEvent,
{ characterId: string } ChatHistoryActorInput
>( >(
({ receive }) => { ({ receive }) => {
receive(() => { receive(() => {
@@ -1,6 +1,9 @@
import { fromCallback, fromPromise } from "xstate"; import { fromCallback, fromPromise } from "xstate";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_ID,
} from "@/data/constants/character";
import { import {
ChatSendResponseSchema, ChatSendResponseSchema,
UnlockPrivateResponseSchema, UnlockPrivateResponseSchema,
@@ -36,6 +39,7 @@ export interface TestUnlockMessageOutput {
export const TEST_CHAT_MACHINE_INPUT = { export const TEST_CHAT_MACHINE_INPUT = {
characterId: DEFAULT_CHARACTER_ID, characterId: DEFAULT_CHARACTER_ID,
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
}; };
export function makeChatSendResponse(): ChatSendResponse { export function makeChatSendResponse(): ChatSendResponse {
@@ -109,7 +113,10 @@ export function createTestChatMachine(
return () => undefined; return () => undefined;
}, },
), ),
unlockHistory: fromPromise<UnlockHistoryOutput, { characterId: string }>( unlockHistory: fromPromise<
UnlockHistoryOutput,
{ characterId: string; emptyChatGreeting: string }
>(
async () => { async () => {
if (options.unlockHistoryError) { if (options.unlockHistoryError) {
throw options.unlockHistoryError; throw options.unlockHistoryError;
@@ -155,7 +155,7 @@ describe("chat send flow", () => {
), ),
unlockHistory: fromPromise< unlockHistory: fromPromise<
UnlockHistoryOutput, UnlockHistoryOutput,
{ characterId: string } { characterId: string; emptyChatGreeting: string }
>(async () => ({ >(async () => ({
unlocked: true, unlocked: true,
reason: "ok", reason: "ok",
@@ -9,10 +9,43 @@ import {
describe("chat session flow", () => { describe("chat session flow", () => {
it("initializes the conversation from the supplied character", () => { it("initializes the conversation from the supplied character", () => {
const actor = createActor(createTestChatMachine(), { const actor = createActor(createTestChatMachine(), {
input: { characterId: "character_aria" }, input: {
characterId: "character_maya",
emptyChatGreeting: "Hello from Maya",
},
}).start(); }).start();
expect(actor.getSnapshot().context.characterId).toBe("character_aria"); expect(actor.getSnapshot().context).toMatchObject({
characterId: "character_maya",
emptyChatGreeting: "Hello from Maya",
});
actor.stop();
});
it("keeps the active character greeting across login state changes", async () => {
const actor = createActor(createTestChatMachine(), {
input: {
characterId: "character_maya",
emptyChatGreeting: "Hello from Maya",
},
}).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context.emptyChatGreeting).toBe(
"Hello from Maya",
);
actor.send({ type: "ChatGuestLogin" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ guestSession: "ready" }),
);
expect(actor.getSnapshot().context.emptyChatGreeting).toBe(
"Hello from Maya",
);
actor.stop(); actor.stop();
}); });
+7 -5
View File
@@ -4,8 +4,6 @@ import { type Dispatch, type ReactNode, useMemo } from "react";
import { createActorContext, shallowEqual } from "@xstate/react"; import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate"; import type { SnapshotFrom } from "xstate";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { chatMachine } from "./chat-machine"; import { chatMachine } from "./chat-machine";
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine"; import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
import { appendPromotionMessage } from "./helper/promotion"; import { appendPromotionMessage } from "./helper/promotion";
@@ -46,15 +44,19 @@ const ChatActorContext = createActorContext(chatMachine);
export interface ChatProviderProps { export interface ChatProviderProps {
children: ReactNode; children: ReactNode;
characterId?: string; characterId: string;
emptyChatGreeting: string;
} }
export function ChatProvider({ export function ChatProvider({
children, children,
characterId = DEFAULT_CHARACTER_ID, characterId,
emptyChatGreeting,
}: ChatProviderProps) { }: ChatProviderProps) {
return ( return (
<ChatActorContext.Provider options={{ input: { characterId } }}> <ChatActorContext.Provider
options={{ input: { characterId, emptyChatGreeting } }}
>
{children} {children}
</ChatActorContext.Provider> </ChatActorContext.Provider>
); );
+12 -8
View File
@@ -30,12 +30,9 @@ export type LocalHistorySnapshotOutput = {
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput; export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
export function createGreetingMessage(): UiMessage { export function createGreetingMessage(content: string): UiMessage {
return { return {
content: content,
"You're here! Facebook is so restrictive, " +
"there were things I couldn't say there. " +
"Finally I can relax. How was your day out?",
isFromAI: true, isFromAI: true,
date: todayString(), date: todayString(),
isSynthetic: true, isSynthetic: true,
@@ -51,9 +48,10 @@ export async function resolveHistoryCacheIdentity(
export async function readLocalHistorySnapshot( export async function readLocalHistorySnapshot(
cacheIdentity: string | null, cacheIdentity: string | null,
emptyChatGreeting: string,
): Promise<LocalHistorySnapshotOutput> { ): Promise<LocalHistorySnapshotOutput> {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage(); const greetingMessage = createGreetingMessage(emptyChatGreeting);
const localResult = cacheIdentity const localResult = cacheIdentity
? await chatRepo.getLocalMessages(cacheIdentity) ? await chatRepo.getLocalMessages(cacheIdentity)
@@ -82,10 +80,11 @@ export async function syncNetworkHistory(
characterId: string, characterId: string,
localCount: number, localCount: number,
cacheIdentity: string | null, cacheIdentity: string | null,
emptyChatGreeting: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<NetworkHistorySyncOutput | null> { ): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage(); const greetingMessage = createGreetingMessage(emptyChatGreeting);
const networkResult = await chatRepo.getHistory( const networkResult = await chatRepo.getHistory(
characterId, characterId,
@@ -149,14 +148,19 @@ export async function syncNetworkHistory(
*/ */
export async function readAndSyncHistory( export async function readAndSyncHistory(
characterId: string, characterId: string,
emptyChatGreeting: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ReadAndSyncHistoryOutput> { ): Promise<ReadAndSyncHistoryOutput> {
const cacheIdentity = await resolveHistoryCacheIdentity(characterId); const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity); const localSnapshot = await readLocalHistorySnapshot(
cacheIdentity,
emptyChatGreeting,
);
const networkSnapshot = await syncNetworkHistory( const networkSnapshot = await syncNetworkHistory(
characterId, characterId,
localSnapshot.localCount, localSnapshot.localCount,
cacheIdentity, cacheIdentity,
emptyChatGreeting,
signal, signal,
); );
if (networkSnapshot) return networkSnapshot; if (networkSnapshot) return networkSnapshot;
+8 -2
View File
@@ -2,7 +2,10 @@ import {
chatMachineSetup, chatMachineSetup,
chatRootStateConfig, chatRootStateConfig,
} from "./machine/session-flow"; } from "./machine/session-flow";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_ID,
} from "@/data/constants/character";
import { createInitialChatState } from "./chat-state"; import { createInitialChatState } from "./chat-state";
@@ -13,7 +16,10 @@ export type { ChatEvent } from "./chat-events";
export const chatMachine = chatMachineSetup.createMachine({ export const chatMachine = chatMachineSetup.createMachine({
id: "chat", id: "chat",
context: ({ input }) => context: ({ input }) =>
createInitialChatState(input?.characterId ?? DEFAULT_CHARACTER_ID), createInitialChatState(
input?.characterId ?? DEFAULT_CHARACTER_ID,
input?.emptyChatGreeting ?? DEFAULT_CHARACTER.emptyChatGreeting,
),
...chatRootStateConfig, ...chatRootStateConfig,
}); });
+7 -1
View File
@@ -2,7 +2,10 @@ import type { UiMessage } from "@/stores/chat/ui-message";
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session"; import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
import type { ChatLockType } from "@/data/schemas/chat"; import type { ChatLockType } from "@/data/schemas/chat";
import type { PendingChatPromotion } from "@/data/storage/navigation"; import type { PendingChatPromotion } from "@/data/storage/navigation";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import {
DEFAULT_CHARACTER,
DEFAULT_CHARACTER_ID,
} from "@/data/constants/character";
import type { ChatPromotionState } from "./helper/promotion"; import type { ChatPromotionState } from "./helper/promotion";
export type ChatUpgradeReason = "insufficient_credits"; export type ChatUpgradeReason = "insufficient_credits";
@@ -26,6 +29,7 @@ export interface ChatUnlockPaywallRequest
export interface ChatState { export interface ChatState {
characterId: string; characterId: string;
emptyChatGreeting: string;
messages: UiMessage[]; messages: UiMessage[];
promotion: ChatPromotionState | null; promotion: ChatPromotionState | null;
outgoingMessageRevision: number; outgoingMessageRevision: number;
@@ -55,9 +59,11 @@ export interface ChatState {
export function createInitialChatState( export function createInitialChatState(
characterId: string = DEFAULT_CHARACTER_ID, characterId: string = DEFAULT_CHARACTER_ID,
emptyChatGreeting: string = DEFAULT_CHARACTER.emptyChatGreeting,
): ChatState { ): ChatState {
return { return {
characterId, characterId,
emptyChatGreeting,
messages: [], messages: [],
promotion: null, promotion: null,
outgoingMessageRevision: 0, outgoingMessageRevision: 0,
+6 -1
View File
@@ -25,6 +25,7 @@ export interface LoadMoreHistoryActorEvent {
export interface ChatHistoryActorInput { export interface ChatHistoryActorInput {
characterId: string; characterId: string;
emptyChatGreeting: string;
} }
export interface LoadMoreHistoryOutput { export interface LoadMoreHistoryOutput {
@@ -43,7 +44,10 @@ export const loadHistoryActor = fromCallback<
void (async () => { void (async () => {
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId); const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity); const localSnapshot = await readLocalHistorySnapshot(
cacheIdentity,
input.emptyChatGreeting,
);
if (cancelled) return; if (cancelled) return;
sendBack({ sendBack({
type: "ChatLocalHistoryLoaded", type: "ChatLocalHistoryLoaded",
@@ -54,6 +58,7 @@ export const loadHistoryActor = fromCallback<
input.characterId, input.characterId,
localSnapshot.localCount, localSnapshot.localCount,
cacheIdentity, cacheIdentity,
input.emptyChatGreeting,
controller.signal, controller.signal,
); );
if (cancelled || !networkSnapshot) return; if (cancelled || !networkSnapshot) return;
+6 -2
View File
@@ -25,7 +25,7 @@ export interface UnlockHistoryOutput {
export const unlockHistoryActor = fromPromise< export const unlockHistoryActor = fromPromise<
UnlockHistoryOutput, UnlockHistoryOutput,
{ characterId: string } { characterId: string; emptyChatGreeting: string }
>(async ({ input, signal }) => { >(async ({ input, signal }) => {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const unlockResult = await chatRepo.unlockHistory(input.characterId, { const unlockResult = await chatRepo.unlockHistory(input.characterId, {
@@ -40,7 +40,11 @@ export const unlockHistoryActor = fromPromise<
} }
signal.throwIfAborted(); signal.throwIfAborted();
const history = await readAndSyncHistory(input.characterId, signal); const history = await readAndSyncHistory(
input.characterId,
input.emptyChatGreeting,
signal,
);
return { return {
unlocked: unlockResult.data.unlocked, unlocked: unlockResult.data.unlocked,
reason: unlockResult.data.reason, reason: unlockResult.data.reason,
+25 -7
View File
@@ -19,20 +19,26 @@ import {
const startGuestSessionAction = unlockMachineSetup.assign( const startGuestSessionAction = unlockMachineSetup.assign(
({ context }) => ({ ({ context }) => ({
...createInitialChatState(context.characterId), ...createInitialChatState(
context.characterId,
context.emptyChatGreeting,
),
promotion: context.promotion, promotion: context.promotion,
}), }),
); );
const startUserSessionAction = unlockMachineSetup.assign( const startUserSessionAction = unlockMachineSetup.assign(
({ context }) => ({ ({ context }) => ({
...createInitialChatState(context.characterId), ...createInitialChatState(
context.characterId,
context.emptyChatGreeting,
),
promotion: context.promotion, promotion: context.promotion,
}), }),
); );
const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({ const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({
...createInitialChatState(context.characterId), ...createInitialChatState(context.characterId, context.emptyChatGreeting),
})); }));
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => { const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
@@ -80,12 +86,18 @@ const guestSessionState = chatMachineSetup.createStateConfig({
{ {
id: "loadHistory", id: "loadHistory",
src: "loadHistory", src: "loadHistory",
input: ({ context }) => ({ characterId: context.characterId }), input: ({ context }) => ({
characterId: context.characterId,
emptyChatGreeting: context.emptyChatGreeting,
}),
}, },
{ {
id: "loadMoreHistory", id: "loadMoreHistory",
src: "loadMoreHistory", src: "loadMoreHistory",
input: ({ context }) => ({ characterId: context.characterId }), input: ({ context }) => ({
characterId: context.characterId,
emptyChatGreeting: context.emptyChatGreeting,
}),
}, },
], ],
on: { on: {
@@ -162,12 +174,18 @@ const userSessionState = chatMachineSetup.createStateConfig({
{ {
id: "loadHistory", id: "loadHistory",
src: "loadHistory", src: "loadHistory",
input: ({ context }) => ({ characterId: context.characterId }), input: ({ context }) => ({
characterId: context.characterId,
emptyChatGreeting: context.emptyChatGreeting,
}),
}, },
{ {
id: "loadMoreHistory", id: "loadMoreHistory",
src: "loadMoreHistory", src: "loadMoreHistory",
input: ({ context }) => ({ characterId: context.characterId }), input: ({ context }) => ({
characterId: context.characterId,
emptyChatGreeting: context.emptyChatGreeting,
}),
}, },
], ],
on: { on: {
+1
View File
@@ -17,6 +17,7 @@ import type { ChatState } from "../chat-state";
export interface ChatMachineInput { export interface ChatMachineInput {
characterId?: string; characterId?: string;
emptyChatGreeting?: string;
} }
export const baseChatMachineSetup = setup({ export const baseChatMachineSetup = setup({
+4 -1
View File
@@ -188,7 +188,10 @@ export const unlockingHistoryState = unlockMachineSetup.createStateConfig({
invoke: { invoke: {
id: "unlockHistory", id: "unlockHistory",
src: "unlockHistory", src: "unlockHistory",
input: ({ context }) => ({ characterId: context.characterId }), input: ({ context }) => ({
characterId: context.characterId,
emptyChatGreeting: context.emptyChatGreeting,
}),
onDone: { onDone: {
target: "ready", target: "ready",
actions: applyUnlockHistoryOutputAction, actions: applyUnlockHistoryOutputAction,
@@ -4,8 +4,6 @@ import type { Dispatch, ReactNode } from "react";
import { createActorContext, shallowEqual } from "@xstate/react"; import { createActorContext, shallowEqual } from "@xstate/react";
import type { SnapshotFrom } from "xstate"; import type { SnapshotFrom } from "xstate";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { privateRoomMachine } from "./private-room-machine"; import { privateRoomMachine } from "./private-room-machine";
import type { import type {
PrivateRoomEvent, PrivateRoomEvent,
@@ -34,12 +32,12 @@ const PrivateRoomActorContext = createActorContext(privateRoomMachine);
export interface PrivateRoomProviderProps { export interface PrivateRoomProviderProps {
children: ReactNode; children: ReactNode;
characterId?: string; characterId: string;
} }
export function PrivateRoomProvider({ export function PrivateRoomProvider({
children, children,
characterId = DEFAULT_CHARACTER_ID, characterId,
}: PrivateRoomProviderProps) { }: PrivateRoomProviderProps) {
return ( return (
<PrivateRoomActorContext.Provider options={{ input: { characterId } }}> <PrivateRoomActorContext.Provider options={{ input: { characterId } }}>