fix(chat): preserve locked message content on unlock
This commit is contained in:
@@ -10,7 +10,6 @@ export class ChatLocalMessageStore {
|
|||||||
|
|
||||||
async markMessageUnlocked(
|
async markMessageUnlocked(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
content: string,
|
|
||||||
lockDetail?: ChatLockDetailData,
|
lockDetail?: ChatLockDetailData,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return Result.wrap(async () => {
|
return Result.wrap(async () => {
|
||||||
@@ -21,11 +20,10 @@ export class ChatLocalMessageStore {
|
|||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const updatedMessages = localResult.data.map((message) => {
|
const updatedMessages = localResult.data.map((message) => {
|
||||||
if (message.id !== messageId) return message;
|
if (!shouldMarkMessageUnlocked(message, messageId)) return message;
|
||||||
changed = true;
|
changed = true;
|
||||||
return ChatMessage.from({
|
return ChatMessage.from({
|
||||||
...message.toJson(),
|
...message.toJson(),
|
||||||
content,
|
|
||||||
lockDetail: lockDetail ?? {
|
lockDetail: lockDetail ?? {
|
||||||
...message.lockDetail,
|
...message.lockDetail,
|
||||||
locked: false,
|
locked: false,
|
||||||
@@ -106,3 +104,17 @@ export class ChatLocalMessageStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldMarkMessageUnlocked(
|
||||||
|
message: ChatMessage,
|
||||||
|
messageId: string,
|
||||||
|
): boolean {
|
||||||
|
if (message.id !== messageId) return false;
|
||||||
|
if (message.role !== "assistant") return false;
|
||||||
|
if (message.lockDetail.locked !== true) return false;
|
||||||
|
return (
|
||||||
|
Boolean(message.image.url) ||
|
||||||
|
message.lockDetail.reason === "private_message" ||
|
||||||
|
message.lockDetail.reason === "voice_message"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,12 +66,10 @@ export class ChatRepository implements IChatRepository {
|
|||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
async markPrivateMessageUnlockedInLocal(
|
async markPrivateMessageUnlockedInLocal(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
content: string,
|
|
||||||
lockDetail?: ChatLockDetailData,
|
lockDetail?: ChatLockDetailData,
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return this.localMessages.markMessageUnlocked(
|
return this.localMessages.markMessageUnlocked(
|
||||||
messageId,
|
messageId,
|
||||||
content,
|
|
||||||
lockDetail,
|
lockDetail,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export interface IChatRepository {
|
|||||||
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
/** 把本地缓存中的单条锁定消息标记为已解锁。 */
|
||||||
markPrivateMessageUnlockedInLocal(
|
markPrivateMessageUnlockedInLocal(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
content: string,
|
|
||||||
lockDetail?: ChatLockDetailData,
|
lockDetail?: ChatLockDetailData,
|
||||||
): Promise<Result<void>>;
|
): Promise<Result<void>>;
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(message.content).toBe("");
|
expect(message.content).toBe("AI reply");
|
||||||
expect(message.audioUrl).toBe(
|
expect(message.audioUrl).toBe(
|
||||||
"https://proapi.banlv-ai.com/audio/5198b93c-2915-406a.mp3",
|
"https://proapi.banlv-ai.com/audio/5198b93c-2915-406a.mp3",
|
||||||
);
|
);
|
||||||
@@ -105,7 +105,7 @@ describe("sendResponseToUiMessage", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(message.content).toBe("");
|
expect(message.content).toBe("AI reply");
|
||||||
expect(message.locked).toBe(true);
|
expect(message.locked).toBe(true);
|
||||||
expect(message.lockReason).toBe("private_message");
|
expect(message.lockReason).toBe("private_message");
|
||||||
expect(message.isPrivate).toBe(true);
|
expect(message.isPrivate).toBe(true);
|
||||||
@@ -293,7 +293,7 @@ describe("localMessagesToUi", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(message.content).toBe("");
|
expect(message.content).toBe("hidden voice transcript");
|
||||||
expect(message.audioUrl).toBe("https://example.com/voice.mp3");
|
expect(message.audioUrl).toBe("https://example.com/voice.mp3");
|
||||||
expect(message.locked).toBe(true);
|
expect(message.locked).toBe(true);
|
||||||
expect(message.lockReason).toBe("voice_message");
|
expect(message.lockReason).toBe("voice_message");
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ describe("chatMachine transitions", () => {
|
|||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-private-locked",
|
id: "msg-private-locked",
|
||||||
content: "",
|
content: "Original private message content.",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
locked: true,
|
locked: true,
|
||||||
@@ -492,7 +492,7 @@ describe("chatMachine transitions", () => {
|
|||||||
unlockMessageOutput: {
|
unlockMessageOutput: {
|
||||||
messageId: "msg-private-locked",
|
messageId: "msg-private-locked",
|
||||||
response: makeUnlockPrivateResponse({
|
response: makeUnlockPrivateResponse({
|
||||||
content: "This is the unlocked private message.",
|
content: "This response content must be ignored.",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -518,7 +518,7 @@ describe("chatMachine transitions", () => {
|
|||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
id: "msg-private-locked",
|
id: "msg-private-locked",
|
||||||
content: "This is the unlocked private message.",
|
content: "Original private message content.",
|
||||||
locked: false,
|
locked: false,
|
||||||
lockReason: null,
|
lockReason: null,
|
||||||
lockedPrivate: false,
|
lockedPrivate: false,
|
||||||
@@ -541,7 +541,7 @@ describe("chatMachine transitions", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "msg-shared-id",
|
id: "msg-shared-id",
|
||||||
content: "",
|
content: "Original AI private message",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
locked: true,
|
locked: true,
|
||||||
@@ -553,7 +553,7 @@ describe("chatMachine transitions", () => {
|
|||||||
unlockMessageOutput: {
|
unlockMessageOutput: {
|
||||||
messageId: "msg-shared-id",
|
messageId: "msg-shared-id",
|
||||||
response: makeUnlockPrivateResponse({
|
response: makeUnlockPrivateResponse({
|
||||||
content: "This is the unlocked private message.",
|
content: "This response content must be ignored.",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -582,7 +582,7 @@ describe("chatMachine transitions", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "msg-shared-id",
|
id: "msg-shared-id",
|
||||||
content: "This is the unlocked private message.",
|
content: "Original AI private message",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
locked: false,
|
locked: false,
|
||||||
lockReason: null,
|
lockReason: null,
|
||||||
@@ -594,6 +594,112 @@ describe("chatMachine transitions", () => {
|
|||||||
actor.stop();
|
actor.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps image message text empty after a single image unlock succeeds", async () => {
|
||||||
|
const actor = createActor(
|
||||||
|
createTestChatMachine({
|
||||||
|
historyMessages: [
|
||||||
|
{
|
||||||
|
id: "msg-image-locked",
|
||||||
|
content: "",
|
||||||
|
isFromAI: true,
|
||||||
|
date: "2026-06-29",
|
||||||
|
imageUrl: "https://example.com/locked.jpg",
|
||||||
|
imagePaywalled: true,
|
||||||
|
locked: true,
|
||||||
|
lockReason: "image",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unlockMessageOutput: {
|
||||||
|
messageId: "msg-image-locked",
|
||||||
|
response: makeUnlockPrivateResponse({
|
||||||
|
content: "This text should not render below the image.",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).start();
|
||||||
|
|
||||||
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
|
await waitFor(actor, (snapshot) =>
|
||||||
|
snapshot.matches({ userSession: "ready" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
actor.send({
|
||||||
|
type: "ChatUnlockMessageRequested",
|
||||||
|
messageId: "msg-image-locked",
|
||||||
|
kind: "image",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(actor, (snapshot) =>
|
||||||
|
snapshot.matches({ userSession: "ready" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
id: "msg-image-locked",
|
||||||
|
content: "",
|
||||||
|
imageUrl: "https://example.com/locked.jpg",
|
||||||
|
imagePaywalled: false,
|
||||||
|
locked: false,
|
||||||
|
lockReason: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps voice message content unchanged after unlock succeeds", async () => {
|
||||||
|
const actor = createActor(
|
||||||
|
createTestChatMachine({
|
||||||
|
historyMessages: [
|
||||||
|
{
|
||||||
|
id: "msg-voice-locked",
|
||||||
|
content: "Original voice transcript.",
|
||||||
|
isFromAI: true,
|
||||||
|
date: "2026-06-29",
|
||||||
|
audioUrl: "https://example.com/voice.mp3",
|
||||||
|
locked: true,
|
||||||
|
lockReason: "voice_message",
|
||||||
|
privateMessageHint: "A voice message is waiting.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unlockMessageOutput: {
|
||||||
|
messageId: "msg-voice-locked",
|
||||||
|
response: makeUnlockPrivateResponse({
|
||||||
|
content: "This response content must be ignored.",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).start();
|
||||||
|
|
||||||
|
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||||
|
await waitFor(actor, (snapshot) =>
|
||||||
|
snapshot.matches({ userSession: "ready" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
actor.send({
|
||||||
|
type: "ChatUnlockMessageRequested",
|
||||||
|
messageId: "msg-voice-locked",
|
||||||
|
kind: "voice",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(actor, (snapshot) =>
|
||||||
|
snapshot.matches({ userSession: "ready" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
id: "msg-voice-locked",
|
||||||
|
content: "Original voice transcript.",
|
||||||
|
audioUrl: "https://example.com/voice.mp3",
|
||||||
|
locked: false,
|
||||||
|
lockReason: null,
|
||||||
|
privateMessageHint: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
actor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("requests payment when single message unlock lacks credits", async () => {
|
it("requests payment when single message unlock lacks credits", async () => {
|
||||||
const actor = createActor(
|
const actor = createActor(
|
||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function localMessagesToUi(
|
|||||||
content: m.content,
|
content: m.content,
|
||||||
isFromAI: m.role === "assistant",
|
isFromAI: m.role === "assistant",
|
||||||
hasImage: Boolean(m.image?.url),
|
hasImage: Boolean(m.image?.url),
|
||||||
lockDetail: m.lockDetail,
|
|
||||||
}),
|
}),
|
||||||
isFromAI: m.role === "assistant",
|
isFromAI: m.role === "assistant",
|
||||||
date: messageDateFromCreatedAt(m.createdAt),
|
date: messageDateFromCreatedAt(m.createdAt),
|
||||||
@@ -51,7 +50,6 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
|||||||
content: response.reply,
|
content: response.reply,
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
hasImage: Boolean(response.image.url),
|
hasImage: Boolean(response.image.url),
|
||||||
lockDetail: response.lockDetail,
|
|
||||||
}),
|
}),
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: todayString(new Date(response.timestamp)),
|
date: todayString(new Date(response.timestamp)),
|
||||||
@@ -70,12 +68,7 @@ function getAiMessageDisplayContent(input: {
|
|||||||
content: string;
|
content: string;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
hasImage?: boolean;
|
hasImage?: boolean;
|
||||||
lockDetail?: {
|
|
||||||
showContent: boolean;
|
|
||||||
reason: string | null;
|
|
||||||
};
|
|
||||||
}): string {
|
}): string {
|
||||||
if (input.lockDetail?.showContent === false) return "";
|
|
||||||
return input.isFromAI && input.hasImage ? "" : input.content;
|
return input.isFromAI && input.hasImage ? "" : input.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export const chatUnlockActors = {
|
|||||||
if (unlockResult.data.unlocked) {
|
if (unlockResult.data.unlocked) {
|
||||||
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||||
input.messageId,
|
input.messageId,
|
||||||
unlockResult.data.content,
|
|
||||||
unlockResult.data.lockDetail,
|
unlockResult.data.lockDetail,
|
||||||
);
|
);
|
||||||
if (Result.isErr(markResult)) {
|
if (Result.isErr(markResult)) {
|
||||||
|
|||||||
@@ -18,14 +18,8 @@ export function applySingleUnlockOutput(
|
|||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
if (!shouldApplySingleUnlock(message, output.messageId)) return message;
|
if (!shouldApplySingleUnlock(message, output.messageId)) return message;
|
||||||
|
|
||||||
const nextContent =
|
|
||||||
output.response.content.length > 0
|
|
||||||
? output.response.content
|
|
||||||
: message.content;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
content: nextContent,
|
|
||||||
locked: output.response.lockDetail.locked,
|
locked: output.response.lockDetail.locked,
|
||||||
lockReason: output.response.lockDetail.reason,
|
lockReason: output.response.lockDetail.reason,
|
||||||
imagePaywalled: message.imageUrl
|
imagePaywalled: message.imageUrl
|
||||||
|
|||||||
Reference in New Issue
Block a user