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