feat(characters): enable isolated pre-release role switching
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
| 类型 | Elio 示例 | 用途 |
|
| 类型 | Elio 示例 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 |
|
| `id` | `elio` | 数据库关联、API 请求和缓存隔离 |
|
||||||
| `slug` | `elio` | 前端 URL 和外部入口 |
|
| `slug` | `elio` | 前端 URL 和外部入口 |
|
||||||
|
|
||||||
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
|
后端业务接口必须使用 `id`,不能使用可能变化的展示名称。
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner |
|
| `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
|
| `elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` |
|
||||||
| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` |
|
| `maya-tan` | `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` |
|
| `nayeli-cervantes` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` |
|
||||||
|
|
||||||
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
|
当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ POST /api/chat/unlock-private
|
|||||||
POST /api/chat/unlock-history
|
POST /api/chat/unlock-history
|
||||||
```
|
```
|
||||||
|
|
||||||
`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。
|
`characterId` 使用前端本地角色目录中的 `id`,例如 `elio`,不能传 slug 或显示名称。
|
||||||
|
|
||||||
后端必须使用以下维度定位聊天数据:
|
后端必须使用以下维度定位聊天数据:
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"message": "Hello Elio",
|
"message": "Hello Elio",
|
||||||
"image": "",
|
"image": "",
|
||||||
"imageId": "",
|
"imageId": "",
|
||||||
@@ -105,7 +105,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
### 3.3 获取聊天历史
|
### 3.3 获取聊天历史
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/chat/history?characterId=character_elio&limit=50&offset=0
|
GET /api/chat/history?characterId=elio&limit=50&offset=0
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"messageId": "message-id",
|
"messageId": "message-id",
|
||||||
"lockType": "voice_message",
|
"lockType": "voice_message",
|
||||||
"clientLockId": "client-generated-id"
|
"clientLockId": "client-generated-id"
|
||||||
@@ -164,7 +164,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"characterId": "character_elio"
|
"characterId": "elio"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
"data": {
|
"data": {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"message": {
|
"message": {
|
||||||
"id": "message-id",
|
"id": "message-id",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@@ -220,7 +220,7 @@ Authorization: Bearer <TOKEN>
|
|||||||
标准请求改为:
|
标准请求改为:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/private-room/albums?characterId=character_elio&limit=20
|
GET /api/private-room/albums?characterId=elio&limit=20
|
||||||
Authorization: Bearer <TOKEN>
|
Authorization: Bearer <TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ POST /api/payment/create-order
|
|||||||
"planId": "tip_coffee_medium",
|
"planId": "tip_coffee_medium",
|
||||||
"payChannel": "stripe",
|
"payChannel": "stripe",
|
||||||
"autoRenew": false,
|
"autoRenew": false,
|
||||||
"recipientCharacterId": "character_elio"
|
"recipientCharacterId": "elio"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -326,9 +326,9 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
|
|||||||
|
|
||||||
上线新接口前执行:
|
上线新接口前执行:
|
||||||
|
|
||||||
1. 创建 Elio 角色,固定 `id=character_elio`、`slug=elio`。
|
1. 创建 Elio 角色,固定 `id=elio`、`slug=elio`。
|
||||||
2. 为相关业务表增加可空 `character_id`。
|
2. 为相关业务表增加可空 `character_id`。
|
||||||
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`。
|
3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `elio`。
|
||||||
4. 检查不存在空角色或无法关联的数据。
|
4. 检查不存在空角色或无法关联的数据。
|
||||||
5. 创建联合索引和外键。
|
5. 创建联合索引和外键。
|
||||||
6. 将必要业务表的 `character_id` 调整为非空。
|
6. 将必要业务表的 `character_id` 调整为非空。
|
||||||
@@ -340,7 +340,7 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归
|
|||||||
### 第一阶段:后端兼容
|
### 第一阶段:后端兼容
|
||||||
|
|
||||||
- 上线角色 ID 配置和新字段。
|
- 上线角色 ID 配置和新字段。
|
||||||
- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。
|
- Chat 请求缺少 `characterId` 时暂时默认 `elio`。
|
||||||
- Private Room 缺少角色时暂时默认 Elio。
|
- Private Room 缺少角色时暂时默认 Elio。
|
||||||
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
- Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。
|
||||||
- 记录缺少角色参数的调用次数和客户端版本。
|
- 记录缺少角色参数的调用次数和客户端版本。
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { CHARACTERS, getCharacterBySlug } from "@/data/constants/character";
|
||||||
|
import { CharacterCatalogProvider } from "@/providers/character-catalog-provider";
|
||||||
|
import { CharacterProvider } from "@/providers/character-provider";
|
||||||
|
|
||||||
|
import { CharacterSwitcher } from "../character-switcher";
|
||||||
|
|
||||||
|
describe("CharacterSwitcher", () => {
|
||||||
|
it("links every chat character and marks the active account conversation", () => {
|
||||||
|
const maya = getCharacterBySlug("maya");
|
||||||
|
expect(maya).not.toBeNull();
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<CharacterCatalogProvider characters={CHARACTERS}>
|
||||||
|
<CharacterProvider character={maya!}>
|
||||||
|
<CharacterSwitcher />
|
||||||
|
</CharacterProvider>
|
||||||
|
</CharacterCatalogProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('aria-label="Choose character"');
|
||||||
|
expect(html).toContain('href="/characters/elio/splash"');
|
||||||
|
expect(html).toContain('href="/characters/maya/splash"');
|
||||||
|
expect(html).toContain('href="/characters/nayeli/splash"');
|
||||||
|
expect(html).toContain('data-character-id="elio"');
|
||||||
|
expect(html).toContain('data-character-id="maya-tan"');
|
||||||
|
expect(html).toContain('data-character-id="nayeli-cervantes"');
|
||||||
|
expect(html).toMatch(
|
||||||
|
/<a(?=[^>]*data-character-id="maya-tan")(?=[^>]*aria-current="page")[^>]*>/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
|
import { useActiveCharacter } from "@/providers/character-provider";
|
||||||
|
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||||
|
|
||||||
|
import { CharacterAvatar } from "./character-avatar";
|
||||||
|
|
||||||
|
export interface CharacterSwitcherProps {
|
||||||
|
destination?: "splash" | "chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CharacterSwitcher({
|
||||||
|
destination = "splash",
|
||||||
|
}: CharacterSwitcherProps) {
|
||||||
|
const activeCharacter = useActiveCharacter();
|
||||||
|
const catalog = useCharacterCatalog();
|
||||||
|
const chatCharacters = catalog.characters.filter(
|
||||||
|
(character) => character.capabilities.chat,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Choose character"
|
||||||
|
className="grid w-full grid-cols-3 gap-1 rounded-lg border border-white/15 bg-black/35 p-1 shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{chatCharacters.map((character) => {
|
||||||
|
const isActive = character.id === activeCharacter.id;
|
||||||
|
const routes = getCharacterRoutes(character.slug);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={character.id}
|
||||||
|
href={routes[destination]}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
aria-label={`Chat with ${character.displayName}`}
|
||||||
|
title={character.displayName}
|
||||||
|
data-character-id={character.id}
|
||||||
|
data-analytics-key="character.switch"
|
||||||
|
data-analytics-label={`Switch to ${character.displayName}`}
|
||||||
|
className={[
|
||||||
|
"flex min-w-0 items-center justify-center gap-1.5 rounded-md px-1.5 py-1.5 text-[11px] font-semibold text-white transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
|
||||||
|
isActive
|
||||||
|
? "bg-white/20 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
|
||||||
|
: "bg-transparent hover:bg-white/10",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<CharacterAvatar
|
||||||
|
src={character.assets.avatar}
|
||||||
|
alt=""
|
||||||
|
size={28}
|
||||||
|
imageSize={56}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "ring-2 ring-white ring-offset-1 ring-offset-transparent"
|
||||||
|
: "opacity-85"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">{character.shortName}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,6 +93,15 @@ describe("core Tailwind components", () => {
|
|||||||
onPrivateRoomClick={() => undefined}
|
onPrivateRoomClick={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
const disabledPrivateRoomHtml = renderToStaticMarkup(
|
||||||
|
<AppBottomNav
|
||||||
|
activeItem="chat"
|
||||||
|
privateRoomLabel="Maya Private room"
|
||||||
|
privateRoomEnabled={false}
|
||||||
|
onChatClick={() => undefined}
|
||||||
|
onPrivateRoomClick={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(chatHtml).toContain('aria-label="Primary navigation"');
|
expect(chatHtml).toContain('aria-label="Primary navigation"');
|
||||||
expect(chatHtml).toMatch(/class="[^"]*dark[^"]*"/);
|
expect(chatHtml).toMatch(/class="[^"]*dark[^"]*"/);
|
||||||
@@ -107,5 +116,8 @@ describe("core Tailwind components", () => {
|
|||||||
expect(privateRoomHtml).toContain(
|
expect(privateRoomHtml).toContain(
|
||||||
'data-analytics-key="navigation.private_room"',
|
'data-analytics-key="navigation.private_room"',
|
||||||
);
|
);
|
||||||
|
expect(disabledPrivateRoomHtml).toMatch(
|
||||||
|
/<button[^>]*aria-disabled="true"[^>]*disabled=""[^>]*>/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,20 @@
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-button-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
color: var(--nav-active-color);
|
color: var(--nav-active-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface AppBottomNavProps {
|
|||||||
activeItem?: AppBottomNavItem | null;
|
activeItem?: AppBottomNavItem | null;
|
||||||
variant?: AppBottomNavVariant;
|
variant?: AppBottomNavVariant;
|
||||||
privateRoomLabel: string;
|
privateRoomLabel: string;
|
||||||
|
privateRoomEnabled?: boolean;
|
||||||
onChatClick: () => void;
|
onChatClick: () => void;
|
||||||
onPrivateRoomClick: () => void;
|
onPrivateRoomClick: () => void;
|
||||||
}
|
}
|
||||||
@@ -19,6 +20,7 @@ export function AppBottomNav({
|
|||||||
activeItem = null,
|
activeItem = null,
|
||||||
variant = "warm",
|
variant = "warm",
|
||||||
privateRoomLabel,
|
privateRoomLabel,
|
||||||
|
privateRoomEnabled = true,
|
||||||
onChatClick,
|
onChatClick,
|
||||||
onPrivateRoomClick,
|
onPrivateRoomClick,
|
||||||
}: AppBottomNavProps) {
|
}: AppBottomNavProps) {
|
||||||
@@ -39,8 +41,13 @@ export function AppBottomNav({
|
|||||||
type="button"
|
type="button"
|
||||||
data-analytics-key="navigation.private_room"
|
data-analytics-key="navigation.private_room"
|
||||||
data-analytics-label="Private room navigation"
|
data-analytics-label="Private room navigation"
|
||||||
className={getButtonClass(activeItem === "privateRoom")}
|
className={getButtonClass(
|
||||||
|
activeItem === "privateRoom",
|
||||||
|
!privateRoomEnabled,
|
||||||
|
)}
|
||||||
aria-current={activeItem === "privateRoom" ? "page" : undefined}
|
aria-current={activeItem === "privateRoom" ? "page" : undefined}
|
||||||
|
aria-disabled={!privateRoomEnabled}
|
||||||
|
disabled={!privateRoomEnabled}
|
||||||
onClick={onPrivateRoomClick}
|
onClick={onPrivateRoomClick}
|
||||||
>
|
>
|
||||||
<Camera size={20} aria-hidden="true" />
|
<Camera size={20} aria-hidden="true" />
|
||||||
@@ -54,6 +61,12 @@ function getRootClass(variant: AppBottomNavVariant): string {
|
|||||||
return [styles.root, styles[variant]].filter(Boolean).join(" ");
|
return [styles.root, styles[variant]].filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getButtonClass(active: boolean): string {
|
function getButtonClass(active: boolean, disabled = false): string {
|
||||||
return [styles.button, active ? styles.active : ""].filter(Boolean).join(" ");
|
return [
|
||||||
|
styles.button,
|
||||||
|
active ? styles.active : "",
|
||||||
|
disabled ? styles.disabled : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { CharacterSwitcher } from "@/app/_components/character-switcher";
|
||||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +58,9 @@ export function SplashScreen() {
|
|||||||
<div className={styles.gradientOverlay} aria-hidden="true" />
|
<div className={styles.gradientOverlay} aria-hidden="true" />
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<SplashLogo />
|
<SplashLogo />
|
||||||
|
<div className="mt-3 w-full max-w-120">
|
||||||
|
<CharacterSwitcher />
|
||||||
|
</div>
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
<SplashLatestMessage
|
<SplashLatestMessage
|
||||||
message={latestMessage.message}
|
message={latestMessage.message}
|
||||||
@@ -78,6 +82,7 @@ export function SplashScreen() {
|
|||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="chat"
|
activeItem="chat"
|
||||||
privateRoomLabel={character.copy.privateRoomTitle}
|
privateRoomLabel={character.copy.privateRoomTitle}
|
||||||
|
privateRoomEnabled={character.capabilities.privateRoom}
|
||||||
onChatClick={handleOpenSplash}
|
onChatClick={handleOpenSplash}
|
||||||
onPrivateRoomClick={handleOpenPrivateRoom}
|
onPrivateRoomClick={handleOpenPrivateRoom}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe("local character catalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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("maya-tan")?.displayName).toBe("Maya Tan");
|
||||||
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
expect(getCharacterBySlug(" NAYELI ")?.displayName).toBe(
|
||||||
"Nayeli Cervantes",
|
"Nayeli Cervantes",
|
||||||
);
|
);
|
||||||
@@ -50,11 +50,11 @@ describe("local character catalog", () => {
|
|||||||
|
|
||||||
it("describes route capabilities and character-specific chat copy", () => {
|
it("describes route capabilities and character-specific chat copy", () => {
|
||||||
for (const character of CHARACTERS) {
|
for (const character of CHARACTERS) {
|
||||||
expect(character.capabilities).toEqual({
|
expect(character.capabilities.chat).toBe(true);
|
||||||
chat: true,
|
expect(character.capabilities.tip).toBe(true);
|
||||||
privateRoom: true,
|
expect(character.capabilities.privateRoom).toBe(
|
||||||
tip: true,
|
character.id === "elio",
|
||||||
});
|
);
|
||||||
expect(character.tagline.length).toBeGreaterThan(0);
|
expect(character.tagline.length).toBeGreaterThan(0);
|
||||||
expect(character.emptyChatGreeting.length).toBeGreaterThan(0);
|
expect(character.emptyChatGreeting.length).toBeGreaterThan(0);
|
||||||
expect(character.assets.chatBackground).toBe(
|
expect(character.assets.chatBackground).toBe(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function defineCharacter(profile: CharacterProfile): CharacterProfile {
|
|||||||
|
|
||||||
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
||||||
defineCharacter({
|
defineCharacter({
|
||||||
id: "character_elio",
|
id: "elio",
|
||||||
slug: "elio",
|
slug: "elio",
|
||||||
displayName: "Elio Silvestri",
|
displayName: "Elio Silvestri",
|
||||||
shortName: "Elio",
|
shortName: "Elio",
|
||||||
@@ -75,7 +75,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
defineCharacter({
|
defineCharacter({
|
||||||
id: "character_maya",
|
id: "maya-tan",
|
||||||
slug: "maya",
|
slug: "maya",
|
||||||
displayName: "Maya Tan",
|
displayName: "Maya Tan",
|
||||||
shortName: "Maya",
|
shortName: "Maya",
|
||||||
@@ -86,7 +86,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
|||||||
"How was your day?",
|
"How was your day?",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
chat: true,
|
chat: true,
|
||||||
privateRoom: true,
|
privateRoom: false,
|
||||||
tip: true,
|
tip: true,
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
@@ -104,7 +104,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
defineCharacter({
|
defineCharacter({
|
||||||
id: "character_nayeli",
|
id: "nayeli-cervantes",
|
||||||
slug: "nayeli",
|
slug: "nayeli",
|
||||||
displayName: "Nayeli Cervantes",
|
displayName: "Nayeli Cervantes",
|
||||||
shortName: "Nayeli",
|
shortName: "Nayeli",
|
||||||
@@ -115,7 +115,7 @@ const CHARACTER_PROFILES: readonly CharacterProfile[] = Object.freeze([
|
|||||||
"Tell me how your day went.",
|
"Tell me how your day went.",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
chat: true,
|
chat: true,
|
||||||
privateRoom: true,
|
privateRoom: false,
|
||||||
tip: true,
|
tip: true,
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"message": "Look at this sunset.",
|
"message": "Look at this sunset.",
|
||||||
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
|
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...",
|
||||||
"imageId": "img_mock_001",
|
"imageId": "img_mock_001",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"message": "I missed you today.",
|
"message": "I missed you today.",
|
||||||
"image": "",
|
"image": "",
|
||||||
"imageId": "",
|
"imageId": "",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"characterId": "character_elio",
|
"characterId": "elio",
|
||||||
"messageId": "msg_private_locked_001"
|
"messageId": "msg_private_locked_001"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
expect(result).toEqual(Result.ok(null));
|
expect(result).toEqual(Result.ok(null));
|
||||||
expect(getMedia).toHaveBeenCalledTimes(1);
|
expect(getMedia).toHaveBeenCalledTimes(1);
|
||||||
expect(getMedia).toHaveBeenCalledWith(
|
expect(getMedia).toHaveBeenCalledWith(
|
||||||
"user:account-a::character:character_elio:message-1:image",
|
"user:account-a::character:elio:message-1:image",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,19 +46,19 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await coordinator.getCachedMedia({
|
await coordinator.getCachedMedia({
|
||||||
characterId: "character_elio",
|
characterId: "elio",
|
||||||
messageId: "shared-message-id",
|
messageId: "shared-message-id",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
await coordinator.getCachedMedia({
|
await coordinator.getCachedMedia({
|
||||||
characterId: "character_aria",
|
characterId: "maya-tan",
|
||||||
messageId: "shared-message-id",
|
messageId: "shared-message-id",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getMedia.mock.calls).toEqual([
|
expect(getMedia.mock.calls).toEqual([
|
||||||
["user:account-a::character:character_elio:shared-message-id:image"],
|
["user:account-a::character:elio:shared-message-id:image"],
|
||||||
["user:account-a::character:character_aria:shared-message-id:image"],
|
["user:account-a::character:maya-tan:shared-message-id:image"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ describe("ChatMediaCacheCoordinator identity isolation", () => {
|
|||||||
kind: "image",
|
kind: "image",
|
||||||
remoteUrl: "https://media.example/expired.jpg",
|
remoteUrl: "https://media.example/expired.jpg",
|
||||||
},
|
},
|
||||||
"user:account-a::character:character_elio",
|
"user:account-a::character:elio",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(Result.isErr(result)).toBe(true);
|
expect(Result.isErr(result)).toBe(true);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ vi.mock("../http_client", () => ({
|
|||||||
import { ChatApi } from "../chat_api";
|
import { ChatApi } from "../chat_api";
|
||||||
import { PrivateRoomApi } from "../private_room_api";
|
import { PrivateRoomApi } from "../private_room_api";
|
||||||
|
|
||||||
const CHARACTER_ID = "character_elio";
|
const CHARACTER_ID = "elio";
|
||||||
|
|
||||||
describe("multi-character API contract", () => {
|
describe("multi-character API contract", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("fetchSplashLatestMessagePreview", () => {
|
|||||||
const result = await fetchSplashLatestMessagePreview();
|
const result = await fetchSplashLatestMessagePreview();
|
||||||
|
|
||||||
expect(getHistoryMock).toHaveBeenCalledWith(
|
expect(getHistoryMock).toHaveBeenCalledWith(
|
||||||
"character_elio",
|
"elio",
|
||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,13 +24,17 @@ export function CharacterProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useActiveCharacter(): CharacterProfile {
|
export function useActiveCharacter(): CharacterProfile {
|
||||||
const character = useContext(CharacterContext);
|
const character = useOptionalActiveCharacter();
|
||||||
if (!character) {
|
if (!character) {
|
||||||
throw new Error("useActiveCharacter must be used within CharacterProvider.");
|
throw new Error("useActiveCharacter must be used within CharacterProvider.");
|
||||||
}
|
}
|
||||||
return character;
|
return character;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useOptionalActiveCharacter(): CharacterProfile | null {
|
||||||
|
return useContext(CharacterContext);
|
||||||
|
}
|
||||||
|
|
||||||
export function useActiveCharacterRoutes(): CharacterRoutes {
|
export function useActiveCharacterRoutes(): CharacterRoutes {
|
||||||
return getCharacterRoutes(useActiveCharacter().slug);
|
return getCharacterRoutes(useActiveCharacter().slug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,11 @@ import {
|
|||||||
} from "@/lib/analytics";
|
} from "@/lib/analytics";
|
||||||
import { useAuthSelector } from "@/stores/auth/auth-context";
|
import { useAuthSelector } from "@/stores/auth/auth-context";
|
||||||
import { useUserSelector } from "@/stores/user/user-context";
|
import { useUserSelector } from "@/stores/user/user-context";
|
||||||
import {
|
import { DEFAULT_CHARACTER } from "@/data/constants/character";
|
||||||
useActiveCharacter,
|
import { useOptionalActiveCharacter } from "@/providers/character-provider";
|
||||||
useActiveCharacterRoutes,
|
|
||||||
} from "@/providers/character-provider";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getCharacterRoutes,
|
||||||
ROUTE_BUILDERS,
|
ROUTE_BUILDERS,
|
||||||
type Route,
|
type Route,
|
||||||
} from "./routes";
|
} from "./routes";
|
||||||
@@ -60,8 +59,8 @@ export interface AppNavigator {
|
|||||||
|
|
||||||
export function useAppNavigator(): AppNavigator {
|
export function useAppNavigator(): AppNavigator {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const character = useActiveCharacter();
|
const character = useOptionalActiveCharacter() ?? DEFAULT_CHARACTER;
|
||||||
const characterRoutes = useActiveCharacterRoutes();
|
const characterRoutes = getCharacterRoutes(character.slug);
|
||||||
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
|
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
|
||||||
const countryCode = useUserSelector(
|
const countryCode = useUserSelector(
|
||||||
(state) => state.context.currentUser?.countryCode,
|
(state) => state.context.currentUser?.countryCode,
|
||||||
|
|||||||
Reference in New Issue
Block a user