From 7bd5defa5e31fc412de842ef5bedd2c2dcbc1e6a Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 17 Jul 2026 19:33:59 +0800 Subject: [PATCH] refactor(characters): drive shared UI from active character --- .../__tests__/tailwind-components.test.tsx | 10 ++++- src/app/_components/character-avatar.tsx | 8 ++-- .../__tests__/tailwind-components.test.tsx | 4 +- src/app/_components/core/app-bottom-nav.tsx | 4 +- .../[characterSlug]/chat/layout.tsx | 5 ++- src/app/chat/chat-screen.tsx | 8 +++- .../__tests__/chat-area-scroll.test.tsx | 1 + .../locked-image-message-card.test.tsx | 15 ++++++-- .../__tests__/tailwind-components.test.tsx | 32 +++++++++++----- src/app/chat/components/chat-area.tsx | 5 +-- src/app/chat/components/image-bubble.tsx | 5 +-- src/app/coins-rules/page.tsx | 22 +++++++++-- .../__tests__/feedback-screen.test.tsx | 18 ++++++++- src/app/feedback/page.tsx | 22 +++++++++-- .../use-private-room-flow-navigation.test.tsx | 10 ++++- src/app/sidebar/page.tsx | 22 +++++++++-- .../__tests__/tailwind-components.test.tsx | 10 +++-- .../splash/components/splash-background.tsx | 4 +- src/app/splash/components/splash-content.tsx | 4 +- .../use-splash-latest-message.test.tsx | 5 +++ .../splash/hooks/use-splash-latest-message.ts | 5 +-- src/app/splash/splash-screen.tsx | 4 +- .../character-actor-scope-providers.test.tsx | 5 ++- src/providers/character-provider.tsx | 13 ++++--- src/providers/chat-route-providers.tsx | 13 +++++-- src/providers/private-room-route-provider.tsx | 5 +-- .../__tests__/chat-actor-cancellation.test.ts | 16 +++++--- .../chat/__tests__/chat-helpers.test.ts | 6 ++- .../chat/__tests__/chat-history-flow.test.ts | 13 ++++--- .../chat/__tests__/chat-machine.test-utils.ts | 11 +++++- .../chat/__tests__/chat-send-flow.test.ts | 2 +- .../chat/__tests__/chat-session-flow.test.ts | 37 ++++++++++++++++++- src/stores/chat/chat-context.tsx | 12 +++--- src/stores/chat/chat-history-sync.ts | 20 ++++++---- src/stores/chat/chat-machine.ts | 10 ++++- src/stores/chat/chat-state.ts | 8 +++- src/stores/chat/machine/actors/history.ts | 7 +++- src/stores/chat/machine/actors/unlock.ts | 8 +++- src/stores/chat/machine/session-flow.ts | 32 ++++++++++++---- src/stores/chat/machine/setup.ts | 1 + src/stores/chat/machine/unlock-flow.ts | 5 ++- .../private-room/private-room-context.tsx | 6 +-- 42 files changed, 335 insertions(+), 118 deletions(-) diff --git a/src/app/_components/__tests__/tailwind-components.test.tsx b/src/app/_components/__tests__/tailwind-components.test.tsx index afd3fca7..1a2b14a6 100644 --- a/src/app/_components/__tests__/tailwind-components.test.tsx +++ b/src/app/_components/__tests__/tailwind-components.test.tsx @@ -48,7 +48,13 @@ describe("shared Tailwind components", () => { it("renders CharacterAvatar with dynamic sizing and image utilities", () => { const html = renderToStaticMarkup( - , + , ); expect(html).toContain("inline-flex"); @@ -56,7 +62,7 @@ describe("shared Tailwind components", () => { expect(html).toContain("width:48px"); expect(html).toContain("height:48px"); 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", () => { diff --git a/src/app/_components/character-avatar.tsx b/src/app/_components/character-avatar.tsx index 533950f7..3135c967 100644 --- a/src/app/_components/character-avatar.tsx +++ b/src/app/_components/character-avatar.tsx @@ -4,8 +4,8 @@ import Image from "next/image"; import type { CSSProperties } from "react"; export interface CharacterAvatarProps { - src?: string; - alt?: string; + src: string; + alt: string; className?: string; size?: number | string; imageSize?: number; @@ -13,8 +13,8 @@ export interface CharacterAvatarProps { } export function CharacterAvatar({ - src = "/images/avatar/elio.png", - alt = "Elio Silvestri", + src, + alt, className, size = 43, imageSize, diff --git a/src/app/_components/core/__tests__/tailwind-components.test.tsx b/src/app/_components/core/__tests__/tailwind-components.test.tsx index 749e4049..9f5c87b3 100644 --- a/src/app/_components/core/__tests__/tailwind-components.test.tsx +++ b/src/app/_components/core/__tests__/tailwind-components.test.tsx @@ -79,6 +79,7 @@ describe("core Tailwind components", () => { undefined} onPrivateRoomClick={() => undefined} />, @@ -87,6 +88,7 @@ describe("core Tailwind components", () => { undefined} onPrivateRoomClick={() => undefined} />, @@ -99,7 +101,7 @@ describe("core Tailwind components", () => { ); expect(privateRoomHtml).toMatch(/class="[^"]*warm[^"]*"/); expect(privateRoomHtml).toMatch( - /]*aria-current="page"[^>]*>.*?Elio Private room<\/span>/, + /]*aria-current="page"[^>]*>.*?Maya Private room<\/span>/, ); expect(chatHtml).toContain('data-analytics-key="navigation.chat"'); expect(privateRoomHtml).toContain( diff --git a/src/app/_components/core/app-bottom-nav.tsx b/src/app/_components/core/app-bottom-nav.tsx index 56a3d8d4..1b4d347d 100644 --- a/src/app/_components/core/app-bottom-nav.tsx +++ b/src/app/_components/core/app-bottom-nav.tsx @@ -10,7 +10,7 @@ export type AppBottomNavVariant = "warm" | "dark"; export interface AppBottomNavProps { activeItem?: AppBottomNavItem | null; variant?: AppBottomNavVariant; - privateRoomLabel?: string; + privateRoomLabel: string; onChatClick: () => void; onPrivateRoomClick: () => void; } @@ -18,7 +18,7 @@ export interface AppBottomNavProps { export function AppBottomNav({ activeItem = null, variant = "warm", - privateRoomLabel = "Elio Private room", + privateRoomLabel, onChatClick, onPrivateRoomClick, }: AppBottomNavProps) { diff --git a/src/app/characters/[characterSlug]/chat/layout.tsx b/src/app/characters/[characterSlug]/chat/layout.tsx index 0f5d7a82..b3aff685 100644 --- a/src/app/characters/[characterSlug]/chat/layout.tsx +++ b/src/app/characters/[characterSlug]/chat/layout.tsx @@ -16,7 +16,10 @@ export default async function CharacterChatLayout({ if (!character?.capabilities.chat) notFound(); return ( - + {children} ); diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 83124f88..3c3dd4a9 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -9,7 +9,10 @@ import { useChatDispatch, useChatState, } from "@/stores/chat/chat-context"; -import { useActiveCharacterRoutes } from "@/providers/character-provider"; +import { + useActiveCharacter, + useActiveCharacterRoutes, +} from "@/providers/character-provider"; import { MobileShell } from "@/app/_components/core"; @@ -46,6 +49,7 @@ const chatShellStyle = { export function ChatScreen() { const router = useRouter(); + const character = useActiveCharacter(); const characterRoutes = useActiveCharacterRoutes(); const searchParams = useSearchParams(); const state = useChatState(); @@ -167,7 +171,7 @@ export function ChatScreen() { >
{ root.render( { 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( - undefined} - />, + + undefined} + /> + , ); expect(html).toContain('aria-label="Locked private image"'); diff --git a/src/app/chat/components/__tests__/tailwind-components.test.tsx b/src/app/chat/components/__tests__/tailwind-components.test.tsx index 695fc9c7..8ea9f848 100644 --- a/src/app/chat/components/__tests__/tailwind-components.test.tsx +++ b/src/app/chat/components/__tests__/tailwind-components.test.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; @@ -25,8 +26,8 @@ vi.mock("@/router/use-app-navigator", () => ({ describe("chat Tailwind components", () => { it("renders MessageAvatar AI and user branches with image utilities", () => { - const aiHtml = renderToStaticMarkup(); - const userHtml = renderToStaticMarkup( + const aiHtml = renderWithCharacter(); + const userHtml = renderWithCharacter( , ); @@ -71,8 +72,8 @@ describe("chat Tailwind components", () => { }); it("renders PrivateMessageCard with fallback and loading states", () => { - const fallbackHtml = renderToStaticMarkup(); - const unlockingHtml = renderToStaticMarkup( + const fallbackHtml = renderWithCharacter(); + const unlockingHtml = renderWithCharacter( undefined} />, ); @@ -126,13 +127,18 @@ describe("chat Tailwind components", () => { it("renders ImageBubble openable and paywalled states", () => { const openableHtml = renderToStaticMarkup( undefined} />, ); const paywalledHtml = renderToStaticMarkup( - , + , ); expect(openableHtml).toContain('role="button"'); @@ -170,14 +176,14 @@ describe("chat Tailwind components", () => { }); it("renders ChatHeader guest and member branches", () => { - const guestHtml = renderToStaticMarkup(); - const memberHtml = renderToStaticMarkup( + const guestHtml = renderWithCharacter(); + const memberHtml = renderWithCharacter( Offer
} />, ); - const memberWithHintHtml = renderToStaticMarkup( + const memberWithHintHtml = renderWithCharacter( , ); - const guestWithHintHtml = renderToStaticMarkup( + const guestWithHintHtml = renderWithCharacter( , ); @@ -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( + {node}, + ); +} diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index be6c5bed..e52053dd 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -24,7 +24,6 @@ import { import { LoaderCircle } from "lucide-react"; 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 { @@ -46,7 +45,7 @@ const ReplyingAnimation = lazy(() => ); export interface ChatAreaProps { - characterId?: string; + characterId: string; messages: readonly UiMessage[]; isReplyingAI: boolean; scrollToBottomSignal?: number; @@ -63,7 +62,7 @@ export interface ChatAreaProps { } export function ChatArea({ - characterId = DEFAULT_CHARACTER_ID, + characterId, messages, isReplyingAI, scrollToBottomSignal = 0, diff --git a/src/app/chat/components/image-bubble.tsx b/src/app/chat/components/image-bubble.tsx index 72902480..59a6bafa 100644 --- a/src/app/chat/components/image-bubble.tsx +++ b/src/app/chat/components/image-bubble.tsx @@ -9,10 +9,9 @@ */ import { ChatMediaImage } from "./chat-media-image"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; export interface ImageBubbleProps { - characterId?: string; + characterId: string; messageId?: string; imageUrl: string; // base64 data URI 或 URL imagePaywalled?: boolean; @@ -20,7 +19,7 @@ export interface ImageBubbleProps { } export function ImageBubble({ - characterId = DEFAULT_CHARACTER_ID, + characterId, messageId, imageUrl, imagePaywalled = false, diff --git a/src/app/coins-rules/page.tsx b/src/app/coins-rules/page.tsx index dc6e6cd3..5a0b2312 100644 --- a/src/app/coins-rules/page.tsx +++ b/src/app/coins-rules/page.tsx @@ -1,5 +1,21 @@ -import { CoinsRulesScreen } from "./coins-rules-screen"; +import { redirect } from "next/navigation"; -export default function CoinsRulesPage() { - return ; +import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; +import { + appendRouteSearchParams, + getCharacterRoutes, + type RouteSearchParams, +} from "@/router/routes"; + +export default async function CoinsRulesPage({ + searchParams, +}: { + searchParams: Promise; +}) { + redirect( + appendRouteSearchParams( + getCharacterRoutes(DEFAULT_CHARACTER_SLUG).coinsRules, + await searchParams, + ), + ); } diff --git a/src/app/feedback/__tests__/feedback-screen.test.tsx b/src/app/feedback/__tests__/feedback-screen.test.tsx index 8cb71f33..6c47fe2f 100644 --- a/src/app/feedback/__tests__/feedback-screen.test.tsx +++ b/src/app/feedback/__tests__/feedback-screen.test.tsx @@ -2,6 +2,8 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; 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 { FeedbackScreen } from "../feedback-screen"; @@ -48,7 +50,7 @@ describe("FeedbackScreen", () => { }); it("renders all categories and defaults to Problem", () => { - act(() => root.render()); + renderFeedbackScreen(root); expect(container.textContent).toContain("Help us improve CozSweet"); expect(container.textContent).toContain("Problem"); @@ -73,7 +75,7 @@ describe("FeedbackScreen", () => { mocks.submitFeedback.mockResolvedValue( Result.ok({ feedbackId: "feedback-123" }), ); - act(() => root.render()); + renderFeedbackScreen(root); const textarea = container.querySelector("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( + + + , + ), + ); +} + function setTextareaValue(element: HTMLTextAreaElement, value: string): void { const setter = Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, diff --git a/src/app/feedback/page.tsx b/src/app/feedback/page.tsx index 05a668fc..6162a361 100644 --- a/src/app/feedback/page.tsx +++ b/src/app/feedback/page.tsx @@ -1,5 +1,21 @@ -import { FeedbackScreen } from "./feedback-screen"; +import { redirect } from "next/navigation"; -export default function FeedbackPage() { - return ; +import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; +import { + appendRouteSearchParams, + getCharacterRoutes, + type RouteSearchParams, +} from "@/router/routes"; + +export default async function FeedbackPage({ + searchParams, +}: { + searchParams: Promise; +}) { + redirect( + appendRouteSearchParams( + getCharacterRoutes(DEFAULT_CHARACTER_SLUG).feedback, + await searchParams, + ), + ); } diff --git a/src/app/private-room/__tests__/use-private-room-flow-navigation.test.tsx b/src/app/private-room/__tests__/use-private-room-flow-navigation.test.tsx index ef6d6cbd..3b772b26 100644 --- a/src/app/private-room/__tests__/use-private-room-flow-navigation.test.tsx +++ b/src/app/private-room/__tests__/use-private-room-flow-navigation.test.tsx @@ -2,7 +2,9 @@ import { act, type Dispatch } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCharacterBySlug } from "@/data/constants/character"; import type { LoginStatus } from "@/data/schemas/auth"; +import { CharacterProvider } from "@/providers/character-provider"; import { getCharacterRoutes } from "@/router/routes"; import type { PrivateRoomEvent } from "@/stores/private-room"; import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state"; @@ -80,7 +82,7 @@ describe("Private Room paywall navigation", () => { renderHarness(root, "guest", roomDispatch); expect(mocks.openAuth).toHaveBeenCalledWith( - getCharacterRoutes("elio").privateRoom, + getCharacterRoutes("maya").privateRoom, ); expect(mocks.openSubscription).not.toHaveBeenCalled(); }); @@ -91,9 +93,13 @@ function renderHarness( loginStatus: LoginStatus, roomDispatch: Dispatch, ): void { + const character = getCharacterBySlug("maya"); + if (!character) throw new Error("Missing Maya character fixture"); act(() => { root.render( - , + + + , ); }); } diff --git a/src/app/sidebar/page.tsx b/src/app/sidebar/page.tsx index bb967dc8..0588f7d1 100644 --- a/src/app/sidebar/page.tsx +++ b/src/app/sidebar/page.tsx @@ -1,5 +1,21 @@ -import { SidebarScreen } from "@/app/sidebar/sidebar-screen"; +import { redirect } from "next/navigation"; -export default function SidebarPage() { - return ; +import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character"; +import { + appendRouteSearchParams, + getCharacterRoutes, + type RouteSearchParams, +} from "@/router/routes"; + +export default async function SidebarPage({ + searchParams, +}: { + searchParams: Promise; +}) { + redirect( + appendRouteSearchParams( + getCharacterRoutes(DEFAULT_CHARACTER_SLUG).sidebar, + await searchParams, + ), + ); } diff --git a/src/app/splash/components/__tests__/tailwind-components.test.tsx b/src/app/splash/components/__tests__/tailwind-components.test.tsx index 3d2d0089..a11af469 100644 --- a/src/app/splash/components/__tests__/tailwind-components.test.tsx +++ b/src/app/splash/components/__tests__/tailwind-components.test.tsx @@ -17,21 +17,23 @@ describe("splash Tailwind components", () => { }); it("renders SplashBackground with Tailwind fill image classes", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup( + , + ); expect(html).toContain("absolute"); expect(html).toContain("bg-sidebar-background"); 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", () => { - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); expect(html).toContain("flex flex-col gap-md"); expect(html).toContain("font-(family-name:--font-athelas)"); 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", () => { diff --git a/src/app/splash/components/splash-background.tsx b/src/app/splash/components/splash-background.tsx index 1830dd16..59f306c9 100644 --- a/src/app/splash/components/splash-background.tsx +++ b/src/app/splash/components/splash-background.tsx @@ -11,9 +11,9 @@ import Image from "next/image"; export function SplashBackground({ - src = "/images/cover/elio.png", + src, }: { - src?: string; + src: string; }) { return (
diff --git a/src/app/splash/components/splash-content.tsx b/src/app/splash/components/splash-content.tsx index f91c21e0..c83b1d40 100644 --- a/src/app/splash/components/splash-content.tsx +++ b/src/app/splash/components/splash-content.tsx @@ -4,11 +4,11 @@ const APOSTROPHE = "'"; -export function SplashContent() { +export function SplashContent({ characterName }: { characterName: string }) { return (

- Welcome to my secret hideout~ + Welcome to {characterName}{APOSTROPHE}s secret hideout~ {"\n"}It{APOSTROPHE}s just the two of us now. {"\n"}Feel free to whisper your {"\n"}little secrets. diff --git a/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx b/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx index c0d75b9e..6c4b13ae 100644 --- a/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx +++ b/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx @@ -26,6 +26,7 @@ import { useSplashLatestMessage } from "../use-splash-latest-message"; function LatestMessageHarness() { const state = useSplashLatestMessage({ + characterId: "character_maya", hasInitialized: true, isAuthLoading: false, loginStatus: "facebook", @@ -64,5 +65,9 @@ describe("useSplashLatestMessage hydration", () => { expect(hydrationErrors).toEqual([]); expect(container.textContent).toBe("Loading"); expect(mocks.loadLatestMessage).toHaveBeenCalledOnce(); + expect(mocks.loadLatestMessage).toHaveBeenCalledWith({ + cache: mocks.cache, + characterId: "character_maya", + }); }); }); diff --git a/src/app/splash/hooks/use-splash-latest-message.ts b/src/app/splash/hooks/use-splash-latest-message.ts index 5f39426b..8d25a17c 100644 --- a/src/app/splash/hooks/use-splash-latest-message.ts +++ b/src/app/splash/hooks/use-splash-latest-message.ts @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import type { LoginStatus } from "@/data/schemas/auth"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import { useHasHydrated } from "@/hooks/use-has-hydrated"; import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider"; @@ -13,7 +12,7 @@ import { Result } from "@/utils/result"; const log = new Logger("SplashLatestMessage"); export interface UseSplashLatestMessageInput { - characterId?: string; + characterId: string; hasInitialized: boolean; isAuthLoading: boolean; loginStatus: LoginStatus; @@ -31,7 +30,7 @@ interface SplashLatestMessageState { } export function useSplashLatestMessage({ - characterId = DEFAULT_CHARACTER_ID, + characterId, hasInitialized, isAuthLoading, loginStatus, diff --git a/src/app/splash/splash-screen.tsx b/src/app/splash/splash-screen.tsx index 46811eac..af60d438 100644 --- a/src/app/splash/splash-screen.tsx +++ b/src/app/splash/splash-screen.tsx @@ -65,12 +65,12 @@ export function SplashScreen() { isLoading={latestMessage.isLoading} onOpenChat={handleStartChat} /> - +

- {character.displayName}, {character.copy.splashRelationship} + {character.displayName}, {character.tagline}
24/7 online | Chat | Companion | Heal | Sweet moments

diff --git a/src/providers/__tests__/character-actor-scope-providers.test.tsx b/src/providers/__tests__/character-actor-scope-providers.test.tsx index 8f90ca07..4cae9214 100644 --- a/src/providers/__tests__/character-actor-scope-providers.test.tsx +++ b/src/providers/__tests__/character-actor-scope-providers.test.tsx @@ -145,7 +145,10 @@ describe("character actor route providers", () => { function renderChat(characterId: string): void { act(() => { root.render( - + chat , ); diff --git a/src/providers/character-provider.tsx b/src/providers/character-provider.tsx index 11000c3f..02f4dd7e 100644 --- a/src/providers/character-provider.tsx +++ b/src/providers/character-provider.tsx @@ -2,13 +2,10 @@ import { createContext, type ReactNode, useContext } from "react"; -import { - DEFAULT_CHARACTER, - type CharacterProfile, -} from "@/data/constants/character"; +import type { CharacterProfile } from "@/data/constants/character"; import { getCharacterRoutes, type CharacterRoutes } from "@/router/routes"; -const CharacterContext = createContext(DEFAULT_CHARACTER); +const CharacterContext = createContext(null); export interface CharacterProviderProps { character: CharacterProfile; @@ -27,7 +24,11 @@ export function CharacterProvider({ } 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 { diff --git a/src/providers/chat-route-providers.tsx b/src/providers/chat-route-providers.tsx index 1faa7da0..b0991721 100644 --- a/src/providers/chat-route-providers.tsx +++ b/src/providers/chat-route-providers.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from "react"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import { ChatProvider } from "@/stores/chat/chat-context"; import { PaymentProvider } from "@/stores/payment/payment-context"; import { ChatAuthSync } from "@/stores/sync/chat-auth-sync"; @@ -11,17 +10,23 @@ import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync"; export interface ChatRouteProvidersProps { children: ReactNode; - characterId?: string; + characterId: string; + emptyChatGreeting: string; } export function ChatRouteProviders({ children, - characterId = DEFAULT_CHARACTER_ID, + characterId, + emptyChatGreeting, }: ChatRouteProvidersProps) { return ( - + {children} diff --git a/src/providers/private-room-route-provider.tsx b/src/providers/private-room-route-provider.tsx index ebec0732..0dfc729c 100644 --- a/src/providers/private-room-route-provider.tsx +++ b/src/providers/private-room-route-provider.tsx @@ -2,17 +2,16 @@ import type { ReactNode } from "react"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; import { PrivateRoomProvider } from "@/stores/private-room"; export interface PrivateRoomRouteProviderProps { children: ReactNode; - characterId?: string; + characterId: string; } export function PrivateRoomRouteProvider({ children, - characterId = DEFAULT_CHARACTER_ID, + characterId, }: PrivateRoomRouteProviderProps) { return ( diff --git a/src/stores/chat/__tests__/chat-actor-cancellation.test.ts b/src/stores/chat/__tests__/chat-actor-cancellation.test.ts index 22003c3c..268c47ca 100644 --- a/src/stores/chat/__tests__/chat-actor-cancellation.test.ts +++ b/src/stores/chat/__tests__/chat-actor-cancellation.test.ts @@ -62,15 +62,18 @@ describe("chat actor request cancellation", () => { () => new Promise(() => undefined), ); const actor = createActor(loadHistoryActor, { - input: { characterId: "character_elio" }, + input: { + characterId: "character_elio", + emptyChatGreeting: "Hello from Elio", + }, }).start(); await vi.waitFor(() => expect(historySync.syncNetworkHistory).toHaveBeenCalled(), ); - const signal = getSignal( - historySync.syncNetworkHistory.mock.calls[0]?.[3], - ); + const historyCall = historySync.syncNetworkHistory.mock.calls[0]; + expect(historyCall?.[3]).toBe("Hello from Elio"); + const signal = getSignal(historyCall?.[4]); expect(signal.aborted).toBe(false); actor.stop(); expect(signal.aborted).toBe(true); @@ -81,7 +84,10 @@ describe("chat actor request cancellation", () => { () => new Promise(() => undefined), ); const actor = createActor(unlockHistoryActor, { - input: { characterId: "character_elio" }, + input: { + characterId: "character_elio", + emptyChatGreeting: "Hello from Elio", + }, }).start(); await vi.waitFor(() => expect(repository.unlockHistory).toHaveBeenCalled()); diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index ff987929..e219d0df 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -1,6 +1,9 @@ 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 { ChatSendResponseSchema, type ChatSendResponse, @@ -35,6 +38,7 @@ function makeResponse( function makeChatState(overrides: Partial = {}): ChatState { return { characterId: DEFAULT_CHARACTER_ID, + emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting, messages: [], promotion: null, outgoingMessageRevision: 0, diff --git a/src/stores/chat/__tests__/chat-history-flow.test.ts b/src/stores/chat/__tests__/chat-history-flow.test.ts index 02e85fb6..865d962c 100644 --- a/src/stores/chat/__tests__/chat-history-flow.test.ts +++ b/src/stores/chat/__tests__/chat-history-flow.test.ts @@ -4,7 +4,10 @@ import { createActor, fromCallback, waitFor } from "xstate"; import type { UiMessage } from "@/stores/chat/ui-message"; import type { ChatEvent } from "@/stores/chat/chat-events"; 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 { createLoadHistoryCallback, @@ -50,7 +53,7 @@ describe("chat history flow", () => { actors: { loadHistory: fromCallback< ChatEvent, - { characterId: string } + ChatHistoryActorInput >(({ sendBack }) => { sendBack({ type: "ChatLocalHistoryLoaded", @@ -119,7 +122,7 @@ describe("chat history flow", () => { ), loadMoreHistory: fromCallback< LoadMoreHistoryActorEvent, - { characterId: string } + ChatHistoryActorInput >( ({ receive, sendBack }) => { receive((event) => { @@ -198,7 +201,7 @@ describe("chat history flow", () => { }), loadMoreHistory: fromCallback< LoadMoreHistoryActorEvent, - { characterId: string } + ChatHistoryActorInput >( ({ receive, sendBack }) => { receive((event) => { @@ -260,7 +263,7 @@ describe("chat history flow", () => { }), loadMoreHistory: fromCallback< LoadMoreHistoryActorEvent, - { characterId: string } + ChatHistoryActorInput >( ({ receive }) => { receive(() => { diff --git a/src/stores/chat/__tests__/chat-machine.test-utils.ts b/src/stores/chat/__tests__/chat-machine.test-utils.ts index 3e88b630..6553f5f7 100644 --- a/src/stores/chat/__tests__/chat-machine.test-utils.ts +++ b/src/stores/chat/__tests__/chat-machine.test-utils.ts @@ -1,6 +1,9 @@ import { fromCallback, fromPromise } from "xstate"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; +import { + DEFAULT_CHARACTER, + DEFAULT_CHARACTER_ID, +} from "@/data/constants/character"; import { ChatSendResponseSchema, UnlockPrivateResponseSchema, @@ -36,6 +39,7 @@ export interface TestUnlockMessageOutput { export const TEST_CHAT_MACHINE_INPUT = { characterId: DEFAULT_CHARACTER_ID, + emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting, }; export function makeChatSendResponse(): ChatSendResponse { @@ -109,7 +113,10 @@ export function createTestChatMachine( return () => undefined; }, ), - unlockHistory: fromPromise( + unlockHistory: fromPromise< + UnlockHistoryOutput, + { characterId: string; emptyChatGreeting: string } + >( async () => { if (options.unlockHistoryError) { throw options.unlockHistoryError; diff --git a/src/stores/chat/__tests__/chat-send-flow.test.ts b/src/stores/chat/__tests__/chat-send-flow.test.ts index e75168a4..d60b2c25 100644 --- a/src/stores/chat/__tests__/chat-send-flow.test.ts +++ b/src/stores/chat/__tests__/chat-send-flow.test.ts @@ -155,7 +155,7 @@ describe("chat send flow", () => { ), unlockHistory: fromPromise< UnlockHistoryOutput, - { characterId: string } + { characterId: string; emptyChatGreeting: string } >(async () => ({ unlocked: true, reason: "ok", diff --git a/src/stores/chat/__tests__/chat-session-flow.test.ts b/src/stores/chat/__tests__/chat-session-flow.test.ts index dd44aa30..56b48bc3 100644 --- a/src/stores/chat/__tests__/chat-session-flow.test.ts +++ b/src/stores/chat/__tests__/chat-session-flow.test.ts @@ -9,10 +9,43 @@ import { describe("chat session flow", () => { it("initializes the conversation from the supplied character", () => { const actor = createActor(createTestChatMachine(), { - input: { characterId: "character_aria" }, + input: { + characterId: "character_maya", + emptyChatGreeting: "Hello from Maya", + }, }).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(); }); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 5a7b47e5..7dd233ba 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -4,8 +4,6 @@ import { type Dispatch, type ReactNode, useMemo } from "react"; import { createActorContext, shallowEqual } from "@xstate/react"; import type { SnapshotFrom } from "xstate"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; - import { chatMachine } from "./chat-machine"; import type { ChatEvent, ChatState as MachineContext } from "./chat-machine"; import { appendPromotionMessage } from "./helper/promotion"; @@ -46,15 +44,19 @@ const ChatActorContext = createActorContext(chatMachine); export interface ChatProviderProps { children: ReactNode; - characterId?: string; + characterId: string; + emptyChatGreeting: string; } export function ChatProvider({ children, - characterId = DEFAULT_CHARACTER_ID, + characterId, + emptyChatGreeting, }: ChatProviderProps) { return ( - + {children} ); diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts index 2f2b1f5e..d221c6a7 100644 --- a/src/stores/chat/chat-history-sync.ts +++ b/src/stores/chat/chat-history-sync.ts @@ -30,12 +30,9 @@ export type LocalHistorySnapshotOutput = { export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput; -export function createGreetingMessage(): UiMessage { +export function createGreetingMessage(content: string): UiMessage { return { - 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?", + content, isFromAI: true, date: todayString(), isSynthetic: true, @@ -51,9 +48,10 @@ export async function resolveHistoryCacheIdentity( export async function readLocalHistorySnapshot( cacheIdentity: string | null, + emptyChatGreeting: string, ): Promise { const chatRepo = await loadChatRepository(); - const greetingMessage = createGreetingMessage(); + const greetingMessage = createGreetingMessage(emptyChatGreeting); const localResult = cacheIdentity ? await chatRepo.getLocalMessages(cacheIdentity) @@ -82,10 +80,11 @@ export async function syncNetworkHistory( characterId: string, localCount: number, cacheIdentity: string | null, + emptyChatGreeting: string, signal?: AbortSignal, ): Promise { const chatRepo = await loadChatRepository(); - const greetingMessage = createGreetingMessage(); + const greetingMessage = createGreetingMessage(emptyChatGreeting); const networkResult = await chatRepo.getHistory( characterId, @@ -149,14 +148,19 @@ export async function syncNetworkHistory( */ export async function readAndSyncHistory( characterId: string, + emptyChatGreeting: string, signal?: AbortSignal, ): Promise { const cacheIdentity = await resolveHistoryCacheIdentity(characterId); - const localSnapshot = await readLocalHistorySnapshot(cacheIdentity); + const localSnapshot = await readLocalHistorySnapshot( + cacheIdentity, + emptyChatGreeting, + ); const networkSnapshot = await syncNetworkHistory( characterId, localSnapshot.localCount, cacheIdentity, + emptyChatGreeting, signal, ); if (networkSnapshot) return networkSnapshot; diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index d40d8352..77b18203 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -2,7 +2,10 @@ import { chatMachineSetup, chatRootStateConfig, } 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"; @@ -13,7 +16,10 @@ export type { ChatEvent } from "./chat-events"; export const chatMachine = chatMachineSetup.createMachine({ id: "chat", context: ({ input }) => - createInitialChatState(input?.characterId ?? DEFAULT_CHARACTER_ID), + createInitialChatState( + input?.characterId ?? DEFAULT_CHARACTER_ID, + input?.emptyChatGreeting ?? DEFAULT_CHARACTER.emptyChatGreeting, + ), ...chatRootStateConfig, }); diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 96e8a74c..79c679e0 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -2,7 +2,10 @@ import type { UiMessage } from "@/stores/chat/ui-message"; import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session"; import type { ChatLockType } from "@/data/schemas/chat"; 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"; export type ChatUpgradeReason = "insufficient_credits"; @@ -26,6 +29,7 @@ export interface ChatUnlockPaywallRequest export interface ChatState { characterId: string; + emptyChatGreeting: string; messages: UiMessage[]; promotion: ChatPromotionState | null; outgoingMessageRevision: number; @@ -55,9 +59,11 @@ export interface ChatState { export function createInitialChatState( characterId: string = DEFAULT_CHARACTER_ID, + emptyChatGreeting: string = DEFAULT_CHARACTER.emptyChatGreeting, ): ChatState { return { characterId, + emptyChatGreeting, messages: [], promotion: null, outgoingMessageRevision: 0, diff --git a/src/stores/chat/machine/actors/history.ts b/src/stores/chat/machine/actors/history.ts index ae47815d..02b2d78b 100644 --- a/src/stores/chat/machine/actors/history.ts +++ b/src/stores/chat/machine/actors/history.ts @@ -25,6 +25,7 @@ export interface LoadMoreHistoryActorEvent { export interface ChatHistoryActorInput { characterId: string; + emptyChatGreeting: string; } export interface LoadMoreHistoryOutput { @@ -43,7 +44,10 @@ export const loadHistoryActor = fromCallback< void (async () => { const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId); - const localSnapshot = await readLocalHistorySnapshot(cacheIdentity); + const localSnapshot = await readLocalHistorySnapshot( + cacheIdentity, + input.emptyChatGreeting, + ); if (cancelled) return; sendBack({ type: "ChatLocalHistoryLoaded", @@ -54,6 +58,7 @@ export const loadHistoryActor = fromCallback< input.characterId, localSnapshot.localCount, cacheIdentity, + input.emptyChatGreeting, controller.signal, ); if (cancelled || !networkSnapshot) return; diff --git a/src/stores/chat/machine/actors/unlock.ts b/src/stores/chat/machine/actors/unlock.ts index 0463d959..c89434cb 100644 --- a/src/stores/chat/machine/actors/unlock.ts +++ b/src/stores/chat/machine/actors/unlock.ts @@ -25,7 +25,7 @@ export interface UnlockHistoryOutput { export const unlockHistoryActor = fromPromise< UnlockHistoryOutput, - { characterId: string } + { characterId: string; emptyChatGreeting: string } >(async ({ input, signal }) => { const chatRepo = await loadChatRepository(); const unlockResult = await chatRepo.unlockHistory(input.characterId, { @@ -40,7 +40,11 @@ export const unlockHistoryActor = fromPromise< } signal.throwIfAborted(); - const history = await readAndSyncHistory(input.characterId, signal); + const history = await readAndSyncHistory( + input.characterId, + input.emptyChatGreeting, + signal, + ); return { unlocked: unlockResult.data.unlocked, reason: unlockResult.data.reason, diff --git a/src/stores/chat/machine/session-flow.ts b/src/stores/chat/machine/session-flow.ts index 27990ba4..1bd60522 100644 --- a/src/stores/chat/machine/session-flow.ts +++ b/src/stores/chat/machine/session-flow.ts @@ -19,20 +19,26 @@ import { const startGuestSessionAction = unlockMachineSetup.assign( ({ context }) => ({ - ...createInitialChatState(context.characterId), + ...createInitialChatState( + context.characterId, + context.emptyChatGreeting, + ), promotion: context.promotion, }), ); const startUserSessionAction = unlockMachineSetup.assign( ({ context }) => ({ - ...createInitialChatState(context.characterId), + ...createInitialChatState( + context.characterId, + context.emptyChatGreeting, + ), promotion: context.promotion, }), ); const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({ - ...createInitialChatState(context.characterId), + ...createInitialChatState(context.characterId, context.emptyChatGreeting), })); const injectPromotionAction = unlockMachineSetup.assign(({ event }) => { @@ -80,12 +86,18 @@ const guestSessionState = chatMachineSetup.createStateConfig({ { id: "loadHistory", src: "loadHistory", - input: ({ context }) => ({ characterId: context.characterId }), + input: ({ context }) => ({ + characterId: context.characterId, + emptyChatGreeting: context.emptyChatGreeting, + }), }, { id: "loadMoreHistory", src: "loadMoreHistory", - input: ({ context }) => ({ characterId: context.characterId }), + input: ({ context }) => ({ + characterId: context.characterId, + emptyChatGreeting: context.emptyChatGreeting, + }), }, ], on: { @@ -162,12 +174,18 @@ const userSessionState = chatMachineSetup.createStateConfig({ { id: "loadHistory", src: "loadHistory", - input: ({ context }) => ({ characterId: context.characterId }), + input: ({ context }) => ({ + characterId: context.characterId, + emptyChatGreeting: context.emptyChatGreeting, + }), }, { id: "loadMoreHistory", src: "loadMoreHistory", - input: ({ context }) => ({ characterId: context.characterId }), + input: ({ context }) => ({ + characterId: context.characterId, + emptyChatGreeting: context.emptyChatGreeting, + }), }, ], on: { diff --git a/src/stores/chat/machine/setup.ts b/src/stores/chat/machine/setup.ts index 03c07cd9..bfc1fefd 100644 --- a/src/stores/chat/machine/setup.ts +++ b/src/stores/chat/machine/setup.ts @@ -17,6 +17,7 @@ import type { ChatState } from "../chat-state"; export interface ChatMachineInput { characterId?: string; + emptyChatGreeting?: string; } export const baseChatMachineSetup = setup({ diff --git a/src/stores/chat/machine/unlock-flow.ts b/src/stores/chat/machine/unlock-flow.ts index 5b8dbde4..5d46cfce 100644 --- a/src/stores/chat/machine/unlock-flow.ts +++ b/src/stores/chat/machine/unlock-flow.ts @@ -188,7 +188,10 @@ export const unlockingHistoryState = unlockMachineSetup.createStateConfig({ invoke: { id: "unlockHistory", src: "unlockHistory", - input: ({ context }) => ({ characterId: context.characterId }), + input: ({ context }) => ({ + characterId: context.characterId, + emptyChatGreeting: context.emptyChatGreeting, + }), onDone: { target: "ready", actions: applyUnlockHistoryOutputAction, diff --git a/src/stores/private-room/private-room-context.tsx b/src/stores/private-room/private-room-context.tsx index 3038ba2e..02e2dbe7 100644 --- a/src/stores/private-room/private-room-context.tsx +++ b/src/stores/private-room/private-room-context.tsx @@ -4,8 +4,6 @@ import type { Dispatch, ReactNode } from "react"; import { createActorContext, shallowEqual } from "@xstate/react"; import type { SnapshotFrom } from "xstate"; -import { DEFAULT_CHARACTER_ID } from "@/data/constants/character"; - import { privateRoomMachine } from "./private-room-machine"; import type { PrivateRoomEvent, @@ -34,12 +32,12 @@ const PrivateRoomActorContext = createActorContext(privateRoomMachine); export interface PrivateRoomProviderProps { children: ReactNode; - characterId?: string; + characterId: string; } export function PrivateRoomProvider({ children, - characterId = DEFAULT_CHARACTER_ID, + characterId, }: PrivateRoomProviderProps) { return (