Compare commits
3 Commits
971a8205cf
...
96a2accde4
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a2accde4 | |||
| 26ab790078 | |||
| 159e8bfd59 |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 19 KiB |
@@ -31,10 +31,7 @@
|
|||||||
padding:
|
padding:
|
||||||
calc(var(--page-padding-y, 20px) + var(--app-safe-top, 0px))
|
calc(var(--page-padding-y, 20px) + var(--app-safe-top, 0px))
|
||||||
calc(var(--page-padding-x, 26px) + var(--app-safe-right, 0px))
|
calc(var(--page-padding-x, 26px) + var(--app-safe-right, 0px))
|
||||||
calc(
|
calc(var(--page-padding-y, 20px) + var(--app-safe-bottom, 0px))
|
||||||
var(--spacing-md, 12px) + var(--app-safe-bottom, 0px) +
|
|
||||||
var(--app-bottom-nav-height, 64px)
|
|
||||||
)
|
|
||||||
calc(var(--page-padding-x, 26px) + var(--app-safe-left, 0px));
|
calc(var(--page-padding-x, 26px) + var(--app-safe-left, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,31 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { AppBottomNav, MobileShell } from "@/app/_components/core";
|
import { MobileShell } from "@/app/_components/core";
|
||||||
|
// import { AppBottomNav } from "@/app/_components/core";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import {
|
import {
|
||||||
useActiveCharacter,
|
useActiveCharacter,
|
||||||
useActiveCharacterRoutes,
|
useActiveCharacterRoutes,
|
||||||
} from "@/providers/character-provider";
|
} from "@/providers/character-provider";
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
// import { useAuthState } from "@/stores/auth/auth-context";
|
||||||
import { pwaUtil } from "@/utils/pwa";
|
import { pwaUtil } from "@/utils/pwa";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SplashBackground,
|
SplashBackground,
|
||||||
SplashButton,
|
SplashButton,
|
||||||
SplashContent,
|
SplashContent,
|
||||||
SplashLatestMessage,
|
// SplashLatestMessage,
|
||||||
SplashLogo,
|
SplashLogo,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { useSplashLatestMessage } from "./hooks/use-splash-latest-message";
|
// import { useSplashLatestMessage } from "./hooks/use-splash-latest-message";
|
||||||
import styles from "./components/splash-screen.module.css";
|
import styles from "./components/splash-screen.module.css";
|
||||||
|
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
const character = useActiveCharacter();
|
const character = useActiveCharacter();
|
||||||
const characterRoutes = useActiveCharacterRoutes();
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
|
/*
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const latestMessage = useSplashLatestMessage({
|
const latestMessage = useSplashLatestMessage({
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
@@ -32,14 +34,17 @@ export function SplashScreen() {
|
|||||||
isAuthLoading: authState.isLoading,
|
isAuthLoading: authState.isLoading,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
const handleStartChat = () => {
|
const handleStartChat = () => {
|
||||||
navigator.openChat({ replace: true });
|
navigator.openChat({ replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
const handleOpenPrivateRoom = () => {
|
const handleOpenPrivateRoom = () => {
|
||||||
navigator.push(characterRoutes.privateRoom, { scroll: false });
|
navigator.push(characterRoutes.privateRoom, { scroll: false });
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
const handleOpenSplash = () => {
|
const handleOpenSplash = () => {
|
||||||
navigator.push(characterRoutes.splash, { scroll: false });
|
navigator.push(characterRoutes.splash, { scroll: false });
|
||||||
@@ -58,6 +63,7 @@ export function SplashScreen() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<SplashLogo />
|
<SplashLogo />
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
|
{/*
|
||||||
<SplashLatestMessage
|
<SplashLatestMessage
|
||||||
message={latestMessage.message}
|
message={latestMessage.message}
|
||||||
characterName={character.shortName}
|
characterName={character.shortName}
|
||||||
@@ -65,6 +71,7 @@ export function SplashScreen() {
|
|||||||
isLoading={latestMessage.isLoading}
|
isLoading={latestMessage.isLoading}
|
||||||
onOpenChat={handleStartChat}
|
onOpenChat={handleStartChat}
|
||||||
/>
|
/>
|
||||||
|
*/}
|
||||||
<SplashContent characterName={character.shortName} />
|
<SplashContent characterName={character.shortName} />
|
||||||
<div className={styles.buttonArea}>
|
<div className={styles.buttonArea}>
|
||||||
<SplashButton onStartChat={handleStartChat} />
|
<SplashButton onStartChat={handleStartChat} />
|
||||||
@@ -75,12 +82,14 @@ export function SplashScreen() {
|
|||||||
24/7 online | Chat | Companion | Heal | Sweet moments
|
24/7 online | Chat | Companion | Heal | Sweet moments
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/*
|
||||||
<AppBottomNav
|
<AppBottomNav
|
||||||
activeItem="chat"
|
activeItem="chat"
|
||||||
privateRoomLabel={character.copy.privateRoomTitle}
|
privateRoomLabel={character.copy.privateRoomTitle}
|
||||||
onChatClick={handleOpenSplash}
|
onChatClick={handleOpenSplash}
|
||||||
onPrivateRoomClick={handleOpenPrivateRoom}
|
onPrivateRoomClick={handleOpenPrivateRoom}
|
||||||
/>
|
/>
|
||||||
|
*/}
|
||||||
</div>
|
</div>
|
||||||
</MobileShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const LEGACY_CHARACTER_ID_ALIASES: Readonly<Record<string, string>> =
|
||||||
|
Object.freeze({
|
||||||
|
character_elio: "elio",
|
||||||
|
character_maya: "maya-tan",
|
||||||
|
character_nayeli: "nayeli-cervantes",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts character IDs persisted by pre-catalog releases to the current
|
||||||
|
* backend IDs. Unknown and already-current IDs pass through unchanged.
|
||||||
|
*/
|
||||||
|
export function normalizeLegacyCharacterId(characterId: string): string {
|
||||||
|
return LEGACY_CHARACTER_ID_ALIASES[characterId] ?? characterId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import "fake-indexeddb/auto";
|
||||||
|
|
||||||
|
import Dexie from "dexie";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
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 v5 migration", () => {
|
||||||
|
it("moves owner-only and old character messages to current conversations", async () => {
|
||||||
|
const legacy = await createLegacyDatabase(3);
|
||||||
|
await legacy.table<LocalMessageRow, number>("messages").bulkAdd([
|
||||||
|
messageRow("owner-only", "user:account-1"),
|
||||||
|
messageRow(
|
||||||
|
"old-elio",
|
||||||
|
buildChatConversationKey("user:account-1", "character_elio"),
|
||||||
|
),
|
||||||
|
messageRow(
|
||||||
|
"old-maya",
|
||||||
|
buildChatConversationKey("user:account-1", "character_maya"),
|
||||||
|
),
|
||||||
|
messageRow(
|
||||||
|
"old-nayeli",
|
||||||
|
buildChatConversationKey("device:guest-1", "character_nayeli"),
|
||||||
|
),
|
||||||
|
messageRow(
|
||||||
|
"current-elio",
|
||||||
|
buildChatConversationKey("user:account-2", "elio"),
|
||||||
|
),
|
||||||
|
messageRow(
|
||||||
|
"unknown-character",
|
||||||
|
buildChatConversationKey("user:account-2", "character_aria"),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
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: "owner-only",
|
||||||
|
sessionId: buildChatConversationKey("user:account-1", "elio"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "old-elio",
|
||||||
|
sessionId: buildChatConversationKey("user:account-1", "elio"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "old-maya",
|
||||||
|
sessionId: buildChatConversationKey("user:account-1", "maya-tan"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "old-nayeli",
|
||||||
|
sessionId: buildChatConversationKey(
|
||||||
|
"device:guest-1",
|
||||||
|
"nayeli-cervantes",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "current-elio",
|
||||||
|
sessionId: buildChatConversationKey("user:account-2", "elio"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unknown-character",
|
||||||
|
sessionId: buildChatConversationKey(
|
||||||
|
"user:account-2",
|
||||||
|
"character_aria",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rekeys media while preserving current data and the newest legacy row", async () => {
|
||||||
|
const legacy = await createLegacyDatabase(4);
|
||||||
|
const owner = "user:account-1";
|
||||||
|
const currentElioOwner = buildChatConversationKey(owner, "elio");
|
||||||
|
const currentElioKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey: currentElioOwner,
|
||||||
|
messageId: "current-wins",
|
||||||
|
kind: "image",
|
||||||
|
});
|
||||||
|
await legacy.table<LocalChatMediaRow, string>("media").bulkAdd([
|
||||||
|
mediaRow({
|
||||||
|
ownerKey: buildChatConversationKey(owner, "character_elio"),
|
||||||
|
messageId: "current-wins",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://example.com/legacy-newer.jpg",
|
||||||
|
updatedAt: 20,
|
||||||
|
}),
|
||||||
|
mediaRow({
|
||||||
|
ownerKey: currentElioOwner,
|
||||||
|
messageId: "current-wins",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://example.com/current.jpg",
|
||||||
|
updatedAt: 10,
|
||||||
|
}),
|
||||||
|
mediaRow({
|
||||||
|
ownerKey: owner,
|
||||||
|
messageId: "newest-legacy-wins",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://example.com/older.mp3",
|
||||||
|
updatedAt: 5,
|
||||||
|
}),
|
||||||
|
mediaRow({
|
||||||
|
ownerKey: buildChatConversationKey(owner, "character_elio"),
|
||||||
|
messageId: "newest-legacy-wins",
|
||||||
|
kind: "audio",
|
||||||
|
remoteUrl: "https://example.com/newer.mp3",
|
||||||
|
updatedAt: 15,
|
||||||
|
}),
|
||||||
|
mediaRow({
|
||||||
|
ownerKey: buildChatConversationKey(owner, "character_maya"),
|
||||||
|
messageId: "maya-media",
|
||||||
|
kind: "image",
|
||||||
|
remoteUrl: "https://example.com/maya.jpg",
|
||||||
|
updatedAt: 8,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
legacy.close();
|
||||||
|
|
||||||
|
const upgraded = track(new LocalChatDB(legacy.name));
|
||||||
|
await upgraded.open();
|
||||||
|
|
||||||
|
expect(await upgraded.media.get(currentElioKey)).toMatchObject({
|
||||||
|
ownerKey: currentElioOwner,
|
||||||
|
remoteUrl: "https://example.com/current.jpg",
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyCollisionKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey: currentElioOwner,
|
||||||
|
messageId: "newest-legacy-wins",
|
||||||
|
kind: "audio",
|
||||||
|
});
|
||||||
|
expect(await upgraded.media.get(legacyCollisionKey)).toMatchObject({
|
||||||
|
ownerKey: currentElioOwner,
|
||||||
|
remoteUrl: "https://example.com/newer.mp3",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mayaOwner = buildChatConversationKey(owner, "maya-tan");
|
||||||
|
expect(
|
||||||
|
await upgraded.media.get(
|
||||||
|
buildChatMediaCacheKey({
|
||||||
|
ownerKey: mayaOwner,
|
||||||
|
messageId: "maya-media",
|
||||||
|
kind: "image",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toMatchObject({ ownerKey: mayaOwner });
|
||||||
|
expect(await upgraded.media.count()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades an existing v4 database and remains idempotent", async () => {
|
||||||
|
const legacy = await createLegacyDatabase(4);
|
||||||
|
await legacy.table<LocalMessageRow, number>("messages").add(
|
||||||
|
messageRow(
|
||||||
|
"old-elio",
|
||||||
|
buildChatConversationKey("device:guest-1", "character_elio"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
expect(firstRows[0]?.sessionId).toBe(
|
||||||
|
buildChatConversationKey("device:guest-1", "elio"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createLegacyDatabase(version: 3 | 4): Promise<Dexie> {
|
||||||
|
const name = `cozsweet-chat-v${version}-${crypto.randomUUID()}`;
|
||||||
|
databaseNames.add(name);
|
||||||
|
const database = track(new Dexie(name));
|
||||||
|
database.version(version).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,
|
||||||
|
"ownerKey" | "messageId" | "kind" | "remoteUrl" | "updatedAt"
|
||||||
|
>,
|
||||||
|
): LocalChatMediaRow {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
cacheKey: buildChatMediaCacheKey(input),
|
||||||
|
bytes: new Uint8Array([1, 2, 3]).buffer,
|
||||||
|
mimeType: input.kind === "image" ? "image/jpeg" : "audio/mpeg",
|
||||||
|
byteSize: 3,
|
||||||
|
createdAt: input.updatedAt,
|
||||||
|
lastAccessedAt: input.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,12 +9,16 @@
|
|||||||
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Dexie, { type Table } from "dexie";
|
import Dexie, { type Table, type Transaction } from "dexie";
|
||||||
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 {
|
||||||
|
buildChatMediaCacheKey,
|
||||||
|
migrateLegacyChatConversationKey,
|
||||||
|
} from "@/lib/chat/chat_cache_keys";
|
||||||
|
|
||||||
export interface LocalMessageRow {
|
export interface LocalMessageRow {
|
||||||
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
||||||
@@ -76,5 +80,71 @@ export class LocalChatDB extends Dexie {
|
|||||||
messages: "++dbId, sessionId",
|
messages: "++dbId, sessionId",
|
||||||
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||||
});
|
});
|
||||||
|
this.version(5)
|
||||||
|
.stores({
|
||||||
|
messages: "++dbId, sessionId",
|
||||||
|
media: "cacheKey, ownerKey, messageId, kind, remoteUrl, updatedAt, lastAccessedAt",
|
||||||
|
})
|
||||||
|
.upgrade(migrateLegacyChatCache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyChatCache(
|
||||||
|
transaction: Transaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const messages = transaction.table<LocalMessageRow, number>("messages");
|
||||||
|
await messages.toCollection().modify((message) => {
|
||||||
|
message.sessionId = migrateLegacyChatConversationKey(message.sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const media = transaction.table<LocalChatMediaRow, string>("media");
|
||||||
|
const rows = await media.toArray();
|
||||||
|
const canonicalCacheKeys = new Set(
|
||||||
|
rows.flatMap((row) => {
|
||||||
|
const ownerKey = migrateLegacyChatConversationKey(row.ownerKey);
|
||||||
|
const cacheKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey,
|
||||||
|
messageId: row.messageId,
|
||||||
|
kind: row.kind,
|
||||||
|
});
|
||||||
|
return ownerKey === row.ownerKey && cacheKey === row.cacheKey
|
||||||
|
? [cacheKey]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const ownerKey = migrateLegacyChatConversationKey(row.ownerKey);
|
||||||
|
const cacheKey = buildChatMediaCacheKey({
|
||||||
|
ownerKey,
|
||||||
|
messageId: row.messageId,
|
||||||
|
kind: row.kind,
|
||||||
|
});
|
||||||
|
if (ownerKey === row.ownerKey && cacheKey === row.cacheKey) continue;
|
||||||
|
|
||||||
|
const existing = await media.get(cacheKey);
|
||||||
|
await media.delete(row.cacheKey);
|
||||||
|
if (existing && canonicalCacheKeys.has(cacheKey)) continue;
|
||||||
|
if (existing && !isNewerMediaRow(row, existing)) continue;
|
||||||
|
|
||||||
|
await media.put({
|
||||||
|
...row,
|
||||||
|
cacheKey,
|
||||||
|
ownerKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewerMediaRow(
|
||||||
|
candidate: LocalChatMediaRow,
|
||||||
|
existing: LocalChatMediaRow,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
candidate.updatedAt > existing.updatedAt ||
|
||||||
|
(candidate.updatedAt === existing.updatedAt &&
|
||||||
|
candidate.lastAccessedAt > existing.lastAccessedAt) ||
|
||||||
|
(candidate.updatedAt === existing.updatedAt &&
|
||||||
|
candidate.lastAccessedAt === existing.lastAccessedAt &&
|
||||||
|
candidate.createdAt > existing.createdAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { createStorage } from "unstorage";
|
import { createStorage } from "unstorage";
|
||||||
import memoryDriver from "unstorage/drivers/memory";
|
import memoryDriver from "unstorage/drivers/memory";
|
||||||
|
|
||||||
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
import { SessionAsyncUtil } from "@/utils/session-storage";
|
import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||||
|
|
||||||
import { NavigationStorage } from "../navigation_storage";
|
import { NavigationStorage } from "../navigation_storage";
|
||||||
@@ -165,4 +166,51 @@ describe("NavigationStorage", () => {
|
|||||||
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes old character ids in pending navigation sessions", async () => {
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const legacyPromotion = {
|
||||||
|
characterId: "character_elio",
|
||||||
|
promotionType: "image" as const,
|
||||||
|
lockType: "image_paywall" as const,
|
||||||
|
clientLockId: "promotion_legacy",
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
await SessionAsyncUtil.setJson(
|
||||||
|
StorageKeys.pendingChatPromotion,
|
||||||
|
legacyPromotion,
|
||||||
|
);
|
||||||
|
await SessionAsyncUtil.setJson(StorageKeys.pendingChatUnlock, {
|
||||||
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
|
displayMessageId: "promotion:promotion_legacy",
|
||||||
|
kind: "image",
|
||||||
|
lockType: "image_paywall",
|
||||||
|
clientLockId: "promotion_legacy",
|
||||||
|
promotion: legacyPromotion,
|
||||||
|
returnUrl: "/characters/elio/chat",
|
||||||
|
stage: "payment",
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
await SessionAsyncUtil.setJson(StorageKeys.pendingChatImageReturn, {
|
||||||
|
reason: "image_paywall",
|
||||||
|
characterId: "character_elio",
|
||||||
|
messageId: "message_legacy",
|
||||||
|
returnUrl: "/characters/elio/chat?image=message_legacy",
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
promotion: { characterId: CHARACTER_ID },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
||||||
import { getCharacterById } from "@/data/constants/character";
|
import { getCharacterById } from "@/data/constants/character";
|
||||||
|
import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import { SessionAsyncUtil } from "@/utils/session-storage";
|
import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||||
|
|
||||||
@@ -138,7 +139,9 @@ export class NavigationStorage {
|
|||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (value.characterId !== characterId) return null;
|
if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -158,7 +161,9 @@ export class NavigationStorage {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
}
|
}
|
||||||
return value?.characterId === characterId ? value : null;
|
return value?.characterId === normalizeLegacyCharacterId(characterId)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
||||||
@@ -206,7 +211,9 @@ export class NavigationStorage {
|
|||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (value.characterId !== characterId) return null;
|
if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -250,7 +257,9 @@ export class NavigationStorage {
|
|||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (value.characterId !== characterId) return null;
|
if (value.characterId !== normalizeLegacyCharacterId(characterId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -263,13 +272,15 @@ export class NavigationStorage {
|
|||||||
value: PendingChatUnlock | null,
|
value: PendingChatUnlock | null,
|
||||||
): PendingChatUnlock | null {
|
): PendingChatUnlock | null {
|
||||||
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||||
if (!getCharacterById(value.characterId)) return null;
|
const characterId = normalizeLegacyCharacterId(value.characterId);
|
||||||
|
if (!getCharacterById(characterId)) return null;
|
||||||
const promotion = value.promotion
|
const promotion = value.promotion
|
||||||
? NavigationStorage.parsePendingChatPromotion(value.promotion)
|
? NavigationStorage.parsePendingChatPromotion(value.promotion)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (value.promotion && !promotion) return null;
|
if (value.promotion && !promotion) return null;
|
||||||
return PendingChatUnlockSchema.parse({
|
return PendingChatUnlockSchema.parse({
|
||||||
...value,
|
...value,
|
||||||
|
characterId,
|
||||||
...(promotion ? { promotion } : {}),
|
...(promotion ? { promotion } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -278,14 +289,20 @@ export class NavigationStorage {
|
|||||||
value: PendingChatImageReturn | null,
|
value: PendingChatImageReturn | null,
|
||||||
): PendingChatImageReturn | null {
|
): PendingChatImageReturn | null {
|
||||||
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||||
return getCharacterById(value.characterId) ? value : null;
|
const characterId = normalizeLegacyCharacterId(value.characterId);
|
||||||
|
return getCharacterById(characterId)
|
||||||
|
? PendingChatImageReturnSchema.parse({ ...value, characterId })
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static parsePendingChatPromotion(
|
private static parsePendingChatPromotion(
|
||||||
value: PendingChatPromotion | null,
|
value: PendingChatPromotion | null,
|
||||||
): PendingChatPromotion | null {
|
): PendingChatPromotion | null {
|
||||||
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||||
return getCharacterById(value.characterId) ? value : null;
|
const characterId = normalizeLegacyCharacterId(value.characterId);
|
||||||
|
return getCharacterById(characterId)
|
||||||
|
? PendingChatPromotionSchema.parse({ ...value, characterId })
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { ChatMediaKind } from "@/data/schemas/chat";
|
import type { ChatMediaKind } from "@/data/schemas/chat";
|
||||||
|
import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases";
|
||||||
|
|
||||||
const CHARACTER_NAMESPACE = "::character:";
|
const CHARACTER_NAMESPACE = "::character:";
|
||||||
|
const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u;
|
||||||
|
const LEGACY_DEFAULT_CHARACTER_ID = "elio";
|
||||||
|
|
||||||
export function buildChatConversationKey(
|
export function buildChatConversationKey(
|
||||||
ownerKey: string,
|
ownerKey: string,
|
||||||
@@ -19,3 +22,37 @@ export function buildChatMediaCacheKey(input: {
|
|||||||
}): string {
|
}): string {
|
||||||
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates both pre-character owner keys and keys containing the first
|
||||||
|
* frontend-only character IDs. This is intentionally idempotent so it can be
|
||||||
|
* used by database upgrades and compatibility reads.
|
||||||
|
*/
|
||||||
|
export function migrateLegacyChatConversationKey(ownerKey: string): string {
|
||||||
|
const namespaceIndex = ownerKey.lastIndexOf(CHARACTER_NAMESPACE);
|
||||||
|
if (namespaceIndex < 0) {
|
||||||
|
return LEGACY_OWNER_KEY_PATTERN.test(ownerKey)
|
||||||
|
? buildChatConversationKey(ownerKey, LEGACY_DEFAULT_CHARACTER_ID)
|
||||||
|
: ownerKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedCharacterId = ownerKey.slice(
|
||||||
|
namespaceIndex + CHARACTER_NAMESPACE.length,
|
||||||
|
);
|
||||||
|
if (encodedCharacterId.length === 0) return ownerKey;
|
||||||
|
|
||||||
|
let characterId: string;
|
||||||
|
try {
|
||||||
|
characterId = decodeURIComponent(encodedCharacterId);
|
||||||
|
} catch {
|
||||||
|
return ownerKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCharacterId = normalizeLegacyCharacterId(characterId);
|
||||||
|
if (normalizedCharacterId === characterId) return ownerKey;
|
||||||
|
|
||||||
|
return buildChatConversationKey(
|
||||||
|
ownerKey.slice(0, namespaceIndex),
|
||||||
|
normalizedCharacterId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user