refactor(chat): simplify paid message lock state
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
* ChatInsufficientCreditsBanner 积分不足横幅
|
* ChatInsufficientCreditsBanner 积分不足横幅
|
||||||
*
|
*
|
||||||
* 何时显示:
|
* 何时显示:
|
||||||
* - 后端返回 cannotSendReason="insufficient_credits"
|
* - 后端返回 canSendMessage=false
|
||||||
*
|
*
|
||||||
* 视觉规格(与设计稿对齐):
|
* 视觉规格(与设计稿对齐):
|
||||||
* - 粉→品红渐变背景(与 splash 渐变一致)
|
* - 粉→品红渐变背景(与 splash 渐变一致)
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ export interface SentencePayload {
|
|||||||
|
|
||||||
export interface PaywallStatusPayload {
|
export interface PaywallStatusPayload {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
showContent: boolean;
|
|
||||||
showUpgrade: boolean;
|
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
hint: string | null;
|
|
||||||
detail: Record<string, unknown> | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatWebSocket {
|
export class ChatWebSocket {
|
||||||
@@ -178,11 +174,7 @@ export class ChatWebSocket {
|
|||||||
const lockDetail = payload.data?.lockDetail;
|
const lockDetail = payload.data?.lockDetail;
|
||||||
this.onPaywallStatus?.({
|
this.onPaywallStatus?.({
|
||||||
locked: lockDetail?.locked ?? false,
|
locked: lockDetail?.locked ?? false,
|
||||||
showContent: lockDetail?.showContent ?? true,
|
|
||||||
showUpgrade: lockDetail?.showUpgrade ?? false,
|
|
||||||
reason: lockDetail?.reason ?? null,
|
reason: lockDetail?.reason ?? null,
|
||||||
hint: lockDetail?.hint ?? null,
|
|
||||||
detail: lockDetail?.detail ?? null,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,8 +45,7 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
},
|
},
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
reason: null,
|
||||||
showUpgrade: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,13 +68,7 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
shortfallCredits: 5,
|
shortfallCredits: 5,
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
reason: "voice_message",
|
||||||
showUpgrade: true,
|
|
||||||
reason: "insufficient_balance",
|
|
||||||
hint: "Insufficient credits.",
|
|
||||||
detail: {
|
|
||||||
messageId: "msg_001",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +78,10 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
expect(response.creditsCharged).toBe(0);
|
expect(response.creditsCharged).toBe(0);
|
||||||
expect(response.requiredCredits).toBe(10);
|
expect(response.requiredCredits).toBe(10);
|
||||||
expect(response.shortfallCredits).toBe(5);
|
expect(response.shortfallCredits).toBe(5);
|
||||||
expect(response.lockDetail.showUpgrade).toBe(true);
|
expect(response.lockDetail).toEqual({
|
||||||
|
locked: true,
|
||||||
|
reason: "voice_message",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults nullable content to an empty string", () => {
|
it("defaults nullable content to an empty string", () => {
|
||||||
@@ -100,11 +92,7 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
reason: "insufficient_balance",
|
reason: "insufficient_balance",
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
reason: "private_message",
|
||||||
showUpgrade: true,
|
|
||||||
reason: "insufficient_balance",
|
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,11 +111,7 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
shortfallCredits: null,
|
shortfallCredits: null,
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
reason: "image_paywall",
|
||||||
showUpgrade: true,
|
|
||||||
reason: "insufficient_balance",
|
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,11 +131,26 @@ describe("UnlockPrivateResponse", () => {
|
|||||||
|
|
||||||
expect(response.lockDetail).toEqual({
|
expect(response.lockDetail).toEqual({
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
});
|
||||||
detail: null,
|
});
|
||||||
|
|
||||||
|
it("strips legacy lock detail fields while preserving lock state and type", () => {
|
||||||
|
const response = UnlockPrivateResponse.fromJson({
|
||||||
|
unlocked: false,
|
||||||
|
lockDetail: {
|
||||||
|
locked: true,
|
||||||
|
reason: "private_message",
|
||||||
|
showContent: false,
|
||||||
|
showUpgrade: true,
|
||||||
|
hint: "Legacy hint",
|
||||||
|
detail: { messageId: "legacy-message" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.lockDetail).toEqual({
|
||||||
|
locked: true,
|
||||||
|
reason: "private_message",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export class ChatSendResponse {
|
|||||||
declare readonly image: ChatImageData;
|
declare readonly image: ChatImageData;
|
||||||
declare readonly lockDetail: ChatLockDetailData;
|
declare readonly lockDetail: ChatLockDetailData;
|
||||||
declare readonly canSendMessage: boolean;
|
declare readonly canSendMessage: boolean;
|
||||||
// declare readonly cannotSendReason: string | null;
|
|
||||||
declare readonly creditBalance: number;
|
declare readonly creditBalance: number;
|
||||||
declare readonly creditsCharged: number;
|
declare readonly creditsCharged: number;
|
||||||
declare readonly requiredCredits: number;
|
declare readonly requiredCredits: number;
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ function makeMessage(
|
|||||||
image: { type: null, url: null },
|
image: { type: null, url: null },
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -45,11 +41,7 @@ function makeResponse(
|
|||||||
image: { type: null, url: null },
|
image: { type: null, url: null },
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,13 +28,8 @@ export class ChatLocalMessageStore {
|
|||||||
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
||||||
image: patch.image ?? message.image,
|
image: patch.image ?? message.image,
|
||||||
lockDetail: patch.lockDetail ?? {
|
lockDetail: patch.lockDetail ?? {
|
||||||
...message.lockDetail,
|
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,21 +5,15 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
booleanOrFalse,
|
booleanOrFalse,
|
||||||
booleanOrTrue,
|
|
||||||
schemaOr,
|
schemaOr,
|
||||||
stringOrNull,
|
stringOrNull,
|
||||||
unknownRecordOrNull,
|
|
||||||
} from "../nullable-defaults";
|
} from "../nullable-defaults";
|
||||||
|
|
||||||
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
|
const CHAT_IMAGE_DEFAULTS = { type: null, url: null } as const;
|
||||||
|
|
||||||
const CHAT_LOCK_DETAIL_DEFAULTS = {
|
const CHAT_LOCK_DETAIL_DEFAULTS = {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ChatImageSchema = schemaOr(
|
export const ChatImageSchema = schemaOr(
|
||||||
@@ -33,11 +27,7 @@ export const ChatImageSchema = schemaOr(
|
|||||||
export const ChatLockDetailSchema = schemaOr(
|
export const ChatLockDetailSchema = schemaOr(
|
||||||
z.object({
|
z.object({
|
||||||
locked: booleanOrFalse,
|
locked: booleanOrFalse,
|
||||||
showContent: booleanOrTrue,
|
|
||||||
showUpgrade: booleanOrFalse,
|
|
||||||
reason: stringOrNull,
|
reason: stringOrNull,
|
||||||
hint: stringOrNull,
|
|
||||||
detail: unknownRecordOrNull,
|
|
||||||
}),
|
}),
|
||||||
CHAT_LOCK_DETAIL_DEFAULTS,
|
CHAT_LOCK_DETAIL_DEFAULTS,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
numberOrLazy,
|
numberOrLazy,
|
||||||
numberOrZero,
|
numberOrZero,
|
||||||
stringOrEmpty,
|
stringOrEmpty,
|
||||||
stringOrNull,
|
|
||||||
} from "../nullable-defaults";
|
} from "../nullable-defaults";
|
||||||
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ export const ChatSendResponseSchema = z.object({
|
|||||||
image: ChatImageSchema,
|
image: ChatImageSchema,
|
||||||
lockDetail: ChatLockDetailSchema,
|
lockDetail: ChatLockDetailSchema,
|
||||||
canSendMessage: booleanOrTrue,
|
canSendMessage: booleanOrTrue,
|
||||||
cannotSendReason: stringOrNull,
|
|
||||||
creditBalance: numberOrZero,
|
creditBalance: numberOrZero,
|
||||||
creditsCharged: numberOrZero,
|
creditsCharged: numberOrZero,
|
||||||
requiredCredits: numberOrZero,
|
requiredCredits: numberOrZero,
|
||||||
|
|||||||
@@ -21,11 +21,7 @@ function makeResponse(
|
|||||||
image: { type: null, url: null },
|
image: { type: null, url: null },
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -40,7 +36,6 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
canSendMessage: true,
|
canSendMessage: true,
|
||||||
cannotSendReason: null,
|
|
||||||
creditBalance: 0,
|
creditBalance: 0,
|
||||||
creditsCharged: 0,
|
creditsCharged: 0,
|
||||||
requiredCredits: 0,
|
requiredCredits: 0,
|
||||||
@@ -73,11 +68,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
audioUrl: "",
|
audioUrl: "",
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
|
||||||
showUpgrade: true,
|
|
||||||
reason: "voice_message",
|
reason: "voice_message",
|
||||||
hint: "这语气好撩呀,让人有点心跳加速呢…",
|
|
||||||
detail: { messageId: "msg-1" },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -87,9 +78,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
expect(message.locked).toBe(true);
|
expect(message.locked).toBe(true);
|
||||||
expect(message.lockReason).toBe("voice_message");
|
expect(message.lockReason).toBe("voice_message");
|
||||||
expect(message.lockedPrivate).toBeUndefined();
|
expect(message.lockedPrivate).toBeUndefined();
|
||||||
expect(message.privateMessageHint).toBe(
|
expect(message.privateMessageHint).toBeUndefined();
|
||||||
"这语气好撩呀,让人有点心跳加速呢…",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps locked private text messages as private messages", () => {
|
it("maps locked private text messages as private messages", () => {
|
||||||
@@ -97,11 +86,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
makeResponse({
|
makeResponse({
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
|
||||||
showUpgrade: true,
|
|
||||||
reason: "private_message",
|
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.lockReason).toBe("private_message");
|
||||||
expect(message.isPrivate).toBe(true);
|
expect(message.isPrivate).toBe(true);
|
||||||
expect(message.lockedPrivate).toBe(true);
|
expect(message.lockedPrivate).toBe(true);
|
||||||
expect(message.privateMessageHint).toBe(
|
expect(message.privateMessageHint).toBeUndefined();
|
||||||
"Elio has a private message for you.",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps paywalled image messages visible but locked", () => {
|
it("keeps paywalled image messages visible but locked", () => {
|
||||||
@@ -125,11 +108,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
},
|
},
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
|
||||||
showUpgrade: true,
|
|
||||||
reason: "image",
|
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.lockReason).toBe("image");
|
||||||
expect(message.lockedPrivate).toBeUndefined();
|
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", () => {
|
describe("applyHttpSendOutput", () => {
|
||||||
@@ -160,22 +158,13 @@ describe("applyHttpSendOutput", () => {
|
|||||||
reply: "",
|
reply: "",
|
||||||
messageId: "",
|
messageId: "",
|
||||||
canSendMessage: false,
|
canSendMessage: false,
|
||||||
cannotSendReason: "insufficient_credits",
|
|
||||||
creditBalance: 0,
|
creditBalance: 0,
|
||||||
creditsCharged: 0,
|
creditsCharged: 0,
|
||||||
requiredCredits: 2,
|
requiredCredits: 2,
|
||||||
shortfallCredits: 2,
|
shortfallCredits: 2,
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: false,
|
||||||
showContent: false,
|
reason: null,
|
||||||
showUpgrade: true,
|
|
||||||
reason: "insufficient_credits",
|
|
||||||
hint: "Insufficient credits.",
|
|
||||||
detail: {
|
|
||||||
requiredCredits: 2,
|
|
||||||
currentCredits: 0,
|
|
||||||
shortfallCredits: 2,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
reply: null,
|
reply: null,
|
||||||
@@ -186,7 +175,6 @@ describe("applyHttpSendOutput", () => {
|
|||||||
expect(nextState.upgradePromptVisible).toBe(true);
|
expect(nextState.upgradePromptVisible).toBe(true);
|
||||||
expect(nextState.upgradeReason).toBe("insufficient_credits");
|
expect(nextState.upgradeReason).toBe("insufficient_credits");
|
||||||
expect(nextState.canSendMessage).toBe(false);
|
expect(nextState.canSendMessage).toBe(false);
|
||||||
expect(nextState.cannotSendReason).toBe("insufficient_credits");
|
|
||||||
expect(nextState.requiredCredits).toBe(2);
|
expect(nextState.requiredCredits).toBe(2);
|
||||||
expect(nextState.shortfallCredits).toBe(2);
|
expect(nextState.shortfallCredits).toBe(2);
|
||||||
});
|
});
|
||||||
@@ -205,7 +193,6 @@ describe("applyHttpSendOutput", () => {
|
|||||||
makeResponse({
|
makeResponse({
|
||||||
reply: "This one went through, but you need credits next.",
|
reply: "This one went through, but you need credits next.",
|
||||||
canSendMessage: false,
|
canSendMessage: false,
|
||||||
cannotSendReason: "insufficient_credits",
|
|
||||||
creditBalance: 0,
|
creditBalance: 0,
|
||||||
requiredCredits: 2,
|
requiredCredits: 2,
|
||||||
shortfallCredits: 2,
|
shortfallCredits: 2,
|
||||||
@@ -216,7 +203,6 @@ describe("applyHttpSendOutput", () => {
|
|||||||
response: makeResponse({
|
response: makeResponse({
|
||||||
reply: "This one went through, but you need credits next.",
|
reply: "This one went through, but you need credits next.",
|
||||||
canSendMessage: false,
|
canSendMessage: false,
|
||||||
cannotSendReason: "insufficient_credits",
|
|
||||||
creditBalance: 0,
|
creditBalance: 0,
|
||||||
requiredCredits: 2,
|
requiredCredits: 2,
|
||||||
shortfallCredits: 2,
|
shortfallCredits: 2,
|
||||||
@@ -248,11 +234,7 @@ describe("localMessagesToUi", () => {
|
|||||||
image: { type: null, url: null },
|
image: { type: null, url: null },
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
|
||||||
showUpgrade: true,
|
|
||||||
reason: "voice_message",
|
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.locked).toBe(true);
|
||||||
expect(message.lockReason).toBe("voice_message");
|
expect(message.lockReason).toBe("voice_message");
|
||||||
expect(message.lockedPrivate).toBeUndefined();
|
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", () => {
|
it("hides text content for image history messages", () => {
|
||||||
@@ -280,11 +262,7 @@ describe("localMessagesToUi", () => {
|
|||||||
},
|
},
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -48,11 +48,7 @@ function makeChatSendResponse(): ChatSendResponse {
|
|||||||
image: { type: null, url: null },
|
image: { type: null, url: null },
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,11 +67,7 @@ function makeUnlockPrivateResponse(
|
|||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
|
||||||
showUpgrade: false,
|
|
||||||
reason: null,
|
reason: null,
|
||||||
hint: null,
|
|
||||||
detail: null,
|
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -870,11 +862,7 @@ describe("chatMachine transitions", () => {
|
|||||||
shortfallCredits: 7,
|
shortfallCredits: 7,
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: true,
|
||||||
showContent: false,
|
reason: "voice_message",
|
||||||
showUpgrade: true,
|
|
||||||
reason: "insufficient_balance",
|
|
||||||
hint: "Insufficient credits.",
|
|
||||||
detail: { messageId: "msg-voice-locked" },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -938,19 +926,10 @@ describe("chatMachine transitions", () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
image: { type: null, url: null },
|
image: { type: null, url: null },
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: true,
|
locked: false,
|
||||||
showContent: false,
|
reason: null,
|
||||||
showUpgrade: true,
|
|
||||||
reason: "insufficient_credits",
|
|
||||||
hint: "Insufficient credits.",
|
|
||||||
detail: {
|
|
||||||
requiredCredits: 2,
|
|
||||||
currentCredits: 0,
|
|
||||||
shortfallCredits: 2,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
canSendMessage: false,
|
canSendMessage: false,
|
||||||
cannotSendReason: "insufficient_credits",
|
|
||||||
creditBalance: 0,
|
creditBalance: 0,
|
||||||
requiredCredits: 2,
|
requiredCredits: 2,
|
||||||
shortfallCredits: 2,
|
shortfallCredits: 2,
|
||||||
@@ -970,7 +949,6 @@ describe("chatMachine transitions", () => {
|
|||||||
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
|
expect(actor.getSnapshot().context.upgradePromptVisible).toBe(false);
|
||||||
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
|
expect(actor.getSnapshot().context.upgradeReason).toBeNull();
|
||||||
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
|
expect(actor.getSnapshot().context.canSendMessage).toBe(true);
|
||||||
expect(actor.getSnapshot().context.cannotSendReason).toBeNull();
|
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ describe("chat promotion", () => {
|
|||||||
},
|
},
|
||||||
lockDetail: {
|
lockDetail: {
|
||||||
locked: false,
|
locked: false,
|
||||||
showContent: true,
|
reason: null,
|
||||||
showUpgrade: false,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
*
|
*
|
||||||
* 发送能力:
|
* 发送能力:
|
||||||
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
* - 前端不再处理本地消息额度;游客 / 注册用户均以后端响应为准。
|
||||||
* - 后端返回 `cannotSendReason="insufficient_credits"` 时展示充值引导。
|
* - 后端返回 `canSendMessage=false` 时展示积分不足充值引导。
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ChatSendResponse, type UiMessage } from "@/data/dto/chat";
|
import { type ChatSendResponse, type UiMessage } from "@/data/dto/chat";
|
||||||
|
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||||
import { todayString } from "@/utils";
|
import { todayString } from "@/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,14 +15,7 @@ export function localMessagesToUi(
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
audioUrl?: string | null;
|
audioUrl?: string | null;
|
||||||
image?: { type: string | null; url: string | null };
|
image?: { type: string | null; url: string | null };
|
||||||
lockDetail?: {
|
lockDetail?: ChatLockDetailData;
|
||||||
locked: boolean;
|
|
||||||
showContent: boolean;
|
|
||||||
showUpgrade: boolean;
|
|
||||||
reason: string | null;
|
|
||||||
hint: string | null;
|
|
||||||
detail: Record<string, unknown> | null;
|
|
||||||
};
|
|
||||||
}>,
|
}>,
|
||||||
): UiMessage[] {
|
): UiMessage[] {
|
||||||
return records.map((m) => ({
|
return records.map((m) => ({
|
||||||
@@ -72,30 +66,19 @@ function getAiMessageDisplayContent(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deriveUiLockFields(
|
function deriveUiLockFields(
|
||||||
lockDetail:
|
lockDetail: ChatLockDetailData | undefined,
|
||||||
| {
|
|
||||||
locked: boolean;
|
|
||||||
showContent: boolean;
|
|
||||||
showUpgrade: boolean;
|
|
||||||
reason: string | null;
|
|
||||||
hint: string | null;
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
imageUrl?: string | null,
|
imageUrl?: string | null,
|
||||||
): Partial<UiMessage> {
|
): Partial<UiMessage> {
|
||||||
const lockReason = lockDetail?.reason ?? null;
|
const lockReason = lockDetail?.reason ?? null;
|
||||||
const isPrivateMessage = lockReason === "private_message";
|
const isLocked = lockDetail?.locked === true;
|
||||||
const isVoiceMessage = lockReason === "voice_message";
|
const isPrivateMessage = isLocked && lockReason === "private_message";
|
||||||
|
const isLockedImage =
|
||||||
|
isLocked && (lockReason === "image_paywall" || lockReason === "image");
|
||||||
return {
|
return {
|
||||||
...(imageUrl ? { imageUrl } : {}),
|
...(imageUrl ? { imageUrl } : {}),
|
||||||
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
...(imageUrl && isLockedImage ? { imagePaywalled: true } : {}),
|
||||||
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
|
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
|
||||||
...(isPrivateMessage ? { isPrivate: true } : {}),
|
...(isPrivateMessage ? { isPrivate: true } : {}),
|
||||||
...(isPrivateMessage && lockDetail?.locked
|
...(isPrivateMessage ? { lockedPrivate: true } : {}),
|
||||||
? { lockedPrivate: true }
|
|
||||||
: {}),
|
|
||||||
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
|
|
||||||
? { privateMessageHint: lockDetail.hint }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export const clearUpgradePromptAction = chatAssign(() => ({
|
|||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
canSendMessage: true,
|
canSendMessage: true,
|
||||||
cannotSendReason: null,
|
|
||||||
requiredCredits: 0,
|
requiredCredits: 0,
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
}));
|
}));
|
||||||
@@ -173,7 +172,6 @@ async function sendMessageViaHttp(content: string): Promise<{
|
|||||||
void chatRepo.prefetchMediaForSendResponse(result.data);
|
void chatRepo.prefetchMediaForSendResponse(result.data);
|
||||||
const isInsufficientCredits =
|
const isInsufficientCredits =
|
||||||
result.data.canSendMessage === false &&
|
result.data.canSendMessage === false &&
|
||||||
result.data.cannotSendReason === "insufficient_credits" &&
|
|
||||||
!hasRenderableSendResponse(result.data);
|
!hasRenderableSendResponse(result.data);
|
||||||
return {
|
return {
|
||||||
response: result.data,
|
response: result.data,
|
||||||
@@ -183,12 +181,17 @@ async function sendMessageViaHttp(content: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasRenderableSendResponse(response: ChatSendResponse): boolean {
|
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 (
|
return (
|
||||||
response.reply.trim().length > 0 ||
|
response.reply.trim().length > 0 ||
|
||||||
response.audioUrl.trim().length > 0 ||
|
response.audioUrl.trim().length > 0 ||
|
||||||
Boolean(response.image.url) ||
|
Boolean(response.image.url) ||
|
||||||
response.lockDetail.reason === "private_message" ||
|
isLockedPaidMessage
|
||||||
response.lockDetail.reason === "voice_message" ||
|
|
||||||
response.lockDetail.reason === "image_paywall"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export function applyHttpSendOutput(
|
|||||||
): Partial<ChatState> {
|
): Partial<ChatState> {
|
||||||
const { response, reply } = output;
|
const { response, reply } = output;
|
||||||
|
|
||||||
const isInsufficientCredits =
|
const isInsufficientCredits = response.canSendMessage === false;
|
||||||
response.canSendMessage === false &&
|
|
||||||
response.cannotSendReason === "insufficient_credits";
|
|
||||||
const upgradeReason: ChatUpgradeReason | null = isInsufficientCredits
|
const upgradeReason: ChatUpgradeReason | null = isInsufficientCredits
|
||||||
? "insufficient_credits"
|
? "insufficient_credits"
|
||||||
: null;
|
: 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 {
|
return {
|
||||||
messages: [...context.messages, reply],
|
messages: [...context.messages, reply],
|
||||||
...finishPendingReply(context),
|
...finishPendingReply(context),
|
||||||
@@ -92,7 +77,6 @@ function getSendCapabilityState(
|
|||||||
): Pick<
|
): Pick<
|
||||||
ChatState,
|
ChatState,
|
||||||
| "canSendMessage"
|
| "canSendMessage"
|
||||||
| "cannotSendReason"
|
|
||||||
| "creditBalance"
|
| "creditBalance"
|
||||||
| "creditsCharged"
|
| "creditsCharged"
|
||||||
| "requiredCredits"
|
| "requiredCredits"
|
||||||
@@ -100,7 +84,6 @@ function getSendCapabilityState(
|
|||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
canSendMessage: response.canSendMessage,
|
canSendMessage: response.canSendMessage,
|
||||||
cannotSendReason: response.cannotSendReason,
|
|
||||||
creditBalance: response.creditBalance,
|
creditBalance: response.creditBalance,
|
||||||
creditsCharged: response.creditsCharged,
|
creditsCharged: response.creditsCharged,
|
||||||
requiredCredits: response.requiredCredits,
|
requiredCredits: response.requiredCredits,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export interface ChatState {
|
|||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
upgradeReason: ChatUpgradeReason | null;
|
upgradeReason: ChatUpgradeReason | null;
|
||||||
canSendMessage: boolean;
|
canSendMessage: boolean;
|
||||||
cannotSendReason: string | null;
|
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
creditsCharged: number;
|
creditsCharged: number;
|
||||||
requiredCredits: number;
|
requiredCredits: number;
|
||||||
@@ -63,7 +62,6 @@ export const initialState: ChatState = {
|
|||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
upgradeReason: null,
|
upgradeReason: null,
|
||||||
canSendMessage: true,
|
canSendMessage: true,
|
||||||
cannotSendReason: null,
|
|
||||||
creditBalance: 0,
|
creditBalance: 0,
|
||||||
creditsCharged: 0,
|
creditsCharged: 0,
|
||||||
requiredCredits: 0,
|
requiredCredits: 0,
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ export function applySingleUnlockOutput(
|
|||||||
locked: output.response.lockDetail.locked,
|
locked: output.response.lockDetail.locked,
|
||||||
lockReason: output.response.lockDetail.reason,
|
lockReason: output.response.lockDetail.reason,
|
||||||
imagePaywalled: resolvedImageUrl
|
imagePaywalled: resolvedImageUrl
|
||||||
? output.response.lockDetail.locked &&
|
? isLockedImage(output.response.lockDetail)
|
||||||
output.response.lockDetail.showUpgrade
|
|
||||||
: undefined,
|
: undefined,
|
||||||
lockedPrivate: false,
|
lockedPrivate: false,
|
||||||
privateMessageHint: null,
|
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(
|
function getUnlockedAudioUrl(
|
||||||
message: UiMessage,
|
message: UiMessage,
|
||||||
response: UnlockPrivateResponse,
|
response: UnlockPrivateResponse,
|
||||||
|
|||||||
Reference in New Issue
Block a user