refactor(chat): simplify paid message lock state
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* 发送能力:
|
||||
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
||||
* - 后端返回 `cannotSendReason="insufficient_credits"` 时展示充值引导。
|
||||
* - 后端返回 `canSendMessage=false` 时展示积分不足充值引导。
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user