feat(chat): sync multi-role backend APIs
This commit is contained in:
@@ -27,7 +27,7 @@ vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||
vi.mock("@/data/repositories/chat_cache_identity", () => ({
|
||||
resolveChatConversationKey: vi.fn(async () => ({
|
||||
success: true,
|
||||
data: "user:test::character:character_elio",
|
||||
data: "user:test::character:elio",
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("chat actor request cancellation", () => {
|
||||
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" },
|
||||
input: { characterId: "elio", content: "hello" },
|
||||
}).start();
|
||||
await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled());
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("chat actor request cancellation", () => {
|
||||
);
|
||||
const actor = createActor(loadHistoryActor, {
|
||||
input: {
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
emptyChatGreeting: "Hello from Elio",
|
||||
},
|
||||
}).start();
|
||||
@@ -85,7 +85,7 @@ describe("chat actor request cancellation", () => {
|
||||
);
|
||||
const actor = createActor(unlockHistoryActor, {
|
||||
input: {
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
emptyChatGreeting: "Hello from Elio",
|
||||
},
|
||||
}).start();
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeResponse(
|
||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
characterErrorCode: null,
|
||||
emptyChatGreeting: DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
messages: [],
|
||||
promotion: null,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function createTestChatMachine(
|
||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||
unlockHistoryError?: Error;
|
||||
unlockMessageOutput?: TestUnlockMessageOutput;
|
||||
unlockMessageError?: Error;
|
||||
} = {},
|
||||
) {
|
||||
return chatMachine.provide({
|
||||
@@ -134,6 +135,7 @@ export function createTestChatMachine(
|
||||
MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest & { characterId: string }
|
||||
>(async ({ input }) => {
|
||||
if (options.unlockMessageError) throw options.unlockMessageError;
|
||||
const output = options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId ?? input.displayMessageId,
|
||||
response: makeUnlockPrivateResponse(),
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@/stores/chat/helper/promotion";
|
||||
|
||||
const promotion: PendingChatPromotion = {
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "promotion-1",
|
||||
|
||||
@@ -10,13 +10,13 @@ describe("chat session flow", () => {
|
||||
it("initializes the conversation from the supplied character", () => {
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: {
|
||||
characterId: "character_maya",
|
||||
characterId: "maya-tan",
|
||||
emptyChatGreeting: "Hello from Maya",
|
||||
},
|
||||
}).start();
|
||||
|
||||
expect(actor.getSnapshot().context).toMatchObject({
|
||||
characterId: "character_maya",
|
||||
characterId: "maya-tan",
|
||||
emptyChatGreeting: "Hello from Maya",
|
||||
});
|
||||
actor.stop();
|
||||
@@ -25,7 +25,7 @@ describe("chat session flow", () => {
|
||||
it("keeps the active character greeting across login state changes", async () => {
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: {
|
||||
characterId: "character_maya",
|
||||
characterId: "maya-tan",
|
||||
emptyChatGreeting: "Hello from Maya",
|
||||
},
|
||||
}).start();
|
||||
@@ -113,7 +113,7 @@ describe("chat session flow", () => {
|
||||
actor.send({
|
||||
type: "ChatPromotionInjected",
|
||||
promotion: {
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
promotionType: "voice",
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { createActor, fromPromise, waitFor } from "xstate";
|
||||
|
||||
import { ChatSendResponseSchema } from "@/data/schemas/chat";
|
||||
import { ApiError } from "@/data/services/api";
|
||||
import type {
|
||||
UnlockMessageOutput,
|
||||
UnlockMessageRequest,
|
||||
@@ -565,6 +566,50 @@ describe("chat unlock flow", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("refreshes history instead of opening payment on character mismatch", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "msg-mismatch",
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
},
|
||||
],
|
||||
unlockMessageError: new ApiError("HTTP_ERROR", "Mismatch", 409, {
|
||||
detail: { errorCode: "CHARACTER_MISMATCH" },
|
||||
}),
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: "msg-mismatch",
|
||||
kind: "voice",
|
||||
});
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) =>
|
||||
snapshot.context.characterErrorCode === "CHARACTER_MISMATCH",
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockPaywallRequest).toBeNull();
|
||||
actor.send({ type: "ChatHistoryRefreshRequested" });
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) => snapshot.context.characterErrorCode === null,
|
||||
);
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("owns the active request in one object and ignores history refreshes while unlocking", async () => {
|
||||
let capturedRequest: UnlockMessageRequest | null = null;
|
||||
let resolveUnlock!: (output: UnlockMessageOutput) => void;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDeviceId: vi.fn(),
|
||||
getLocalMessages: vi.fn(),
|
||||
syncGuestHistory: vi.fn(),
|
||||
clearLocalMessages: vi.fn(),
|
||||
getGuestChatOwnerKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/data/storage/auth", () => ({
|
||||
AuthStorage: {
|
||||
getInstance: () => ({ getDeviceId: mocks.getDeviceId }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||
loadChatRepository: async () => ({
|
||||
getLocalMessages: mocks.getLocalMessages,
|
||||
syncGuestHistory: mocks.syncGuestHistory,
|
||||
clearLocalMessages: mocks.clearLocalMessages,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/data/storage/navigation", () => ({
|
||||
NavigationStorage: {
|
||||
getGuestChatOwnerKey: mocks.getGuestChatOwnerKey,
|
||||
},
|
||||
}));
|
||||
|
||||
import { syncGuestHistoriesToUser } from "../guest-history-sync";
|
||||
|
||||
describe("syncGuestHistoriesToUser", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getDeviceId.mockResolvedValue(Result.ok("device-1"));
|
||||
mocks.getGuestChatOwnerKey.mockResolvedValue(null);
|
||||
mocks.clearLocalMessages.mockResolvedValue(Result.ok(undefined));
|
||||
});
|
||||
|
||||
it("groups guest history by character and clears only successful caches", async () => {
|
||||
mocks.getLocalMessages.mockImplementation(async (identity: string) =>
|
||||
Result.ok([
|
||||
{
|
||||
id: `${identity}:message`,
|
||||
role: "user",
|
||||
type: "text",
|
||||
content: identity,
|
||||
createdAt: "2026-07-20T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
);
|
||||
mocks.syncGuestHistory.mockImplementation(
|
||||
async (request: { characterId: string }) =>
|
||||
request.characterId === "elio"
|
||||
? Result.ok(undefined)
|
||||
: Result.err(new Error("offline")),
|
||||
);
|
||||
|
||||
await syncGuestHistoriesToUser(["elio", "maya-tan"]);
|
||||
|
||||
expect(mocks.syncGuestHistory).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ characterId: "elio" }),
|
||||
);
|
||||
expect(mocks.syncGuestHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ characterId: "maya-tan" }),
|
||||
);
|
||||
expect(mocks.clearLocalMessages).toHaveBeenCalledOnce();
|
||||
expect(mocks.clearLocalMessages).toHaveBeenCalledWith(
|
||||
"device:device-1::character:elio",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips characters without guest messages", async () => {
|
||||
mocks.getLocalMessages.mockResolvedValue(Result.ok([]));
|
||||
|
||||
await syncGuestHistoriesToUser(["elio"]);
|
||||
|
||||
expect(mocks.syncGuestHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,13 @@ import { appendPromotionMessage } from "./helper/promotion";
|
||||
*/
|
||||
interface ChatState {
|
||||
characterId: string;
|
||||
characterErrorCode: MachineContext["characterErrorCode"];
|
||||
messages: MachineContext["messages"];
|
||||
historyMessages: MachineContext["messages"];
|
||||
promotion: MachineContext["promotion"];
|
||||
outgoingMessageRevision: number;
|
||||
isReplyingAI: boolean;
|
||||
canSendMessage: boolean;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
/** history 初始加载完成标志(local → network → save 跑完) —— UI 可用此显示 loading */
|
||||
@@ -87,10 +89,12 @@ type SelectedChatState = Omit<ChatState, "messages">;
|
||||
function selectChatState(state: ChatSnapshot): SelectedChatState {
|
||||
return {
|
||||
characterId: state.context.characterId,
|
||||
characterErrorCode: state.context.characterErrorCode,
|
||||
historyMessages: state.context.messages,
|
||||
promotion: state.context.promotion,
|
||||
outgoingMessageRevision: state.context.outgoingMessageRevision,
|
||||
isReplyingAI: state.context.isReplyingAI,
|
||||
canSendMessage: state.context.canSendMessage,
|
||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||
upgradeReason: state.context.upgradeReason,
|
||||
historyLoaded: state.context.historyLoaded,
|
||||
|
||||
@@ -39,6 +39,7 @@ export type ChatEvent =
|
||||
output: import("./machine/actors/history").LoadMoreHistoryOutput;
|
||||
}
|
||||
| { type: "ChatOlderHistoryLoadFailed"; error: unknown }
|
||||
| { type: "ChatHistoryRefreshRequested" }
|
||||
// 业务事件
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
@@ -65,4 +66,9 @@ export type ChatEvent =
|
||||
type: "ChatQueuedHttpDone";
|
||||
output: import("./helper/send-state").HttpSendOutput;
|
||||
}
|
||||
| { type: "ChatQueuedSendError"; content: string; errorMessage: string };
|
||||
| {
|
||||
type: "ChatQueuedSendError";
|
||||
content: string;
|
||||
errorMessage: string;
|
||||
characterErrorCode?: import("@/data/services/api").CharacterErrorCode;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
import { todayString } from "@/utils/date";
|
||||
import { isAbortError } from "@/utils/abort";
|
||||
import { getCharacterErrorCode } from "@/data/services/api";
|
||||
|
||||
import { CHAT_HISTORY_LIMIT } from "./helper/history";
|
||||
import { localMessagesToUi } from "./helper/message-mappers";
|
||||
@@ -94,6 +95,7 @@ export async function syncNetworkHistory(
|
||||
);
|
||||
if (Result.isErr(networkResult)) {
|
||||
if (isAbortError(networkResult.error)) throw networkResult.error;
|
||||
if (getCharacterErrorCode(networkResult.error)) throw networkResult.error;
|
||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||
error: networkResult.error,
|
||||
});
|
||||
|
||||
@@ -2,11 +2,6 @@ import {
|
||||
chatMachineSetup,
|
||||
chatRootStateConfig,
|
||||
} from "./machine/session-flow";
|
||||
import {
|
||||
DEFAULT_CHARACTER,
|
||||
DEFAULT_CHARACTER_ID,
|
||||
} from "@/data/constants/character";
|
||||
|
||||
import { createInitialChatState } from "./chat-state";
|
||||
|
||||
export type { ChatState } from "./chat-state";
|
||||
@@ -17,8 +12,8 @@ export const chatMachine = chatMachineSetup.createMachine({
|
||||
id: "chat",
|
||||
context: ({ input }) =>
|
||||
createInitialChatState(
|
||||
input?.characterId ?? DEFAULT_CHARACTER_ID,
|
||||
input?.emptyChatGreeting ?? DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
input.characterId,
|
||||
input.emptyChatGreeting,
|
||||
),
|
||||
...chatRootStateConfig,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DEFAULT_CHARACTER_ID,
|
||||
} from "@/data/constants/character";
|
||||
import type { ChatPromotionState } from "./helper/promotion";
|
||||
import type { CharacterErrorCode } from "@/data/services/api";
|
||||
|
||||
export type ChatUpgradeReason = "insufficient_credits";
|
||||
|
||||
@@ -29,6 +30,7 @@ export interface ChatUnlockPaywallRequest
|
||||
|
||||
export interface ChatState {
|
||||
characterId: string;
|
||||
characterErrorCode: CharacterErrorCode | null;
|
||||
emptyChatGreeting: string;
|
||||
messages: UiMessage[];
|
||||
promotion: ChatPromotionState | null;
|
||||
@@ -58,11 +60,12 @@ export interface ChatState {
|
||||
}
|
||||
|
||||
export function createInitialChatState(
|
||||
characterId: string = DEFAULT_CHARACTER_ID,
|
||||
emptyChatGreeting: string = DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
characterId: string,
|
||||
emptyChatGreeting: string,
|
||||
): ChatState {
|
||||
return {
|
||||
characterId,
|
||||
characterErrorCode: null,
|
||||
emptyChatGreeting,
|
||||
messages: [],
|
||||
promotion: null,
|
||||
@@ -91,4 +94,7 @@ export function createInitialChatState(
|
||||
};
|
||||
}
|
||||
|
||||
export const initialState: ChatState = createInitialChatState();
|
||||
export const initialState: ChatState = createInitialChatState(
|
||||
DEFAULT_CHARACTER_ID,
|
||||
DEFAULT_CHARACTER.emptyChatGreeting,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AuthStorage } from "@/data/storage/auth";
|
||||
import { NavigationStorage } from "@/data/storage/navigation";
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { ChatSyncRequestSchema } from "@/data/schemas/chat";
|
||||
import { buildChatConversationKey } from "@/lib/chat/chat_cache_keys";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
const log = new Logger("StoresChatGuestHistorySync");
|
||||
|
||||
export async function syncGuestHistoriesToUser(
|
||||
characterIds: readonly string[],
|
||||
): Promise<void> {
|
||||
const deviceResult = await AuthStorage.getInstance().getDeviceId();
|
||||
const savedOwnerKey = await NavigationStorage.getGuestChatOwnerKey();
|
||||
const ownerKey =
|
||||
savedOwnerKey ??
|
||||
(Result.isOk(deviceResult) && deviceResult.data
|
||||
? `device:${deviceResult.data}`
|
||||
: null);
|
||||
if (!ownerKey) return;
|
||||
|
||||
const repository = await loadChatRepository();
|
||||
await Promise.allSettled(
|
||||
[...new Set(characterIds)].map(async (characterId) => {
|
||||
const cacheIdentity = buildChatConversationKey(ownerKey, characterId);
|
||||
const messagesResult = await repository.getLocalMessages(cacheIdentity);
|
||||
if (Result.isErr(messagesResult) || messagesResult.data.length === 0) return;
|
||||
|
||||
const messages = messagesResult.data.flatMap((message) => {
|
||||
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||
return [{
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: normalizeTimestamp(message.createdAt),
|
||||
}];
|
||||
});
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const request = ChatSyncRequestSchema.parse({ characterId, messages });
|
||||
const syncResult = await repository.syncGuestHistory(request);
|
||||
if (Result.isErr(syncResult)) {
|
||||
log.warn("[chat-sync] guest history sync failed", {
|
||||
characterId,
|
||||
error: syncResult.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clearResult = await repository.clearLocalMessages(cacheIdentity);
|
||||
if (Result.isErr(clearResult)) {
|
||||
log.warn("[chat-sync] guest history cleanup failed", {
|
||||
characterId,
|
||||
error: clearResult.error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime())
|
||||
? new Date().toISOString()
|
||||
: parsed.toISOString();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { resolveChatConversationKey } from "@/data/repositories/chat_cache_ident
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
import { isAbortError } from "@/utils/abort";
|
||||
import { getCharacterErrorCode } from "@/data/services/api";
|
||||
|
||||
import type { ChatEvent } from "../../chat-events";
|
||||
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
||||
@@ -61,6 +62,9 @@ function createMessageQueueActor(
|
||||
type: "ChatQueuedSendError",
|
||||
content,
|
||||
errorMessage,
|
||||
...(getCharacterErrorCode(error)
|
||||
? { characterErrorCode: getCharacterErrorCode(error) ?? undefined }
|
||||
: {}),
|
||||
});
|
||||
} finally {
|
||||
if (activeController === controller) activeController = null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../helper/unlock";
|
||||
import type { ChatState } from "../chat-state";
|
||||
import { baseChatMachineSetup } from "./setup";
|
||||
import { getCharacterErrorCode } from "@/data/services/api";
|
||||
|
||||
const applyLocalHistoryLoadedAction = baseChatMachineSetup.assign(
|
||||
({ event }) => {
|
||||
@@ -48,9 +49,21 @@ const showUnlockHistoryPromptFromNetworkAction = baseChatMachineSetup.assign(
|
||||
},
|
||||
);
|
||||
|
||||
const markHistoryLoadFailedAction = baseChatMachineSetup.assign({
|
||||
historyLoaded: true,
|
||||
});
|
||||
const markHistoryLoadFailedAction = baseChatMachineSetup.assign(
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatHistoryLoadFailed") return {};
|
||||
const characterErrorCode = getCharacterErrorCode(event.error);
|
||||
return {
|
||||
historyLoaded: true,
|
||||
characterErrorCode,
|
||||
canSendMessage:
|
||||
characterErrorCode === "CHARACTER_DISABLED" ||
|
||||
characterErrorCode === "CHARACTER_NOT_FOUND"
|
||||
? false
|
||||
: context.canSendMessage,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const markLoadMoreHistoryStartedAction = baseChatMachineSetup.assign({
|
||||
isLoadingMoreHistory: true,
|
||||
|
||||
@@ -76,16 +76,30 @@ const appendUserMessageAction = historyMachineSetup.assign(
|
||||
);
|
||||
|
||||
const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
|
||||
({ context }) => {
|
||||
({ context, event }) => {
|
||||
if (event.type !== "ChatQueuedSendError") return {};
|
||||
const characterErrorCode = event.characterErrorCode ?? null;
|
||||
const unavailable = characterErrorCode === "CHARACTER_STATE_UNAVAILABLE";
|
||||
const messages = [
|
||||
...context.messages,
|
||||
{
|
||||
content: "Something went wrong. Try sending again?",
|
||||
content: unavailable
|
||||
? "This character is temporarily unavailable. Please try again shortly."
|
||||
: "Something went wrong. Try sending again?",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
},
|
||||
];
|
||||
return { messages, ...finishPendingReply(context) };
|
||||
return {
|
||||
messages,
|
||||
...finishPendingReply(context),
|
||||
characterErrorCode,
|
||||
canSendMessage:
|
||||
characterErrorCode === "CHARACTER_DISABLED" ||
|
||||
characterErrorCode === "CHARACTER_NOT_FOUND"
|
||||
? false
|
||||
: context.canSendMessage,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -189,6 +189,11 @@ const userSessionState = chatMachineSetup.createStateConfig({
|
||||
},
|
||||
],
|
||||
on: {
|
||||
ChatHistoryRefreshRequested: {
|
||||
target: "#chat.userSession",
|
||||
reenter: true,
|
||||
actions: "startUserSession",
|
||||
},
|
||||
...historyPaginationTransitions,
|
||||
ChatGuestLogin: {
|
||||
target: "#chat.guestSession",
|
||||
|
||||
@@ -16,15 +16,15 @@ import {
|
||||
import type { ChatState } from "../chat-state";
|
||||
|
||||
export interface ChatMachineInput {
|
||||
characterId?: string;
|
||||
emptyChatGreeting?: string;
|
||||
characterId: string;
|
||||
emptyChatGreeting: string;
|
||||
}
|
||||
|
||||
export const baseChatMachineSetup = setup({
|
||||
types: {
|
||||
context: {} as ChatState,
|
||||
events: {} as ChatEvent,
|
||||
input: {} as ChatMachineInput | undefined,
|
||||
input: {} as ChatMachineInput,
|
||||
},
|
||||
actors: {
|
||||
loadHistory: loadHistoryActor,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import type { UnlockHistoryOutput } from "./actors/unlock";
|
||||
import { sendMachineSetup } from "./send-flow";
|
||||
import { createChatActorActionSetup } from "./setup";
|
||||
import { getCharacterErrorCode } from "@/data/services/api";
|
||||
|
||||
const markPaymentUnlockPendingAction = sendMachineSetup.assign(() => ({
|
||||
paymentUnlockPending: true,
|
||||
@@ -146,6 +147,13 @@ const clearUnlockPaywallRequestAction = sendMachineSetup.assign(() => ({
|
||||
unlockPaywallRequest: null,
|
||||
}));
|
||||
|
||||
const handleCharacterMismatchAction = sendMachineSetup.assign(() => ({
|
||||
unlockingMessage: null,
|
||||
unlockMessageError: "character_mismatch",
|
||||
unlockPaywallRequest: null,
|
||||
characterErrorCode: "CHARACTER_MISMATCH" as const,
|
||||
}));
|
||||
|
||||
export const unlockMachineSetup = sendMachineSetup.extend({
|
||||
actions: {
|
||||
markPaymentUnlockPending: markPaymentUnlockPendingAction,
|
||||
@@ -158,6 +166,7 @@ export const unlockMachineSetup = sendMachineSetup.extend({
|
||||
requestUnlockPaymentFromOutput: requestUnlockPaymentFromOutputAction,
|
||||
requestUnlockPaymentFromError: requestUnlockPaymentFromErrorAction,
|
||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||
handleCharacterMismatch: handleCharacterMismatchAction,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -225,9 +234,17 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
|
||||
actions: "requestUnlockPaymentFromOutput",
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "ready",
|
||||
actions: "requestUnlockPaymentFromError",
|
||||
},
|
||||
onError: [
|
||||
{
|
||||
guard: ({ event }) =>
|
||||
getCharacterErrorCode(event.error) === "CHARACTER_MISMATCH",
|
||||
target: "ready",
|
||||
actions: "handleCharacterMismatch",
|
||||
},
|
||||
{
|
||||
target: "ready",
|
||||
actions: "requestUnlockPaymentFromError",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("payment order flow", () => {
|
||||
await initialize(actor);
|
||||
actor.send({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
recipientCharacterId: "character_maya",
|
||||
recipientCharacterId: "maya-tan",
|
||||
});
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("paid"));
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("payment order flow", () => {
|
||||
planId: "vip_monthly",
|
||||
payChannel: "stripe",
|
||||
autoRenew: true,
|
||||
recipientCharacterId: "character_maya",
|
||||
recipientCharacterId: "maya-tan",
|
||||
});
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./actors/albums";
|
||||
|
||||
export interface PrivateRoomMachineInput {
|
||||
characterId?: string;
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
export const basePrivateRoomMachineSetup = setup({
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
privateRoomMachineSetup,
|
||||
privateRoomRootStateConfig,
|
||||
} from "./machine/unlock-flow";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
import { createInitialPrivateRoomState } from "./private-room-state";
|
||||
|
||||
export type { PrivateRoomEvent } from "./private-room-events";
|
||||
@@ -13,9 +11,7 @@ export { initialState } from "./private-room-state";
|
||||
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
|
||||
id: "privateRoom",
|
||||
context: ({ input }) =>
|
||||
createInitialPrivateRoomState(
|
||||
input?.characterId ?? DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
createInitialPrivateRoomState(input.characterId),
|
||||
...privateRoomRootStateConfig,
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface PrivateRoomState {
|
||||
}
|
||||
|
||||
export function createInitialPrivateRoomState(
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
characterId: string,
|
||||
): PrivateRoomState {
|
||||
return {
|
||||
characterId,
|
||||
@@ -37,4 +37,4 @@ export function createInitialPrivateRoomState(
|
||||
};
|
||||
}
|
||||
|
||||
export const initialState = createInitialPrivateRoomState();
|
||||
export const initialState = createInitialPrivateRoomState(DEFAULT_CHARACTER_ID);
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
useAuthSelector,
|
||||
} from "@/stores/auth/auth-context";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { syncGuestHistoriesToUser } from "@/stores/chat/guest-history-sync";
|
||||
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { NavigationStorage } from "@/data/storage/navigation";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
export function ChatAuthSync() {
|
||||
const authState = useAuthSelector(
|
||||
@@ -27,6 +32,7 @@ export function ChatAuthSync() {
|
||||
shallowEqual,
|
||||
);
|
||||
const chatDispatch = useChatDispatch();
|
||||
const characterCatalog = useCharacterCatalog();
|
||||
const prevSessionKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,14 +48,27 @@ export function ChatAuthSync() {
|
||||
}
|
||||
|
||||
if (authState.loginStatus === "guest") {
|
||||
chatDispatch({ type: "ChatGuestLogin" });
|
||||
return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const ownerResult = await resolveChatCacheOwnerKey();
|
||||
if (Result.isOk(ownerResult)) {
|
||||
await NavigationStorage.saveGuestChatOwnerKey(ownerResult.data);
|
||||
}
|
||||
if (!cancelled) chatDispatch({ type: "ChatGuestLogin" });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||
if (!cancelled && tokenR.success && tokenR.data) {
|
||||
await syncGuestHistoriesToUser(
|
||||
characterCatalog.characters.map((character) => character.id),
|
||||
);
|
||||
if (cancelled) return;
|
||||
chatDispatch({
|
||||
type: "ChatUserLogin",
|
||||
token: tokenR.data,
|
||||
@@ -64,6 +83,7 @@ export function ChatAuthSync() {
|
||||
authState.hasInitialized,
|
||||
authState.isLoading,
|
||||
authState.loginStatus,
|
||||
characterCatalog.characters,
|
||||
chatDispatch,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user