refactor(chat): simplify paid message lock state

This commit is contained in:
2026-07-13 15:20:07 +08:00
parent fd631168c8
commit 4682b4bf3f
17 changed files with 86 additions and 190 deletions
@@ -21,11 +21,7 @@ function makeResponse(
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
@@ -40,7 +36,6 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
@@ -73,11 +68,7 @@ describe("sendResponseToUiMessage", () => {
audioUrl: "",
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "这语气好撩呀,让人有点心跳加速呢…",
detail: { messageId: "msg-1" },
},
}),
);
@@ -87,9 +78,7 @@ describe("sendResponseToUiMessage", () => {
expect(message.locked).toBe(true);
expect(message.lockReason).toBe("voice_message");
expect(message.lockedPrivate).toBeUndefined();
expect(message.privateMessageHint).toBe(
"这语气好撩呀,让人有点心跳加速呢…",
);
expect(message.privateMessageHint).toBeUndefined();
});
it("maps locked private text messages as private messages", () => {
@@ -97,11 +86,7 @@ describe("sendResponseToUiMessage", () => {
makeResponse({
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "private_message",
hint: "Elio has a private message for you.",
detail: { messageId: "msg-1" },
},
}),
);
@@ -111,9 +96,7 @@ describe("sendResponseToUiMessage", () => {
expect(message.lockReason).toBe("private_message");
expect(message.isPrivate).toBe(true);
expect(message.lockedPrivate).toBe(true);
expect(message.privateMessageHint).toBe(
"Elio has a private message for you.",
);
expect(message.privateMessageHint).toBeUndefined();
});
it("keeps paywalled image messages visible but locked", () => {
@@ -125,11 +108,7 @@ describe("sendResponseToUiMessage", () => {
},
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "image",
hint: "Activate VIP to view the full photo.",
detail: { imageId: "image-1" },
},
}),
);
@@ -141,6 +120,25 @@ describe("sendResponseToUiMessage", () => {
expect(message.lockReason).toBe("image");
expect(message.lockedPrivate).toBeUndefined();
});
it("does not mark a message as paywalled when its lock reason is unlocked", () => {
const message = sendResponseToUiMessage(
makeResponse({
image: {
type: "jpg",
url: "https://example.com/unlocked-photo.jpg",
},
lockDetail: {
locked: false,
reason: "image_paywall",
},
}),
);
expect(message.locked).toBe(false);
expect(message.lockReason).toBe("image_paywall");
expect(message.imagePaywalled).toBeUndefined();
});
});
describe("applyHttpSendOutput", () => {
@@ -160,22 +158,13 @@ describe("applyHttpSendOutput", () => {
reply: "",
messageId: "",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 2,
shortfallCredits: 2,
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_credits",
hint: "Insufficient credits.",
detail: {
requiredCredits: 2,
currentCredits: 0,
shortfallCredits: 2,
},
locked: false,
reason: null,
},
}),
reply: null,
@@ -186,7 +175,6 @@ describe("applyHttpSendOutput", () => {
expect(nextState.upgradePromptVisible).toBe(true);
expect(nextState.upgradeReason).toBe("insufficient_credits");
expect(nextState.canSendMessage).toBe(false);
expect(nextState.cannotSendReason).toBe("insufficient_credits");
expect(nextState.requiredCredits).toBe(2);
expect(nextState.shortfallCredits).toBe(2);
});
@@ -205,7 +193,6 @@ describe("applyHttpSendOutput", () => {
makeResponse({
reply: "This one went through, but you need credits next.",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
@@ -216,7 +203,6 @@ describe("applyHttpSendOutput", () => {
response: makeResponse({
reply: "This one went through, but you need credits next.",
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
@@ -248,11 +234,7 @@ describe("localMessagesToUi", () => {
image: { type: null, url: null },
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "voice_message",
hint: "A locked voice message is waiting.",
detail: { messageId: "voice-1" },
},
},
]);
@@ -262,7 +244,7 @@ describe("localMessagesToUi", () => {
expect(message.locked).toBe(true);
expect(message.lockReason).toBe("voice_message");
expect(message.lockedPrivate).toBeUndefined();
expect(message.privateMessageHint).toBe("A locked voice message is waiting.");
expect(message.privateMessageHint).toBeUndefined();
});
it("hides text content for image history messages", () => {
@@ -280,11 +262,7 @@ describe("localMessagesToUi", () => {
},
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
},
]);
@@ -48,11 +48,7 @@ function makeChatSendResponse(): ChatSendResponse {
image: { type: null, url: null },
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
});
}
@@ -71,11 +67,7 @@ function makeUnlockPrivateResponse(
shortfallCredits: 0,
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
hint: null,
detail: null,
},
...overrides,
});
@@ -870,11 +862,7 @@ describe("chatMachine transitions", () => {
shortfallCredits: 7,
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_balance",
hint: "Insufficient credits.",
detail: { messageId: "msg-voice-locked" },
reason: "voice_message",
},
}),
},
@@ -938,19 +926,10 @@ describe("chatMachine transitions", () => {
timestamp: Date.now(),
image: { type: null, url: null },
lockDetail: {
locked: true,
showContent: false,
showUpgrade: true,
reason: "insufficient_credits",
hint: "Insufficient credits.",
detail: {
requiredCredits: 2,
currentCredits: 0,
shortfallCredits: 2,
},
locked: false,
reason: null,
},
canSendMessage: false,
cannotSendReason: "insufficient_credits",
creditBalance: 0,
requiredCredits: 2,
shortfallCredits: 2,
@@ -970,7 +949,6 @@ describe("chatMachine transitions", () => {
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
expect(actor.getSnapshot().context.cannotSendReason).toBeNull();
actor.stop();
});
@@ -59,8 +59,7 @@ describe("chat promotion", () => {
},
lockDetail: {
locked: false,
showContent: true,
showUpgrade: false,
reason: null,
},
}),
});
+1 -1
View File
@@ -23,7 +23,7 @@
*
* 发送能力:
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
* - 后端返回 `cannotSendReason="insufficient_credits"` 时展示充值引导。
* - 后端返回 `canSendMessage=false` 时展示积分不足充值引导。
*
*/
+9 -26
View File
@@ -1,4 +1,5 @@
import { type ChatSendResponse, type UiMessage } from "@/data/dto/chat";
import type { ChatLockDetailData } from "@/data/schemas/chat";
import { todayString } from "@/utils";
/**
@@ -14,14 +15,7 @@ export function localMessagesToUi(
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;
};
lockDetail?: ChatLockDetailData;
}>,
): UiMessage[] {
return records.map((m) => ({
@@ -72,30 +66,19 @@ function getAiMessageDisplayContent(input: {
}
function deriveUiLockFields(
lockDetail:
| {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason: string | null;
hint: string | null;
}
| undefined,
lockDetail: ChatLockDetailData | undefined,
imageUrl?: string | null,
): Partial<UiMessage> {
const lockReason = lockDetail?.reason ?? null;
const isPrivateMessage = lockReason === "private_message";
const isVoiceMessage = lockReason === "voice_message";
const isLocked = lockDetail?.locked === true;
const isPrivateMessage = isLocked && lockReason === "private_message";
const isLockedImage =
isLocked && (lockReason === "image_paywall" || lockReason === "image");
return {
...(imageUrl ? { imageUrl } : {}),
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
...(imageUrl && isLockedImage ? { imagePaywalled: true } : {}),
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
...(isPrivateMessage ? { isPrivate: true } : {}),
...(isPrivateMessage && lockDetail?.locked
? { lockedPrivate: true }
: {}),
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
? { privateMessageHint: lockDetail.hint }
: {}),
...(isPrivateMessage ? { lockedPrivate: true } : {}),
};
}
+8 -5
View File
@@ -119,7 +119,6 @@ export const clearUpgradePromptAction = chatAssign(() => ({
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
requiredCredits: 0,
shortfallCredits: 0,
}));
@@ -173,7 +172,6 @@ async function sendMessageViaHttp(content: string): Promise<{
void chatRepo.prefetchMediaForSendResponse(result.data);
const isInsufficientCredits =
result.data.canSendMessage === false &&
result.data.cannotSendReason === "insufficient_credits" &&
!hasRenderableSendResponse(result.data);
return {
response: result.data,
@@ -183,12 +181,17 @@ async function sendMessageViaHttp(content: string): Promise<{
}
function hasRenderableSendResponse(response: ChatSendResponse): boolean {
const isLockedPaidMessage =
response.lockDetail.locked &&
(response.lockDetail.reason === "private_message" ||
response.lockDetail.reason === "voice_message" ||
response.lockDetail.reason === "image_paywall" ||
response.lockDetail.reason === "image");
return (
response.reply.trim().length > 0 ||
response.audioUrl.trim().length > 0 ||
Boolean(response.image.url) ||
response.lockDetail.reason === "private_message" ||
response.lockDetail.reason === "voice_message" ||
response.lockDetail.reason === "image_paywall"
isLockedPaidMessage
);
}
+1 -18
View File
@@ -34,9 +34,7 @@ export function applyHttpSendOutput(
): Partial<ChatState> {
const { response, reply } = output;
const isInsufficientCredits =
response.canSendMessage === false &&
response.cannotSendReason === "insufficient_credits";
const isInsufficientCredits = response.canSendMessage === false;
const upgradeReason: ChatUpgradeReason | null = isInsufficientCredits
? "insufficient_credits"
: null;
@@ -65,19 +63,6 @@ export function applyHttpSendOutput(
};
}
if (
response.lockDetail.showUpgrade &&
response.lockDetail.reason === "private_message"
) {
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
upgradePromptVisible: false,
upgradeReason: null,
...sendCapabilityState,
};
}
return {
messages: [...context.messages, reply],
...finishPendingReply(context),
@@ -92,7 +77,6 @@ function getSendCapabilityState(
): Pick<
ChatState,
| "canSendMessage"
| "cannotSendReason"
| "creditBalance"
| "creditsCharged"
| "requiredCredits"
@@ -100,7 +84,6 @@ function getSendCapabilityState(
> {
return {
canSendMessage: response.canSendMessage,
cannotSendReason: response.cannotSendReason,
creditBalance: response.creditBalance,
creditsCharged: response.creditsCharged,
requiredCredits: response.requiredCredits,
-2
View File
@@ -27,7 +27,6 @@ export interface ChatState {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
canSendMessage: boolean;
cannotSendReason: string | null;
creditBalance: number;
creditsCharged: number;
requiredCredits: number;
@@ -63,7 +62,6 @@ export const initialState: ChatState = {
upgradePromptVisible: false,
upgradeReason: null,
canSendMessage: true,
cannotSendReason: null,
creditBalance: 0,
creditsCharged: 0,
requiredCredits: 0,
+11 -2
View File
@@ -51,8 +51,7 @@ export function applySingleUnlockOutput(
locked: output.response.lockDetail.locked,
lockReason: output.response.lockDetail.reason,
imagePaywalled: resolvedImageUrl
? output.response.lockDetail.locked &&
output.response.lockDetail.showUpgrade
? isLockedImage(output.response.lockDetail)
: undefined,
lockedPrivate: false,
privateMessageHint: null,
@@ -60,6 +59,16 @@ export function applySingleUnlockOutput(
});
}
function isLockedImage(lockDetail: {
locked: boolean;
reason: string | null;
}): boolean {
return (
lockDetail.locked &&
(lockDetail.reason === "image_paywall" || lockDetail.reason === "image")
);
}
function getUnlockedAudioUrl(
message: UiMessage,
response: UnlockPrivateResponse,