fix(chat): isolate pending flows and cancel stale requests
This commit is contained in:
@@ -20,6 +20,7 @@ const defaultScope = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const promotionSession: PendingChatPromotion = {
|
const promotionSession: PendingChatPromotion = {
|
||||||
|
characterId: "character_elio",
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
@@ -181,6 +182,7 @@ function createPendingUnlock(
|
|||||||
): PendingChatUnlock {
|
): PendingChatUnlock {
|
||||||
return {
|
return {
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
...input,
|
...input,
|
||||||
stage: "auth",
|
stage: "auth",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export function ChatScreen() {
|
|||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
messages: state.historyMessages,
|
messages: state.historyMessages,
|
||||||
});
|
});
|
||||||
const isPromotionBootstrapReady = useChatPromotionBootstrap();
|
const isPromotionBootstrapReady = useChatPromotionBootstrap(
|
||||||
|
state.characterId,
|
||||||
|
);
|
||||||
const visibleMessages = isPromotionBootstrapReady
|
const visibleMessages = isPromotionBootstrapReady
|
||||||
? state.messages
|
? state.messages
|
||||||
: state.historyMessages;
|
: state.historyMessages;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Logger } from "@/utils/logger";
|
|||||||
|
|
||||||
const log = new Logger("UseChatPromotionBootstrap");
|
const log = new Logger("UseChatPromotionBootstrap");
|
||||||
|
|
||||||
export function useChatPromotionBootstrap(): boolean {
|
export function useChatPromotionBootstrap(characterId: string): boolean {
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
@@ -23,8 +23,8 @@ export function useChatPromotionBootstrap(): boolean {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [entryPromotion, pendingUnlock] = await Promise.all([
|
const [entryPromotion, pendingUnlock] = await Promise.all([
|
||||||
consumePendingChatPromotion(),
|
consumePendingChatPromotion(characterId),
|
||||||
peekPendingChatUnlock(),
|
peekPendingChatUnlock(characterId),
|
||||||
]);
|
]);
|
||||||
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
|
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export function useChatPromotionBootstrap(): boolean {
|
|||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [chatDispatch]);
|
}, [characterId, chatDispatch]);
|
||||||
|
|
||||||
return isReady;
|
return isReady;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function useChatUnlockCoordinator({
|
|||||||
const trackedPaywallRef = useRef<string | null>(null);
|
const trackedPaywallRef = useRef<string | null>(null);
|
||||||
const chatState = useChatSelector(
|
const chatState = useChatSelector(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
|
characterId: state.context.characterId,
|
||||||
historyLoaded: state.context.historyLoaded,
|
historyLoaded: state.context.historyLoaded,
|
||||||
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
||||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||||
@@ -125,7 +126,7 @@ export function useChatUnlockCoordinator({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const resumePendingUnlock = async () => {
|
const resumePendingUnlock = async () => {
|
||||||
const pending = await peekPendingChatUnlock();
|
const pending = await peekPendingChatUnlock(chatState.characterId);
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
!pending ||
|
!pending ||
|
||||||
@@ -138,7 +139,7 @@ export function useChatUnlockCoordinator({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumed = await consumePendingChatUnlock();
|
const consumed = await consumePendingChatUnlock(chatState.characterId);
|
||||||
if (cancelled || !consumed) return;
|
if (cancelled || !consumed) return;
|
||||||
|
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
@@ -157,6 +158,7 @@ export function useChatUnlockCoordinator({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
chatDispatch,
|
chatDispatch,
|
||||||
|
chatState.characterId,
|
||||||
chatState.historyLoaded,
|
chatState.historyLoaded,
|
||||||
defaultReturnUrl,
|
defaultReturnUrl,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
savePendingChatPromotion,
|
savePendingChatPromotion,
|
||||||
} from "@/lib/navigation/chat_unlock_session";
|
} from "@/lib/navigation/chat_unlock_session";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import {
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ export default function ExternalEntryPersist({
|
|||||||
const hasReinitializedForPsidRef = useRef(false);
|
const hasReinitializedForPsidRef = useRef(false);
|
||||||
const targetRoute = resolveExternalEntryTarget({ target });
|
const targetRoute = resolveExternalEntryTarget({ target });
|
||||||
const destination = resolveExternalEntryDestination({ target, character });
|
const destination = resolveExternalEntryDestination({ target, character });
|
||||||
|
const characterId =
|
||||||
|
getCharacterBySlug(character)?.id ?? DEFAULT_CHARACTER_ID;
|
||||||
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
||||||
mode,
|
mode,
|
||||||
promotionType,
|
promotionType,
|
||||||
@@ -66,7 +72,7 @@ export default function ExternalEntryPersist({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
}),
|
}),
|
||||||
targetRoute === ROUTES.chat && resolvedPromotionType
|
targetRoute === ROUTES.chat && resolvedPromotionType
|
||||||
? savePendingChatPromotion(resolvedPromotionType)
|
? savePendingChatPromotion(resolvedPromotionType, characterId)
|
||||||
: clearPendingChatPromotion(),
|
: clearPendingChatPromotion(),
|
||||||
]);
|
]);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
@@ -87,6 +93,7 @@ export default function ExternalEntryPersist({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
asid,
|
asid,
|
||||||
character,
|
character,
|
||||||
|
characterId,
|
||||||
destination,
|
destination,
|
||||||
deviceId,
|
deviceId,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
import type {
|
||||||
|
ChatRequestOptions,
|
||||||
|
ChatSendOptions,
|
||||||
|
UnlockPrivateMessageInput,
|
||||||
|
} from "@/data/repositories/interfaces";
|
||||||
import {
|
import {
|
||||||
ChatHistoryResponse,
|
ChatHistoryResponse,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
@@ -17,7 +21,7 @@ export class ChatRemoteDataSource {
|
|||||||
async sendMessage(
|
async sendMessage(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: ChatSendOptions,
|
||||||
): Promise<Result<ChatSendResponse>> {
|
): Promise<Result<ChatSendResponse>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
const request = SendMessageRequestSchema.parse({
|
const request = SendMessageRequestSchema.parse({
|
||||||
@@ -26,7 +30,7 @@ export class ChatRemoteDataSource {
|
|||||||
image: options?.image ?? "",
|
image: options?.image ?? "",
|
||||||
useWebSocket: options?.useWebSocket ?? false,
|
useWebSocket: options?.useWebSocket ?? false,
|
||||||
});
|
});
|
||||||
return await this.api.sendMessage(request);
|
return await this.api.sendMessage(request, options);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,23 +38,34 @@ export class ChatRemoteDataSource {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<ChatHistoryResponse>> {
|
): Promise<Result<ChatHistoryResponse>> {
|
||||||
return Result.wrap(() => this.api.getHistory(characterId, limit, offset));
|
return Result.wrap(() =>
|
||||||
|
this.api.getHistory(characterId, limit, offset, options),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockPrivateResponse>> {
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
return Result.wrap(() =>
|
return Result.wrap(() =>
|
||||||
this.api.unlockPrivateMessage(UnlockPrivateRequestSchema.parse(input)),
|
this.api.unlockPrivateMessage(
|
||||||
|
UnlockPrivateRequestSchema.parse(input),
|
||||||
|
options,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlockHistory(
|
async unlockHistory(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockHistoryResponse>> {
|
): Promise<Result<UnlockHistoryResponse>> {
|
||||||
return Result.wrap(() =>
|
return Result.wrap(() =>
|
||||||
this.api.unlockHistory(UnlockHistoryRequestSchema.parse({ characterId })),
|
this.api.unlockHistory(
|
||||||
|
UnlockHistoryRequestSchema.parse({ characterId }),
|
||||||
|
options,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
CacheRemoteChatMediaInput,
|
CacheRemoteChatMediaInput,
|
||||||
ChatMediaLookupInput,
|
ChatMediaLookupInput,
|
||||||
|
ChatRequestOptions,
|
||||||
|
ChatSendOptions,
|
||||||
IChatRepository,
|
IChatRepository,
|
||||||
UnlockPrivateMessageInput,
|
UnlockPrivateMessageInput,
|
||||||
UnlockedPrivateMessageLocalPatch,
|
UnlockedPrivateMessageLocalPatch,
|
||||||
@@ -38,7 +40,7 @@ export class ChatRepository implements IChatRepository {
|
|||||||
async sendMessage(
|
async sendMessage(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: ChatSendOptions,
|
||||||
): Promise<Result<ChatSendResponse>> {
|
): Promise<Result<ChatSendResponse>> {
|
||||||
return this.remote.sendMessage(characterId, message, options);
|
return this.remote.sendMessage(characterId, message, options);
|
||||||
}
|
}
|
||||||
@@ -48,22 +50,25 @@ export class ChatRepository implements IChatRepository {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<ChatHistoryResponse>> {
|
): Promise<Result<ChatHistoryResponse>> {
|
||||||
return this.remote.getHistory(characterId, limit, offset);
|
return this.remote.getHistory(characterId, limit, offset, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockPrivateResponse>> {
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
return this.remote.unlockPrivateMessage(input);
|
return this.remote.unlockPrivateMessage(input, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
async unlockHistory(
|
async unlockHistory(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockHistoryResponse>> {
|
): Promise<Result<UnlockHistoryResponse>> {
|
||||||
return this.remote.unlockHistory(characterId);
|
return this.remote.unlockHistory(characterId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
|
|||||||
@@ -42,12 +42,21 @@ export interface UnlockPrivateMessageInput {
|
|||||||
clientLockId?: string;
|
clientLockId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatRequestOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSendOptions extends ChatRequestOptions {
|
||||||
|
image?: string;
|
||||||
|
useWebSocket?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IChatRepository {
|
export interface IChatRepository {
|
||||||
/** 发送一条消息。 */
|
/** 发送一条消息。 */
|
||||||
sendMessage(
|
sendMessage(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { image?: string; useWebSocket?: boolean },
|
options?: ChatSendOptions,
|
||||||
): Promise<Result<ChatSendResponse>>;
|
): Promise<Result<ChatSendResponse>>;
|
||||||
|
|
||||||
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
/** 获取聊天历史,分页参数默认 limit=50, offset=0。 */
|
||||||
@@ -55,15 +64,20 @@ export interface IChatRepository {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number,
|
offset?: number,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<ChatHistoryResponse>>;
|
): Promise<Result<ChatHistoryResponse>>;
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
unlockPrivateMessage(
|
unlockPrivateMessage(
|
||||||
input: UnlockPrivateMessageInput,
|
input: UnlockPrivateMessageInput,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
): Promise<Result<UnlockPrivateResponse>>;
|
): Promise<Result<UnlockPrivateResponse>>;
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
unlockHistory(characterId: string): Promise<Result<UnlockHistoryResponse>>;
|
unlockHistory(
|
||||||
|
characterId: string,
|
||||||
|
options?: ChatRequestOptions,
|
||||||
|
): Promise<Result<UnlockHistoryResponse>>;
|
||||||
|
|
||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
markPrivateMessageUnlockedInLocal(
|
markPrivateMessageUnlockedInLocal(
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ describe("multi-character API contract", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards request cancellation to the HTTP client", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
httpClientMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: { messages: [], total: 0, limit: 50, offset: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await new ChatApi().getHistory(CHARACTER_ID, 50, 0, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(httpClientMock).toHaveBeenCalledWith("/api/chat/history", {
|
||||||
|
query: { characterId: CHARACTER_ID, limit: 50, offset: 0 },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("sends characterId with private and history unlocks", async () => {
|
it("sends characterId with private and history unlocks", async () => {
|
||||||
const api = new ChatApi();
|
const api = new ChatApi();
|
||||||
httpClientMock
|
httpClientMock
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ export class ChatApi {
|
|||||||
/**
|
/**
|
||||||
* 发送消息
|
* 发送消息
|
||||||
*/
|
*/
|
||||||
async sendMessage(body: SendMessageRequest): Promise<ChatSendResponse> {
|
async sendMessage(
|
||||||
|
body: SendMessageRequest,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
|
): Promise<ChatSendResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatSend, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
});
|
});
|
||||||
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
return ChatSendResponseSchema.parse(unwrap(env) as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
@@ -40,9 +44,11 @@ export class ChatApi {
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<ChatHistoryResponse> {
|
): Promise<ChatHistoryResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.chatHistory, {
|
||||||
query: { characterId, limit, offset },
|
query: { characterId, limit, offset },
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
});
|
});
|
||||||
return ChatHistoryResponseSchema.parse(
|
return ChatHistoryResponseSchema.parse(
|
||||||
unwrap(env) as Record<string, unknown>,
|
unwrap(env) as Record<string, unknown>,
|
||||||
@@ -54,12 +60,14 @@ export class ChatApi {
|
|||||||
*/
|
*/
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
body: UnlockPrivateRequest,
|
body: UnlockPrivateRequest,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<UnlockPrivateResponse> {
|
): Promise<UnlockPrivateResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.chatUnlockPrivate,
|
ApiPath.chatUnlockPrivate,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return UnlockPrivateResponseSchema.parse(
|
return UnlockPrivateResponseSchema.parse(
|
||||||
@@ -72,12 +80,14 @@ export class ChatApi {
|
|||||||
*/
|
*/
|
||||||
async unlockHistory(
|
async unlockHistory(
|
||||||
body: UnlockHistoryRequest,
|
body: UnlockHistoryRequest,
|
||||||
|
options?: { signal?: AbortSignal },
|
||||||
): Promise<UnlockHistoryResponse> {
|
): Promise<UnlockHistoryResponse> {
|
||||||
const env = await httpClient<ApiEnvelope<unknown>>(
|
const env = await httpClient<ApiEnvelope<unknown>>(
|
||||||
ApiPath.chatUnlockHistory,
|
ApiPath.chatUnlockHistory,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return UnlockHistoryResponseSchema.parse(
|
return UnlockHistoryResponseSchema.parse(
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { SessionAsyncUtil } from "@/utils/session-storage";
|
|||||||
|
|
||||||
import { NavigationStorage } from "../navigation_storage";
|
import { NavigationStorage } from "../navigation_storage";
|
||||||
|
|
||||||
|
const CHARACTER_ID = "character_elio";
|
||||||
|
const OTHER_CHARACTER_ID = "character_maya";
|
||||||
|
|
||||||
describe("NavigationStorage", () => {
|
describe("NavigationStorage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
|
||||||
@@ -14,13 +17,7 @@ describe("NavigationStorage", () => {
|
|||||||
|
|
||||||
it("saves, peeks, and consumes pending chat unlock sessions", async () => {
|
it("saves, peeks, and consumes pending chat unlock sessions", async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
messageId: "msg_1",
|
characterId: CHARACTER_ID,
|
||||||
kind: "image",
|
|
||||||
returnUrl: "/chat?image=msg_1",
|
|
||||||
stage: "payment",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
@@ -28,12 +25,25 @@ describe("NavigationStorage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatUnlock(),
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
messageId: "msg_1",
|
||||||
|
kind: "image",
|
||||||
|
returnUrl: "/chat?image=msg_1",
|
||||||
|
stage: "payment",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
await expect(
|
||||||
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops expired pending chat unlock sessions", async () => {
|
it("drops expired pending chat unlock sessions", async () => {
|
||||||
@@ -41,6 +51,7 @@ describe("NavigationStorage", () => {
|
|||||||
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
|
||||||
|
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "private",
|
kind: "private",
|
||||||
returnUrl: "/chat",
|
returnUrl: "/chat",
|
||||||
@@ -49,25 +60,32 @@ describe("NavigationStorage", () => {
|
|||||||
|
|
||||||
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z"));
|
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z"));
|
||||||
|
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
await expect(
|
||||||
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
||||||
const promotion = await NavigationStorage.savePendingChatPromotion("image");
|
const promotion = await NavigationStorage.savePendingChatPromotion(
|
||||||
|
"image",
|
||||||
|
CHARACTER_ID,
|
||||||
|
);
|
||||||
|
|
||||||
expect(promotion).toMatchObject({
|
expect(promotion).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
});
|
});
|
||||||
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatPromotion(),
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
).resolves.toEqual(promotion);
|
).resolves.toEqual(promotion);
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatPromotion(),
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
|
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
displayMessageId: `promotion:${promotion.clientLockId}`,
|
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||||
kind: "image",
|
kind: "image",
|
||||||
lockType: promotion.lockType,
|
lockType: promotion.lockType,
|
||||||
@@ -76,7 +94,10 @@ describe("NavigationStorage", () => {
|
|||||||
returnUrl: "/chat",
|
returnUrl: "/chat",
|
||||||
stage: "auth",
|
stage: "auth",
|
||||||
});
|
});
|
||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
await expect(
|
||||||
|
NavigationStorage.peekPendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: promotion.clientLockId,
|
clientLockId: promotion.clientLockId,
|
||||||
promotion,
|
promotion,
|
||||||
@@ -85,18 +106,62 @@ describe("NavigationStorage", () => {
|
|||||||
|
|
||||||
it("saves and consumes pending chat image return sessions", async () => {
|
it("saves and consumes pending chat image return sessions", async () => {
|
||||||
await NavigationStorage.savePendingChatImageReturn({
|
await NavigationStorage.savePendingChatImageReturn({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatImageReturn(),
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
NavigationStorage.consumePendingChatImageReturn(),
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves another character's pending navigation state untouched", async () => {
|
||||||
|
const promotion = await NavigationStorage.savePendingChatPromotion(
|
||||||
|
"voice",
|
||||||
|
CHARACTER_ID,
|
||||||
|
);
|
||||||
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||||
|
kind: "voice",
|
||||||
|
lockType: promotion.lockType,
|
||||||
|
clientLockId: promotion.clientLockId,
|
||||||
|
promotion,
|
||||||
|
returnUrl: "/characters/elio/chat",
|
||||||
|
stage: "auth",
|
||||||
|
});
|
||||||
|
await NavigationStorage.savePendingChatImageReturn({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
messageId: "msg_1",
|
||||||
|
returnUrl: "/characters/elio/chat?image=msg_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatPromotion(OTHER_CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatUnlock(OTHER_CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatImageReturn(OTHER_CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
|
).resolves.toEqual(promotion);
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatUnlock(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatImageReturn(CHARACTER_ID),
|
||||||
|
).resolves.toMatchObject({ characterId: CHARACTER_ID });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type PendingChatUnlockStage = "auth" | "payment";
|
|||||||
export type PendingChatPromotionType = "voice" | "image" | "private";
|
export type PendingChatPromotionType = "voice" | "image" | "private";
|
||||||
|
|
||||||
export const PendingChatPromotionSchema = z.object({
|
export const PendingChatPromotionSchema = z.object({
|
||||||
|
characterId: z.string().min(1),
|
||||||
promotionType: z.enum(["voice", "image", "private"]),
|
promotionType: z.enum(["voice", "image", "private"]),
|
||||||
lockType: ChatLockTypeSchema,
|
lockType: ChatLockTypeSchema,
|
||||||
clientLockId: z.string().min(1),
|
clientLockId: z.string().min(1),
|
||||||
@@ -23,6 +24,7 @@ export const PendingChatPromotionSchema = z.object({
|
|||||||
const PendingChatUnlockSchema = z
|
const PendingChatUnlockSchema = z
|
||||||
.object({
|
.object({
|
||||||
reason: z.literal("single_message_unlock"),
|
reason: z.literal("single_message_unlock"),
|
||||||
|
characterId: z.string().min(1),
|
||||||
displayMessageId: z.string().min(1),
|
displayMessageId: z.string().min(1),
|
||||||
messageId: z.string().min(1).optional(),
|
messageId: z.string().min(1).optional(),
|
||||||
kind: z.enum(["private", "voice", "image"]),
|
kind: z.enum(["private", "voice", "image"]),
|
||||||
@@ -45,6 +47,7 @@ const PendingChatUnlockSchema = z
|
|||||||
|
|
||||||
const PendingChatImageReturnSchema = z.object({
|
const PendingChatImageReturnSchema = z.object({
|
||||||
reason: z.literal("image_paywall"),
|
reason: z.literal("image_paywall"),
|
||||||
|
characterId: z.string().min(1),
|
||||||
messageId: z.string().min(1),
|
messageId: z.string().min(1),
|
||||||
returnUrl: z
|
returnUrl: z
|
||||||
.string()
|
.string()
|
||||||
@@ -75,6 +78,7 @@ export class NavigationStorage {
|
|||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
static async savePendingChatUnlock(input: {
|
static async savePendingChatUnlock(input: {
|
||||||
|
characterId: string;
|
||||||
displayMessageId?: string;
|
displayMessageId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
@@ -91,8 +95,15 @@ export class NavigationStorage {
|
|||||||
if (!displayMessageId) {
|
if (!displayMessageId) {
|
||||||
throw new Error("displayMessageId is required");
|
throw new Error("displayMessageId is required");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
input.promotion &&
|
||||||
|
input.promotion.characterId !== input.characterId
|
||||||
|
) {
|
||||||
|
throw new Error("Pending promotion belongs to a different character.");
|
||||||
|
}
|
||||||
const payload: PendingChatUnlock = {
|
const payload: PendingChatUnlock = {
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: input.characterId,
|
||||||
displayMessageId,
|
displayMessageId,
|
||||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
@@ -110,31 +121,49 @@ export class NavigationStorage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
static async consumePendingChatUnlock(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatUnlock,
|
StorageKeys.pendingChatUnlock,
|
||||||
PendingChatUnlockSchema,
|
PendingChatUnlockSchema,
|
||||||
);
|
);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
if (Result.isErr(result)) return null;
|
return null;
|
||||||
return NavigationStorage.parsePendingChatUnlock(result.data);
|
}
|
||||||
|
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||||
|
if (!value) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.characterId !== characterId) return null;
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
static async peekPendingChatUnlock(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatUnlock,
|
StorageKeys.pendingChatUnlock,
|
||||||
PendingChatUnlockSchema,
|
PendingChatUnlockSchema,
|
||||||
);
|
);
|
||||||
if (Result.isErr(result)) return null;
|
if (Result.isErr(result)) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
const value = NavigationStorage.parsePendingChatUnlock(result.data);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||||
}
|
}
|
||||||
return value;
|
return value?.characterId === characterId ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async hasPendingChatUnlock(): Promise<boolean> {
|
static async hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
||||||
return (await NavigationStorage.peekPendingChatUnlock()) !== null;
|
return (
|
||||||
|
(await NavigationStorage.peekPendingChatUnlock(characterId)) !== null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async clearPendingChatUnlock(): Promise<void> {
|
static async clearPendingChatUnlock(): Promise<void> {
|
||||||
@@ -143,8 +172,10 @@ export class NavigationStorage {
|
|||||||
|
|
||||||
static async savePendingChatPromotion(
|
static async savePendingChatPromotion(
|
||||||
promotionType: PendingChatPromotionType,
|
promotionType: PendingChatPromotionType,
|
||||||
|
characterId: string,
|
||||||
): Promise<PendingChatPromotion> {
|
): Promise<PendingChatPromotion> {
|
||||||
const promotion: PendingChatPromotion = {
|
const promotion: PendingChatPromotion = {
|
||||||
|
characterId,
|
||||||
promotionType,
|
promotionType,
|
||||||
lockType: toPromotionLockType(promotionType),
|
lockType: toPromotionLockType(promotionType),
|
||||||
clientLockId: `promotion_${createUuidV4()}`,
|
clientLockId: `promotion_${createUuidV4()}`,
|
||||||
@@ -158,14 +189,25 @@ export class NavigationStorage {
|
|||||||
return promotion;
|
return promotion;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
static async consumePendingChatPromotion(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatPromotion | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatPromotion,
|
StorageKeys.pendingChatPromotion,
|
||||||
PendingChatPromotionSchema,
|
PendingChatPromotionSchema,
|
||||||
);
|
);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
if (Result.isErr(result)) return null;
|
return null;
|
||||||
return NavigationStorage.parsePendingChatPromotion(result.data);
|
}
|
||||||
|
const value = NavigationStorage.parsePendingChatPromotion(result.data);
|
||||||
|
if (!value) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.characterId !== characterId) return null;
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async clearPendingChatPromotion(): Promise<void> {
|
static async clearPendingChatPromotion(): Promise<void> {
|
||||||
@@ -173,11 +215,13 @@ export class NavigationStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async savePendingChatImageReturn(input: {
|
static async savePendingChatImageReturn(input: {
|
||||||
|
characterId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const payload: PendingChatImageReturn = {
|
const payload: PendingChatImageReturn = {
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
|
characterId: input.characterId,
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
returnUrl: input.returnUrl,
|
returnUrl: input.returnUrl,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
@@ -189,14 +233,25 @@ export class NavigationStorage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
static async consumePendingChatImageReturn(
|
||||||
|
characterId: string,
|
||||||
|
): Promise<PendingChatImageReturn | null> {
|
||||||
const result = await SessionAsyncUtil.getJson(
|
const result = await SessionAsyncUtil.getJson(
|
||||||
StorageKeys.pendingChatImageReturn,
|
StorageKeys.pendingChatImageReturn,
|
||||||
PendingChatImageReturnSchema,
|
PendingChatImageReturnSchema,
|
||||||
);
|
);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
if (Result.isErr(result)) return null;
|
return null;
|
||||||
return NavigationStorage.parsePendingChatImageReturn(result.data);
|
}
|
||||||
|
const value = NavigationStorage.parsePendingChatImageReturn(result.data);
|
||||||
|
if (!value) {
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.characterId !== characterId) return null;
|
||||||
|
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static parsePendingChatUnlock(
|
private static parsePendingChatUnlock(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("uses explicit chat image return urls first", async () => {
|
it("uses explicit chat image return urls first", async () => {
|
||||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
|
characterId: "character_elio",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
@@ -73,12 +74,14 @@ describe("subscription exit helpers", () => {
|
|||||||
it("returns directly to private room without consuming chat state", async () => {
|
it("returns directly to private room without consuming chat state", async () => {
|
||||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
reason: "image_paywall",
|
reason: "image_paywall",
|
||||||
|
characterId: "character_elio",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
});
|
});
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -101,6 +104,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("keeps private room ahead of stale chat state after payment", async () => {
|
it("keeps private room ahead of stale chat state after payment", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -119,6 +123,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -136,6 +141,7 @@ describe("subscription exit helpers", () => {
|
|||||||
it("peeks chat unlock return urls without clearing them", async () => {
|
it("peeks chat unlock return urls without clearing them", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@@ -154,6 +160,7 @@ describe("subscription exit helpers", () => {
|
|||||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
characterId: "character_elio",
|
||||||
displayMessageId: "msg_1",
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import {
|
|||||||
export type { PendingChatImageReturn };
|
export type { PendingChatImageReturn };
|
||||||
|
|
||||||
export async function savePendingChatImageReturn(input: {
|
export async function savePendingChatImageReturn(input: {
|
||||||
|
characterId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await NavigationStorage.savePendingChatImageReturn(input);
|
await NavigationStorage.savePendingChatImageReturn(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
|
export async function consumePendingChatImageReturn(
|
||||||
return NavigationStorage.consumePendingChatImageReturn();
|
characterId: string,
|
||||||
|
): Promise<PendingChatImageReturn | null> {
|
||||||
|
return NavigationStorage.consumePendingChatImageReturn(characterId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function savePendingChatUnlock(input: {
|
export async function savePendingChatUnlock(input: {
|
||||||
|
characterId: string;
|
||||||
displayMessageId?: string;
|
displayMessageId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
@@ -30,16 +31,20 @@ export async function savePendingChatUnlock(input: {
|
|||||||
await NavigationStorage.savePendingChatUnlock(input);
|
await NavigationStorage.savePendingChatUnlock(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
export async function consumePendingChatUnlock(
|
||||||
return NavigationStorage.consumePendingChatUnlock();
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
|
return NavigationStorage.consumePendingChatUnlock(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
|
export async function peekPendingChatUnlock(
|
||||||
return NavigationStorage.peekPendingChatUnlock();
|
characterId: string,
|
||||||
|
): Promise<PendingChatUnlock | null> {
|
||||||
|
return NavigationStorage.peekPendingChatUnlock(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hasPendingChatUnlock(): Promise<boolean> {
|
export async function hasPendingChatUnlock(characterId: string): Promise<boolean> {
|
||||||
return NavigationStorage.hasPendingChatUnlock();
|
return NavigationStorage.hasPendingChatUnlock(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingChatUnlock(): Promise<void> {
|
export async function clearPendingChatUnlock(): Promise<void> {
|
||||||
@@ -48,12 +53,15 @@ export async function clearPendingChatUnlock(): Promise<void> {
|
|||||||
|
|
||||||
export async function savePendingChatPromotion(
|
export async function savePendingChatPromotion(
|
||||||
promotionType: PendingChatPromotionType,
|
promotionType: PendingChatPromotionType,
|
||||||
|
characterId: string,
|
||||||
): Promise<PendingChatPromotion> {
|
): Promise<PendingChatPromotion> {
|
||||||
return NavigationStorage.savePendingChatPromotion(promotionType);
|
return NavigationStorage.savePendingChatPromotion(promotionType, characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
export async function consumePendingChatPromotion(
|
||||||
return NavigationStorage.consumePendingChatPromotion();
|
characterId: string,
|
||||||
|
): Promise<PendingChatPromotion | null> {
|
||||||
|
return NavigationStorage.consumePendingChatPromotion(characterId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingChatPromotion(): Promise<void> {
|
export async function clearPendingChatPromotion(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
import {
|
||||||
|
DEFAULT_CHARACTER_ID,
|
||||||
|
DEFAULT_CHARACTER_SLUG,
|
||||||
|
getCharacterBySlug,
|
||||||
|
} from "@/data/constants/character";
|
||||||
import { getCharacterRoutes } from "@/router/routes";
|
import { getCharacterRoutes } from "@/router/routes";
|
||||||
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||||
|
|
||||||
@@ -12,10 +16,13 @@ import {
|
|||||||
|
|
||||||
export type SubscriptionReturnTo = AppSubscriptionReturnTo;
|
export type SubscriptionReturnTo = AppSubscriptionReturnTo;
|
||||||
|
|
||||||
export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> {
|
export async function consumeSubscriptionExplicitExitUrl(
|
||||||
const pendingImageReturn = await consumePendingChatImageReturn();
|
characterSlug: string = DEFAULT_CHARACTER_SLUG,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const characterId = resolveCharacterId(characterSlug);
|
||||||
|
const pendingImageReturn = await consumePendingChatImageReturn(characterId);
|
||||||
if (pendingImageReturn) return pendingImageReturn.returnUrl;
|
if (pendingImageReturn) return pendingImageReturn.returnUrl;
|
||||||
const pendingChatUnlock = await peekPendingChatUnlock();
|
const pendingChatUnlock = await peekPendingChatUnlock(characterId);
|
||||||
if (pendingChatUnlock) {
|
if (pendingChatUnlock) {
|
||||||
await clearPendingChatUnlock();
|
await clearPendingChatUnlock();
|
||||||
return pendingChatUnlock.returnUrl;
|
return pendingChatUnlock.returnUrl;
|
||||||
@@ -23,8 +30,12 @@ export async function consumeSubscriptionExplicitExitUrl(): Promise<string | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function peekSubscriptionExplicitExitUrl(): Promise<string | null> {
|
export async function peekSubscriptionExplicitExitUrl(
|
||||||
const pendingChatUnlock = await peekPendingChatUnlock();
|
characterSlug: string = DEFAULT_CHARACTER_SLUG,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const pendingChatUnlock = await peekPendingChatUnlock(
|
||||||
|
resolveCharacterId(characterSlug),
|
||||||
|
);
|
||||||
if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
|
if (pendingChatUnlock) return pendingChatUnlock.returnUrl;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -48,7 +59,7 @@ export async function consumeSubscriptionExitUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await consumeSubscriptionExplicitExitUrl()) ??
|
(await consumeSubscriptionExplicitExitUrl(characterSlug)) ??
|
||||||
getSubscriptionFallbackExitUrl(returnTo, characterSlug)
|
getSubscriptionFallbackExitUrl(returnTo, characterSlug)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,7 +72,11 @@ export async function resolveSubscriptionSuccessExitUrl(
|
|||||||
return getCharacterRoutes(characterSlug).privateRoom;
|
return getCharacterRoutes(characterSlug).privateRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
|
const pendingExitUrl = await peekSubscriptionExplicitExitUrl(characterSlug);
|
||||||
if (pendingExitUrl) return pendingExitUrl;
|
if (pendingExitUrl) return pendingExitUrl;
|
||||||
return consumeSubscriptionExitUrl(returnTo, characterSlug);
|
return consumeSubscriptionExitUrl(returnTo, characterSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCharacterId(characterSlug: string): string {
|
||||||
|
return getCharacterBySlug(characterSlug)?.id ?? DEFAULT_CHARACTER_ID;
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: character.id,
|
||||||
displayMessageId,
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
@@ -165,7 +166,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[isAuthenticatedUser, router],
|
[character.id, isAuthenticatedUser, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openSubscriptionForPendingUnlock = useCallback(
|
const openSubscriptionForPendingUnlock = useCallback(
|
||||||
@@ -183,6 +184,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
}: OpenSubscriptionForPendingUnlockInput): void => {
|
}: OpenSubscriptionForPendingUnlockInput): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
characterId: character.id,
|
||||||
displayMessageId,
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
@@ -200,7 +202,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[getDefaultPayChannel, openSubscription],
|
[character.id, getDefaultPayChannel, openSubscription],
|
||||||
);
|
);
|
||||||
|
|
||||||
const exitSubscription = useCallback(
|
const exitSubscription = useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createActor } from "xstate";
|
||||||
|
|
||||||
|
const repository = vi.hoisted(() => ({
|
||||||
|
prefetchMediaForSendResponse: vi.fn(async () => ({
|
||||||
|
success: true as const,
|
||||||
|
data: undefined,
|
||||||
|
})),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
unlockHistory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const historySync = vi.hoisted(() => ({
|
||||||
|
readAndSyncHistory: vi.fn(),
|
||||||
|
readLocalHistorySnapshot: vi.fn(async () => ({
|
||||||
|
messages: [],
|
||||||
|
localCount: 0,
|
||||||
|
})),
|
||||||
|
resolveHistoryCacheIdentity: vi.fn(async () => null),
|
||||||
|
syncNetworkHistory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||||
|
loadChatRepository: vi.fn(async () => repository),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/repositories/chat_cache_identity", () => ({
|
||||||
|
resolveChatConversationKey: vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
data: "user:test::character:character_elio",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/chat/chat-history-sync", () => historySync);
|
||||||
|
|
||||||
|
import { loadHistoryActor } from "@/stores/chat/machine/actors/history";
|
||||||
|
import { sendMessageHttpActor } from "@/stores/chat/machine/actors/send";
|
||||||
|
import { unlockHistoryActor } from "@/stores/chat/machine/actors/unlock";
|
||||||
|
|
||||||
|
describe("chat actor request cancellation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
repository.sendMessage.mockReset();
|
||||||
|
repository.unlockHistory.mockReset();
|
||||||
|
historySync.syncNetworkHistory.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
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" },
|
||||||
|
}).start();
|
||||||
|
await vi.waitFor(() => expect(repository.sendMessage).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const signal = getSignal(repository.sendMessage.mock.calls[0]?.[2]);
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
actor.stop();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts an in-flight history request when its callback actor stops", async () => {
|
||||||
|
historySync.syncNetworkHistory.mockImplementation(
|
||||||
|
() => new Promise(() => undefined),
|
||||||
|
);
|
||||||
|
const actor = createActor(loadHistoryActor, {
|
||||||
|
input: { characterId: "character_elio" },
|
||||||
|
}).start();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(historySync.syncNetworkHistory).toHaveBeenCalled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const signal = getSignal(
|
||||||
|
historySync.syncNetworkHistory.mock.calls[0]?.[3],
|
||||||
|
);
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
actor.stop();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts an in-flight unlock request when its actor stops", async () => {
|
||||||
|
repository.unlockHistory.mockImplementation(
|
||||||
|
() => new Promise(() => undefined),
|
||||||
|
);
|
||||||
|
const actor = createActor(unlockHistoryActor, {
|
||||||
|
input: { characterId: "character_elio" },
|
||||||
|
}).start();
|
||||||
|
await vi.waitFor(() => expect(repository.unlockHistory).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const signal = getSignal(repository.unlockHistory.mock.calls[0]?.[1]);
|
||||||
|
expect(signal.aborted).toBe(false);
|
||||||
|
actor.stop();
|
||||||
|
expect(signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSignal(options: unknown): AbortSignal {
|
||||||
|
if (options instanceof AbortSignal) return options;
|
||||||
|
const signal = (options as { signal?: AbortSignal } | undefined)?.signal;
|
||||||
|
if (!signal) throw new Error("Missing AbortSignal");
|
||||||
|
return signal;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/stores/chat/helper/promotion";
|
} from "@/stores/chat/helper/promotion";
|
||||||
|
|
||||||
const promotion: PendingChatPromotion = {
|
const promotion: PendingChatPromotion = {
|
||||||
|
characterId: "character_elio",
|
||||||
promotionType: "image",
|
promotionType: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe("chat session flow", () => {
|
|||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatPromotionInjected",
|
type: "ChatPromotionInjected",
|
||||||
promotion: {
|
promotion: {
|
||||||
|
characterId: "character_elio",
|
||||||
promotionType: "voice",
|
promotionType: "voice",
|
||||||
lockType: "voice_message",
|
lockType: "voice_message",
|
||||||
clientLockId: "promotion-1",
|
clientLockId: "promotion-1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
|||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
import { todayString } from "@/utils/date";
|
import { todayString } from "@/utils/date";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import { CHAT_HISTORY_LIMIT } from "./helper/history";
|
import { CHAT_HISTORY_LIMIT } from "./helper/history";
|
||||||
import { localMessagesToUi } from "./helper/message-mappers";
|
import { localMessagesToUi } from "./helper/message-mappers";
|
||||||
@@ -81,6 +82,7 @@ export async function syncNetworkHistory(
|
|||||||
characterId: string,
|
characterId: string,
|
||||||
localCount: number,
|
localCount: number,
|
||||||
cacheIdentity: string | null,
|
cacheIdentity: string | null,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<NetworkHistorySyncOutput | null> {
|
): Promise<NetworkHistorySyncOutput | null> {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const greetingMessage = createGreetingMessage();
|
const greetingMessage = createGreetingMessage();
|
||||||
@@ -89,13 +91,16 @@ export async function syncNetworkHistory(
|
|||||||
characterId,
|
characterId,
|
||||||
CHAT_HISTORY_LIMIT,
|
CHAT_HISTORY_LIMIT,
|
||||||
0,
|
0,
|
||||||
|
{ signal },
|
||||||
);
|
);
|
||||||
if (Result.isErr(networkResult)) {
|
if (Result.isErr(networkResult)) {
|
||||||
|
if (isAbortError(networkResult.error)) throw networkResult.error;
|
||||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||||
error: networkResult.error,
|
error: networkResult.error,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
|
||||||
const networkUi = localMessagesToUi(networkResult.data.messages);
|
const networkUi = localMessagesToUi(networkResult.data.messages);
|
||||||
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
log.debug("[chat-machine] loadHistory NETWORK DONE", {
|
||||||
@@ -144,6 +149,7 @@ export async function syncNetworkHistory(
|
|||||||
*/
|
*/
|
||||||
export async function readAndSyncHistory(
|
export async function readAndSyncHistory(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<ReadAndSyncHistoryOutput> {
|
): Promise<ReadAndSyncHistoryOutput> {
|
||||||
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
|
const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
|
||||||
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
const localSnapshot = await readLocalHistorySnapshot(cacheIdentity);
|
||||||
@@ -151,6 +157,7 @@ export async function readAndSyncHistory(
|
|||||||
characterId,
|
characterId,
|
||||||
localSnapshot.localCount,
|
localSnapshot.localCount,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
if (networkSnapshot) return networkSnapshot;
|
if (networkSnapshot) return networkSnapshot;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { UiMessage } from "@/stores/chat/ui-message";
|
|||||||
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
readLocalHistorySnapshot,
|
readLocalHistorySnapshot,
|
||||||
@@ -38,6 +39,7 @@ export const loadHistoryActor = fromCallback<
|
|||||||
ChatHistoryActorInput
|
ChatHistoryActorInput
|
||||||
>(({ input, sendBack }) => {
|
>(({ input, sendBack }) => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
|
||||||
@@ -52,6 +54,7 @@ export const loadHistoryActor = fromCallback<
|
|||||||
input.characterId,
|
input.characterId,
|
||||||
localSnapshot.localCount,
|
localSnapshot.localCount,
|
||||||
cacheIdentity,
|
cacheIdentity,
|
||||||
|
controller.signal,
|
||||||
);
|
);
|
||||||
if (cancelled || !networkSnapshot) return;
|
if (cancelled || !networkSnapshot) return;
|
||||||
sendBack({
|
sendBack({
|
||||||
@@ -59,13 +62,14 @@ export const loadHistoryActor = fromCallback<
|
|||||||
output: networkSnapshot,
|
output: networkSnapshot,
|
||||||
});
|
});
|
||||||
})().catch((error: unknown) => {
|
})().catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled || isAbortError(error)) return;
|
||||||
log.error("[chat-machine] loadHistoryActor failed", { error });
|
log.error("[chat-machine] loadHistoryActor failed", { error });
|
||||||
sendBack({ type: "ChatHistoryLoadFailed", error });
|
sendBack({ type: "ChatHistoryLoadFailed", error });
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,10 +78,13 @@ export const loadMoreHistoryActor =
|
|||||||
({ input, receive, sendBack }) => {
|
({ input, receive, sendBack }) => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let running = false;
|
let running = false;
|
||||||
|
let activeController: AbortController | null = null;
|
||||||
|
|
||||||
receive((event) => {
|
receive((event) => {
|
||||||
if (running || cancelled) return;
|
if (running || cancelled) return;
|
||||||
running = true;
|
running = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
@@ -85,6 +92,7 @@ export const loadMoreHistoryActor =
|
|||||||
input.characterId,
|
input.characterId,
|
||||||
event.limit,
|
event.limit,
|
||||||
event.offset,
|
event.offset,
|
||||||
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
if (Result.isErr(historyResult)) throw historyResult.error;
|
if (Result.isErr(historyResult)) throw historyResult.error;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -111,17 +119,19 @@ export const loadMoreHistoryActor =
|
|||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (cancelled || isAbortError(error)) return;
|
||||||
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
log.warn("[chat-machine] loadMoreHistoryActor failed", { error });
|
||||||
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
sendBack({ type: "ChatOlderHistoryLoadFailed", error });
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (activeController === controller) activeController = null;
|
||||||
running = false;
|
running = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
activeController?.abort();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
|||||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import type { ChatEvent } from "../../chat-events";
|
import type { ChatEvent } from "../../chat-events";
|
||||||
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
import { sendResponseToUiMessage } from "../../helper/message-mappers";
|
||||||
@@ -18,8 +19,8 @@ type UiMessage = import("@/stores/chat/ui-message").UiMessage;
|
|||||||
export const sendMessageHttpActor = fromPromise<
|
export const sendMessageHttpActor = fromPromise<
|
||||||
{ response: ChatSendResponse; reply: UiMessage | null },
|
{ response: ChatSendResponse; reply: UiMessage | null },
|
||||||
{ characterId: string; content: string }
|
{ characterId: string; content: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input, signal }) => {
|
||||||
return sendMessageViaHttp(input.characterId, input.content);
|
return sendMessageViaHttp(input.characterId, input.content, signal);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const httpMessageQueueActor = fromCallback<
|
export const httpMessageQueueActor = fromCallback<
|
||||||
@@ -37,13 +38,21 @@ function createMessageQueueActor(
|
|||||||
receive: (listener: (event: ChatEvent) => void) => void,
|
receive: (listener: (event: ChatEvent) => void) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const queue = new MessageQueue();
|
const queue = new MessageQueue();
|
||||||
|
let activeController: AbortController | null = null;
|
||||||
|
|
||||||
queue.setConsumer(async (content) => {
|
queue.setConsumer(async (content) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeController = controller;
|
||||||
sendBack({ type: "ChatQueuedSendStarted" });
|
sendBack({ type: "ChatQueuedSendStarted" });
|
||||||
try {
|
try {
|
||||||
const output = await sendMessageViaHttp(characterId, content);
|
const output = await sendMessageViaHttp(
|
||||||
|
characterId,
|
||||||
|
content,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
sendBack({ type: "ChatQueuedHttpDone", output });
|
sendBack({ type: "ChatQueuedHttpDone", output });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAbortError(error)) return;
|
||||||
const errorMessage = ExceptionHandler.message(error);
|
const errorMessage = ExceptionHandler.message(error);
|
||||||
log.error("[chat-machine] message queue send failed", {
|
log.error("[chat-machine] message queue send failed", {
|
||||||
error,
|
error,
|
||||||
@@ -53,6 +62,8 @@ function createMessageQueueActor(
|
|||||||
content,
|
content,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) activeController = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,22 +74,31 @@ function createMessageQueueActor(
|
|||||||
queue.enqueue(content);
|
queue.enqueue(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => queue.dispose();
|
return () => {
|
||||||
|
activeController?.abort();
|
||||||
|
queue.dispose();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessageViaHttp(characterId: string, content: string): Promise<{
|
async function sendMessageViaHttp(
|
||||||
|
characterId: string,
|
||||||
|
content: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{
|
||||||
response: ChatSendResponse;
|
response: ChatSendResponse;
|
||||||
reply: UiMessage | null;
|
reply: UiMessage | null;
|
||||||
}> {
|
}> {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
const cacheIdentityResult = await resolveChatConversationKey(characterId);
|
||||||
const result = await chatRepo.sendMessage(characterId, content);
|
const result = await chatRepo.sendMessage(characterId, content, { signal });
|
||||||
if (Result.isErr(result)) {
|
if (Result.isErr(result)) {
|
||||||
|
if (isAbortError(result.error)) throw result.error;
|
||||||
log.error("[chat-machine] sendMessageHttpActor failed", {
|
log.error("[chat-machine] sendMessageHttpActor failed", {
|
||||||
error: result.error,
|
error: result.error,
|
||||||
});
|
});
|
||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (Result.isOk(cacheIdentityResult)) {
|
if (Result.isOk(cacheIdentityResult)) {
|
||||||
void chatRepo.prefetchMediaForSendResponse(
|
void chatRepo.prefetchMediaForSendResponse(
|
||||||
result.data,
|
result.data,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadChatRepository } from "@/data/repositories/chat_repository_loader";
|
|||||||
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
import { resolveChatConversationKey } from "@/data/repositories/chat_cache_identity";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { Result } from "@/utils/result";
|
import { Result } from "@/utils/result";
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
|
||||||
import { readAndSyncHistory } from "../../chat-history-sync";
|
import { readAndSyncHistory } from "../../chat-history-sync";
|
||||||
import {
|
import {
|
||||||
@@ -25,17 +26,21 @@ export interface UnlockHistoryOutput {
|
|||||||
export const unlockHistoryActor = fromPromise<
|
export const unlockHistoryActor = fromPromise<
|
||||||
UnlockHistoryOutput,
|
UnlockHistoryOutput,
|
||||||
{ characterId: string }
|
{ characterId: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input, signal }) => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const unlockResult = await chatRepo.unlockHistory(input.characterId);
|
const unlockResult = await chatRepo.unlockHistory(input.characterId, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
if (Result.isErr(unlockResult)) {
|
if (Result.isErr(unlockResult)) {
|
||||||
|
if (isAbortError(unlockResult.error)) throw unlockResult.error;
|
||||||
log.error("[chat-machine] unlockHistoryActor failed", {
|
log.error("[chat-machine] unlockHistoryActor failed", {
|
||||||
error: unlockResult.error,
|
error: unlockResult.error,
|
||||||
});
|
});
|
||||||
throw unlockResult.error;
|
throw unlockResult.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = await readAndSyncHistory(input.characterId);
|
signal.throwIfAborted();
|
||||||
|
const history = await readAndSyncHistory(input.characterId, signal);
|
||||||
return {
|
return {
|
||||||
unlocked: unlockResult.data.unlocked,
|
unlocked: unlockResult.data.unlocked,
|
||||||
reason: unlockResult.data.reason,
|
reason: unlockResult.data.reason,
|
||||||
@@ -47,18 +52,22 @@ export const unlockHistoryActor = fromPromise<
|
|||||||
export const unlockMessageActor = fromPromise<
|
export const unlockMessageActor = fromPromise<
|
||||||
UnlockMessageOutput,
|
UnlockMessageOutput,
|
||||||
UnlockMessageRequest & { characterId: string }
|
UnlockMessageRequest & { characterId: string }
|
||||||
>(async ({ input }) => {
|
>(async ({ input, signal }) => {
|
||||||
const chatRepo = await loadChatRepository();
|
const chatRepo = await loadChatRepository();
|
||||||
const cacheIdentityResult = await resolveChatConversationKey(
|
const cacheIdentityResult = await resolveChatConversationKey(
|
||||||
input.characterId,
|
input.characterId,
|
||||||
);
|
);
|
||||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
const unlockResult = await chatRepo.unlockPrivateMessage(
|
||||||
|
{
|
||||||
characterId: input.characterId,
|
characterId: input.characterId,
|
||||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||||
});
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
if (Result.isErr(unlockResult)) {
|
if (Result.isErr(unlockResult)) {
|
||||||
|
if (isAbortError(unlockResult.error)) throw unlockResult.error;
|
||||||
log.error("[chat-machine] unlockMessageActor failed", {
|
log.error("[chat-machine] unlockMessageActor failed", {
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
clientLockId: input.clientLockId,
|
clientLockId: input.clientLockId,
|
||||||
@@ -66,6 +75,7 @@ export const unlockMessageActor = fromPromise<
|
|||||||
});
|
});
|
||||||
throw unlockResult.error;
|
throw unlockResult.error;
|
||||||
}
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
unlockResult.data.unlocked &&
|
unlockResult.data.unlocked &&
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useEffect, useRef } from "react";
|
|||||||
import { shallowEqual } from "@xstate/react";
|
import { shallowEqual } from "@xstate/react";
|
||||||
|
|
||||||
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
|
import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
import {
|
||||||
|
useChatDispatch,
|
||||||
|
useChatSelector,
|
||||||
|
} from "@/stores/chat/chat-context";
|
||||||
import { usePaymentSelector } from "@/stores/payment/payment-context";
|
import { usePaymentSelector } from "@/stores/payment/payment-context";
|
||||||
|
|
||||||
export function ChatPaymentSuccessSync() {
|
export function ChatPaymentSuccessSync() {
|
||||||
@@ -17,6 +20,7 @@ export function ChatPaymentSuccessSync() {
|
|||||||
}),
|
}),
|
||||||
shallowEqual,
|
shallowEqual,
|
||||||
);
|
);
|
||||||
|
const characterId = useChatSelector((state) => state.context.characterId);
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
const lastPaidKeyRef = useRef<string | null>(null);
|
const lastPaidKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ export function ChatPaymentSuccessSync() {
|
|||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (await hasPendingChatUnlock()) return;
|
if (await hasPendingChatUnlock(characterId)) return;
|
||||||
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
|
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -38,6 +42,7 @@ export function ChatPaymentSuccessSync() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
chatDispatch,
|
chatDispatch,
|
||||||
|
characterId,
|
||||||
paymentState.currentOrderId,
|
paymentState.currentOrderId,
|
||||||
paymentState.isPaid,
|
paymentState.isPaid,
|
||||||
paymentState.orderStatus,
|
paymentState.orderStatus,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isAbortError } from "@/utils/abort";
|
||||||
|
import { Result } from "@/utils/result";
|
||||||
|
|
||||||
|
describe("isAbortError", () => {
|
||||||
|
it("recognizes cancellation after Result normalizes the error", async () => {
|
||||||
|
const result = await Result.wrap(async () => {
|
||||||
|
throw new DOMException("This operation was aborted", "AbortError");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Result.isErr(result) && isAbortError(result.error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat request timeouts as actor cancellation", () => {
|
||||||
|
expect(isAbortError(new DOMException("Timed out", "TimeoutError"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function isAbortError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false;
|
||||||
|
if (error.name === "AbortError") return true;
|
||||||
|
const cause = error.cause;
|
||||||
|
return cause !== error && isAbortError(cause);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user