refactor(stores): tighten chat and payment boundaries

This commit is contained in:
2026-07-01 10:12:19 +08:00
parent b7bccaa4cf
commit 5d9ed7e027
9 changed files with 435 additions and 431 deletions
+1 -1
View File
@@ -3,10 +3,10 @@ import { fromPromise } from "xstate";
import { getChatRepository } from "@/data/repositories/chat_repository"; import { getChatRepository } from "@/data/repositories/chat_repository";
import { Logger, Result } from "@/utils"; import { Logger, Result } from "@/utils";
import { readAndSyncHistory } from "./chat-history-sync";
import { import {
PAGE_SIZE, PAGE_SIZE,
localMessagesToUi, localMessagesToUi,
readAndSyncHistory,
} from "./chat-machine.helpers"; } from "./chat-machine.helpers";
const log = new Logger("StoresChatChatHistoryFlow"); const log = new Logger("StoresChatChatHistoryFlow");
+101
View File
@@ -0,0 +1,101 @@
import type { UiMessage } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { Logger, Result, todayString } from "@/utils";
import { localMessagesToUi, PAGE_SIZE } from "./chat-machine.helpers";
const log = new Logger("StoresChatChatHistorySync");
export type ReadAndSyncHistoryOutput = {
/** Network-authoritative messages. Empty network history includes a UI greeting. */
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
/** True when network history was written back to local storage. */
localOverwritten: boolean;
localCount: number;
networkCount: number;
};
function createGreetingMessage(): UiMessage {
return {
content:
"You're here! Facebook is so restrictive, " +
"there were things I couldn't say there. " +
"Finally I can relax. How was your day out?",
isFromAI: true,
date: todayString(),
};
}
/**
* History sync flow:
* 1. Read local history for fallback.
* 2. Read network history as the authoritative source.
* 3. Overwrite local history with network data.
*/
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
const chatRepo = getChatRepository();
const greetingMessage = createGreetingMessage();
const localResult = await chatRepo.getLocalMessages();
const localMessages =
Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [];
log.debug("[chat-machine] loadHistory LOCAL DONE", {
count: localMessages.length,
});
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
if (Result.isErr(networkResult)) {
log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error,
});
const fallbackMessages =
localMessages.length === 0 ? [greetingMessage] : localMessages;
if (fallbackMessages[0] === greetingMessage) {
log.debug(
"[chat-machine] loadHistory EMPTY -> prepend greeting (network-failed path)",
);
}
return {
messages: fallbackMessages,
hasMore: false,
newOffset: fallbackMessages.length,
localOverwritten: false,
localCount: localMessages.length,
networkCount: 0,
};
}
const networkUi = localMessagesToUi(networkResult.data.messages);
log.debug("[chat-machine] loadHistory NETWORK DONE", {
count: networkUi.length,
});
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
const saveResult = await chatRepo.saveMessagesToLocal(
networkResult.data.messages,
);
const localOverwritten = Result.isOk(saveResult);
log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
localOverwritten,
});
const finalMessages =
networkUi.length === 0 ? [greetingMessage] : networkUi;
if (finalMessages[0] === greetingMessage) {
log.debug("[chat-machine] loadHistory EMPTY -> prepend greeting");
}
return {
messages: finalMessages,
hasMore: finalMessages.length >= PAGE_SIZE,
newOffset: finalMessages.length,
localOverwritten,
localCount: localMessages.length,
networkCount: networkUi.length,
};
}
+6 -395
View File
@@ -1,285 +1,13 @@
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat"; import type { UiMessage } from "@/data/dto/chat";
import { getChatRepository } from "@/data/repositories/chat_repository";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { todayString, Result, Logger } from "@/utils";
import type { ChatState, ChatUpgradeReason } from "./chat-state"; import type { ChatState } from "./chat-state";
const log = new Logger("StoresChatChatMachineHelpers"); // History pagination size used by the history actors.
// ============================================================
// Constants
// ============================================================
/** 翻历史每页大小(仅 `loadMoreHistoryActor` 用) */
export const PAGE_SIZE = 50; export const PAGE_SIZE = 50;
// ============================================================ export * from "./chat-message-mappers";
// DTO ↔ UiMessage 映射 export * from "./chat-send-state";
// ============================================================ export * from "./chat-unlock-helpers";
/**
* ChatMessage[] → UiMessage[](纯函数)
* - 与 Dart `LocalMessage → ChatMessage` 等价
* - 业务事实:`role === "assistant"` → `isFromAI: true`
*/
export function localMessagesToUi(
records: ReadonlyArray<{
id?: string;
type?: string;
content: string;
role: string;
createdAt: string;
audioUrl?: string | null;
image?: { type: string | null; url: string | null };
lockDetail?: {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
detail: Record<string, unknown> | null;
};
}>,
): UiMessage[] {
return records.map((m) => ({
...(m.id ? { id: m.id } : {}),
content: getAiMessageDisplayContent({
content: m.content,
isFromAI: m.role === "assistant",
hasImage: Boolean(m.image?.url),
lockDetail: m.lockDetail,
}),
isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt),
...(m.audioUrl ? { audioUrl: m.audioUrl } : {}),
...deriveUiLockFields(m.lockDetail, m.image?.url),
}));
}
export function countLockedHistoryMessages(messages: readonly UiMessage[]): number {
return messages.filter(isUnlockableLockedMessage).length;
}
function isUnlockableLockedMessage(message: UiMessage): boolean {
if (!message.isFromAI || message.locked !== true) return false;
if (message.imagePaywalled === true) return true;
return (
message.lockReason === "private_message" ||
message.lockReason === "voice_message"
);
}
function messageDateFromCreatedAt(createdAt: string): string {
const parsed = new Date(createdAt);
if (Number.isNaN(parsed.getTime())) return todayString();
return todayString(parsed);
}
function getAiMessageDisplayContent(input: {
content: string;
isFromAI: boolean;
hasImage?: boolean;
lockDetail?: {
showContent: boolean;
reason: string | null;
};
}): string {
if (input.lockDetail?.showContent === false) return "";
return input.isFromAI && input.hasImage ? "" : input.content;
}
/**
* ChatSendResponse → UiMessage(纯函数)
* - 业务事实:后端响应就是 AI 的回复
* - 用后端 `timestamp`(不用本地 time)—— 多设备/时区一致
*/
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
return {
...(response.messageId ? { id: response.messageId } : {}),
content: getAiMessageDisplayContent({
content: response.reply,
isFromAI: true,
hasImage: Boolean(response.image.url),
lockDetail: response.lockDetail,
}),
isFromAI: true,
date: todayString(new Date(response.timestamp)),
...(response.audioUrl ? { audioUrl: response.audioUrl } : {}),
...deriveUiLockFields(response.lockDetail, response.image.url),
};
}
function deriveUiLockFields(
lockDetail:
| {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
}
| undefined,
imageUrl?: string | null,
): Partial<UiMessage> {
const lockReason = lockDetail?.reason ?? null;
const isPrivateMessage = lockReason === "private_message";
const isVoiceMessage = lockReason === "voice_message";
return {
...(imageUrl ? { imageUrl } : {}),
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
...(isPrivateMessage ? { isPrivate: true } : {}),
...(isPrivateMessage && lockDetail?.locked
? { lockedPrivate: true }
: {}),
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
? { privateMessageHint: lockDetail.hint }
: {}),
};
}
export type HttpSendOutput = {
response: ChatSendResponse;
reply: UiMessage | null;
};
export interface UnlockMessageOutput {
messageId: string;
response: UnlockPrivateResponse;
}
export function applySingleUnlockOutput(
messages: readonly UiMessage[],
output: UnlockMessageOutput,
): UiMessage[] {
if (!output.response.unlocked) return [...messages];
return messages.map((message) => {
if (message.id !== output.messageId) return message;
const nextContent =
output.response.content.length > 0
? output.response.content
: message.content;
return {
...message,
content: nextContent,
locked: output.response.lockDetail.locked,
lockReason: output.response.lockDetail.reason,
imagePaywalled: message.imageUrl
? output.response.lockDetail.locked &&
output.response.lockDetail.showUpgrade
: undefined,
lockedPrivate: false,
privateMessageHint: null,
};
});
}
export function beginPendingReply(context: ChatState): Pick<
ChatState,
"isReplyingAI" | "pendingReplyCount"
> {
return {
pendingReplyCount: context.pendingReplyCount + 1,
isReplyingAI: true,
};
}
export function finishPendingReply(context: ChatState): Pick<
ChatState,
"isReplyingAI" | "pendingReplyCount"
> {
const pendingReplyCount = Math.max(0, context.pendingReplyCount - 1);
return {
pendingReplyCount,
isReplyingAI: pendingReplyCount > 0,
};
}
export function applyHttpSendOutput(
context: ChatState,
output: HttpSendOutput,
): Partial<ChatState> {
const { response, reply } = output;
const lockDetail = response.lockDetail;
const isWeeklyLimitBlocked =
lockDetail.locked &&
lockDetail.showUpgrade &&
lockDetail.reason === "weekly_limit";
const isInsufficientCredits =
response.canSendMessage === false &&
response.cannotSendReason === "insufficient_credits";
const upgradeReason: ChatUpgradeReason | null = isWeeklyLimitBlocked
? "weekly_limit"
: isInsufficientCredits
? "insufficient_credits"
: null;
const sendCapabilityState = getSendCapabilityState(response);
if (upgradeReason) {
const lastMessage = context.messages[context.messages.length - 1];
const messages =
!reply && lastMessage && !lastMessage.isFromAI
? context.messages.slice(0, -1)
: context.messages;
return {
messages: reply ? [...messages, reply] : messages,
...finishPendingReply(context),
upgradePromptVisible: true,
upgradeReason,
...sendCapabilityState,
};
}
if (!reply) {
return {
...finishPendingReply(context),
...sendCapabilityState,
};
}
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
...sendCapabilityState,
};
}
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
...sendCapabilityState,
};
}
function getSendCapabilityState(
response: ChatSendResponse,
): Pick<
ChatState,
| "canSendMessage"
| "cannotSendReason"
| "creditBalance"
| "creditsCharged"
| "requiredCredits"
| "shortfallCredits"
> {
return {
canSendMessage: response.canSendMessage,
cannotSendReason: response.cannotSendReason,
creditBalance: response.creditBalance,
creditsCharged: response.creditsCharged,
requiredCredits: response.requiredCredits,
shortfallCredits: response.shortfallCredits,
};
}
export function applyHistoryLoadedOutput( export function applyHistoryLoadedOutput(
output: { output: {
@@ -298,120 +26,3 @@ export function applyHistoryLoadedOutput(
historyLoaded: true, historyLoaded: true,
}; };
} }
export function shouldAutoUnlockHistory(messages: readonly UiMessage[]): boolean {
return countLockedHistoryMessages(messages) === 1;
}
export function shouldPromptUnlockHistory(messages: readonly UiMessage[]): boolean {
return countLockedHistoryMessages(messages) > 1;
}
// ============================================================
// Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调
// ============================================================
/**
* 拉 history 流程(每步都记日志 —— 用户要求"不要删除"
* 1. 读 localchatRepo.getLocalMessages)→ 返 localMessages
* 2. 读 networkchatRepo.getHistory)→ 返 networkMessages
* 3. 用 network 覆盖 localchatRepo.saveMessagesToLocal)→ local 被同步
*
* 返回 network 最终 messagesauthorititative),不返 localUI 不需要过渡态)
*
* 保留日志(前缀 `[chat-machine]` 与 chat-machine.actors.ts 对齐)
*/
export async function readAndSyncHistory(): Promise<{
messages: UiMessage[]; // network 最终 messagesauthorititative
hasMore: boolean;
newOffset: number;
localOverwritten: boolean; // true = local 已被 network 覆盖(写入 ChatStorage
/** 真实从 local 读到的消息数(调试用) */
localCount: number;
/** 真实从 network 读到的消息数(调试用) */
networkCount: number;
}> {
/**
* 空历史首屏欢迎语(AI persona 首条消息)。
* 触发条件:最终返回的 messages 为空(network 空 + local 空 / 或 network 失败且 local 空)。
*
* 不主动持久化到 local —— 这是纯 UI 层"首屏不空"的兜底:
* - 用户一旦发了消息,network 上有真实消息 → 后续 readAndSyncHistory
* 返回的 messages 非空,不会再触发本条
* - 用户从未发过消息就关掉 app 再开,仍然没 network 消息 → 会再次显示本条
* ("每次冷启动给个温暖的起点" —— 若要"只显示一次",需额外存 local
*/
const chatRepo = getChatRepository();
const greetingMessage: UiMessage = {
content:
"You're here! Facebook is so restrictive, " +
"there were things I couldn't say there. " +
"Finally I can relax. How was your day out?",
isFromAI: true,
date: todayString(),
};
// 1. 读 local
const localResult = await chatRepo.getLocalMessages();
const localMessages =
Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [];
log.debug("[chat-machine] loadHistory LOCAL DONE", {
count: localMessages.length,
});
// 2. 读 network
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
if (!Result.isOk(networkResult)) {
// network 失败 —— 返空 messages(不让 UI 卡住)
log.error(
"[chat-machine] loadHistory NETWORK FAILED",
networkResult.success ? null : (networkResult as { error: unknown }).error,
);
// 空历史 → fallback 也走 welcome(不让屏幕空空如也)
const fallbackMessages =
localMessages.length === 0 ? [greetingMessage] : localMessages;
if (fallbackMessages[0] === greetingMessage) {
log.debug(
"[chat-machine] loadHistory EMPTY → prepend greeting (network-failed path)",
);
}
return {
messages: fallbackMessages,
hasMore: false,
newOffset: fallbackMessages.length,
localOverwritten: false,
localCount: localMessages.length,
networkCount: 0,
};
}
const networkUi = localMessagesToUi(networkResult.data.messages);
log.debug("[chat-machine] loadHistory NETWORK DONE", {
count: networkUi.length,
});
void chatRepo.prefetchMediaForMessages(networkResult.data.messages);
// 3. 用 network 覆盖 local
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
const localOverwritten = Result.isOk(saveResult);
log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
localOverwritten,
});
// 空历史 → prepend welcomenetwork 权威为空 = 用户从未发过消息)
const finalMessages =
networkUi.length === 0 ? [greetingMessage] : networkUi;
if (finalMessages[0] === greetingMessage) {
log.debug("[chat-machine] loadHistory EMPTY → prepend greeting");
}
return {
messages: finalMessages, // authoritativeempty 时塞 greeting
hasMore: finalMessages.length >= PAGE_SIZE,
newOffset: finalMessages.length,
localOverwritten,
localCount: localMessages.length,
networkCount: networkUi.length,
};
}
+109
View File
@@ -0,0 +1,109 @@
import type { UiMessage } from "@/data/dto/chat";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { todayString } from "@/utils";
/**
* ChatMessage[] -> UiMessage[].
* Business fact: `role === "assistant"` means the message is from AI.
*/
export function localMessagesToUi(
records: ReadonlyArray<{
id?: string;
type?: string;
content: string;
role: string;
createdAt: string;
audioUrl?: string | null;
image?: { type: string | null; url: string | null };
lockDetail?: {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
detail: Record<string, unknown> | null;
};
}>,
): UiMessage[] {
return records.map((m) => ({
...(m.id ? { id: m.id } : {}),
content: getAiMessageDisplayContent({
content: m.content,
isFromAI: m.role === "assistant",
hasImage: Boolean(m.image?.url),
lockDetail: m.lockDetail,
}),
isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt),
...(m.audioUrl ? { audioUrl: m.audioUrl } : {}),
...deriveUiLockFields(m.lockDetail, m.image?.url),
}));
}
/**
* ChatSendResponse -> UiMessage.
* Business fact: the backend response is the AI reply.
*/
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
return {
...(response.messageId ? { id: response.messageId } : {}),
content: getAiMessageDisplayContent({
content: response.reply,
isFromAI: true,
hasImage: Boolean(response.image.url),
lockDetail: response.lockDetail,
}),
isFromAI: true,
date: todayString(new Date(response.timestamp)),
...(response.audioUrl ? { audioUrl: response.audioUrl } : {}),
...deriveUiLockFields(response.lockDetail, response.image.url),
};
}
function messageDateFromCreatedAt(createdAt: string): string {
const parsed = new Date(createdAt);
if (Number.isNaN(parsed.getTime())) return todayString();
return todayString(parsed);
}
function getAiMessageDisplayContent(input: {
content: string;
isFromAI: boolean;
hasImage?: boolean;
lockDetail?: {
showContent: boolean;
reason: string | null;
};
}): string {
if (input.lockDetail?.showContent === false) return "";
return input.isFromAI && input.hasImage ? "" : input.content;
}
function deriveUiLockFields(
lockDetail:
| {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
}
| undefined,
imageUrl?: string | null,
): Partial<UiMessage> {
const lockReason = lockDetail?.reason ?? null;
const isPrivateMessage = lockReason === "private_message";
const isVoiceMessage = lockReason === "voice_message";
return {
...(imageUrl ? { imageUrl } : {}),
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
...(isPrivateMessage ? { isPrivate: true } : {}),
...(isPrivateMessage && lockDetail?.locked
? { lockedPrivate: true }
: {}),
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
? { privateMessageHint: lockDetail.hint }
: {}),
};
}
+114
View File
@@ -0,0 +1,114 @@
import type { UiMessage } from "@/data/dto/chat";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import type { ChatState, ChatUpgradeReason } from "./chat-state";
export type HttpSendOutput = {
response: ChatSendResponse;
reply: UiMessage | null;
};
export function beginPendingReply(context: ChatState): Pick<
ChatState,
"isReplyingAI" | "pendingReplyCount"
> {
return {
pendingReplyCount: context.pendingReplyCount + 1,
isReplyingAI: true,
};
}
export function finishPendingReply(context: ChatState): Pick<
ChatState,
"isReplyingAI" | "pendingReplyCount"
> {
const pendingReplyCount = Math.max(0, context.pendingReplyCount - 1);
return {
pendingReplyCount,
isReplyingAI: pendingReplyCount > 0,
};
}
export function applyHttpSendOutput(
context: ChatState,
output: HttpSendOutput,
): Partial<ChatState> {
const { response, reply } = output;
const lockDetail = response.lockDetail;
const isWeeklyLimitBlocked =
lockDetail.locked &&
lockDetail.showUpgrade &&
lockDetail.reason === "weekly_limit";
const isInsufficientCredits =
response.canSendMessage === false &&
response.cannotSendReason === "insufficient_credits";
const upgradeReason: ChatUpgradeReason | null = isWeeklyLimitBlocked
? "weekly_limit"
: isInsufficientCredits
? "insufficient_credits"
: null;
const sendCapabilityState = getSendCapabilityState(response);
if (upgradeReason) {
const lastMessage = context.messages[context.messages.length - 1];
const messages =
!reply && lastMessage && !lastMessage.isFromAI
? context.messages.slice(0, -1)
: context.messages;
return {
messages: reply ? [...messages, reply] : messages,
...finishPendingReply(context),
upgradePromptVisible: true,
upgradeReason,
...sendCapabilityState,
};
}
if (!reply) {
return {
...finishPendingReply(context),
...sendCapabilityState,
};
}
if (lockDetail.showUpgrade && lockDetail.reason === "private_message") {
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
...sendCapabilityState,
};
}
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
...sendCapabilityState,
};
}
function getSendCapabilityState(
response: ChatSendResponse,
): Pick<
ChatState,
| "canSendMessage"
| "cannotSendReason"
| "creditBalance"
| "creditsCharged"
| "requiredCredits"
| "shortfallCredits"
> {
return {
canSendMessage: response.canSendMessage,
cannotSendReason: response.cannotSendReason,
creditBalance: response.creditBalance,
creditsCharged: response.creditsCharged,
requiredCredits: response.requiredCredits,
shortfallCredits: response.shortfallCredits,
};
}
+1 -1
View File
@@ -8,10 +8,10 @@ import {
type ChatActionArgs, type ChatActionArgs,
type ChatContextArgs, type ChatContextArgs,
} from "./chat-flow-actions"; } from "./chat-flow-actions";
import { readAndSyncHistory } from "./chat-history-sync";
import { import {
applySingleUnlockOutput, applySingleUnlockOutput,
countLockedHistoryMessages, countLockedHistoryMessages,
readAndSyncHistory,
type UnlockMessageOutput, type UnlockMessageOutput,
} from "./chat-machine.helpers"; } from "./chat-machine.helpers";
+56
View File
@@ -0,0 +1,56 @@
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
export interface UnlockMessageOutput {
messageId: string;
response: UnlockPrivateResponse;
}
export function countLockedHistoryMessages(messages: readonly UiMessage[]): number {
return messages.filter(isUnlockableLockedMessage).length;
}
export function applySingleUnlockOutput(
messages: readonly UiMessage[],
output: UnlockMessageOutput,
): UiMessage[] {
if (!output.response.unlocked) return [...messages];
return messages.map((message) => {
if (message.id !== output.messageId) return message;
const nextContent =
output.response.content.length > 0
? output.response.content
: message.content;
return {
...message,
content: nextContent,
locked: output.response.lockDetail.locked,
lockReason: output.response.lockDetail.reason,
imagePaywalled: message.imageUrl
? output.response.lockDetail.locked &&
output.response.lockDetail.showUpgrade
: undefined,
lockedPrivate: false,
privateMessageHint: null,
};
});
}
export function shouldAutoUnlockHistory(messages: readonly UiMessage[]): boolean {
return countLockedHistoryMessages(messages) === 1;
}
export function shouldPromptUnlockHistory(messages: readonly UiMessage[]): boolean {
return countLockedHistoryMessages(messages) > 1;
}
function isUnlockableLockedMessage(message: UiMessage): boolean {
if (!message.isFromAI || message.locked !== true) return false;
if (message.imagePaywalled === true) return true;
return (
message.lockReason === "private_message" ||
message.lockReason === "voice_message"
);
}
@@ -41,6 +41,45 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string {
); );
} }
export function hydratePlansState(
plans: PaymentPlan[],
selectedPlanId: string,
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
const nextSelectedPlanId = selectedPlanId || getDefaultPlanId(plans);
return {
plans,
selectedPlanId: nextSelectedPlanId,
autoRenew: defaultAutoRenewForPlan(nextSelectedPlanId, plans),
errorMessage: null,
};
}
export function refreshPlansState(
plans: PaymentPlan[],
selectedPlanId: string,
): Pick<PaymentState, "plans" | "selectedPlanId" | "autoRenew" | "errorMessage"> {
const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId)
? selectedPlanId
: getDefaultPlanId(plans);
return {
plans,
selectedPlanId: nextSelectedPlanId,
autoRenew: defaultAutoRenewForPlan(nextSelectedPlanId, plans),
errorMessage: null,
};
}
export function selectPlanState(
planId: string,
plans: readonly PaymentPlan[],
): Partial<PaymentState> {
return {
...resetOrderState(),
selectedPlanId: planId,
autoRenew: defaultAutoRenewForPlan(planId, plans),
};
}
export function resetOrderState(): Partial<PaymentState> { export function resetOrderState(): Partial<PaymentState> {
return { return {
currentOrderId: null, currentOrderId: null,
+8 -34
View File
@@ -12,11 +12,12 @@ import {
refreshPaymentPlansActor, refreshPaymentPlansActor,
} from "./payment-machine.actors"; } from "./payment-machine.actors";
import { import {
defaultAutoRenewForPlan,
getDefaultPlanId,
hasOrderPollingTimedOut, hasOrderPollingTimedOut,
hydratePlansState,
PAYMENT_TIMEOUT_ERROR_MESSAGE, PAYMENT_TIMEOUT_ERROR_MESSAGE,
refreshPlansState,
resetOrderState, resetOrderState,
selectPlanState,
toPaymentErrorMessage, toPaymentErrorMessage,
} from "./payment-machine.helpers"; } from "./payment-machine.helpers";
@@ -58,14 +59,7 @@ export const paymentMachine = setup({
target: "refreshingPlans", target: "refreshingPlans",
actions: assign(({ context, event }) => { actions: assign(({ context, event }) => {
const plans = event.output?.plans ?? []; const plans = event.output?.plans ?? [];
const selectedPlanId = return hydratePlansState(plans, context.selectedPlanId);
context.selectedPlanId || getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}), }),
}, },
{ {
@@ -85,14 +79,7 @@ export const paymentMachine = setup({
target: "ready", target: "ready",
actions: assign(({ context, event }) => { actions: assign(({ context, event }) => {
const plans = event.output.plans; const plans = event.output.plans;
const selectedPlanId = return hydratePlansState(plans, context.selectedPlanId);
context.selectedPlanId || getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}), }),
}, },
onError: { onError: {
@@ -111,16 +98,7 @@ export const paymentMachine = setup({
target: "ready", target: "ready",
actions: assign(({ context, event }) => { actions: assign(({ context, event }) => {
const plans = event.output.plans; const plans = event.output.plans;
const selectedPlanId = return refreshPlansState(plans, context.selectedPlanId);
plans.some((plan) => plan.planId === context.selectedPlanId)
? context.selectedPlanId
: getDefaultPlanId(plans);
return {
plans,
selectedPlanId,
autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans),
errorMessage: null,
};
}), }),
}, },
onError: { onError: {
@@ -137,9 +115,7 @@ export const paymentMachine = setup({
PaymentInit: "refreshingPlans", PaymentInit: "refreshingPlans",
PaymentPlanSelected: { PaymentPlanSelected: {
actions: assign(({ context, event }) => ({ actions: assign(({ context, event }) => ({
...resetOrderState(), ...selectPlanState(event.planId, context.plans),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})), })),
}, },
PaymentPayChannelChanged: { PaymentPayChannelChanged: {
@@ -283,9 +259,7 @@ export const paymentMachine = setup({
PaymentPlanSelected: { PaymentPlanSelected: {
target: "ready", target: "ready",
actions: assign(({ context, event }) => ({ actions: assign(({ context, event }) => ({
...resetOrderState(), ...selectPlanState(event.planId, context.plans),
selectedPlanId: event.planId,
autoRenew: defaultAutoRenewForPlan(event.planId, context.plans),
})), })),
}, },
PaymentPayChannelChanged: { PaymentPayChannelChanged: {