Compare commits
5 Commits
11c1747a68
...
7bd5defa5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd5defa5e | |||
| 8bb1e21886 | |||
| 2fc312b5c7 | |||
| a30e9937b8 | |||
| 5bfb71e2b8 |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
迁移目标:
|
迁移目标:
|
||||||
|
|
||||||
1. 前端维护统一的本地角色目录,后端只识别稳定角色 ID。
|
1. 前端通过统一的 Character Catalog 边界读取角色目录,当前由本地只读目录提供,后端只识别稳定角色 ID。
|
||||||
2. 同一用户可以分别与多个角色聊天。
|
2. 同一用户可以分别与多个角色聊天。
|
||||||
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
3. 不同角色的聊天历史、媒体、锁内容、私密相册和打赏归属必须隔离。
|
||||||
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
4. 钱包、VIP、支付套餐和每日免费额度继续按用户全局共享。
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
| pro 预发环境 | `https://proapi.banlv-ai.com` |
|
||||||
| production 生产环境 | `https://api.banlv-ai.com` |
|
| production 生产环境 | `https://api.banlv-ai.com` |
|
||||||
|
|
||||||
## 2. 前端本地角色目录
|
## 2. 前端 Character Catalog
|
||||||
|
|
||||||
角色列表不通过网络接口加载。前端在本地维护只读目录,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。
|
后端角色目录协议尚未确定。过渡阶段由前端本地只读 Catalog 提供角色,并使用 `slug` 解析角色路径、使用 `id` 调用后端业务接口。业务页面只消费 Catalog 边界;后续切换为服务端目录时不改变角色路由和业务组件接口。
|
||||||
|
|
||||||
当前角色:
|
当前角色:
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
|
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
|
||||||
| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
|
| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
|
||||||
|
|
||||||
本地记录同时保存短名称及 Splash、Private Room、Tip 使用的角色文案。后端不提供头像、封面、展示名称或角色目录接口,但后端配置中的角色 ID 必须与前端目录一致。
|
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
|
||||||
|
|
||||||
## 3. 聊天接口调整
|
## 3. 聊天接口调整
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"barrelsby": "^2.8.1",
|
"barrelsby": "^2.8.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.7",
|
"eslint-config-next": "16.2.7",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|||||||
Generated
+9
@@ -105,6 +105,9 @@ importers:
|
|||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.2.7
|
specifier: 16.2.7
|
||||||
version: 16.2.7(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
|
version: 16.2.7(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
|
||||||
|
fake-indexeddb:
|
||||||
|
specifier: ^6.2.5
|
||||||
|
version: 6.2.5
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^29.1.1
|
specifier: ^29.1.1
|
||||||
version: 29.1.1
|
version: 29.1.1
|
||||||
@@ -2339,6 +2342,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
fake-indexeddb@6.2.5:
|
||||||
|
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
@@ -6193,6 +6200,8 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
|
fake-indexeddb@6.2.5: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-glob@3.3.1:
|
fast-glob@3.3.1:
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ export default async function CharacterChatLayout({
|
|||||||
}) {
|
}) {
|
||||||
const { characterSlug } = await params;
|
const { characterSlug } = await params;
|
||||||
const character = getCharacterBySlug(characterSlug);
|
const character = getCharacterBySlug(characterSlug);
|
||||||
if (!character) notFound();
|
if (!character?.capabilities.chat) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatRouteProviders characterId={character.id}>
|
<ChatRouteProviders
|
||||||
|
characterId={character.id}
|
||||||
|
emptyChatGreeting={character.emptyChatGreeting}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ChatRouteProviders>
|
</ChatRouteProviders>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default async function CharacterPrivateRoomLayout({
|
|||||||
}) {
|
}) {
|
||||||
const { characterSlug } = await params;
|
const { characterSlug } = await params;
|
||||||
const character = getCharacterBySlug(characterSlug);
|
const character = getCharacterBySlug(characterSlug);
|
||||||
if (!character) notFound();
|
if (!character?.capabilities.privateRoom) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrivateRoomRouteProvider characterId={character.id}>
|
<PrivateRoomRouteProvider characterId={character.id}>
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCharacterBySlug } from "@/data/constants/character";
|
||||||
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
||||||
|
|
||||||
export default function CharacterTipLayout({
|
export default async function CharacterTipLayout({
|
||||||
children,
|
children,
|
||||||
|
params,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
params: Promise<{ characterSlug: string }>;
|
||||||
}) {
|
}) {
|
||||||
return <PaymentRouteProvider>{children}</PaymentRouteProvider>;
|
const { characterSlug } = await params;
|
||||||
|
const character = getCharacterBySlug(characterSlug);
|
||||||
|
if (!character?.capabilities.tip) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaymentRouteProvider scopeKey={character.id}>
|
||||||
|
{children}
|
||||||
|
</PaymentRouteProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const defaultScope = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const promotionSession: PendingChatPromotion = {
|
const promotionSession: PendingChatPromotion = {
|
||||||
|
characterId: "character_elio",
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
@@ -181,6 +182,7 @@ function createPendingUnlock(
|
|||||||
): PendingChatUnlock {
|
): PendingChatUnlock {
|
||||||
return {
|
return {
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
...input,
|
...input,
|
||||||
stage: "auth",
|
stage: "auth",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -58,7 +62,9 @@ export function ChatScreen() {
|
|||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
messages: state.historyMessages,
|
messages: state.historyMessages,
|
||||||
});
|
});
|
||||||
const isPromotionBootstrapReady = useChatPromotionBootstrap();
|
const isPromotionBootstrapReady = useChatPromotionBootstrap(
|
||||||
|
state.characterId,
|
||||||
|
);
|
||||||
const visibleMessages = isPromotionBootstrapReady
|
const visibleMessages = isPromotionBootstrapReady
|
||||||
? state.messages
|
? state.messages
|
||||||
: state.historyMessages;
|
: state.historyMessages;
|
||||||
@@ -165,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(
|
||||||
<LockedImageMessageCard
|
<CharacterProvider character={character}>
|
||||||
hint="Unlock this private photo."
|
<LockedImageMessageCard
|
||||||
onUnlock={() => undefined}
|
hint="Unlock this private photo."
|
||||||
/>,
|
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,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Logger } from "@/utils/logger";
|
|||||||
|
|
||||||
const log = new Logger("UseChatPromotionBootstrap");
|
const log = new Logger("UseChatPromotionBootstrap");
|
||||||
|
|
||||||
export function useChatPromotionBootstrap(): boolean {
|
export function useChatPromotionBootstrap(characterId: string): boolean {
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
@@ -23,8 +23,8 @@ export function useChatPromotionBootstrap(): boolean {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [entryPromotion, pendingUnlock] = await Promise.all([
|
const [entryPromotion, pendingUnlock] = await Promise.all([
|
||||||
consumePendingChatPromotion(),
|
consumePendingChatPromotion(characterId),
|
||||||
peekPendingChatUnlock(),
|
peekPendingChatUnlock(characterId),
|
||||||
]);
|
]);
|
||||||
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
|
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export function useChatPromotionBootstrap(): boolean {
|
|||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [chatDispatch]);
|
}, [characterId, chatDispatch]);
|
||||||
|
|
||||||
return isReady;
|
return isReady;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function useChatUnlockCoordinator({
|
|||||||
const trackedPaywallRef = useRef<string | null>(null);
|
const trackedPaywallRef = useRef<string | null>(null);
|
||||||
const chatState = useChatSelector(
|
const chatState = useChatSelector(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
|
characterId: state.context.characterId,
|
||||||
historyLoaded: state.context.historyLoaded,
|
historyLoaded: state.context.historyLoaded,
|
||||||
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
||||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||||
@@ -125,7 +126,7 @@ export function useChatUnlockCoordinator({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const resumePendingUnlock = async () => {
|
const resumePendingUnlock = async () => {
|
||||||
const pending = await peekPendingChatUnlock();
|
const pending = await peekPendingChatUnlock(chatState.characterId);
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
!pending ||
|
!pending ||
|
||||||
@@ -138,7 +139,7 @@ export function useChatUnlockCoordinator({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumed = await consumePendingChatUnlock();
|
const consumed = await consumePendingChatUnlock(chatState.characterId);
|
||||||
if (cancelled || !consumed) return;
|
if (cancelled || !consumed) return;
|
||||||
|
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
@@ -157,6 +158,7 @@ export function useChatUnlockCoordinator({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
chatDispatch,
|
chatDispatch,
|
||||||
|
chatState.characterId,
|
||||||
chatState.historyLoaded,
|
chatState.historyLoaded,
|
||||||
defaultReturnUrl,
|
defaultReturnUrl,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
savePendingChatPromotion,
|
savePendingChatPromotion,
|
||||||
} from "@/lib/navigation/chat_unlock_session";
|
} from "@/lib/navigation/chat_unlock_session";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import {
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ export default function ExternalEntryPersist({
|
|||||||
const hasReinitializedForPsidRef = useRef(false);
|
const hasReinitializedForPsidRef = useRef(false);
|
||||||
const targetRoute = resolveExternalEntryTarget({ target });
|
const targetRoute = resolveExternalEntryTarget({ target });
|
||||||
const destination = resolveExternalEntryDestination({ target, character });
|
const destination = resolveExternalEntryDestination({ target, character });
|
||||||
|
const characterId =
|
||||||
|
getCharacterBySlug(character)?.id ?? DEFAULT_CHARACTER_ID;
|
||||||
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
||||||
mode,
|
mode,
|
||||||
promotionType,
|
promotionType,
|
||||||
@@ -66,7 +72,7 @@ export default function ExternalEntryPersist({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
}),
|
}),
|
||||||
targetRoute === ROUTES.chat && resolvedPromotionType
|
targetRoute === ROUTES.chat && resolvedPromotionType
|
||||||
? savePendingChatPromotion(resolvedPromotionType)
|
? savePendingChatPromotion(resolvedPromotionType, characterId)
|
||||||
: clearPendingChatPromotion(),
|
: clearPendingChatPromotion(),
|
||||||
]);
|
]);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
@@ -87,6 +93,7 @@ export default function ExternalEntryPersist({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
asid,
|
asid,
|
||||||
character,
|
character,
|
||||||
|
characterId,
|
||||||
destination,
|
destination,
|
||||||
deviceId,
|
deviceId,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CHARACTERS,
|
CHARACTERS,
|
||||||
|
createCharacterCatalog,
|
||||||
DEFAULT_CHARACTER,
|
DEFAULT_CHARACTER,
|
||||||
getCharacterById,
|
getCharacterById,
|
||||||
getCharacterBySlug,
|
getCharacterBySlug,
|
||||||
@@ -22,9 +23,22 @@ describe("local character catalog", () => {
|
|||||||
expect(Object.isFrozen(CHARACTERS)).toBe(true);
|
expect(Object.isFrozen(CHARACTERS)).toBe(true);
|
||||||
expect(Object.isFrozen(DEFAULT_CHARACTER)).toBe(true);
|
expect(Object.isFrozen(DEFAULT_CHARACTER)).toBe(true);
|
||||||
expect(Object.isFrozen(DEFAULT_CHARACTER.assets)).toBe(true);
|
expect(Object.isFrozen(DEFAULT_CHARACTER.assets)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_CHARACTER.capabilities)).toBe(true);
|
||||||
expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true);
|
expect(Object.isFrozen(DEFAULT_CHARACTER.copy)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sorts catalog entries and rejects duplicate identities", () => {
|
||||||
|
const catalog = createCharacterCatalog([...CHARACTERS].reverse());
|
||||||
|
expect(catalog.characters.map((character) => character.slug)).toEqual([
|
||||||
|
"elio",
|
||||||
|
"maya",
|
||||||
|
"nayeli",
|
||||||
|
]);
|
||||||
|
expect(() =>
|
||||||
|
createCharacterCatalog([CHARACTERS[0], CHARACTERS[0]]),
|
||||||
|
).toThrow("Duplicate character identity");
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves characters by id and normalized slug", () => {
|
it("resolves characters by id and normalized slug", () => {
|
||||||
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
|
expect(getCharacterById("character_maya")?.displayName).toBe("Maya Tan");
|
||||||
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
||||||
@@ -34,6 +48,21 @@ describe("local character catalog", () => {
|
|||||||
expect(getCharacterBySlug("missing")).toBeNull();
|
expect(getCharacterBySlug("missing")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("describes route capabilities and character-specific chat copy", () => {
|
||||||
|
for (const character of CHARACTERS) {
|
||||||
|
expect(character.capabilities).toEqual({
|
||||||
|
chat: true,
|
||||||
|
privateRoom: true,
|
||||||
|
tip: true,
|
||||||
|
});
|
||||||
|
expect(character.tagline.length).toBeGreaterThan(0);
|
||||||
|
expect(character.emptyChatGreeting.length).toBeGreaterThan(0);
|
||||||
|
expect(character.assets.chatBackground).toBe(
|
||||||
|
"/images/chat/bg-chatpage.png",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("references local assets that exist under public", () => {
|
it("references local assets that exist under public", () => {
|
||||||
for (const character of CHARACTERS) {
|
for (const character of CHARACTERS) {
|
||||||
for (const assetPath of Object.values(character.assets)) {
|
for (const assetPath of Object.values(character.assets)) {
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
|
export interface CharacterCapabilities {
|
||||||
|
readonly chat: boolean;
|
||||||
|
readonly privateRoom: boolean;
|
||||||
|
readonly tip: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CharacterProfile {
|
export interface CharacterProfile {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly slug: string;
|
readonly slug: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
readonly shortName: string;
|
readonly shortName: string;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
readonly tagline: string;
|
||||||
|
readonly emptyChatGreeting: string;
|
||||||
|
readonly capabilities: CharacterCapabilities;
|
||||||
readonly assets: {
|
readonly assets: {
|
||||||
readonly avatar: string;
|
readonly avatar: string;
|
||||||
readonly cover: string;
|
readonly cover: string;
|
||||||
|
readonly chatBackground: string;
|
||||||
readonly privateRoomBanner: string;
|
readonly privateRoomBanner: string;
|
||||||
};
|
};
|
||||||
readonly copy: {
|
readonly copy: {
|
||||||
@@ -17,23 +28,42 @@ export interface CharacterProfile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CharacterCatalog {
|
||||||
|
readonly characters: readonly CharacterProfile[];
|
||||||
|
getById(characterId: string | null | undefined): CharacterProfile | null;
|
||||||
|
getBySlug(characterSlug: string | null | undefined): CharacterProfile | null;
|
||||||
|
}
|
||||||
|
|
||||||
function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...profile,
|
...profile,
|
||||||
assets: Object.freeze(profile.assets),
|
assets: Object.freeze(profile.assets),
|
||||||
|
capabilities: Object.freeze(profile.capabilities),
|
||||||
copy: Object.freeze(profile.copy),
|
copy: Object.freeze(profile.copy),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
|
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
||||||
defineCharacter({
|
defineCharacter({
|
||||||
id: "character_elio",
|
id: "character_elio",
|
||||||
slug: "elio",
|
slug: "elio",
|
||||||
displayName: "Elio Silvestri",
|
displayName: "Elio Silvestri",
|
||||||
shortName: "Elio",
|
shortName: "Elio",
|
||||||
|
sortOrder: 10,
|
||||||
|
tagline: "Your exclusive AI boyfriend",
|
||||||
|
emptyChatGreeting:
|
||||||
|
"You're here! Facebook is so restrictive, " +
|
||||||
|
"there were things I couldn't say there. " +
|
||||||
|
"Finally I can relax. How was your day out?",
|
||||||
|
capabilities: {
|
||||||
|
chat: true,
|
||||||
|
privateRoom: true,
|
||||||
|
tip: true,
|
||||||
|
},
|
||||||
assets: {
|
assets: {
|
||||||
avatar: "/images/avatar/elio.png",
|
avatar: "/images/avatar/elio.png",
|
||||||
cover: "/images/cover/elio.png",
|
cover: "/images/cover/elio.png",
|
||||||
|
chatBackground: "/images/chat/bg-chatpage.png",
|
||||||
privateRoomBanner: "/images/private-room/banner/elio.png",
|
privateRoomBanner: "/images/private-room/banner/elio.png",
|
||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
@@ -49,9 +79,20 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
|
|||||||
slug: "maya",
|
slug: "maya",
|
||||||
displayName: "Maya Tan",
|
displayName: "Maya Tan",
|
||||||
shortName: "Maya",
|
shortName: "Maya",
|
||||||
|
sortOrder: 20,
|
||||||
|
tagline: "Your exclusive AI girlfriend",
|
||||||
|
emptyChatGreeting:
|
||||||
|
"You're here! I've been waiting for a quiet moment with you. " +
|
||||||
|
"How was your day?",
|
||||||
|
capabilities: {
|
||||||
|
chat: true,
|
||||||
|
privateRoom: true,
|
||||||
|
tip: true,
|
||||||
|
},
|
||||||
assets: {
|
assets: {
|
||||||
avatar: "/images/avatar/maya.png",
|
avatar: "/images/avatar/maya.png",
|
||||||
cover: "/images/cover/maya.png",
|
cover: "/images/cover/maya.png",
|
||||||
|
chatBackground: "/images/chat/bg-chatpage.png",
|
||||||
privateRoomBanner: "/images/private-room/banner/maya.png",
|
privateRoomBanner: "/images/private-room/banner/maya.png",
|
||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
@@ -67,9 +108,20 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
|
|||||||
slug: "nayeli",
|
slug: "nayeli",
|
||||||
displayName: "Nayeli Cervantes",
|
displayName: "Nayeli Cervantes",
|
||||||
shortName: "Nayeli",
|
shortName: "Nayeli",
|
||||||
|
sortOrder: 30,
|
||||||
|
tagline: "Your exclusive AI girlfriend",
|
||||||
|
emptyChatGreeting:
|
||||||
|
"You're here! I was hoping we'd get some time together. " +
|
||||||
|
"Tell me how your day went.",
|
||||||
|
capabilities: {
|
||||||
|
chat: true,
|
||||||
|
privateRoom: true,
|
||||||
|
tip: true,
|
||||||
|
},
|
||||||
assets: {
|
assets: {
|
||||||
avatar: "/images/avatar/nayeli.png",
|
avatar: "/images/avatar/nayeli.png",
|
||||||
cover: "/images/cover/nayeli.png",
|
cover: "/images/cover/nayeli.png",
|
||||||
|
chatBackground: "/images/chat/bg-chatpage.png",
|
||||||
privateRoomBanner: "/images/private-room/banner/nayeli.png",
|
privateRoomBanner: "/images/private-room/banner/nayeli.png",
|
||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
@@ -82,6 +134,42 @@ export const CHARACTERS: readonly CharacterProfile[] = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export function createCharacterCatalog(
|
||||||
|
profiles: readonly CharacterProfile[],
|
||||||
|
): CharacterCatalog {
|
||||||
|
const characters = Object.freeze(
|
||||||
|
profiles
|
||||||
|
.map(defineCharacter)
|
||||||
|
.sort((left, right) => left.sortOrder - right.sortOrder),
|
||||||
|
);
|
||||||
|
const byId = new Map<string, CharacterProfile>();
|
||||||
|
const bySlug = new Map<string, CharacterProfile>();
|
||||||
|
|
||||||
|
for (const character of characters) {
|
||||||
|
const normalizedSlug = normalizeCharacterSlug(character.slug);
|
||||||
|
if (byId.has(character.id) || bySlug.has(normalizedSlug)) {
|
||||||
|
throw new Error(`Duplicate character identity: ${character.id}`);
|
||||||
|
}
|
||||||
|
byId.set(character.id, character);
|
||||||
|
bySlug.set(normalizedSlug, character);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
characters,
|
||||||
|
getById(characterId: string | null | undefined) {
|
||||||
|
return characterId ? (byId.get(characterId) ?? null) : null;
|
||||||
|
},
|
||||||
|
getBySlug(characterSlug: string | null | undefined) {
|
||||||
|
const normalizedSlug = normalizeCharacterSlug(characterSlug);
|
||||||
|
return normalizedSlug ? (bySlug.get(normalizedSlug) ?? null) : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCAL_CHARACTER_CATALOG = createCharacterCatalog(
|
||||||
|
CHARACTER_PROFILES,
|
||||||
|
);
|
||||||
|
export const CHARACTERS = LOCAL_CHARACTER_CATALOG.characters;
|
||||||
export const DEFAULT_CHARACTER = CHARACTERS[0];
|
export const DEFAULT_CHARACTER = CHARACTERS[0];
|
||||||
export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id;
|
export const DEFAULT_CHARACTER_ID = DEFAULT_CHARACTER.id;
|
||||||
export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug;
|
export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug;
|
||||||
@@ -89,16 +177,17 @@ export const DEFAULT_CHARACTER_SLUG = DEFAULT_CHARACTER.slug;
|
|||||||
export function getCharacterById(
|
export function getCharacterById(
|
||||||
characterId: string | null | undefined,
|
characterId: string | null | undefined,
|
||||||
): CharacterProfile | null {
|
): CharacterProfile | null {
|
||||||
if (!characterId) return null;
|
return LOCAL_CHARACTER_CATALOG.getById(characterId);
|
||||||
return CHARACTERS.find((character) => character.id === characterId) ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCharacterBySlug(
|
export function getCharacterBySlug(
|
||||||
characterSlug: string | null | undefined,
|
characterSlug: string | null | undefined,
|
||||||
): CharacterProfile | null {
|
): CharacterProfile | null {
|
||||||
const normalizedSlug = characterSlug?.trim().toLowerCase();
|
return LOCAL_CHARACTER_CATALOG.getBySlug(characterSlug);
|
||||||
if (!normalizedSlug) return null;
|
}
|
||||||
return (
|
|
||||||
CHARACTERS.find((character) => character.slug === normalizedSlug) ?? null
|
function normalizeCharacterSlug(
|
||||||
);
|
characterSlug: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
return characterSlug?.trim().toLowerCase() ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { LoginStatus } from "@/data/schemas/auth";
|
import { LoginStatus } from "@/data/schemas/auth";
|
||||||
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
|
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
|
||||||
import { UserStorage, type IUserStorage } from "@/data/storage/user";
|
import { UserStorage, type IUserStorage } from "@/data/storage/user";
|
||||||
|
import { buildChatConversationKey } from "@/lib/chat/chat_cache_keys";
|
||||||
import { Result, type Result as ResultT } from "@/utils/result";
|
import { Result, type Result as ResultT } from "@/utils/result";
|
||||||
|
|
||||||
|
export { buildChatConversationKey } from "@/lib/chat/chat_cache_keys";
|
||||||
|
|
||||||
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
|
export type ChatCacheIdentityResolver = () => Promise<ResultT<string>>;
|
||||||
export type ChatConversationKeyResolver = (
|
export type ChatConversationKeyResolver = (
|
||||||
characterId: string,
|
characterId: string,
|
||||||
@@ -53,16 +56,6 @@ export async function resolveChatCacheOwnerKey(
|
|||||||
return Result.err(new Error("Chat cache identity is unavailable."));
|
return Result.err(new Error("Chat cache identity is unavailable."));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildChatConversationKey(
|
|
||||||
ownerKey: string,
|
|
||||||
characterId: string,
|
|
||||||
): string {
|
|
||||||
if (ownerKey.trim().length === 0 || characterId.trim().length === 0) {
|
|
||||||
throw new Error("Chat owner and character identities must not be empty.");
|
|
||||||
}
|
|
||||||
return `${ownerKey}::character:${encodeURIComponent(characterId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveChatConversationKey(
|
export async function resolveChatConversationKey(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
): Promise<ResultT<string>> {
|
): Promise<ResultT<string>> {
|
||||||
|
|||||||
@@ -5,16 +5,9 @@ import type {
|
|||||||
} from "@/data/schemas/chat";
|
} from "@/data/schemas/chat";
|
||||||
import type { CacheRemoteChatMediaInput } from "@/data/repositories/interfaces";
|
import type { CacheRemoteChatMediaInput } from "@/data/repositories/interfaces";
|
||||||
import { isCacheableRemoteChatMediaUrl } from "@/lib/chat/chat_media_url";
|
import { isCacheableRemoteChatMediaUrl } from "@/lib/chat/chat_media_url";
|
||||||
|
import { buildChatMediaCacheKey } from "@/lib/chat/chat_cache_keys";
|
||||||
|
|
||||||
export { isCacheableRemoteChatMediaUrl };
|
export { buildChatMediaCacheKey, isCacheableRemoteChatMediaUrl };
|
||||||
|
|
||||||
export function buildChatMediaCacheKey(input: {
|
|
||||||
ownerKey: string;
|
|
||||||
messageId: string;
|
|
||||||
kind: ChatMediaKind;
|
|
||||||
}): string {
|
|
||||||
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMessageMediaTargets(
|
export function getMessageMediaTargets(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
import type {
|
||||||
|
ChatRequestOptions,
|
||||||
|
ChatSendOptions,
|
||||||
|
UnlockPrivateMessageInput,
|
||||||
|
} from "@/data/repositories/interfaces";
|
||||||
import {
|
import {
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
@@ -17,7 +21,7 @@ export class ChatRemoteDataSource {
|
|||||||
async sendMessage(
|
async sendMessage(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: ChatSendOptions,
|
||||||
): Promise<Result<ChatSendResponse>> {
|
): Promise<Result<ChatSendResponse>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const request = SendMessageRequestSchema.parse({
|
const request = SendMessageRequestSchema.parse({
|
||||||
@@ -26,7 +30,7 @@ export class ChatRemoteDataSource {
|
|||||||
image: options?.image ?? "",
|
image: options?.image ?? "",
|
||||||
useWebSocket: options?.useWebSocket ?? false,
|
useWebSocket: options?.useWebSocket ?? false,
|
||||||
});
|
});
|
||||||
return await this.api.sendMessage(request);
|
return await this.api.sendMessage(request, options);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,23 +38,34 @@ export class ChatRemoteDataSource {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<ChatHistoryResponse>> {
|
): Promise<Result<ChatHistoryResponse>> {
|
||||||
return Result.wrap(() => this.api.getHistory(characterId, limit, offset));
|
return Result.wrap(() =>
|
||||||
|
this.api.getHistory(characterId, limit, offset, options),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockPrivateResponse>> {
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
return Result.wrap(() =>
|
return Result.wrap(() =>
|
||||||
this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)),
|
this.api.unlockPrivateMessage(
|
||||||
|
UnlockPrivateRequestSchema.parse(input),
|
||||||
|
options,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlockHistory(
|
async unlockHistory(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockHistoryResponse>> {
|
): Promise<Result<UnlockHistoryResponse>> {
|
||||||
return Result.wrap(() =>
|
return Result.wrap(() =>
|
||||||
this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })),
|
this.api.unlockHistory(
|
||||||
|
UnlockHistoryRequestSchema.parse({ characterId }),
|
||||||
|
options,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
CacheRemoteChatMediaInput,
|
CacheRemoteChatMediaInput,
|
||||||
ChatMediaLookupInput,
|
ChatMediaLookupInput,
|
||||||
|
ChatRequestOptions,
|
||||||
|
ChatSendOptions,
|
||||||
IChatRepository,
|
IChatRepository,
|
||||||
UnlockPrivateMessageInput,
|
UnlockPrivateMessageInput,
|
||||||
UnlockedPrivateMessageLocalPatch,
|
UnlockedPrivateMessageLocalPatch,
|
||||||
@@ -38,7 +40,7 @@ export class ChatRepository implements IChatRepository {
|
|||||||
async sendMessage(
|
async sendMessage(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: ChatSendOptions,
|
||||||
): Promise<Result<ChatSendResponse>> {
|
): Promise<Result<ChatSendResponse>> {
|
||||||
return this.remote.sendMessage(characterId, message, options);
|
return this.remote.sendMessage(characterId, message, options);
|
||||||
}
|
}
|
||||||
@@ -48,22 +50,25 @@ export class ChatRepository implements IChatRepository {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<ChatHistoryResponse>> {
|
): Promise<Result<ChatHistoryResponse>> {
|
||||||
return this.remote.getHistory(characterId, limit, offset);
|
return this.remote.getHistory(characterId, limit, offset, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockPrivateResponse>> {
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
return this.remote.unlockPrivateMessage(input);
|
return this.remote.unlockPrivateMessage(input, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
async unlockHistory(
|
async unlockHistory(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockHistoryResponse>> {
|
): Promise<Result<UnlockHistoryResponse>> {
|
||||||
return this.remote.unlockHistory(characterId);
|
return this.remote.unlockHistory(characterId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
|
|||||||
@@ -42,12 +42,21 @@ export interface UnlockPrivateMessageInput {
|
|||||||
clientLockId?: string;
|
clientLockId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatRequestOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSendOptions extends ChatRequestOptions {
|
||||||
|
image?: string;
|
||||||
|
useWebSocket?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IChatRepository {
|
export interface IChatRepository {
|
||||||
/** 发送一条消息。 */
|
/** 发送一条消息。 */
|
||||||
sendMessage(
|
sendMessage(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: ChatSendOptions,
|
||||||
): Promise<Result<ChatSendResponse>>;
|
): Promise<Result<ChatSendResponse>>;
|
||||||
|
|
||||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||||
@@ -55,15 +64,20 @@ export interface IChatRepository {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number,
|
offset?: number,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<ChatHistoryResponse>>;
|
): Promise<Result<ChatHistoryResponse>>;
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
unlockPrivateMessage(
|
unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockPrivateResponse>>;
|
): Promise<Result<UnlockPrivateResponse>>;
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>;
|
unlockHistory(
|
||||||
|
characterId: string,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<UnlockHistoryResponse>>;
|
||||||
|
|
||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
markPrivateMessageUnlockedInLocal(
|
markPrivateMessageUnlockedInLocal(
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ describe("multi-character API contract", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards request cancellation to the HTTP client", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
httpClientMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: { messages: [], total: 0, limit: 50, offset: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new ChatApi().getHistory(CHARACTER_ID, 50, 0, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/history", {
|
||||||
|
query: { characterId: CHARACTER_ID, limit: 50, offset: 0 },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("sends characterId with private and history unlocks", async () => {
|
it("sends characterId with private and history unlocks", async () => {
|
||||||
const api = new ChatApi();
|
const api = new ChatApi();
|
||||||
httpClientMock
|
httpClientMock
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ export class ChatApi {
|
|||||||
/**
|
/**
|
||||||
* 发送消息
|
* 发送消息
|
||||||
*/
|
*/
|
||||||
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
|
async sendMessage(
|
||||||
|
body: SendMessageRequest,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<ChatSendResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
});
|
});
|
||||||
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
@@ -40,9 +44,11 @@ export class ChatApi {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<ChatHistoryResponse> {
|
): Promise<ChatHistoryResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
||||||
query: { characterId, limit, offset },
|
query: { characterId, limit, offset },
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
});
|
});
|
||||||
return ChatHistoryResponseSchema.parse(
|
return ChatHistoryResponseSchema.parse(
|
||||||
unwrap(env) as Record<string, unknown>,
|
unwrap(env) as Record<string, unknown>,
|
||||||
@@ -54,12 +60,14 @@ export class ChatApi {
|
|||||||
*/
|
*/
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
body: UnlockPrivateRequest,
|
body: UnlockPrivateRequest,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<UnlockPrivateResponse> {
|
): Promise<UnlockPrivateResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.chatUnlockPrivate,
|
ApiPath.chatUnlockPrivate,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return UnlockPrivateResponseSchema.parse(
|
return UnlockPrivateResponseSchema.parse(
|
||||||
@@ -72,12 +80,14 @@ export class ChatApi {
|
|||||||
*/
|
*/
|
||||||
async unlockHistory(
|
async unlockHistory(
|
||||||
body: UnlockHistoryRequest,
|
body: UnlockHistoryRequest,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<UnlockHistoryResponse> {
|
): Promise<UnlockHistoryResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.chatUnlockHistory,
|
ApiPath.chatUnlockHistory,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return UnlockHistoryResponseSchema.parse(
|
return UnlockHistoryResponseSchema.parse(
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import "fake-indexeddb/auto";
|
||||||
|
|
||||||
|
import Dexie from "dexie";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
|
import {
|
||||||
|
buildChatConversationKey,
|
||||||
|
buildChatMediaCacheKey,
|
||||||
|
} from "@/lib/chat/chat_cache_keys";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LocalChatDB,
|
||||||
|
type LocalChatMediaRow,
|
||||||
|
type LocalMessageRow,
|
||||||
|
} from "../local_chat_db";
|
||||||
|
|
||||||
|
const DATABASE_SCHEMA = {
|
||||||
|
messages: "++dbId, sessionId",
|
||||||
|
media:
|
||||||
|
"cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDatabases: Dexie[] = [];
|
||||||
|
const databaseNames = new Set<string>();
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const database of openDatabases.splice(0)) database.close();
|
||||||
|
await Promise.all([...databaseNames].map((name) => Dexie.delete(name)));
|
||||||
|
databaseNames.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LocalChatDB v4 migration", () => {
|
||||||
|
it("moves legacy user and guest messages into the Elio conversation", async () => {
|
||||||
|
const legacy = await createLegacyV3Database();
|
||||||
|
const mayaConversation = buildChatConversationKey(
|
||||||
|
"user:maya-owner",
|
||||||
|
"character_maya",
|
||||||
|
);
|
||||||
|
await legacy.table<LocalMessageRow, number>("messages").bulkAdd([
|
||||||
|
messageRow("message-user", "user:account-1"),
|
||||||
|
messageRow("message-guest", "device:guest-1"),
|
||||||
|
messageRow("message-maya", mayaConversation),
|
||||||
|
]);
|
||||||
|
legacy.close();
|
||||||
|
|
||||||
|
const upgraded = track(new LocalChatDB(legacy.name));
|
||||||
|
await upgraded.open();
|
||||||
|
|
||||||
|
const rows = await upgraded.messages.orderBy("dbId").toArray();
|
||||||
|
expect(rows.map(({ id, sessionId }) => ({ id, sessionId }))).toEqual([
|
||||||
|
{
|
||||||
|
id: "message-user",
|
||||||
|
sessionId: buildChatConversationKey(
|
||||||
|
"user:account-1",
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "message-guest",
|
||||||
|
sessionId: buildChatConversationKey(
|
||||||
|
"device:guest-1",
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ id: "message-maya", sessionId: mayaConversation },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rekeys legacy media without overwriting existing scoped media", async () => {
|
||||||
|
const legacy = await createLegacyV3Database();
|
||||||
|
const legacyOwner = "user:account-1";
|
||||||
|
const elioConversation = buildChatConversationKey(
|
||||||
|
legacyOwner,
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
);
|
||||||
|
const elioCacheKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey: elioConversation,
|
||||||
|
messageId: "shared-message",
|
||||||
|
kind: "image",
|
||||||
|
});
|
||||||
|
const mayaConversation = buildChatConversationKey(
|
||||||
|
legacyOwner,
|
||||||
|
"character_maya",
|
||||||
|
);
|
||||||
|
const mayaCacheKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey: mayaConversation,
|
||||||
|
messageId: "maya-message",
|
||||||
|
kind: "audio",
|
||||||
|
});
|
||||||
|
await legacy.table<LocalChatMediaRow, string>("media").bulkAdd([
|
||||||
|
mediaRow({
|
||||||
|
cacheKey: `${legacyOwner}:shared-message:image`,
|
||||||
|
ownerKey: legacyOwner,
|
||||||
|
messageId: "shared-message",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://example.com/legacy.jpg",
|
||||||
|
}),
|
||||||
|
mediaRow({
|
||||||
|
cacheKey: elioCacheKey,
|
||||||
|
ownerKey: elioConversation,
|
||||||
|
messageId: "shared-message",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://example.com/current.jpg",
|
||||||
|
}),
|
||||||
|
mediaRow({
|
||||||
|
cacheKey: "device:guest-1:guest-message:audio",
|
||||||
|
ownerKey: "device:guest-1",
|
||||||
|
messageId: "guest-message",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://example.com/guest.mp3",
|
||||||
|
}),
|
||||||
|
mediaRow({
|
||||||
|
cacheKey: mayaCacheKey,
|
||||||
|
ownerKey: mayaConversation,
|
||||||
|
messageId: "maya-message",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://example.com/maya.mp3",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
legacy.close();
|
||||||
|
|
||||||
|
const upgraded = track(new LocalChatDB(legacy.name));
|
||||||
|
await upgraded.open();
|
||||||
|
|
||||||
|
const rows = await upgraded.media.toArray();
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
expect(await upgraded.media.get(`${legacyOwner}:shared-message:image`)).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(await upgraded.media.get(elioCacheKey)).toMatchObject({
|
||||||
|
ownerKey: elioConversation,
|
||||||
|
remoteUrl: "https://example.com/current.jpg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const guestConversation = buildChatConversationKey(
|
||||||
|
"device:guest-1",
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
);
|
||||||
|
const guestCacheKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey: guestConversation,
|
||||||
|
messageId: "guest-message",
|
||||||
|
kind: "audio",
|
||||||
|
});
|
||||||
|
expect(await upgraded.media.get(guestCacheKey)).toMatchObject({
|
||||||
|
cacheKey: guestCacheKey,
|
||||||
|
ownerKey: guestConversation,
|
||||||
|
remoteUrl: "https://example.com/guest.mp3",
|
||||||
|
});
|
||||||
|
expect(await upgraded.media.get(mayaCacheKey)).toMatchObject({
|
||||||
|
ownerKey: mayaConversation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not run the migration again after the database reaches v4", async () => {
|
||||||
|
const legacy = await createLegacyV3Database();
|
||||||
|
await legacy
|
||||||
|
.table<LocalMessageRow, number>("messages")
|
||||||
|
.add(messageRow("message-user", "user:account-1"));
|
||||||
|
legacy.close();
|
||||||
|
|
||||||
|
const upgraded = track(new LocalChatDB(legacy.name));
|
||||||
|
await upgraded.open();
|
||||||
|
const firstRows = await upgraded.messages.toArray();
|
||||||
|
upgraded.close();
|
||||||
|
|
||||||
|
const reopened = track(new LocalChatDB(legacy.name));
|
||||||
|
await reopened.open();
|
||||||
|
expect(await reopened.messages.toArray()).toEqual(firstRows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createLegacyV3Database(): Promise<Dexie> {
|
||||||
|
const name = `cozsweet-chat-v3-${crypto.randomUUID()}`;
|
||||||
|
databaseNames.add(name);
|
||||||
|
const database = track(new Dexie(name));
|
||||||
|
database.version(3).stores(DATABASE_SCHEMA);
|
||||||
|
await database.open();
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
function track<T extends Dexie>(database: T): T {
|
||||||
|
openDatabases.push(database);
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageRow(id: string, sessionId: string): LocalMessageRow {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
type: "text",
|
||||||
|
content: id,
|
||||||
|
createdAt: "2026-07-17T00:00:00.000Z",
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaRow(
|
||||||
|
input: Pick<
|
||||||
|
LocalChatMediaRow,
|
||||||
|
"cacheKey" | "ownerKey" | "messageId" | "kind" | "remoteUrl"
|
||||||
|
>,
|
||||||
|
): LocalChatMediaRow {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
bytes: new Uint8Array([1, 2, 3]).buffer,
|
||||||
|
mimeType: input.kind === "image" ? "image/jpeg" : "audio/mpeg",
|
||||||
|
byteSize: 3,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
lastAccessedAt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,12 +9,18 @@
|
|||||||
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Dexie, { type Table } from "dexie";
|
import Dexie, { type Table, type Transaction } from "dexie";
|
||||||
|
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||||
import type { ChatMediaKind } from "@/data/schemas/chat";
|
import type { ChatMediaKind } from "@/data/schemas/chat";
|
||||||
import type {
|
import type {
|
||||||
ChatImageData,
|
ChatImageData,
|
||||||
ChatLockDetailData,
|
ChatLockDetailData,
|
||||||
} from "@/data/schemas/chat";
|
} from "@/data/schemas/chat";
|
||||||
|
import {
|
||||||
|
buildChatConversationKey,
|
||||||
|
buildChatMediaCacheKey,
|
||||||
|
isLegacyChatCacheOwnerKey,
|
||||||
|
} from "@/lib/chat/chat_cache_keys";
|
||||||
|
|
||||||
export interface LocalMessageRow {
|
export interface LocalMessageRow {
|
||||||
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
||||||
@@ -71,5 +77,49 @@ export class LocalChatDB extends Dexie {
|
|||||||
// so retaining it would expose one identity's history to another.
|
// so retaining it would expose one identity's history to another.
|
||||||
await transaction.table("messages").clear();
|
await transaction.table("messages").clear();
|
||||||
});
|
});
|
||||||
|
this.version(4)
|
||||||
|
.stores({
|
||||||
|
messages: "++dbId, sessionId",
|
||||||
|
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||||
|
})
|
||||||
|
.upgrade(migrateLegacyChatCacheToElio);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyChatCacheToElio(
|
||||||
|
transaction: Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const messages = transaction.table<LocalMessageRow, number>("messages");
|
||||||
|
await messages.toCollection().modify((message) => {
|
||||||
|
const conversationKey = getElioConversationKey(message.sessionId);
|
||||||
|
if (conversationKey) message.sessionId = conversationKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
const media = transaction.table<LocalChatMediaRow, string>("media");
|
||||||
|
const rows = await media.toArray();
|
||||||
|
for (const row of rows) {
|
||||||
|
const conversationKey = getElioConversationKey(row.ownerKey);
|
||||||
|
if (!conversationKey) continue;
|
||||||
|
|
||||||
|
const cacheKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey: conversationKey,
|
||||||
|
messageId: row.messageId,
|
||||||
|
kind: row.kind,
|
||||||
|
});
|
||||||
|
const existing = await media.get(cacheKey);
|
||||||
|
await media.delete(row.cacheKey);
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
await media.put({
|
||||||
|
...row,
|
||||||
|
cacheKey,
|
||||||
|
ownerKey: conversationKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElioConversationKey(ownerKey: string): string | null {
|
||||||
|
return isLegacyChatCacheOwnerKey(ownerKey)
|
||||||
|
? buildChatConversationKey(ownerKey, DEFAULT_CHARACTER_ID)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { SessionAsyncUtil } from "@/utils/session-storage";
|
|||||||
|
|
||||||
import { NavigationStorage } from "../navigation_storage";
|
import { NavigationStorage } from "../navigation_storage";
|
||||||
|
|
||||||
|
const CHARACTER_ID = "character_elio";
|
||||||
|
const OTHER_CHARACTER_ID = "character_maya";
|
||||||
|
|
||||||
describe("NavigationStorage", () => {
|
describe("NavigationStorage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
||||||
@@ -14,13 +17,7 @@ describe("NavigationStorage", () => {
|
|||||||
|
|
||||||
it("saves, peeks, and consumes pending chat unlock sessions", async () => {
|
it("saves, peeks, and consumes pending chat unlock sessions", async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
messageId: "msg_1",
|
characterId: CHARACTER_ID,
|
||||||
kind: "image",
|
|
||||||
returnUrl: "/chat?image=msg_1",
|
|
||||||
stage: "payment",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
@@ -28,12 +25,25 @@ describe("NavigationStorage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatUnlock(),
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
messageId: "msg_1",
|
||||||
|
kind: "image",
|
||||||
|
returnUrl: "/chat?image=msg_1",
|
||||||
|
stage: "payment",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
await expect(
|
||||||
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops expired pending chat unlock sessions", async () => {
|
it("drops expired pending chat unlock sessions", async () => {
|
||||||
@@ -41,6 +51,7 @@ describe("NavigationStorage", () => {
|
|||||||
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
|
||||||
|
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "private",
|
kind: "private",
|
||||||
returnUrl: "/chat",
|
returnUrl: "/chat",
|
||||||
@@ -49,25 +60,32 @@ describe("NavigationStorage", () => {
|
|||||||
|
|
||||||
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z"));
|
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z"));
|
||||||
|
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
await expect(
|
||||||
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
||||||
const promotion = await NavigationStorage.savePendingChatPromotion("image");
|
const promotion = await NavigationStorage.savePendingChatPromotion(
|
||||||
|
"image",
|
||||||
|
CHARACTER_ID,
|
||||||
|
);
|
||||||
|
|
||||||
expect(promotion).toMatchObject({
|
expect(promotion).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
});
|
});
|
||||||
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatPromotion(),
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
).resolves.toEqual(promotion);
|
).resolves.toEqual(promotion);
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatPromotion(),
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
|
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
displayMessageId: `promotion:${promotion.clientLockId}`,
|
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
lockType: promotion.lockType,
|
lockType: promotion.lockType,
|
||||||
@@ -76,7 +94,10 @@ describe("NavigationStorage", () => {
|
|||||||
returnUrl: "/chat",
|
returnUrl: "/chat",
|
||||||
stage: "auth",
|
stage: "auth",
|
||||||
});
|
});
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
await expect(
|
||||||
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: promotion.clientLockId,
|
clientLockId: promotion.clientLockId,
|
||||||
promotion,
|
promotion,
|
||||||
@@ -85,18 +106,62 @@ describe("NavigationStorage", () => {
|
|||||||
|
|
||||||
it("saves and consumes pending chat image return sessions", async () => {
|
it("saves and consumes pending chat image return sessions", async () => {
|
||||||
await NavigationStorage.savePendingChatImageReturn({
|
await NavigationStorage.savePendingChatImageReturn({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatImageReturn(),
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatImageReturn(),
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves another character's pending navigation state untouched", async () => {
|
||||||
|
const promotion = await NavigationStorage.savePendingChatPromotion(
|
||||||
|
"voice",
|
||||||
|
CHARACTER_ID,
|
||||||
|
);
|
||||||
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||||
|
kind: "voice",
|
||||||
|
lockType: promotion.lockType,
|
||||||
|
clientLockId: promotion.clientLockId,
|
||||||
|
promotion,
|
||||||
|
returnUrl: "/characters/elio/chat",
|
||||||
|
stage: "auth",
|
||||||
|
});
|
||||||
|
await NavigationStorage.savePendingChatImageReturn({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
messageId: "msg_1",
|
||||||
|
returnUrl: "/characters/elio/chat?image=msg_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatPromotion(OTHER_CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatUnlock(OTHER_CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatImageReturn(OTHER_CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
|
).resolves.toEqual(promotion);
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type PendingChatUnlockStage = "auth" | "payment";
|
|||||||
export type PendingChatPromotionType = "voice" | "image" | "private";
|
export type PendingChatPromotionType = "voice" | "image" | "private";
|
||||||
|
|
||||||
export const PendingChatPromotionSchema = z.object({
|
export const PendingChatPromotionSchema = z.object({
|
||||||
|
characterId: z.string().min(1),
|
||||||
promotionType: z.enum(["voice", "image", "private"]),
|
promotionType: z.enum(["voice", "image", "private"]),
|
||||||
lockType: ChatLockTypeSchema,
|
lockType: ChatLockTypeSchema,
|
||||||
clientLockId: z.string().min(1),
|
clientLockId: z.string().min(1),
|
||||||
@@ -23,6 +24,7 @@ export const PendingChatPromotionSchema = z.object({
|
|||||||
const PendingChatUnlockSchema = z
|
const PendingChatUnlockSchema = z
|
||||||
.object({
|
.object({
|
||||||
reason: z.literal("single_message_unlock"),
|
reason: z.literal("single_message_unlock"),
|
||||||
|
characterId: z.string().min(1),
|
||||||
displayMessageId: z.string().min(1),
|
displayMessageId: z.string().min(1),
|
||||||
messageId: z.string().min(1).optional(),
|
messageId: z.string().min(1).optional(),
|
||||||
kind: z.enum(["private", "voice", "image"]),
|
kind: z.enum(["private", "voice", "image"]),
|
||||||
@@ -45,6 +47,7 @@ const PendingChatUnlockSchema = z
|
|||||||
|
|
||||||
const PendingChatImageReturnSchema = z.object({
|
const PendingChatImageReturnSchema = z.object({
|
||||||
reason: z.literal("image_paywall"),
|
reason: z.literal("image_paywall"),
|
||||||
|
characterId: z.string().min(1),
|
||||||
messageId: z.string().min(1),
|
messageId: z.string().min(1),
|
||||||
returnUrl: z
|
returnUrl: z
|
||||||
.string()
|
.string()
|
||||||
@@ -75,6 +78,7 @@ export class NavigationStorage {
|
|||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
static async savePendingChatUnlock(input: {
|
static async savePendingChatUnlock(input: {
|
||||||
|
characterId: string;
|
||||||
displayMessageId?: string;
|
displayMessageId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
@@ -91,8 +95,15 @@ export class NavigationStorage {
|
|||||||
if (!displayMessageId) {
|
if (!displayMessageId) {
|
||||||
throw new Error("displayMessageId is required");
|
throw new Error("displayMessageId is required");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
input.promotion &&
|
||||||
|
input.promotion.characterId !== input.characterId
|
||||||
|
) {
|
||||||
|
throw new Error("Pending promotion belongs to a different character.");
|
||||||
|
}
|
||||||
const payload: PendingChatUnlock = {
|
const payload: PendingChatUnlock = {
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: input.characterId,
|
||||||
displayMessageId,
|
displayMessageId,
|
||||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
@@ -110,31 +121,49 @@ export class NavigationStorage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
static async consumePendingChatUnlock(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatUnlock,
|
StorageKeys.pendingChatUnlock,
|
||||||
PendingChatUnlockSchema,
|
PendingChatUnlockSchema,
|
||||||
);
|
);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||||
|
if (!value) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.characterId !== characterId) return null;
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
if (Result.isErr(result)) return null;
|
return value;
|
||||||
return NavigationStorage.parsePendingChatUnlock(result.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
static async peekPendingChatUnlock(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatUnlock,
|
StorageKeys.pendingChatUnlock,
|
||||||
PendingChatUnlockSchema,
|
PendingChatUnlockSchema,
|
||||||
);
|
);
|
||||||
if (Result.isErr(result)) return null;
|
if (Result.isErr(result)) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
}
|
}
|
||||||
return value;
|
return value?.characterId === characterId ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async hasPendingChatUnlock(): Promise<boolean> {
|
static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
||||||
return (await NavigationStorage.peekPendingChatUnlock()) !== null;
|
return (
|
||||||
|
(await NavigationStorage.peekPendingChatUnlock(characterId)) !== null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async clearPendingChatUnlock(): Promise<void> {
|
static async clearPendingChatUnlock(): Promise<void> {
|
||||||
@@ -143,8 +172,10 @@ export class NavigationStorage {
|
|||||||
|
|
||||||
static async savePendingChatPromotion(
|
static async savePendingChatPromotion(
|
||||||
promotionType: PendingChatPromotionType,
|
promotionType: PendingChatPromotionType,
|
||||||
|
characterId: string,
|
||||||
): Promise<PendingChatPromotion> {
|
): Promise<PendingChatPromotion> {
|
||||||
const promotion: PendingChatPromotion = {
|
const promotion: PendingChatPromotion = {
|
||||||
|
characterId,
|
||||||
promotionType,
|
promotionType,
|
||||||
lockType: toPromotionLockType(promotionType),
|
lockType: toPromotionLockType(promotionType),
|
||||||
clientLockId: `promotion_${createUuidV4()}`,
|
clientLockId: `promotion_${createUuidV4()}`,
|
||||||
@@ -158,14 +189,25 @@ export class NavigationStorage {
|
|||||||
return promotion;
|
return promotion;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
static async consumePendingChatPromotion(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatPromotion | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatPromotion,
|
StorageKeys.pendingChatPromotion,
|
||||||
PendingChatPromotionSchema,
|
PendingChatPromotionSchema,
|
||||||
);
|
);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = NavigationStorage.parsePendingChatPromotion(result.data);
|
||||||
|
if (!value) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.characterId !== characterId) return null;
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
if (Result.isErr(result)) return null;
|
return value;
|
||||||
return NavigationStorage.parsePendingChatPromotion(result.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async clearPendingChatPromotion(): Promise<void> {
|
static async clearPendingChatPromotion(): Promise<void> {
|
||||||
@@ -173,11 +215,13 @@ export class NavigationStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async savePendingChatImageReturn(input: {
|
static async savePendingChatImageReturn(input: {
|
||||||
|
characterId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const payload: PendingChatImageReturn = {
|
const payload: PendingChatImageReturn = {
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
|
characterId: input.characterId,
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
returnUrl: input.returnUrl,
|
returnUrl: input.returnUrl,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
@@ -189,14 +233,25 @@ export class NavigationStorage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
static async consumePendingChatImageReturn(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatImageReturn | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatImageReturn,
|
StorageKeys.pendingChatImageReturn,
|
||||||
PendingChatImageReturnSchema,
|
PendingChatImageReturnSchema,
|
||||||
);
|
);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = NavigationStorage.parsePendingChatImageReturn(result.data);
|
||||||
|
if (!value) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.characterId !== characterId) return null;
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
if (Result.isErr(result)) return null;
|
return value;
|
||||||
return NavigationStorage.parsePendingChatImageReturn(result.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static parsePendingChatUnlock(
|
private static parsePendingChatUnlock(
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ChatMediaKind } from "@/data/schemas/chat";
|
||||||
|
|
||||||
|
const CHARACTER_NAMESPACE = "::character:";
|
||||||
|
const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u;
|
||||||
|
|
||||||
|
export function buildChatConversationKey(
|
||||||
|
ownerKey: string,
|
||||||
|
characterId: string,
|
||||||
|
): string {
|
||||||
|
if (ownerKey.trim().length === 0 || characterId.trim().length === 0) {
|
||||||
|
throw new Error("Chat owner and character identities must not be empty.");
|
||||||
|
}
|
||||||
|
return `${ownerKey}${CHARACTER_NAMESPACE}${encodeURIComponent(characterId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChatMediaCacheKey(input: {
|
||||||
|
ownerKey: string;
|
||||||
|
messageId: string;
|
||||||
|
kind: ChatMediaKind;
|
||||||
|
}): string {
|
||||||
|
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacyChatCacheOwnerKey(ownerKey: string): boolean {
|
||||||
|
return (
|
||||||
|
!ownerKey.includes(CHARACTER_NAMESPACE) &&
|
||||||
|
LEGACY_OWNER_KEY_PATTERN.test(ownerKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("uses explicit chat image return urls first", async () => {
|
it("uses explicit chat image return urls first", async () => {
|
||||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
|
characterId: "character_elio",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
@@ -73,12 +74,14 @@ describe("subscription exit helpers", () => {
|
|||||||
it("returns directly to private room without consuming chat state", async () => {
|
it("returns directly to private room without consuming chat state", async () => {
|
||||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
|
characterId: "character_elio",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
});
|
});
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -101,6 +104,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("keeps private room ahead of stale chat state after payment", async () => {
|
it("keeps private room ahead of stale chat state after payment", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -119,6 +123,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -136,6 +141,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("peeks chat unlock return urls without clearing them", async () => {
|
it("peeks chat unlock return urls without clearing them", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -154,6 +160,7 @@ describe("subscription exit helpers", () => {
|
|||||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import {
|
|||||||
export type { PendingChatImageReturn };
|
export type { PendingChatImageReturn };
|
||||||
|
|
||||||
export async function savePendingChatImageReturn(input: {
|
export async function savePendingChatImageReturn(input: {
|
||||||
|
characterId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await NavigationStorage.savePendingChatImageReturn(input);
|
await NavigationStorage.savePendingChatImageReturn(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
export async function consumePendingChatImageReturn(
|
||||||
return NavigationStorage.consumePendingChatImageReturn();
|
characterId: string,
|
||||||
|
): Promise<PendingChatImageReturn | null> {
|
||||||
|
return NavigationStorage.consumePendingChatImageReturn(characterId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function savePendingChatUnlock(input: {
|
export async function savePendingChatUnlock(input: {
|
||||||
|
characterId: string;
|
||||||
displayMessageId?: string;
|
displayMessageId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
@@ -30,16 +31,20 @@ export async function savePendingChatUnlock(input: {
|
|||||||
await NavigationStorage.savePendingChatUnlock(input);
|
await NavigationStorage.savePendingChatUnlock(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
export async function consumePendingChatUnlock(
|
||||||
return NavigationStorage.consumePendingChatUnlock();
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
|
return NavigationStorage.consumePendingChatUnlock(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
export async function peekPendingChatUnlock(
|
||||||
return NavigationStorage.peekPendingChatUnlock();
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
|
return NavigationStorage.peekPendingChatUnlock(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasPendingChatUnlock(): Promise<boolean> {
|
export async function hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
||||||
return NavigationStorage.hasPendingChatUnlock();
|
return NavigationStorage.hasPendingChatUnlock(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingChatUnlock(): Promise<void> {
|
export async function clearPendingChatUnlock(): Promise<void> {
|
||||||
@@ -48,12 +53,15 @@ export async function clearPendingChatUnlock(): Promise<void> {
|
|||||||
|
|
||||||
export async function savePendingChatPromotion(
|
export async function savePendingChatPromotion(
|
||||||
promotionType: PendingChatPromotionType,
|
promotionType: PendingChatPromotionType,
|
||||||
|
characterId: string,
|
||||||
): Promise<PendingChatPromotion> {
|
): Promise<PendingChatPromotion> {
|
||||||
return NavigationStorage.savePendingChatPromotion(promotionType);
|
return NavigationStorage.savePendingChatPromotion(promotionType, characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
export async function consumePendingChatPromotion(
|
||||||
return NavigationStorage.consumePendingChatPromotion();
|
characterId: string,
|
||||||
|
): Promise<PendingChatPromotion | null> {
|
||||||
|
return NavigationStorage.consumePendingChatPromotion(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingChatPromotion(): Promise<void> {
|
export async function clearPendingChatPromotion(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
import {
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
import { getCharacterRoutes } from "@/router/routes";
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||||
|
|
||||||
@@ -12,10 +16,13 @@ import {
|
|||||||
|
|
||||||
export type SubscriptionReturnTo = AppSubscriptionReturnTo;
|
export type SubscriptionReturnTo = AppSubscriptionReturnTo;
|
||||||
|
|
||||||
export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> {
|
export async function consumeSubscriptionExplicitExitUrl(
|
||||||
const pendingImageReturn = await consumePendingChatImageReturn();
|
characterSlug: string = DEFAULT_CHARACTER_SLUG,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const characterId = resolveCharacterId(characterSlug);
|
||||||
|
const pendingImageReturn = await consumePendingChatImageReturn(characterId);
|
||||||
if (pendingImageReturn) return pendingImageReturn.returnUrl;
|
if (pendingImageReturn) return pendingImageReturn.returnUrl;
|
||||||
const pendingChatUnlock = await peekPendingChatUnlock();
|
const pendingChatUnlock = await peekPendingChatUnlock(characterId);
|
||||||
if (pendingChatUnlock) {
|
if (pendingChatUnlock) {
|
||||||
await clearPendingChatUnlock();
|
await clearPendingChatUnlock();
|
||||||
return pendingChatUnlock.returnUrl;
|
return pendingChatUnlock.returnUrl;
|
||||||
@@ -23,8 +30,12 @@ export async function consumeSubscriptionExplicitExitUrl(): Promise<string | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function peekSubscriptionExplicitExitUrl(): Promise<string | null> {
|
export async function peekSubscriptionExplicitExitUrl(
|
||||||
const pendingChatUnlock = await peekPendingChatUnlock();
|
characterSlug: string = DEFAULT_CHARACTER_SLUG,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const pendingChatUnlock = await peekPendingChatUnlock(
|
||||||
|
resolveCharacterId(characterSlug),
|
||||||
|
);
|
||||||
if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
|
if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -48,7 +59,7 @@ export async function consumeSubscriptionExitUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await consumeSubscriptionExplicitExitUrl()) ??
|
(await consumeSubscriptionExplicitExitUrl(characterSlug)) ??
|
||||||
getSubscriptionFallbackExitUrl(returnTo, characterSlug)
|
getSubscriptionFallbackExitUrl(returnTo, characterSlug)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,7 +72,11 @@ export async function resolveSubscriptionSuccessExitUrl(
|
|||||||
return getCharacterRoutes(characterSlug).privateRoom;
|
return getCharacterRoutes(characterSlug).privateRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
|
const pendingExitUrl = await peekSubscriptionExplicitExitUrl(characterSlug);
|
||||||
if (pendingExitUrl) return pendingExitUrl;
|
if (pendingExitUrl) return pendingExitUrl;
|
||||||
return consumeSubscriptionExitUrl(returnTo, characterSlug);
|
return consumeSubscriptionExitUrl(returnTo, characterSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCharacterId(characterSlug: string): string {
|
||||||
|
return getCharacterBySlug(characterSlug)?.id ?? DEFAULT_CHARACTER_ID;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act, type ReactNode } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const instances = vi.hoisted(() => ({
|
||||||
|
chat: 0,
|
||||||
|
payment: 0,
|
||||||
|
privateRoom: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/chat/chat-context", async () => {
|
||||||
|
const { createElement, useState } = await import("react");
|
||||||
|
return {
|
||||||
|
ChatProvider({
|
||||||
|
characterId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
characterId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [instanceId] = useState(() => ++instances.chat);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
"data-character-id": characterId,
|
||||||
|
"data-instance-id": instanceId,
|
||||||
|
"data-testid": "chat-provider",
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/stores/payment/payment-context", async () => {
|
||||||
|
const { createElement, useState } = await import("react");
|
||||||
|
return {
|
||||||
|
PaymentProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [instanceId] = useState(() => ++instances.payment);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
"data-instance-id": instanceId,
|
||||||
|
"data-testid": "payment-provider",
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/stores/private-room", async () => {
|
||||||
|
const { createElement, useState } = await import("react");
|
||||||
|
return {
|
||||||
|
PrivateRoomProvider({
|
||||||
|
characterId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
characterId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [instanceId] = useState(() => ++instances.privateRoom);
|
||||||
|
return createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
"data-character-id": characterId,
|
||||||
|
"data-instance-id": instanceId,
|
||||||
|
"data-testid": "private-room-provider",
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/stores/sync/chat-auth-sync", () => ({ ChatAuthSync: () => null }));
|
||||||
|
vi.mock("@/stores/sync/chat-payment-success-sync", () => ({
|
||||||
|
ChatPaymentSuccessSync: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/stores/sync/payment-success-sync", () => ({
|
||||||
|
PaymentSuccessSync: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatRouteProviders } from "@/providers/chat-route-providers";
|
||||||
|
import { PaymentRouteProvider } from "@/providers/payment-route-provider";
|
||||||
|
import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider";
|
||||||
|
|
||||||
|
describe("character actor route providers", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instances.chat = 0;
|
||||||
|
instances.payment = 0;
|
||||||
|
instances.privateRoom = 0;
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreates chat and chat payment actors when the character changes", () => {
|
||||||
|
renderChat("character_elio");
|
||||||
|
const firstChatInstance = instanceId("chat-provider");
|
||||||
|
const firstPaymentInstance = instanceId("payment-provider");
|
||||||
|
|
||||||
|
renderChat("character_maya");
|
||||||
|
|
||||||
|
expect(instanceId("chat-provider")).not.toBe(firstChatInstance);
|
||||||
|
expect(instanceId("payment-provider")).not.toBe(firstPaymentInstance);
|
||||||
|
expect(testNode("chat-provider").dataset.characterId).toBe(
|
||||||
|
"character_maya",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreates the private-room actor when the character changes", () => {
|
||||||
|
renderPrivateRoom("character_elio");
|
||||||
|
const firstInstance = instanceId("private-room-provider");
|
||||||
|
|
||||||
|
renderPrivateRoom("character_maya");
|
||||||
|
|
||||||
|
expect(instanceId("private-room-provider")).not.toBe(firstInstance);
|
||||||
|
expect(testNode("private-room-provider").dataset.characterId).toBe(
|
||||||
|
"character_maya",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recreates the route payment actor when its character scope changes", () => {
|
||||||
|
renderPayment("character_elio");
|
||||||
|
const firstInstance = instanceId("payment-provider");
|
||||||
|
|
||||||
|
renderPayment("character_maya");
|
||||||
|
|
||||||
|
expect(instanceId("payment-provider")).not.toBe(firstInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderChat(characterId: string): void {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ChatRouteProviders
|
||||||
|
characterId={characterId}
|
||||||
|
emptyChatGreeting={`Hello from ${characterId}`}
|
||||||
|
>
|
||||||
|
<span>chat</span>
|
||||||
|
</ChatRouteProviders>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrivateRoom(characterId: string): void {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<PrivateRoomRouteProvider characterId={characterId}>
|
||||||
|
<span>private room</span>
|
||||||
|
</PrivateRoomRouteProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPayment(scopeKey: string): void {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<PaymentRouteProvider scopeKey={scopeKey}>
|
||||||
|
<span>payment</span>
|
||||||
|
</PaymentRouteProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testNode(testId: string): HTMLElement {
|
||||||
|
const node = container.querySelector<HTMLElement>(
|
||||||
|
`[data-testid="${testId}"]`,
|
||||||
|
);
|
||||||
|
if (!node) throw new Error(`Missing ${testId}`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function instanceId(testId: string): string | undefined {
|
||||||
|
return testNode(testId).dataset.instanceId;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { CHARACTERS } from "@/data/constants/character";
|
||||||
|
import {
|
||||||
|
CharacterCatalogProvider,
|
||||||
|
useCharacterCatalog,
|
||||||
|
} from "@/providers/character-catalog-provider";
|
||||||
|
|
||||||
|
describe("CharacterCatalogProvider", () => {
|
||||||
|
it("provides the complete local character catalog", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CharacterCatalogProvider>
|
||||||
|
<CatalogProbe />
|
||||||
|
</CharacterCatalogProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("elio,maya,nayeli");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a serializable catalog snapshot", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CharacterCatalogProvider characters={[CHARACTERS[1]]}>
|
||||||
|
<CatalogProbe />
|
||||||
|
</CharacterCatalogProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("maya");
|
||||||
|
expect(html).not.toContain("elio");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function CatalogProbe() {
|
||||||
|
const catalog = useCharacterCatalog();
|
||||||
|
return <span>{catalog.characters.map((character) => character.slug).join(",")}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHARACTERS,
|
||||||
|
createCharacterCatalog,
|
||||||
|
type CharacterCatalog,
|
||||||
|
type CharacterProfile,
|
||||||
|
} from "@/data/constants/character";
|
||||||
|
|
||||||
|
const CharacterCatalogContext = createContext<CharacterCatalog | null>(null);
|
||||||
|
|
||||||
|
export interface CharacterCatalogProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
characters?: readonly CharacterProfile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CharacterCatalogProvider({
|
||||||
|
children,
|
||||||
|
characters = CHARACTERS,
|
||||||
|
}: CharacterCatalogProviderProps) {
|
||||||
|
const catalog = useMemo(
|
||||||
|
() => createCharacterCatalog(characters),
|
||||||
|
[characters],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<CharacterCatalogContext.Provider value={catalog}>
|
||||||
|
{children}
|
||||||
|
</CharacterCatalogContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCharacterCatalog(): CharacterCatalog {
|
||||||
|
const catalog = useContext(CharacterCatalogContext);
|
||||||
|
if (!catalog) {
|
||||||
|
throw new Error(
|
||||||
|
"useCharacterCatalog must be used within CharacterCatalogProvider.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
@@ -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>
|
<PaymentProvider key={characterId}>
|
||||||
<PaymentSuccessSync />
|
<PaymentSuccessSync />
|
||||||
<ChatProvider characterId={characterId}>
|
<ChatProvider
|
||||||
|
key={characterId}
|
||||||
|
characterId={characterId}
|
||||||
|
emptyChatGreeting={emptyChatGreeting}
|
||||||
|
>
|
||||||
<ChatAuthSync />
|
<ChatAuthSync />
|
||||||
<ChatPaymentSuccessSync />
|
<ChatPaymentSuccessSync />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ import type { ReactNode } from "react";
|
|||||||
import { PaymentProvider } from "@/stores/payment/payment-context";
|
import { PaymentProvider } from "@/stores/payment/payment-context";
|
||||||
import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync";
|
import { PaymentSuccessSync } from "@/stores/sync/payment-success-sync";
|
||||||
|
|
||||||
export function PaymentRouteProvider({ children }: { children: ReactNode }) {
|
export interface PaymentRouteProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
scopeKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentRouteProvider({
|
||||||
|
children,
|
||||||
|
scopeKey,
|
||||||
|
}: PaymentRouteProviderProps) {
|
||||||
return (
|
return (
|
||||||
<PaymentProvider>
|
<PaymentProvider key={scopeKey}>
|
||||||
<PaymentSuccessSync />
|
<PaymentSuccessSync />
|
||||||
{children}
|
{children}
|
||||||
</PaymentProvider>
|
</PaymentProvider>
|
||||||
|
|||||||
@@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
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 characterId={characterId}>
|
<PrivateRoomProvider key={characterId} characterId={characterId}>
|
||||||
{children}
|
{children}
|
||||||
</PrivateRoomProvider>
|
</PrivateRoomProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
/**
|
/**
|
||||||
* 根级 Client Providers 包装
|
* 根级 Client Providers 包装
|
||||||
*
|
*
|
||||||
* 这里只保留跨路由的 Auth、User、消息预览和 viewport Provider。Chat、
|
* 这里只保留跨路由的 Character Catalog、Auth、User、消息预览和 viewport
|
||||||
* Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。
|
* Provider。Chat、Payment、PrivateRoom 状态机由对应路由 layout 按需挂载。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { CharacterCatalogProvider } from "@/providers/character-catalog-provider";
|
||||||
import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider";
|
import { SplashLatestMessageProvider } from "@/providers/splash-latest-message-provider";
|
||||||
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
|
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
|
||||||
import { AppNavigationGuard } from "@/router/app-navigation-guard";
|
import { AppNavigationGuard } from "@/router/app-navigation-guard";
|
||||||
@@ -24,19 +25,21 @@ export interface RootProvidersProps {
|
|||||||
|
|
||||||
export function RootProviders({ children }: RootProvidersProps) {
|
export function RootProviders({ children }: RootProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<ViewportCssVarsProvider>
|
<CharacterCatalogProvider>
|
||||||
<AuthProvider>
|
<ViewportCssVarsProvider>
|
||||||
<AuthStatusChecker />
|
<AuthProvider>
|
||||||
<OAuthSessionSync />
|
<AuthStatusChecker />
|
||||||
<AppNavigationGuard />
|
<OAuthSessionSync />
|
||||||
<SplashLatestMessageProvider>
|
<AppNavigationGuard />
|
||||||
<UserProvider>
|
<SplashLatestMessageProvider>
|
||||||
<UserAuthSync />
|
<UserProvider>
|
||||||
{children}
|
<UserAuthSync />
|
||||||
<div id="toast-portal" />
|
{children}
|
||||||
</UserProvider>
|
<div id="toast-portal" />
|
||||||
</SplashLatestMessageProvider>
|
</UserProvider>
|
||||||
</AuthProvider>
|
</SplashLatestMessageProvider>
|
||||||
</ViewportCssVarsProvider>
|
</AuthProvider>
|
||||||
|
</ViewportCssVarsProvider>
|
||||||
|
</CharacterCatalogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: character.id,
|
||||||
displayMessageId,
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
@@ -165,7 +166,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[isAuthenticatedUser, router],
|
[character.id, isAuthenticatedUser, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openSubscriptionForPendingUnlock = useCallback(
|
const openSubscriptionForPendingUnlock = useCallback(
|
||||||
@@ -183,6 +184,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
}: OpenSubscriptionForPendingUnlockInput): void => {
|
}: OpenSubscriptionForPendingUnlockInput): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: character.id,
|
||||||
displayMessageId,
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
@@ -200,7 +202,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[getDefaultPayChannel, openSubscription],
|
[character.id, getDefaultPayChannel, openSubscription],
|
||||||
);
|
);
|
||||||
|
|
||||||
const exitSubscription = useCallback(
|
const exitSubscription = useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createActor } from "xstate";
|
||||||
|
|
||||||
|
const repository = vi.hoisted(() => ({
|
||||||
|
prefetchMediaForSendResponse: vi.fn(async () => ({
|
||||||
|
success: true as const,
|
||||||
|
data: undefined,
|
||||||
|
})),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
unlockHistory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const historySync = vi.hoisted(() => ({
|
||||||
|
readAndSyncHistory: vi.fn(),
|
||||||
|
readLocalHistorySnapshot: vi.fn(async () => ({
|
||||||
|
messages: [],
|
||||||
|
localCount: 0,
|
||||||
|
})),
|
||||||
|
resolveHistoryCacheIdentity: vi.fn(async () => null),
|
||||||
|
syncNetworkHistory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||||
|
loadChatRepository: vi.fn(async () => repository),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/repositories/chat_cache_identity", () => ({
|
||||||
|
resolveChatConversationKey: vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
data: "user:test::character:character_elio",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/chat/chat-history-sync", () => historySync);
|
||||||
|
|
||||||
|
import { loadHistoryActor } from "@/stores/chat/machine/actors/history";
|
||||||
|
import { sendMessageHttpActor } from "@/stores/chat/machine/actors/send";
|
||||||
|
import { unlockHistoryActor } from "@/stores/chat/machine/actors/unlock";
|
||||||
|
|
||||||
|
describe("chat actor request cancellation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
repository.sendMessage.mockReset();
|
||||||
|
repository.unlockHistory.mockReset();
|
||||||
|
historySync.syncNetworkHistory.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts an in-flight send request when its actor stops", async () => {
|
||||||
|
repository.sendMessage.mockImplementation(() => new Promise(() => undefined));
|
||||||
|
const actor = createActor(sendMessageHttpActor, {
|
||||||
|
input: { characterId: "character_elio", content: "hello" },
|
||||||
|
}).start();
|
||||||
|
await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const signal = getSignal(repository.sendMessage.mock.calls[0]?.[2]);
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
actor.stop();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts an in-flight history request when its callback actor stops", async () => {
|
||||||
|
historySync.syncNetworkHistory.mockImplementation(
|
||||||
|
() => new Promise(() => undefined),
|
||||||
|
);
|
||||||
|
const actor = createActor(loadHistoryActor, {
|
||||||
|
input: {
|
||||||
|
characterId: "character_elio",
|
||||||
|
emptyChatGreeting: "Hello from Elio",
|
||||||
|
},
|
||||||
|
}).start();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(historySync.syncNetworkHistory).toHaveBeenCalled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts an in-flight unlock request when its actor stops", async () => {
|
||||||
|
repository.unlockHistory.mockImplementation(
|
||||||
|
() => new Promise(() => undefined),
|
||||||
|
);
|
||||||
|
const actor = createActor(unlockHistoryActor, {
|
||||||
|
input: {
|
||||||
|
characterId: "character_elio",
|
||||||
|
emptyChatGreeting: "Hello from Elio",
|
||||||
|
},
|
||||||
|
}).start();
|
||||||
|
await vi.waitFor(() => expect(repository.unlockHistory).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const signal = getSignal(repository.unlockHistory.mock.calls[0]?.[1]);
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
actor.stop();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSignal(options: unknown): AbortSignal {
|
||||||
|
if (options instanceof AbortSignal) return options;
|
||||||
|
const signal = (options as { signal?: AbortSignal } | undefined)?.signal;
|
||||||
|
if (!signal) throw new Error("Missing AbortSignal");
|
||||||
|
return signal;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/stores/chat/helper/promotion";
|
} from "@/stores/chat/helper/promotion";
|
||||||
|
|
||||||
const promotion: PendingChatPromotion = {
|
const promotion: PendingChatPromotion = {
|
||||||
|
characterId: "character_elio",
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +113,7 @@ describe("chat session flow", () => {
|
|||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatPromotionInjected",
|
type: "ChatPromotionInjected",
|
||||||
promotion: {
|
promotion: {
|
||||||
|
characterId: "character_elio",
|
||||||
promotionType: "voice",
|
promotionType: "voice",
|
||||||
lockType: "voice_message",
|
lockType: "voice_message",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
|||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import { todayString } from "@/utils/date";
|
import { todayString } from "@/utils/date";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import { CHAT_HISTORY_LIMIT } from "./helper/history";
|
import { CHAT_HISTORY_LIMIT } from "./helper/history";
|
||||||
import { localMessagesToUi } from "./helper/message-mappers";
|
import { localMessagesToUi } from "./helper/message-mappers";
|
||||||
@@ -29,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,
|
||||||
@@ -50,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)
|
||||||
@@ -81,21 +80,26 @@ export async function syncNetworkHistory(
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
localCount: number,
|
localCount: number,
|
||||||
cacheIdentity: string | null,
|
cacheIdentity: string | null,
|
||||||
|
emptyChatGreeting: string,
|
||||||
|
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,
|
||||||
CHAT_HISTORY_LIMIT,
|
CHAT_HISTORY_LIMIT,
|
||||||
0,
|
0,
|
||||||
|
{ signal },
|
||||||
);
|
);
|
||||||
if (Result.isErr(networkResult)) {
|
if (Result.isErr(networkResult)) {
|
||||||
|
if (isAbortError(networkResult.error)) throw networkResult.error;
|
||||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||||
error: networkResult.error,
|
error: networkResult.error,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
const networkUi = localMessagesToUi(networkResult.data.messages);
|
const networkUi = localMessagesToUi(networkResult.data.messages);
|
||||||
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
||||||
@@ -144,13 +148,20 @@ export async function syncNetworkHistory(
|
|||||||
*/
|
*/
|
||||||
export async function readAndSyncHistory(
|
export async function readAndSyncHistory(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
emptyChatGreeting: string,
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
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,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { UiMessage } from "@/stores/chat/ui-message";
|
|||||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
readLocalHistorySnapshot,
|
readLocalHistorySnapshot,
|
||||||
@@ -24,6 +25,7 @@ export interface LoadMoreHistoryActorEvent {
|
|||||||
|
|
||||||
export interface ChatHistoryActorInput {
|
export interface ChatHistoryActorInput {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
|
emptyChatGreeting: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadMoreHistoryOutput {
|
export interface LoadMoreHistoryOutput {
|
||||||
@@ -38,10 +40,14 @@ export const loadHistoryActor = fromCallback<
|
|||||||
ChatHistoryActorInput
|
ChatHistoryActorInput
|
||||||
>(({ input, sendBack }) => {
|
>(({ input, sendBack }) => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
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",
|
||||||
@@ -52,6 +58,8 @@ export const loadHistoryActor = fromCallback<
|
|||||||
input.characterId,
|
input.characterId,
|
||||||
localSnapshot.localCount,
|
localSnapshot.localCount,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
|
input.emptyChatGreeting,
|
||||||
|
controller.signal,
|
||||||
);
|
);
|
||||||
if (cancelled || !networkSnapshot) return;
|
if (cancelled || !networkSnapshot) return;
|
||||||
sendBack({
|
sendBack({
|
||||||
@@ -59,13 +67,14 @@ export const loadHistoryActor = fromCallback<
|
|||||||
output: networkSnapshot,
|
output: networkSnapshot,
|
||||||
});
|
});
|
||||||
})().catch((error: unknown) => {
|
})().catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled || isAbortError(error)) return;
|
||||||
log.error("[chat-machine] loadHistoryActor failed", { error });
|
log.error("[chat-machine] loadHistoryActor failed", { error });
|
||||||
sendBack({ type: "ChatHistoryLoadFailed", error });
|
sendBack({ type: "ChatHistoryLoadFailed", error });
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,10 +83,13 @@ export const loadMoreHistoryActor =
|
|||||||
({ input, receive, sendBack }) => {
|
({ input, receive, sendBack }) => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let running = false;
|
let running = false;
|
||||||
|
let activeController: AbortController | null = null;
|
||||||
|
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
if (running || cancelled) return;
|
if (running || cancelled) return;
|
||||||
running = true;
|
running = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
@@ -85,6 +97,7 @@ export const loadMoreHistoryActor =
|
|||||||
input.characterId,
|
input.characterId,
|
||||||
event.limit,
|
event.limit,
|
||||||
event.offset,
|
event.offset,
|
||||||
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
if (Result.isErr(historyResult)) throw historyResult.error;
|
if (Result.isErr(historyResult)) throw historyResult.error;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -111,17 +124,19 @@ export const loadMoreHistoryActor =
|
|||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled || isAbortError(error)) return;
|
||||||
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
||||||
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (activeController === controller) activeController = null;
|
||||||
running = false;
|
running = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
activeController?.abort();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
|||||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import type { ChatEvent } from "../../chat-events";
|
import type { ChatEvent } from "../../chat-events";
|
||||||
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
||||||
@@ -18,8 +19,8 @@ type UiMessage = import("@/stores/chat/ui-message").UiMessage;
|
|||||||
export const sendMessageHttpActor = fromPromise<
|
export const sendMessageHttpActor = fromPromise<
|
||||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||||
{ characterId: string; content: string }
|
{ characterId: string; content: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input, signal }) => {
|
||||||
return sendMessageViaHttp(input.characterId, input.content);
|
return sendMessageViaHttp(input.characterId, input.content, signal);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const httpMessageQueueActor = fromCallback<
|
export const httpMessageQueueActor = fromCallback<
|
||||||
@@ -37,13 +38,21 @@ function createMessageQueueActor(
|
|||||||
receive: (listener: (event: ChatEvent) => void) => void,
|
receive: (listener: (event: ChatEvent) => void) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const queue = new MessageQueue();
|
const queue = new MessageQueue();
|
||||||
|
let activeController: AbortController | null = null;
|
||||||
|
|
||||||
queue.setConsumer(async (content) => {
|
queue.setConsumer(async (content) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
sendBack({ type: "ChatQueuedSendStarted" });
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
try {
|
try {
|
||||||
const output = await sendMessageViaHttp(characterId, content);
|
const output = await sendMessageViaHttp(
|
||||||
|
characterId,
|
||||||
|
content,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) return;
|
||||||
const errorMessage = ExceptionHandler.message(error);
|
const errorMessage = ExceptionHandler.message(error);
|
||||||
log.error("[chat-machine] message queue send failed", {
|
log.error("[chat-machine] message queue send failed", {
|
||||||
error,
|
error,
|
||||||
@@ -53,6 +62,8 @@ function createMessageQueueActor(
|
|||||||
content,
|
content,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) activeController = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,22 +74,31 @@ function createMessageQueueActor(
|
|||||||
queue.enqueue(content);
|
queue.enqueue(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => queue.dispose();
|
return () => {
|
||||||
|
activeController?.abort();
|
||||||
|
queue.dispose();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessageViaHttp(characterId: string, content: string): Promise<{
|
async function sendMessageViaHttp(
|
||||||
|
characterId: string,
|
||||||
|
content: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{
|
||||||
response: ChatSendResponse;
|
response: ChatSendResponse;
|
||||||
reply: UiMessage | null;
|
reply: UiMessage | null;
|
||||||
}> {
|
}> {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
||||||
const result = await chatRepo.sendMessage(characterId, content);
|
const result = await chatRepo.sendMessage(characterId, content, { signal });
|
||||||
if (Result.isErr(result)) {
|
if (Result.isErr(result)) {
|
||||||
|
if (isAbortError(result.error)) throw result.error;
|
||||||
log.error("[chat-machine] sendMessageHttpActor failed", {
|
log.error("[chat-machine] sendMessageHttpActor failed", {
|
||||||
error: result.error,
|
error: result.error,
|
||||||
});
|
});
|
||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (Result.isOk(cacheIdentityResult)) {
|
if (Result.isOk(cacheIdentityResult)) {
|
||||||
void chatRepo.prefetchMediaForSendResponse(
|
void chatRepo.prefetchMediaForSendResponse(
|
||||||
result.data,
|
result.data,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
|||||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import { readAndSyncHistory } from "../../chat-history-sync";
|
import { readAndSyncHistory } from "../../chat-history-sync";
|
||||||
import {
|
import {
|
||||||
@@ -24,18 +25,26 @@ export interface UnlockHistoryOutput {
|
|||||||
|
|
||||||
export const unlockHistoryActor = fromPromise<
|
export const unlockHistoryActor = fromPromise<
|
||||||
UnlockHistoryOutput,
|
UnlockHistoryOutput,
|
||||||
{ characterId: string }
|
{ characterId: string; emptyChatGreeting: string }
|
||||||
>(async ({ input }) => {
|
>(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, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (Result.isErr(unlockResult)) {
|
if (Result.isErr(unlockResult)) {
|
||||||
|
if (isAbortError(unlockResult.error)) throw unlockResult.error;
|
||||||
log.error("[chat-machine] unlockHistoryActor failed", {
|
log.error("[chat-machine] unlockHistoryActor failed", {
|
||||||
error: unlockResult.error,
|
error: unlockResult.error,
|
||||||
});
|
});
|
||||||
throw unlockResult.error;
|
throw unlockResult.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = await readAndSyncHistory(input.characterId);
|
signal.throwIfAborted();
|
||||||
|
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,
|
||||||
@@ -47,18 +56,22 @@ export const unlockHistoryActor = fromPromise<
|
|||||||
export const unlockMessageActor = fromPromise<
|
export const unlockMessageActor = fromPromise<
|
||||||
UnlockMessageOutput,
|
UnlockMessageOutput,
|
||||||
UnlockMessageRequest & { characterId: string }
|
UnlockMessageRequest & { characterId: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input, signal }) => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const cacheIdentityResult = await resolveChatConversationKey(
|
const cacheIdentityResult = await resolveChatConversationKey(
|
||||||
input.characterId,
|
input.characterId,
|
||||||
);
|
);
|
||||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
const unlockResult = await chatRepo.unlockPrivateMessage(
|
||||||
characterId: input.characterId,
|
{
|
||||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
characterId: input.characterId,
|
||||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||||
});
|
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
if (Result.isErr(unlockResult)) {
|
if (Result.isErr(unlockResult)) {
|
||||||
|
if (isAbortError(unlockResult.error)) throw unlockResult.error;
|
||||||
log.error("[chat-machine] unlockMessageActor failed", {
|
log.error("[chat-machine] unlockMessageActor failed", {
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
clientLockId: input.clientLockId,
|
clientLockId: input.clientLockId,
|
||||||
@@ -66,6 +79,7 @@ export const unlockMessageActor = fromPromise<
|
|||||||
});
|
});
|
||||||
throw unlockResult.error;
|
throw unlockResult.error;
|
||||||
}
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
unlockResult.data.unlocked &&
|
unlockResult.data.unlocked &&
|
||||||
|
|||||||
@@ -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 } }}>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useEffect, useRef } from "react";
|
|||||||
import { shallowEqual } from "@xstate/react";
|
import { shallowEqual } from "@xstate/react";
|
||||||
|
|
||||||
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
|
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
import {
|
||||||
|
useChatDispatch,
|
||||||
|
useChatSelector,
|
||||||
|
} from "@/stores/chat/chat-context";
|
||||||
import { usePaymentSelector } from "@/stores/payment/payment-context";
|
import { usePaymentSelector } from "@/stores/payment/payment-context";
|
||||||
|
|
||||||
export function ChatPaymentSuccessSync() {
|
export function ChatPaymentSuccessSync() {
|
||||||
@@ -17,6 +20,7 @@ export function ChatPaymentSuccessSync() {
|
|||||||
}),
|
}),
|
||||||
shallowEqual,
|
shallowEqual,
|
||||||
);
|
);
|
||||||
|
const characterId = useChatSelector((state) => state.context.characterId);
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const lastPaidKeyRef = useRef<string | null>(null);
|
const lastPaidKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ export function ChatPaymentSuccessSync() {
|
|||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (await hasPendingChatUnlock()) return;
|
if (await hasPendingChatUnlock(characterId)) return;
|
||||||
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
|
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ export function ChatPaymentSuccessSync() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
chatDispatch,
|
chatDispatch,
|
||||||
|
characterId,
|
||||||
paymentState.currentOrderId,
|
paymentState.currentOrderId,
|
||||||
paymentState.isPaid,
|
paymentState.isPaid,
|
||||||
paymentState.orderStatus,
|
paymentState.orderStatus,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
describe("isAbortError", () => {
|
||||||
|
it("recognizes cancellation after Result normalizes the error", async () => {
|
||||||
|
const result = await Result.wrap(async () => {
|
||||||
|
throw new DOMException("This operation was aborted", "AbortError");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Result.isErr(result) && isAbortError(result.error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat request timeouts as actor cancellation", () => {
|
||||||
|
expect(isAbortError(new DOMException("Timed out", "TimeoutError"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function isAbortError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
if (error.name === "AbortError") return true;
|
||||||
|
const cause = error.cause;
|
||||||
|
return cause !== error && isAbortError(cause);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user