refactor(characters): drive shared UI from active character
This commit is contained in:
@@ -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,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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 } }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user