feat(characters): support character-scoped conversations
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { ChatSendResponse } from "@/data/dto/chat";
|
||||
import type { ChatState } from "@/stores/chat/chat-state";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ function makeResponse(
|
||||
|
||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messages: [],
|
||||
promotion: null,
|
||||
outgoingMessageRevision: 0,
|
||||
|
||||
@@ -9,11 +9,14 @@ import type { LoadMoreHistoryActorEvent } from "@/stores/chat/machine/actors/his
|
||||
import {
|
||||
createLoadHistoryCallback,
|
||||
createTestChatMachine,
|
||||
TEST_CHAT_MACHINE_INPUT,
|
||||
} from "./chat-machine.test-utils";
|
||||
|
||||
describe("chat history flow", () => {
|
||||
it("enters user ready after history is loaded", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -45,7 +48,10 @@ describe("chat history flow", () => {
|
||||
};
|
||||
const machine = chatMachine.provide({
|
||||
actors: {
|
||||
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
loadHistory: fromCallback<
|
||||
ChatEvent,
|
||||
{ characterId: string }
|
||||
>(({ sendBack }) => {
|
||||
sendBack({
|
||||
type: "ChatLocalHistoryLoaded",
|
||||
output: {
|
||||
@@ -70,7 +76,9 @@ describe("chat history flow", () => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
const actor = createActor(machine, {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -109,7 +117,10 @@ describe("chat history flow", () => {
|
||||
[latestMessage],
|
||||
{ total: 120, limit: 50 },
|
||||
),
|
||||
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
|
||||
loadMoreHistory: fromCallback<
|
||||
LoadMoreHistoryActorEvent,
|
||||
{ characterId: string }
|
||||
>(
|
||||
({ receive, sendBack }) => {
|
||||
receive((event) => {
|
||||
requests.push(event);
|
||||
@@ -143,7 +154,9 @@ describe("chat history flow", () => {
|
||||
),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
const actor = createActor(machine, {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -183,7 +196,10 @@ describe("chat history flow", () => {
|
||||
total: 75,
|
||||
limit: 50,
|
||||
}),
|
||||
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
|
||||
loadMoreHistory: fromCallback<
|
||||
LoadMoreHistoryActorEvent,
|
||||
{ characterId: string }
|
||||
>(
|
||||
({ receive, sendBack }) => {
|
||||
receive((event) => {
|
||||
requestCount += 1;
|
||||
@@ -209,7 +225,9 @@ describe("chat history flow", () => {
|
||||
),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
const actor = createActor(machine, {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatGuestLogin" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -240,7 +258,10 @@ describe("chat history flow", () => {
|
||||
total: 50,
|
||||
limit: 50,
|
||||
}),
|
||||
loadMoreHistory: fromCallback<LoadMoreHistoryActorEvent>(
|
||||
loadMoreHistory: fromCallback<
|
||||
LoadMoreHistoryActorEvent,
|
||||
{ characterId: string }
|
||||
>(
|
||||
({ receive }) => {
|
||||
receive(() => {
|
||||
requested = true;
|
||||
@@ -250,7 +271,9 @@ describe("chat history flow", () => {
|
||||
),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
const actor = createActor(machine, {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
UnlockPrivateResponse,
|
||||
type UiMessage,
|
||||
} from "@/data/dto/chat";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||
import type {
|
||||
UnlockMessageOutput as MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest,
|
||||
} from "@/stores/chat/helper/unlock";
|
||||
import type { ChatHistoryActorInput } from "@/stores/chat/machine/actors/history";
|
||||
|
||||
export interface SendMessageHttpOutput {
|
||||
response: ChatSendResponse;
|
||||
@@ -29,6 +31,10 @@ export interface TestUnlockMessageOutput {
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
export const TEST_CHAT_MACHINE_INPUT = {
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
};
|
||||
|
||||
export function makeChatSendResponse(): ChatSendResponse {
|
||||
return ChatSendResponse.from({
|
||||
reply: "",
|
||||
@@ -74,7 +80,7 @@ export function createTestChatMachine(
|
||||
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
|
||||
sendMessageHttp: fromPromise<
|
||||
SendMessageHttpOutput,
|
||||
{ content: string }
|
||||
{ characterId: string; content: string }
|
||||
>(async () => {
|
||||
if (options.sendMessageHttpError) {
|
||||
throw options.sendMessageHttpError;
|
||||
@@ -84,21 +90,26 @@ export function createTestChatMachine(
|
||||
reply: null,
|
||||
};
|
||||
}),
|
||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||
receive((event) => {
|
||||
if (event.type !== "ChatSendMessage") return;
|
||||
sendBack({ type: "ChatQueuedSendStarted" });
|
||||
sendBack({
|
||||
type: "ChatQueuedHttpDone",
|
||||
output: {
|
||||
response: makeChatSendResponse(),
|
||||
reply: null,
|
||||
},
|
||||
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
|
||||
({ receive, sendBack }) => {
|
||||
receive((event) => {
|
||||
if (event.type !== "ChatSendMessage") return;
|
||||
sendBack({ type: "ChatQueuedSendStarted" });
|
||||
sendBack({
|
||||
type: "ChatQueuedHttpDone",
|
||||
output: {
|
||||
response: makeChatSendResponse(),
|
||||
reply: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => {
|
||||
return () => undefined;
|
||||
},
|
||||
),
|
||||
unlockHistory: fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
>(async () => {
|
||||
if (options.unlockHistoryError) {
|
||||
throw options.unlockHistoryError;
|
||||
}
|
||||
@@ -112,7 +123,7 @@ export function createTestChatMachine(
|
||||
}),
|
||||
unlockMessage: fromPromise<
|
||||
MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
UnlockMessageRequest & { characterId: string }
|
||||
>(async ({ input }) => {
|
||||
const output = options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId ?? input.displayMessageId,
|
||||
@@ -136,7 +147,7 @@ export function createLoadHistoryCallback(
|
||||
limit: 50,
|
||||
},
|
||||
) {
|
||||
return fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
return fromCallback<ChatEvent, ChatHistoryActorInput>(({ sendBack }) => {
|
||||
sendBack({
|
||||
type: "ChatLocalHistoryLoaded",
|
||||
output: {
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
createLoadHistoryCallback,
|
||||
createTestChatMachine,
|
||||
makeChatSendResponse,
|
||||
TEST_CHAT_MACHINE_INPUT,
|
||||
type SendMessageHttpOutput,
|
||||
type UnlockHistoryOutput,
|
||||
} from "./chat-machine.test-utils";
|
||||
|
||||
describe("chat send flow", () => {
|
||||
it("allows multiple messages to be queued without leaving ready state", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -44,7 +47,9 @@ describe("chat send flow", () => {
|
||||
});
|
||||
|
||||
it("applies direct HTTP actor output with a type-bound action", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatGuestLogin" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -70,7 +75,9 @@ describe("chat send flow", () => {
|
||||
});
|
||||
|
||||
it("increments the outgoing revision for a user image", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -95,6 +102,7 @@ describe("chat send flow", () => {
|
||||
createTestChatMachine({
|
||||
sendMessageHttpError: new Error("send failed"),
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatGuestLogin" });
|
||||
@@ -121,29 +129,34 @@ describe("chat send flow", () => {
|
||||
loadHistory: createLoadHistoryCallback([]),
|
||||
sendMessageHttp: fromPromise<
|
||||
SendMessageHttpOutput,
|
||||
{ content: string }
|
||||
{ characterId: string; content: string }
|
||||
>(async () => ({
|
||||
response: makeChatSendResponse(),
|
||||
reply: null,
|
||||
})),
|
||||
httpMessageQueue: fromCallback<ChatEvent>(({ receive, sendBack }) => {
|
||||
const pending: string[] = [];
|
||||
receive((event) => {
|
||||
if (event.type !== "ChatSendMessage") return;
|
||||
pending.push(event.content);
|
||||
if (pending.length !== 2) return;
|
||||
sendBack({ type: "ChatQueuedSendStarted" });
|
||||
sendBack({
|
||||
type: "ChatQueuedHttpDone",
|
||||
output: {
|
||||
response: makeChatSendResponse(),
|
||||
reply: null,
|
||||
},
|
||||
httpMessageQueue: fromCallback<ChatEvent, { characterId: string }>(
|
||||
({ receive, sendBack }) => {
|
||||
const pending: string[] = [];
|
||||
receive((event) => {
|
||||
if (event.type !== "ChatSendMessage") return;
|
||||
pending.push(event.content);
|
||||
if (pending.length !== 2) return;
|
||||
sendBack({ type: "ChatQueuedSendStarted" });
|
||||
sendBack({
|
||||
type: "ChatQueuedHttpDone",
|
||||
output: {
|
||||
response: makeChatSendResponse(),
|
||||
reply: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
unlockHistory: fromPromise<UnlockHistoryOutput>(async () => ({
|
||||
return () => undefined;
|
||||
},
|
||||
),
|
||||
unlockHistory: fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
>(async () => ({
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
shortfallCredits: 0,
|
||||
@@ -151,7 +164,9 @@ describe("chat send flow", () => {
|
||||
})),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
const actor = createActor(machine, {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createActor, waitFor } from "xstate";
|
||||
|
||||
import { createTestChatMachine } from "./chat-machine.test-utils";
|
||||
import {
|
||||
createTestChatMachine,
|
||||
TEST_CHAT_MACHINE_INPUT,
|
||||
} from "./chat-machine.test-utils";
|
||||
|
||||
describe("chat session flow", () => {
|
||||
it("initializes the conversation from the supplied character", () => {
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: { characterId: "character_aria" },
|
||||
}).start();
|
||||
|
||||
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps guest logout disabled and supports user to guest transition", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatGuestLogin" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -29,7 +43,9 @@ describe("chat session flow", () => {
|
||||
});
|
||||
|
||||
it("allows explicit logout from user session", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -54,6 +70,7 @@ describe("chat session flow", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -82,7 +99,9 @@ describe("chat session flow", () => {
|
||||
});
|
||||
|
||||
it("switches to guest session when guest login arrives during user session", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -98,7 +117,9 @@ describe("chat session flow", () => {
|
||||
});
|
||||
|
||||
it("ignores repeated user login while an authenticated user session is active", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import {
|
||||
createTestChatMachine,
|
||||
makeUnlockPrivateResponse,
|
||||
TEST_CHAT_MACHINE_INPUT,
|
||||
} from "./chat-machine.test-utils";
|
||||
|
||||
describe("chat unlock flow", () => {
|
||||
@@ -33,6 +34,7 @@ describe("chat unlock flow", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -80,6 +82,7 @@ describe("chat unlock flow", () => {
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -137,6 +140,7 @@ describe("chat unlock flow", () => {
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -179,6 +183,7 @@ describe("chat unlock flow", () => {
|
||||
],
|
||||
unlockHistoryError: new Error("unlock failed"),
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -222,6 +227,7 @@ describe("chat unlock flow", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -283,6 +289,7 @@ describe("chat unlock flow", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -342,6 +349,7 @@ describe("chat unlock flow", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -395,6 +403,7 @@ describe("chat unlock flow", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -453,6 +462,7 @@ describe("chat unlock flow", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -520,6 +530,7 @@ describe("chat unlock flow", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{ input: TEST_CHAT_MACHINE_INPUT },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
@@ -575,14 +586,16 @@ describe("chat unlock flow", () => {
|
||||
actors: {
|
||||
unlockMessage: fromPromise<
|
||||
UnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
UnlockMessageRequest & { characterId: string }
|
||||
>(async ({ input }) => {
|
||||
capturedRequest = input;
|
||||
return unlockDeferred;
|
||||
}),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
const actor = createActor(machine, {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
@@ -641,7 +654,9 @@ describe("chat unlock flow", () => {
|
||||
});
|
||||
|
||||
it("clears the insufficient credits prompt after payment succeeds", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
const actor = createActor(createTestChatMachine(), {
|
||||
input: TEST_CHAT_MACHINE_INPUT,
|
||||
}).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { type Dispatch, type ReactNode, useMemo } from "react";
|
||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||
import type { SnapshotFrom } from "xstate";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
import { chatMachine } from "./chat-machine";
|
||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
import { appendPromotionMessage } from "./helper/promotion";
|
||||
@@ -15,6 +17,7 @@ import { appendPromotionMessage } from "./helper/promotion";
|
||||
* 消费方(chat-screen)通过 `useAuthState()` 取 loginStatus,本地派生 isGuest。
|
||||
*/
|
||||
interface ChatState {
|
||||
characterId: string;
|
||||
messages: MachineContext["messages"];
|
||||
historyMessages: MachineContext["messages"];
|
||||
promotion: MachineContext["promotion"];
|
||||
@@ -43,10 +46,18 @@ const ChatActorContext = createActorContext(chatMachine);
|
||||
|
||||
export interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
export function ChatProvider({ children }: ChatProviderProps) {
|
||||
return <ChatActorContext.Provider>{children}</ChatActorContext.Provider>;
|
||||
export function ChatProvider({
|
||||
children,
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
}: ChatProviderProps) {
|
||||
return (
|
||||
<ChatActorContext.Provider options={{ input: { characterId } }}>
|
||||
{children}
|
||||
</ChatActorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatState(): ChatState {
|
||||
@@ -73,6 +84,7 @@ type SelectedChatState = Omit<ChatState, "messages">;
|
||||
|
||||
function selectChatState(state: ChatSnapshot): SelectedChatState {
|
||||
return {
|
||||
characterId: state.context.characterId,
|
||||
historyMessages: state.context.messages,
|
||||
promotion: state.context.promotion,
|
||||
outgoingMessageRevision: state.context.outgoingMessageRevision,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
@@ -41,8 +41,10 @@ export function createGreetingMessage(): UiMessage {
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveHistoryCacheIdentity(): Promise<string | null> {
|
||||
const result = await resolveChatCacheOwnerKey();
|
||||
export async function resolveHistoryCacheIdentity(
|
||||
characterId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await resolveChatConversationKey(characterId);
|
||||
return Result.isOk(result) ? result.data : null;
|
||||
}
|
||||
|
||||
@@ -76,13 +78,18 @@ export async function readLocalHistorySnapshot(
|
||||
}
|
||||
|
||||
export async function syncNetworkHistory(
|
||||
characterId: string,
|
||||
localCount: number,
|
||||
cacheIdentity: string | null,
|
||||
): Promise<NetworkHistorySyncOutput | null> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const greetingMessage = createGreetingMessage();
|
||||
|
||||
const networkResult = await chatRepo.getHistory(CHAT_HISTORY_LIMIT, 0);
|
||||
const networkResult = await chatRepo.getHistory(
|
||||
characterId,
|
||||
CHAT_HISTORY_LIMIT,
|
||||
0,
|
||||
);
|
||||
if (Result.isErr(networkResult)) {
|
||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||
error: networkResult.error,
|
||||
@@ -97,6 +104,7 @@ export async function syncNetworkHistory(
|
||||
if (cacheIdentity) {
|
||||
void chatRepo.prefetchMediaForMessages(
|
||||
networkResult.data.messages,
|
||||
characterId,
|
||||
cacheIdentity,
|
||||
);
|
||||
}
|
||||
@@ -134,10 +142,13 @@ export async function syncNetworkHistory(
|
||||
* 2. Read network history as the authoritative source.
|
||||
* 3. Overwrite local history with network data.
|
||||
*/
|
||||
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity();
|
||||
export async function readAndSyncHistory(
|
||||
characterId: string,
|
||||
): Promise<ReadAndSyncHistoryOutput> {
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
characterId,
|
||||
localSnapshot.localCount,
|
||||
cacheIdentity,
|
||||
);
|
||||
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
chatMachineSetup,
|
||||
chatRootStateConfig,
|
||||
} from "./machine/session-flow";
|
||||
import { initialState } from "./chat-state";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
import { createInitialChatState } from "./chat-state";
|
||||
|
||||
export type { ChatState } from "./chat-state";
|
||||
export { initialState } from "./chat-state";
|
||||
@@ -10,7 +12,8 @@ export type { ChatEvent } from "./chat-events";
|
||||
|
||||
export const chatMachine = chatMachineSetup.createMachine({
|
||||
id: "chat",
|
||||
context: initialState,
|
||||
context: ({ input }) =>
|
||||
createInitialChatState(input?.characterId ?? DEFAULT_CHARACTER_ID),
|
||||
...chatRootStateConfig,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import type { ChatPromotionState } from "./helper/promotion";
|
||||
|
||||
export type ChatUpgradeReason = "insufficient_credits";
|
||||
@@ -24,6 +25,7 @@ export interface ChatUnlockPaywallRequest
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
characterId: string;
|
||||
messages: UiMessage[];
|
||||
promotion: ChatPromotionState | null;
|
||||
outgoingMessageRevision: number;
|
||||
@@ -51,29 +53,36 @@ export interface ChatState {
|
||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||
}
|
||||
|
||||
export const initialState: ChatState = {
|
||||
messages: [],
|
||||
promotion: null,
|
||||
outgoingMessageRevision: 0,
|
||||
isReplyingAI: false,
|
||||
pendingReplyCount: 0,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
canSendMessage: true,
|
||||
creditBalance: 0,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
historyLoaded: false,
|
||||
historyTotal: 0,
|
||||
historyLimit: 50,
|
||||
nextHistoryOffset: 0,
|
||||
isLoadingMoreHistory: false,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
lockedHistoryCount: 0,
|
||||
unlockHistoryError: null,
|
||||
unlockingMessage: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
export function createInitialChatState(
|
||||
characterId: string = DEFAULT_CHARACTER_ID,
|
||||
): ChatState {
|
||||
return {
|
||||
characterId,
|
||||
messages: [],
|
||||
promotion: null,
|
||||
outgoingMessageRevision: 0,
|
||||
isReplyingAI: false,
|
||||
pendingReplyCount: 0,
|
||||
upgradePromptVisible: false,
|
||||
upgradeReason: null,
|
||||
canSendMessage: true,
|
||||
creditBalance: 0,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 0,
|
||||
shortfallCredits: 0,
|
||||
historyLoaded: false,
|
||||
historyTotal: 0,
|
||||
historyLimit: 50,
|
||||
nextHistoryOffset: 0,
|
||||
isLoadingMoreHistory: false,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: false,
|
||||
lockedHistoryCount: 0,
|
||||
unlockHistoryError: null,
|
||||
unlockingMessage: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialState: ChatState = createInitialChatState();
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface LoadMoreHistoryActorEvent {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ChatHistoryActorInput {
|
||||
characterId: string;
|
||||
}
|
||||
|
||||
export interface LoadMoreHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
offset: number;
|
||||
@@ -29,11 +33,14 @@ export interface LoadMoreHistoryOutput {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
export const loadHistoryActor = fromCallback<
|
||||
ChatEvent,
|
||||
ChatHistoryActorInput
|
||||
>(({ input, sendBack }) => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity();
|
||||
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||
if (cancelled) return;
|
||||
sendBack({
|
||||
@@ -42,6 +49,7 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
});
|
||||
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
input.characterId,
|
||||
localSnapshot.localCount,
|
||||
cacheIdentity,
|
||||
);
|
||||
@@ -62,52 +70,58 @@ export const loadHistoryActor = fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
});
|
||||
|
||||
export const loadMoreHistoryActor =
|
||||
fromCallback<LoadMoreHistoryActorEvent>(({ receive, sendBack }) => {
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
fromCallback<LoadMoreHistoryActorEvent, ChatHistoryActorInput>(
|
||||
({ input, receive, sendBack }) => {
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
|
||||
receive((event) => {
|
||||
if (running || cancelled) return;
|
||||
running = true;
|
||||
receive((event) => {
|
||||
if (running || cancelled) return;
|
||||
running = true;
|
||||
|
||||
void (async () => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const historyResult = await chatRepo.getHistory(
|
||||
event.limit,
|
||||
event.offset,
|
||||
);
|
||||
if (Result.isErr(historyResult)) throw historyResult.error;
|
||||
if (cancelled) return;
|
||||
|
||||
const response = historyResult.data;
|
||||
sendBack({
|
||||
type: "ChatOlderHistoryLoaded",
|
||||
output: {
|
||||
messages: localMessagesToUi(response.messages),
|
||||
offset: event.offset,
|
||||
total: response.total,
|
||||
limit: normalizeHistoryLimit(response.limit, event.limit),
|
||||
} satisfies LoadMoreHistoryOutput,
|
||||
});
|
||||
void resolveHistoryCacheIdentity().then((cacheIdentity) => {
|
||||
if (!cacheIdentity) return;
|
||||
void chatRepo.prefetchMediaForMessages(
|
||||
response.messages,
|
||||
cacheIdentity,
|
||||
void (async () => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const historyResult = await chatRepo.getHistory(
|
||||
input.characterId,
|
||||
event.limit,
|
||||
event.offset,
|
||||
);
|
||||
});
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (Result.isErr(historyResult)) throw historyResult.error;
|
||||
if (cancelled) return;
|
||||
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
||||
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
const response = historyResult.data;
|
||||
sendBack({
|
||||
type: "ChatOlderHistoryLoaded",
|
||||
output: {
|
||||
messages: localMessagesToUi(response.messages),
|
||||
offset: event.offset,
|
||||
total: response.total,
|
||||
limit: normalizeHistoryLimit(response.limit, event.limit),
|
||||
} satisfies LoadMoreHistoryOutput,
|
||||
});
|
||||
void resolveHistoryCacheIdentity(input.characterId).then(
|
||||
(cacheIdentity) => {
|
||||
if (!cacheIdentity) return;
|
||||
void chatRepo.prefetchMediaForMessages(
|
||||
response.messages,
|
||||
input.characterId,
|
||||
cacheIdentity,
|
||||
);
|
||||
},
|
||||
);
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
||||
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ExceptionHandler } from "@/core/errors";
|
||||
import { MessageQueue } from "@/core/net/message-queue";
|
||||
import type { ChatSendResponse } from "@/data/dto/chat";
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
@@ -17,18 +17,22 @@ type UiMessage = import("@/data/dto/chat").UiMessage;
|
||||
|
||||
export const sendMessageHttpActor = fromPromise<
|
||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||
{ content: string }
|
||||
{ characterId: string; content: string }
|
||||
>(async ({ input }) => {
|
||||
return sendMessageViaHttp(input.content);
|
||||
return sendMessageViaHttp(input.characterId, input.content);
|
||||
});
|
||||
|
||||
export const httpMessageQueueActor = fromCallback<ChatEvent>(
|
||||
({ sendBack, receive }) => {
|
||||
return createMessageQueueActor(sendBack, receive);
|
||||
export const httpMessageQueueActor = fromCallback<
|
||||
ChatEvent,
|
||||
{ characterId: string }
|
||||
>(
|
||||
({ input, sendBack, receive }) => {
|
||||
return createMessageQueueActor(input.characterId, sendBack, receive);
|
||||
},
|
||||
);
|
||||
|
||||
function createMessageQueueActor(
|
||||
characterId: string,
|
||||
sendBack: (event: ChatEvent) => void,
|
||||
receive: (listener: (event: ChatEvent) => void) => void,
|
||||
): () => void {
|
||||
@@ -37,7 +41,7 @@ function createMessageQueueActor(
|
||||
queue.setConsumer(async (content) => {
|
||||
sendBack({ type: "ChatQueuedSendStarted" });
|
||||
try {
|
||||
const output = await sendMessageViaHttp(content);
|
||||
const output = await sendMessageViaHttp(characterId, content);
|
||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||
} catch (error) {
|
||||
const errorMessage = ExceptionHandler.message(error);
|
||||
@@ -62,13 +66,13 @@ function createMessageQueueActor(
|
||||
return () => queue.dispose();
|
||||
}
|
||||
|
||||
async function sendMessageViaHttp(content: string): Promise<{
|
||||
async function sendMessageViaHttp(characterId: string, content: string): Promise<{
|
||||
response: ChatSendResponse;
|
||||
reply: UiMessage | null;
|
||||
}> {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
||||
const result = await chatRepo.sendMessage(content);
|
||||
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
||||
const result = await chatRepo.sendMessage(characterId, content);
|
||||
if (Result.isErr(result)) {
|
||||
log.error("[chat-machine] sendMessageHttpActor failed", {
|
||||
error: result.error,
|
||||
@@ -78,6 +82,7 @@ async function sendMessageViaHttp(content: string): Promise<{
|
||||
if (Result.isOk(cacheIdentityResult)) {
|
||||
void chatRepo.prefetchMediaForSendResponse(
|
||||
result.data,
|
||||
characterId,
|
||||
cacheIdentityResult.data,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fromPromise } from "xstate";
|
||||
|
||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||
import { resolveChatCacheOwnerKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
@@ -22,9 +22,12 @@ export interface UnlockHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
}
|
||||
|
||||
export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
||||
export const unlockHistoryActor = fromPromise<
|
||||
UnlockHistoryOutput,
|
||||
{ characterId: string }
|
||||
>(async ({ input }) => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const unlockResult = await chatRepo.unlockHistory();
|
||||
const unlockResult = await chatRepo.unlockHistory(input.characterId);
|
||||
if (Result.isErr(unlockResult)) {
|
||||
log.error("[chat-machine] unlockHistoryActor failed", {
|
||||
error: unlockResult.error,
|
||||
@@ -32,7 +35,7 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
||||
throw unlockResult.error;
|
||||
}
|
||||
|
||||
const history = await readAndSyncHistory();
|
||||
const history = await readAndSyncHistory(input.characterId);
|
||||
return {
|
||||
unlocked: unlockResult.data.unlocked,
|
||||
reason: unlockResult.data.reason,
|
||||
@@ -43,11 +46,14 @@ export const unlockHistoryActor = fromPromise<UnlockHistoryOutput>(async () => {
|
||||
|
||||
export const unlockMessageActor = fromPromise<
|
||||
UnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
UnlockMessageRequest & { characterId: string }
|
||||
>(async ({ input }) => {
|
||||
const chatRepo = await loadChatRepository();
|
||||
const cacheIdentityResult = await resolveChatCacheOwnerKey();
|
||||
const cacheIdentityResult = await resolveChatConversationKey(
|
||||
input.characterId,
|
||||
);
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||
characterId: input.characterId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
|
||||
@@ -205,7 +205,8 @@ export const guestReadyState = sendMachineSetup.createStateConfig({
|
||||
export const guestSendingState = sendMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "sendMessageHttp",
|
||||
input: ({ event }) => ({
|
||||
input: ({ context, event }) => ({
|
||||
characterId: context.characterId,
|
||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||
}),
|
||||
onDone: {
|
||||
@@ -222,7 +223,8 @@ export const guestSendingState = sendMachineSetup.createStateConfig({
|
||||
export const userSendingViaHttpState = sendMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
src: "sendMessageHttp",
|
||||
input: ({ event }) => ({
|
||||
input: ({ context, event }) => ({
|
||||
characterId: context.characterId,
|
||||
content: event.type === "ChatSendMessage" ? event.content : "",
|
||||
}),
|
||||
onDone: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChatPromotionState } from "../helper/promotion";
|
||||
import { shouldPromptUnlockHistory } from "../helper/unlock";
|
||||
import { initialState } from "../chat-state";
|
||||
import { createInitialChatState } from "../chat-state";
|
||||
import {
|
||||
guestInitializingState,
|
||||
historyPaginationTransitions,
|
||||
@@ -19,20 +19,20 @@ import {
|
||||
|
||||
const startGuestSessionAction = unlockMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...initialState,
|
||||
...createInitialChatState(context.characterId),
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const startUserSessionAction = unlockMachineSetup.assign(
|
||||
({ context }) => ({
|
||||
...initialState,
|
||||
...createInitialChatState(context.characterId),
|
||||
promotion: context.promotion,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearChatSessionAction = unlockMachineSetup.assign(() => ({
|
||||
...initialState,
|
||||
const clearChatSessionAction = unlockMachineSetup.assign(({ context }) => ({
|
||||
...createInitialChatState(context.characterId),
|
||||
}));
|
||||
|
||||
const injectPromotionAction = unlockMachineSetup.assign(({ event }) => {
|
||||
@@ -75,14 +75,17 @@ const guestSessionState = chatMachineSetup.createStateConfig({
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadMoreHistory",
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
],
|
||||
on: {
|
||||
@@ -154,14 +157,17 @@ const userSessionState = chatMachineSetup.createStateConfig({
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
{
|
||||
id: "loadMoreHistory",
|
||||
src: "loadMoreHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
},
|
||||
],
|
||||
on: {
|
||||
|
||||
@@ -15,10 +15,15 @@ import {
|
||||
} from "./actors/unlock";
|
||||
import type { ChatState } from "../chat-state";
|
||||
|
||||
export interface ChatMachineInput {
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
export const baseChatMachineSetup = setup({
|
||||
types: {
|
||||
context: {} as ChatState,
|
||||
events: {} as ChatEvent,
|
||||
input: {} as ChatMachineInput | undefined,
|
||||
},
|
||||
actors: {
|
||||
loadHistory: loadHistoryActor,
|
||||
|
||||
@@ -188,6 +188,7 @@ export const unlockingHistoryState = unlockMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
id: "unlockHistory",
|
||||
src: "unlockHistory",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: applyUnlockHistoryOutputAction,
|
||||
@@ -203,11 +204,13 @@ export const unlockingMessageState = unlockMachineSetup.createStateConfig({
|
||||
invoke: {
|
||||
id: "unlockMessage",
|
||||
src: "unlockMessage",
|
||||
input: ({ context }) =>
|
||||
context.unlockingMessage ?? {
|
||||
input: ({ context }) => ({
|
||||
characterId: context.characterId,
|
||||
...(context.unlockingMessage ?? {
|
||||
displayMessageId: "",
|
||||
kind: "private",
|
||||
},
|
||||
kind: "private" as const,
|
||||
}),
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) => event.output.response.unlocked,
|
||||
|
||||
@@ -6,9 +6,29 @@ import {
|
||||
loadPrivateRoom,
|
||||
makeAlbum,
|
||||
makeAlbumsResponse,
|
||||
TEST_PRIVATE_ROOM_INPUT,
|
||||
} from "./private-room-machine.test-utils";
|
||||
|
||||
describe("private room album flow", () => {
|
||||
it("loads albums for the supplied character", async () => {
|
||||
let loadedCharacterId: string | null = null;
|
||||
const actor = createActor(
|
||||
createTestPrivateRoomMachine({
|
||||
onLoad: ({ characterId }) => {
|
||||
loadedCharacterId = characterId;
|
||||
},
|
||||
}),
|
||||
{ input: { characterId: "character_aria" } },
|
||||
).start();
|
||||
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
|
||||
expect(actor.getSnapshot().context.characterId).toBe("character_aria");
|
||||
expect(loadedCharacterId).toBe("character_aria");
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("loads only the first non-paginated album page", async () => {
|
||||
const actor = await loadPrivateRoom({
|
||||
loadResponse: makeAlbumsResponse({
|
||||
@@ -26,6 +46,7 @@ describe("private room album flow", () => {
|
||||
createTestPrivateRoomMachine({
|
||||
loadError: new Error("albums unavailable"),
|
||||
}),
|
||||
{ input: TEST_PRIVATE_ROOM_INPUT },
|
||||
).start();
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("failed"));
|
||||
|
||||
@@ -4,11 +4,15 @@ import {
|
||||
PrivateAlbumsResponse,
|
||||
PrivateAlbumUnlockResponse,
|
||||
} from "@/data/dto/private-room";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { privateRoomMachine } from "@/stores/private-room/private-room-machine";
|
||||
|
||||
export const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
|
||||
export const COVER_URL =
|
||||
"https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg";
|
||||
export const TEST_PRIVATE_ROOM_INPUT = {
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
};
|
||||
|
||||
export function makeAlbum(index = 0) {
|
||||
return {
|
||||
@@ -62,12 +66,15 @@ export function createTestPrivateRoomMachine(options: {
|
||||
unlockResponse?: PrivateAlbumUnlockResponse;
|
||||
loadError?: Error;
|
||||
unlockError?: Error;
|
||||
onLoad?: () => void;
|
||||
onLoad?: (input: { characterId: string }) => void;
|
||||
} = {}) {
|
||||
return privateRoomMachine.provide({
|
||||
actors: {
|
||||
loadAlbums: fromPromise(async () => {
|
||||
options.onLoad?.();
|
||||
loadAlbums: fromPromise<
|
||||
PrivateAlbumsResponse,
|
||||
{ characterId: string }
|
||||
>(async ({ input }) => {
|
||||
options.onLoad?.(input);
|
||||
if (options.loadError) throw options.loadError;
|
||||
return options.loadResponse ?? makeAlbumsResponse();
|
||||
}),
|
||||
@@ -82,7 +89,9 @@ export function createTestPrivateRoomMachine(options: {
|
||||
export async function loadPrivateRoom(
|
||||
options: Parameters<typeof createTestPrivateRoomMachine>[0] = {},
|
||||
) {
|
||||
const actor = createActor(createTestPrivateRoomMachine(options)).start();
|
||||
const actor = createActor(createTestPrivateRoomMachine(options), {
|
||||
input: TEST_PRIVATE_ROOM_INPUT,
|
||||
}).start();
|
||||
actor.send({ type: "PrivateRoomInit" });
|
||||
await waitFor(actor, (snapshot) => snapshot.matches("ready"));
|
||||
return actor;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
|
||||
import type { PrivateRoomState } from "../private-room-state";
|
||||
|
||||
export const PRIVATE_ROOM_CHARACTER = "elio";
|
||||
export const PRIVATE_ROOM_PAGE_SIZE = 20;
|
||||
|
||||
export function applyAlbumsResponse(
|
||||
|
||||
@@ -7,15 +7,14 @@ import type {
|
||||
import { getPrivateRoomRepository } from "@/data/repositories/private_room_repository";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import {
|
||||
PRIVATE_ROOM_CHARACTER,
|
||||
PRIVATE_ROOM_PAGE_SIZE,
|
||||
} from "../../helper/albums";
|
||||
import { PRIVATE_ROOM_PAGE_SIZE } from "../../helper/albums";
|
||||
|
||||
export const loadPrivateAlbumsActor =
|
||||
fromPromise<PrivateAlbumsResponse>(async () => {
|
||||
fromPromise<PrivateAlbumsResponse, { characterId: string }>(async ({
|
||||
input,
|
||||
}) => {
|
||||
const result = await getPrivateRoomRepository().getAlbums({
|
||||
character: PRIVATE_ROOM_CHARACTER,
|
||||
characterId: input.characterId,
|
||||
limit: PRIVATE_ROOM_PAGE_SIZE,
|
||||
});
|
||||
if (Result.isErr(result)) throw result.error;
|
||||
|
||||
@@ -45,6 +45,7 @@ export const loadingState = albumMachineSetup.createStateConfig({
|
||||
entry: "clearAlbumLoadState",
|
||||
invoke: {
|
||||
src: "loadAlbums",
|
||||
input: ({ context }) => ({ characterId: context.characterId }),
|
||||
onDone: {
|
||||
target: "ready",
|
||||
actions: applyAlbumsResponseAction,
|
||||
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
unlockPrivateAlbumActor,
|
||||
} from "./actors/albums";
|
||||
|
||||
export interface PrivateRoomMachineInput {
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
export const basePrivateRoomMachineSetup = setup({
|
||||
types: {
|
||||
context: {} as PrivateRoomState,
|
||||
events: {} as PrivateRoomEvent,
|
||||
input: {} as PrivateRoomMachineInput,
|
||||
},
|
||||
actors: {
|
||||
loadAlbums: loadPrivateAlbumsActor,
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Dispatch, ReactNode } from "react";
|
||||
import { createActorContext, shallowEqual } from "@xstate/react";
|
||||
import type { SnapshotFrom } from "xstate";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
import { privateRoomMachine } from "./private-room-machine";
|
||||
import type {
|
||||
PrivateRoomEvent,
|
||||
@@ -11,6 +13,7 @@ import type {
|
||||
} from "./private-room-machine";
|
||||
|
||||
export interface PrivateRoomContextState {
|
||||
characterId: string;
|
||||
status: string;
|
||||
items: MachineContext["items"];
|
||||
creditBalance: number;
|
||||
@@ -31,11 +34,17 @@ const PrivateRoomActorContext = createActorContext(privateRoomMachine);
|
||||
|
||||
export interface PrivateRoomProviderProps {
|
||||
children: ReactNode;
|
||||
characterId?: string;
|
||||
}
|
||||
|
||||
export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) {
|
||||
export function PrivateRoomProvider({
|
||||
children,
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
}: PrivateRoomProviderProps) {
|
||||
return (
|
||||
<PrivateRoomActorContext.Provider>{children}</PrivateRoomActorContext.Provider>
|
||||
<PrivateRoomActorContext.Provider options={{ input: { characterId } }}>
|
||||
{children}
|
||||
</PrivateRoomActorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +67,7 @@ function selectPrivateRoomState(
|
||||
state: PrivateRoomSnapshot,
|
||||
): PrivateRoomContextState {
|
||||
return {
|
||||
characterId: state.context.characterId,
|
||||
status: String(state.value),
|
||||
items: state.context.items,
|
||||
creditBalance: state.context.creditBalance,
|
||||
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
privateRoomMachineSetup,
|
||||
privateRoomRootStateConfig,
|
||||
} from "./machine/unlock-flow";
|
||||
import { initialState } from "./private-room-state";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
import { createInitialPrivateRoomState } from "./private-room-state";
|
||||
|
||||
export type { PrivateRoomEvent } from "./private-room-events";
|
||||
export type { PrivateRoomState } from "./private-room-state";
|
||||
@@ -10,7 +12,10 @@ export { initialState } from "./private-room-state";
|
||||
|
||||
export const privateRoomMachine = privateRoomMachineSetup.createMachine({
|
||||
id: "privateRoom",
|
||||
context: initialState,
|
||||
context: ({ input }) =>
|
||||
createInitialPrivateRoomState(
|
||||
input?.characterId ?? DEFAULT_CHARACTER_ID,
|
||||
),
|
||||
...privateRoomRootStateConfig,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PrivateAlbum } from "@/data/dto/private-room";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
|
||||
export interface PrivateRoomUnlockPaywallRequest {
|
||||
albumId: string;
|
||||
@@ -9,6 +10,7 @@ export interface PrivateRoomUnlockPaywallRequest {
|
||||
}
|
||||
|
||||
export interface PrivateRoomState {
|
||||
characterId: string;
|
||||
items: PrivateAlbum[];
|
||||
creditBalance: number;
|
||||
errorMessage: string | null;
|
||||
@@ -19,13 +21,20 @@ export interface PrivateRoomState {
|
||||
unlockSuccessNonce: number;
|
||||
}
|
||||
|
||||
export const initialState: PrivateRoomState = {
|
||||
items: [],
|
||||
creditBalance: 0,
|
||||
errorMessage: null,
|
||||
unlockingAlbumId: null,
|
||||
unlockErrorMessage: null,
|
||||
pendingConfirmAlbumId: null,
|
||||
unlockPaywallRequest: null,
|
||||
unlockSuccessNonce: 0,
|
||||
};
|
||||
export function createInitialPrivateRoomState(
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
): PrivateRoomState {
|
||||
return {
|
||||
characterId,
|
||||
items: [],
|
||||
creditBalance: 0,
|
||||
errorMessage: null,
|
||||
unlockingAlbumId: null,
|
||||
unlockErrorMessage: null,
|
||||
pendingConfirmAlbumId: null,
|
||||
unlockPaywallRequest: null,
|
||||
unlockSuccessNonce: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialState = createInitialPrivateRoomState();
|
||||
|
||||
Reference in New Issue
Block a user