feat(chat): add promotional external entry flow
This commit is contained in:
@@ -34,6 +34,7 @@ function makeResponse(
|
||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
messages: [],
|
||||
promotion: null,
|
||||
isReplyingAI: true,
|
||||
pendingReplyCount: 1,
|
||||
upgradePromptVisible: false,
|
||||
@@ -56,6 +57,9 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
...overrides,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from "@/data/dto/chat";
|
||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||
import type {
|
||||
UnlockMessageOutput as MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest,
|
||||
} from "@/stores/chat/chat-machine.helpers";
|
||||
|
||||
interface LoadMoreHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
@@ -29,7 +33,7 @@ interface UnlockHistoryOutput {
|
||||
newOffset: number;
|
||||
}
|
||||
|
||||
interface UnlockMessageOutput {
|
||||
interface TestUnlockMessageOutput {
|
||||
messageId: string;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
@@ -81,7 +85,7 @@ function createTestChatMachine(
|
||||
options: {
|
||||
historyMessages?: UiMessage[];
|
||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||
unlockMessageOutput?: UnlockMessageOutput;
|
||||
unlockMessageOutput?: TestUnlockMessageOutput;
|
||||
} = {},
|
||||
) {
|
||||
return chatMachine.provide({
|
||||
@@ -124,13 +128,20 @@ function createTestChatMachine(
|
||||
newOffset: 0,
|
||||
...options.unlockHistoryOutput,
|
||||
})),
|
||||
unlockMessage: fromPromise<UnlockMessageOutput, { messageId: string }>(
|
||||
async ({ input }) =>
|
||||
options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId,
|
||||
response: makeUnlockPrivateResponse(),
|
||||
},
|
||||
),
|
||||
unlockMessage: fromPromise<
|
||||
MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
>(async ({ input }) => {
|
||||
const output = options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId ?? input.displayMessageId,
|
||||
response: makeUnlockPrivateResponse(),
|
||||
};
|
||||
return {
|
||||
displayMessageId: input.displayMessageId,
|
||||
request: input,
|
||||
response: output.response,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -217,6 +228,45 @@ describe("chatMachine transitions", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps a promotion separate from normal history", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "history-1",
|
||||
content: "Existing history",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatPromotionInjected",
|
||||
promotion: {
|
||||
promotionType: "voice",
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.messages).toHaveLength(1);
|
||||
expect(context.promotion?.message).toMatchObject({
|
||||
id: "promotion:promotion-1",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("renders local history before network history sync finishes", async () => {
|
||||
let resolveNetwork!: () => void;
|
||||
const networkReleased = new Promise<void>((resolve) => {
|
||||
@@ -586,7 +636,7 @@ describe("chatMachine transitions", () => {
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-private-locked",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This response content must be ignored.",
|
||||
content: "Unlocked private message content.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -612,7 +662,7 @@ describe("chatMachine transitions", () => {
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-private-locked",
|
||||
content: "Original private message content.",
|
||||
content: "Unlocked private message content.",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
lockedPrivate: false,
|
||||
@@ -647,7 +697,7 @@ describe("chatMachine transitions", () => {
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-shared-id",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This response content must be ignored.",
|
||||
content: "Unlocked private AI message.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -676,7 +726,7 @@ describe("chatMachine transitions", () => {
|
||||
},
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
content: "Original AI private message",
|
||||
content: "Unlocked private AI message.",
|
||||
isFromAI: true,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
@@ -848,6 +898,7 @@ describe("chatMachine transitions", () => {
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({
|
||||
displayMessageId: "msg-voice-locked",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
reason: "insufficient_balance",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import {
|
||||
appendPromotionMessage,
|
||||
applyPromotionUnlockOutput,
|
||||
createChatPromotionState,
|
||||
} from "@/stores/chat/chat-promotion";
|
||||
|
||||
const promotion: PendingChatPromotion = {
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "promotion-1",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
describe("chat promotion", () => {
|
||||
it("creates the requested locked message and appends it at the tail", () => {
|
||||
const state = createChatPromotionState(promotion);
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
id: "history-1",
|
||||
content: "History",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
"history-1",
|
||||
"promotion:promotion-1",
|
||||
]);
|
||||
expect(messages[1]).toMatchObject({
|
||||
locked: true,
|
||||
lockReason: "image_paywall",
|
||||
imagePaywalled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a temporary promotion with the real unlocked image", () => {
|
||||
const state = createChatPromotionState(promotion);
|
||||
const next = applyPromotionUnlockOutput(state, {
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
request: {
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "promotion-1",
|
||||
},
|
||||
response: UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
messageId: "backend-1",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/unlocked.jpg",
|
||||
},
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(next?.message).toMatchObject({
|
||||
id: "backend-1",
|
||||
imageUrl: "https://example.com/unlocked.jpg",
|
||||
imagePaywalled: false,
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the promotion last and removes matching history duplicates", () => {
|
||||
const state = createChatPromotionState(promotion, "backend-1");
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
id: "backend-1",
|
||||
content: "Stale history copy",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages).toEqual([state.message]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useMachine } from "@xstate/react";
|
||||
|
||||
import { chatMachine } from "./chat-machine";
|
||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
import { appendPromotionMessage } from "./chat-promotion";
|
||||
|
||||
/**
|
||||
* 对外暴露的 State 形状
|
||||
@@ -20,6 +21,8 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
*/
|
||||
interface ChatState {
|
||||
messages: MachineContext["messages"];
|
||||
historyMessages: MachineContext["messages"];
|
||||
promotion: MachineContext["promotion"];
|
||||
isReplyingAI: boolean;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
@@ -51,7 +54,12 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
// 映射 XState 状态机快照 → 原 ChatState 形状
|
||||
const chatState = useMemo<ChatState>(
|
||||
() => ({
|
||||
messages: state.context.messages,
|
||||
messages: appendPromotionMessage(
|
||||
state.context.messages,
|
||||
state.context.promotion,
|
||||
),
|
||||
historyMessages: state.context.messages,
|
||||
promotion: state.context.promotion,
|
||||
isReplyingAI: state.context.isReplyingAI,
|
||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||
upgradeReason: state.context.upgradeReason,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
||||
*/
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
|
||||
export type ChatEvent =
|
||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||
@@ -35,10 +37,19 @@ export type ChatEvent =
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| {
|
||||
type: "ChatPromotionInjected";
|
||||
promotion: PendingChatPromotion;
|
||||
messageId?: string;
|
||||
}
|
||||
| { type: "ChatPromotionCleared" }
|
||||
| {
|
||||
type: "ChatUnlockMessageRequested";
|
||||
messageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
| { type: "ChatUnlockPaywallNavigationConsumed" }
|
||||
| { type: "ChatPaymentSucceeded" }
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ChatState } from "./chat-state";
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
export * from "./chat-message-mappers";
|
||||
export * from "./chat-promotion";
|
||||
export * from "./chat-send-state";
|
||||
export * from "./chat-unlock-helpers";
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
countLockedHistoryMessages,
|
||||
shouldPromptUnlockHistory,
|
||||
createChatPromotionState,
|
||||
} from "./chat-machine.helpers";
|
||||
import {
|
||||
loadHistoryActor,
|
||||
@@ -113,18 +114,32 @@ export const chatMachine = setup({
|
||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||
|
||||
startGuestSession: assign(() => ({
|
||||
startGuestSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
startUserSession: assign(() => ({
|
||||
startUserSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
injectPromotion: assign(({ event }) => {
|
||||
if (event.type !== "ChatPromotionInjected") return {};
|
||||
return {
|
||||
promotion: createChatPromotionState(
|
||||
event.promotion,
|
||||
event.messageId,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
clearPromotion: assign({ promotion: null }),
|
||||
|
||||
applyLocalHistoryLoaded: assign(({ event }) => {
|
||||
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
||||
return applyHistoryLoadedOutput(event.output);
|
||||
@@ -169,6 +184,10 @@ export const chatMachine = setup({
|
||||
id: "chat",
|
||||
initial: "idle",
|
||||
context: initialState,
|
||||
on: {
|
||||
ChatPromotionInjected: { actions: "injectPromotion" },
|
||||
ChatPromotionCleared: { actions: "clearPromotion" },
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
@@ -471,7 +490,16 @@ export const chatMachine = setup({
|
||||
id: "unlockMessage",
|
||||
src: "unlockMessage",
|
||||
input: ({ context }) => ({
|
||||
messageId: context.unlockingMessageId ?? "",
|
||||
displayMessageId: context.unlockingMessageId ?? "",
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import { todayString } from "@/utils";
|
||||
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-unlock-helpers";
|
||||
|
||||
const PROMOTION_COPY = {
|
||||
voice: "I left a voice message for you. Unlock it to listen.",
|
||||
image: "I sent you a private photo. Unlock it to view.",
|
||||
private: "I have something private to tell you. Unlock it to read.",
|
||||
} as const;
|
||||
|
||||
export interface ChatPromotionState {
|
||||
session: PendingChatPromotion;
|
||||
message: UiMessage;
|
||||
}
|
||||
|
||||
export function createChatPromotionState(
|
||||
session: PendingChatPromotion,
|
||||
messageId?: string,
|
||||
): ChatPromotionState {
|
||||
const isImage = session.promotionType === "image";
|
||||
const isPrivate = session.promotionType === "private";
|
||||
|
||||
return {
|
||||
session,
|
||||
message: {
|
||||
id: messageId || `promotion:${session.clientLockId}`,
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
locked: true,
|
||||
lockReason: session.lockType,
|
||||
imagePaywalled: isImage ? true : undefined,
|
||||
lockedPrivate: isPrivate ? true : undefined,
|
||||
privateMessageHint: PROMOTION_COPY[session.promotionType],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function appendPromotionMessage(
|
||||
messages: readonly UiMessage[],
|
||||
promotion: ChatPromotionState | null,
|
||||
): UiMessage[] {
|
||||
if (!promotion) return [...messages];
|
||||
return [
|
||||
...messages.filter((message) => message.id !== promotion.message.id),
|
||||
promotion.message,
|
||||
];
|
||||
}
|
||||
|
||||
export function applyPromotionUnlockOutput(
|
||||
promotion: ChatPromotionState | null,
|
||||
output: UnlockMessageOutput,
|
||||
): ChatPromotionState | null {
|
||||
if (!promotion || promotion.message.id !== output.displayMessageId) {
|
||||
return promotion;
|
||||
}
|
||||
return {
|
||||
...promotion,
|
||||
message:
|
||||
applySingleUnlockOutput([promotion.message], output)[0] ??
|
||||
promotion.message,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import type { ChatPromotionState } from "./chat-promotion";
|
||||
|
||||
export type ChatUpgradeReason = "insufficient_credits";
|
||||
|
||||
export interface ChatUnlockPaywallRequest {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
reason: string;
|
||||
creditBalance: number;
|
||||
requiredCredits: number;
|
||||
@@ -14,6 +21,7 @@ export interface ChatUnlockPaywallRequest {
|
||||
|
||||
export interface ChatState {
|
||||
messages: UiMessage[];
|
||||
promotion: ChatPromotionState | null;
|
||||
isReplyingAI: boolean;
|
||||
pendingReplyCount: number;
|
||||
upgradePromptVisible: boolean;
|
||||
@@ -40,12 +48,16 @@ export interface ChatState {
|
||||
isUnlockingMessage: boolean;
|
||||
unlockingMessageId: string | null;
|
||||
unlockingMessageKind: PendingChatUnlockKind | null;
|
||||
unlockingRemoteMessageId: string | null;
|
||||
unlockingLockType: ChatLockType | null;
|
||||
unlockingClientLockId: string | null;
|
||||
unlockMessageError: string | null;
|
||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||
}
|
||||
|
||||
export const initialState: ChatState = {
|
||||
messages: [],
|
||||
promotion: null,
|
||||
isReplyingAI: false,
|
||||
pendingReplyCount: 0,
|
||||
upgradePromptVisible: false,
|
||||
@@ -68,6 +80,9 @@ export const initialState: ChatState = {
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { readAndSyncHistory } from "./chat-history-sync";
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
applyPromotionUnlockOutput,
|
||||
countLockedHistoryMessages,
|
||||
type UnlockMessageRequest,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-machine.helpers";
|
||||
|
||||
@@ -49,24 +51,32 @@ export const unlockHistoryActor = fromPromise<{
|
||||
|
||||
export const unlockMessageActor = fromPromise<
|
||||
UnlockMessageOutput,
|
||||
{ messageId: string }
|
||||
UnlockMessageRequest
|
||||
>(async ({ input }) => {
|
||||
const chatRepo = getChatRepository();
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId);
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
});
|
||||
if (Result.isErr(unlockResult)) {
|
||||
log.error("[chat-machine] unlockMessageActor failed", {
|
||||
messageId: input.messageId,
|
||||
clientLockId: input.clientLockId,
|
||||
error: unlockResult.error,
|
||||
});
|
||||
throw unlockResult.error;
|
||||
}
|
||||
|
||||
if (unlockResult.data.unlocked) {
|
||||
if (unlockResult.data.unlocked && input.messageId && !input.clientLockId) {
|
||||
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||
input.messageId,
|
||||
{
|
||||
content: unlockResult.data.content,
|
||||
audioUrl: unlockResult.data.audioUrl,
|
||||
...(unlockResult.data.image.url
|
||||
? { image: unlockResult.data.image }
|
||||
: {}),
|
||||
lockDetail: unlockResult.data.lockDetail,
|
||||
},
|
||||
);
|
||||
@@ -79,7 +89,8 @@ export const unlockMessageActor = fromPromise<
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: input.messageId,
|
||||
displayMessageId: input.displayMessageId,
|
||||
request: input,
|
||||
response: unlockResult.data,
|
||||
};
|
||||
});
|
||||
@@ -124,6 +135,10 @@ export const markUnlockMessageStartedAction = chatAssign(
|
||||
isUnlockingMessage: true,
|
||||
unlockingMessageId: event.messageId,
|
||||
unlockingMessageKind: event.kind,
|
||||
unlockingRemoteMessageId:
|
||||
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
|
||||
unlockingLockType: event.lockType ?? null,
|
||||
unlockingClientLockId: event.clientLockId ?? null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
@@ -136,9 +151,13 @@ export const applyUnlockMessageSucceededAction = chatAssign(
|
||||
if (!output) return {};
|
||||
return {
|
||||
messages: applySingleUnlockOutput(context.messages, output),
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
creditBalance: output.response.creditBalance,
|
||||
@@ -154,13 +173,33 @@ export const requestUnlockPaymentFromOutputAction = chatAssign(
|
||||
const output = (event as { output?: UnlockMessageOutput }).output;
|
||||
if (!output) return {};
|
||||
return {
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: output.response.reason,
|
||||
unlockPaywallRequest: {
|
||||
messageId: output.messageId,
|
||||
displayMessageId:
|
||||
output.response.messageId || output.displayMessageId,
|
||||
...(output.response.messageId || output.request.messageId
|
||||
? {
|
||||
messageId:
|
||||
output.response.messageId || output.request.messageId,
|
||||
}
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind ?? "private",
|
||||
...(output.request.lockType
|
||||
? { lockType: output.request.lockType }
|
||||
: {}),
|
||||
...(output.request.clientLockId
|
||||
? { clientLockId: output.request.clientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === output.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: output.response.reason,
|
||||
creditBalance: output.response.creditBalance,
|
||||
requiredCredits: output.response.requiredCredits,
|
||||
@@ -180,8 +219,20 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
||||
unlockPaywallRequest:
|
||||
context.unlockingMessageId && context.unlockingMessageKind
|
||||
? {
|
||||
messageId: context.unlockingMessageId,
|
||||
displayMessageId: context.unlockingMessageId,
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind,
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === context.unlockingMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: "unlock_failed",
|
||||
creditBalance: context.creditBalance,
|
||||
requiredCredits: context.requiredCredits,
|
||||
@@ -190,6 +241,9 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
||||
: null,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
|
||||
export interface UnlockMessageRequest {
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface UnlockMessageOutput {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
request: UnlockMessageRequest;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
@@ -13,17 +22,35 @@ export function applySingleUnlockOutput(
|
||||
messages: readonly UiMessage[],
|
||||
output: UnlockMessageOutput,
|
||||
): UiMessage[] {
|
||||
if (!output.response.unlocked) return [...messages];
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!shouldApplySingleUnlock(message, output.messageId)) return message;
|
||||
if (!shouldApplySingleUnlock(message, output.displayMessageId)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const resolvedId = output.response.messageId || message.id;
|
||||
if (!output.response.unlocked) {
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedImageUrl =
|
||||
output.response.image.url ?? message.imageUrl;
|
||||
const resolvedContent =
|
||||
message.lockReason === "private_message"
|
||||
? output.response.content || message.content
|
||||
: message.content;
|
||||
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
content: resolvedContent,
|
||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||
imageUrl: resolvedImageUrl,
|
||||
locked: output.response.lockDetail.locked,
|
||||
lockReason: output.response.lockDetail.reason,
|
||||
imagePaywalled: message.imageUrl
|
||||
imagePaywalled: resolvedImageUrl
|
||||
? output.response.lockDetail.locked &&
|
||||
output.response.lockDetail.showUpgrade
|
||||
: undefined,
|
||||
@@ -48,6 +75,8 @@ function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean
|
||||
if (message.locked !== true) return false;
|
||||
return (
|
||||
message.imagePaywalled === true ||
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
@@ -61,6 +90,8 @@ function isUnlockableLockedMessage(message: UiMessage): boolean {
|
||||
if (!message.isFromAI || message.locked !== true) return false;
|
||||
if (message.imagePaywalled === true) return true;
|
||||
return (
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user