From 5d9ed7e0279c0c7c712cc8c8fd2ed0e653d4d229 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 1 Jul 2026 10:12:19 +0800 Subject: [PATCH] refactor(stores): tighten chat and payment boundaries --- src/stores/chat/chat-history-flow.ts | 2 +- src/stores/chat/chat-history-sync.ts | 101 +++++ src/stores/chat/chat-machine.helpers.ts | 401 +----------------- src/stores/chat/chat-message-mappers.ts | 109 +++++ src/stores/chat/chat-send-state.ts | 114 +++++ src/stores/chat/chat-unlock-flow.ts | 2 +- src/stores/chat/chat-unlock-helpers.ts | 56 +++ src/stores/payment/payment-machine.helpers.ts | 39 ++ src/stores/payment/payment-machine.ts | 42 +- 9 files changed, 435 insertions(+), 431 deletions(-) create mode 100644 src/stores/chat/chat-history-sync.ts create mode 100644 src/stores/chat/chat-message-mappers.ts create mode 100644 src/stores/chat/chat-send-state.ts create mode 100644 src/stores/chat/chat-unlock-helpers.ts diff --git a/src/stores/chat/chat-history-flow.ts b/src/stores/chat/chat-history-flow.ts index 99a3db85..f4a31419 100644 --- a/src/stores/chat/chat-history-flow.ts +++ b/src/stores/chat/chat-history-flow.ts @@ -3,10 +3,10 @@ import { fromPromise } from "xstate"; import { getChatRepository } from "@/data/repositories/chat_repository"; import { Logger, Result } from "@/utils"; +import { readAndSyncHistory } from "./chat-history-sync"; import { PAGE_SIZE, localMessagesToUi, - readAndSyncHistory, } from "./chat-machine.helpers"; const log = new Logger("StoresChatChatHistoryFlow"); diff --git a/src/stores/chat/chat-history-sync.ts b/src/stores/chat/chat-history-sync.ts new file mode 100644 index 00000000..4e3b76b5 --- /dev/null +++ b/src/stores/chat/chat-history-sync.ts @@ -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 { + 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, + }; +} diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 2906bfcb..f726c617 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -1,285 +1,13 @@ -import type { UiMessage, UnlockPrivateResponse } 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 { UiMessage } from "@/data/dto/chat"; -import type { ChatState, ChatUpgradeReason } from "./chat-state"; +import type { ChatState } from "./chat-state"; -const log = new Logger("StoresChatChatMachineHelpers"); - -// ============================================================ -// Constants -// ============================================================ -/** 翻历史每页大小(仅 `loadMoreHistoryActor` 用) */ +// History pagination size used by the history actors. export const PAGE_SIZE = 50; -// ============================================================ -// DTO ↔ UiMessage 映射 -// ============================================================ -/** - * 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 | 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 { - 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 { - 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 * from "./chat-message-mappers"; +export * from "./chat-send-state"; +export * from "./chat-unlock-helpers"; export function applyHistoryLoadedOutput( output: { @@ -298,120 +26,3 @@ export function applyHistoryLoadedOutput( 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 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调 -// ============================================================ -/** - * 拉 history 流程(每步都记日志 —— 用户要求"不要删除") - * 1. 读 local(chatRepo.getLocalMessages)→ 返 localMessages - * 2. 读 network(chatRepo.getHistory)→ 返 networkMessages - * 3. 用 network 覆盖 local(chatRepo.saveMessagesToLocal)→ local 被同步 - * - * 返回 network 最终 messages(authorititative),不返 local(UI 不需要过渡态) - * - * 保留日志(前缀 `[chat-machine]` 与 chat-machine.actors.ts 对齐) - */ -export async function readAndSyncHistory(): Promise<{ - messages: UiMessage[]; // network 最终 messages(authorititative) - 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 welcome(network 权威为空 = 用户从未发过消息) - const finalMessages = - networkUi.length === 0 ? [greetingMessage] : networkUi; - if (finalMessages[0] === greetingMessage) { - log.debug("[chat-machine] loadHistory EMPTY → prepend greeting"); - } - - return { - messages: finalMessages, // authoritative(empty 时塞 greeting) - hasMore: finalMessages.length >= PAGE_SIZE, - newOffset: finalMessages.length, - localOverwritten, - localCount: localMessages.length, - networkCount: networkUi.length, - }; -} diff --git a/src/stores/chat/chat-message-mappers.ts b/src/stores/chat/chat-message-mappers.ts new file mode 100644 index 00000000..493c9282 --- /dev/null +++ b/src/stores/chat/chat-message-mappers.ts @@ -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 | 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 { + 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 } + : {}), + }; +} diff --git a/src/stores/chat/chat-send-state.ts b/src/stores/chat/chat-send-state.ts new file mode 100644 index 00000000..f78050ee --- /dev/null +++ b/src/stores/chat/chat-send-state.ts @@ -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 { + 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, + }; +} diff --git a/src/stores/chat/chat-unlock-flow.ts b/src/stores/chat/chat-unlock-flow.ts index e1d3ebc3..4ef745d2 100644 --- a/src/stores/chat/chat-unlock-flow.ts +++ b/src/stores/chat/chat-unlock-flow.ts @@ -8,10 +8,10 @@ import { type ChatActionArgs, type ChatContextArgs, } from "./chat-flow-actions"; +import { readAndSyncHistory } from "./chat-history-sync"; import { applySingleUnlockOutput, countLockedHistoryMessages, - readAndSyncHistory, type UnlockMessageOutput, } from "./chat-machine.helpers"; diff --git a/src/stores/chat/chat-unlock-helpers.ts b/src/stores/chat/chat-unlock-helpers.ts new file mode 100644 index 00000000..ad34a973 --- /dev/null +++ b/src/stores/chat/chat-unlock-helpers.ts @@ -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" + ); +} diff --git a/src/stores/payment/payment-machine.helpers.ts b/src/stores/payment/payment-machine.helpers.ts index 2611452b..e839574c 100644 --- a/src/stores/payment/payment-machine.helpers.ts +++ b/src/stores/payment/payment-machine.helpers.ts @@ -41,6 +41,45 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string { ); } +export function hydratePlansState( + plans: PaymentPlan[], + selectedPlanId: string, +): Pick { + const nextSelectedPlanId = selectedPlanId || getDefaultPlanId(plans); + return { + plans, + selectedPlanId: nextSelectedPlanId, + autoRenew: defaultAutoRenewForPlan(nextSelectedPlanId, plans), + errorMessage: null, + }; +} + +export function refreshPlansState( + plans: PaymentPlan[], + selectedPlanId: string, +): Pick { + 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 { + return { + ...resetOrderState(), + selectedPlanId: planId, + autoRenew: defaultAutoRenewForPlan(planId, plans), + }; +} + export function resetOrderState(): Partial { return { currentOrderId: null, diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index 60b1968c..8251fc2e 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -12,11 +12,12 @@ import { refreshPaymentPlansActor, } from "./payment-machine.actors"; import { - defaultAutoRenewForPlan, - getDefaultPlanId, hasOrderPollingTimedOut, + hydratePlansState, PAYMENT_TIMEOUT_ERROR_MESSAGE, + refreshPlansState, resetOrderState, + selectPlanState, toPaymentErrorMessage, } from "./payment-machine.helpers"; @@ -58,14 +59,7 @@ export const paymentMachine = setup({ target: "refreshingPlans", actions: assign(({ context, event }) => { const plans = event.output?.plans ?? []; - const selectedPlanId = - context.selectedPlanId || getDefaultPlanId(plans); - return { - plans, - selectedPlanId, - autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans), - errorMessage: null, - }; + return hydratePlansState(plans, context.selectedPlanId); }), }, { @@ -85,14 +79,7 @@ export const paymentMachine = setup({ target: "ready", actions: assign(({ context, event }) => { const plans = event.output.plans; - const selectedPlanId = - context.selectedPlanId || getDefaultPlanId(plans); - return { - plans, - selectedPlanId, - autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans), - errorMessage: null, - }; + return hydratePlansState(plans, context.selectedPlanId); }), }, onError: { @@ -111,16 +98,7 @@ export const paymentMachine = setup({ target: "ready", actions: assign(({ context, event }) => { const plans = event.output.plans; - const selectedPlanId = - plans.some((plan) => plan.planId === context.selectedPlanId) - ? context.selectedPlanId - : getDefaultPlanId(plans); - return { - plans, - selectedPlanId, - autoRenew: defaultAutoRenewForPlan(selectedPlanId, plans), - errorMessage: null, - }; + return refreshPlansState(plans, context.selectedPlanId); }), }, onError: { @@ -137,9 +115,7 @@ export const paymentMachine = setup({ PaymentInit: "refreshingPlans", PaymentPlanSelected: { actions: assign(({ context, event }) => ({ - ...resetOrderState(), - selectedPlanId: event.planId, - autoRenew: defaultAutoRenewForPlan(event.planId, context.plans), + ...selectPlanState(event.planId, context.plans), })), }, PaymentPayChannelChanged: { @@ -283,9 +259,7 @@ export const paymentMachine = setup({ PaymentPlanSelected: { target: "ready", actions: assign(({ context, event }) => ({ - ...resetOrderState(), - selectedPlanId: event.planId, - autoRenew: defaultAutoRenewForPlan(event.planId, context.plans), + ...selectPlanState(event.planId, context.plans), })), }, PaymentPayChannelChanged: {