From 617f1d477780b13b359b02dfb86e747d64d96f0f Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 1 Jul 2026 10:47:55 +0800 Subject: [PATCH] fix(chat): preserve locked message content on unlock --- .../repositories/chat_local_message_store.ts | 18 ++- src/data/repositories/chat_repository.ts | 2 - .../interfaces/ichat_repository.ts | 1 - .../__tests__/chat-machine.helpers.test.ts | 6 +- .../chat-machine.transitions.test.ts | 118 +++++++++++++++++- src/stores/chat/chat-message-mappers.ts | 7 -- src/stores/chat/chat-unlock-flow.ts | 1 - src/stores/chat/chat-unlock-helpers.ts | 6 - 8 files changed, 130 insertions(+), 29 deletions(-) diff --git a/src/data/repositories/chat_local_message_store.ts b/src/data/repositories/chat_local_message_store.ts index 4dc96611..15cd7bb0 100644 --- a/src/data/repositories/chat_local_message_store.ts +++ b/src/data/repositories/chat_local_message_store.ts @@ -10,7 +10,6 @@ export class ChatLocalMessageStore { async markMessageUnlocked( messageId: string, - content: string, lockDetail?: ChatLockDetailData, ): Promise> { return Result.wrap(async () => { @@ -21,11 +20,10 @@ export class ChatLocalMessageStore { let changed = false; const updatedMessages = localResult.data.map((message) => { - if (message.id !== messageId) return message; + if (!shouldMarkMessageUnlocked(message, messageId)) return message; changed = true; return ChatMessage.from({ ...message.toJson(), - content, lockDetail: lockDetail ?? { ...message.lockDetail, 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" + ); +} diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 0d011a2c..81a583d7 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -66,12 +66,10 @@ export class ChatRepository implements IChatRepository { /** 把本地缓存中的单条锁定消息标记为已解锁。 */ async markPrivateMessageUnlockedInLocal( messageId: string, - content: string, lockDetail?: ChatLockDetailData, ): Promise> { return this.localMessages.markMessageUnlocked( messageId, - content, lockDetail, ); } diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 64c48a99..0eb0043a 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -51,7 +51,6 @@ export interface IChatRepository { /** 把本地缓存中的单条锁定消息标记为已解锁。 */ markPrivateMessageUnlockedInLocal( messageId: string, - content: string, lockDetail?: ChatLockDetailData, ): Promise>; diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index 7115f3a9..a03ddf2c 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -79,7 +79,7 @@ describe("sendResponseToUiMessage", () => { }), ); - expect(message.content).toBe(""); + expect(message.content).toBe("AI reply"); expect(message.audioUrl).toBe( "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.lockReason).toBe("private_message"); 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.locked).toBe(true); expect(message.lockReason).toBe("voice_message"); diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index c23cb0a2..61d3e527 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -480,7 +480,7 @@ describe("chatMachine transitions", () => { historyMessages: [ { id: "msg-private-locked", - content: "", + content: "Original private message content.", isFromAI: true, date: "2026-06-29", locked: true, @@ -492,7 +492,7 @@ describe("chatMachine transitions", () => { unlockMessageOutput: { messageId: "msg-private-locked", 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([ { id: "msg-private-locked", - content: "This is the unlocked private message.", + content: "Original private message content.", locked: false, lockReason: null, lockedPrivate: false, @@ -541,7 +541,7 @@ describe("chatMachine transitions", () => { }, { id: "msg-shared-id", - content: "", + content: "Original AI private message", isFromAI: true, date: "2026-06-29", locked: true, @@ -553,7 +553,7 @@ describe("chatMachine transitions", () => { unlockMessageOutput: { messageId: "msg-shared-id", 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", - content: "This is the unlocked private message.", + content: "Original AI private message", isFromAI: true, locked: false, lockReason: null, @@ -594,6 +594,112 @@ describe("chatMachine transitions", () => { 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 () => { const actor = createActor( createTestChatMachine({ diff --git a/src/stores/chat/chat-message-mappers.ts b/src/stores/chat/chat-message-mappers.ts index 493c9282..bd50b0ea 100644 --- a/src/stores/chat/chat-message-mappers.ts +++ b/src/stores/chat/chat-message-mappers.ts @@ -31,7 +31,6 @@ export function localMessagesToUi( content: m.content, isFromAI: m.role === "assistant", hasImage: Boolean(m.image?.url), - lockDetail: m.lockDetail, }), isFromAI: m.role === "assistant", date: messageDateFromCreatedAt(m.createdAt), @@ -51,7 +50,6 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { content: response.reply, isFromAI: true, hasImage: Boolean(response.image.url), - lockDetail: response.lockDetail, }), isFromAI: true, date: todayString(new Date(response.timestamp)), @@ -70,12 +68,7 @@ function getAiMessageDisplayContent(input: { content: string; isFromAI: boolean; hasImage?: boolean; - lockDetail?: { - showContent: boolean; - reason: string | null; - }; }): string { - if (input.lockDetail?.showContent === false) return ""; return input.isFromAI && input.hasImage ? "" : input.content; } diff --git a/src/stores/chat/chat-unlock-flow.ts b/src/stores/chat/chat-unlock-flow.ts index 4ef745d2..e3e39cd3 100644 --- a/src/stores/chat/chat-unlock-flow.ts +++ b/src/stores/chat/chat-unlock-flow.ts @@ -63,7 +63,6 @@ export const chatUnlockActors = { if (unlockResult.data.unlocked) { const markResult = await chatRepo.markPrivateMessageUnlockedInLocal( input.messageId, - unlockResult.data.content, unlockResult.data.lockDetail, ); if (Result.isErr(markResult)) { diff --git a/src/stores/chat/chat-unlock-helpers.ts b/src/stores/chat/chat-unlock-helpers.ts index e4477536..661a6386 100644 --- a/src/stores/chat/chat-unlock-helpers.ts +++ b/src/stores/chat/chat-unlock-helpers.ts @@ -18,14 +18,8 @@ export function applySingleUnlockOutput( return messages.map((message) => { if (!shouldApplySingleUnlock(message, output.messageId)) return message; - const nextContent = - output.response.content.length > 0 - ? output.response.content - : message.content; - return { ...message, - content: nextContent, locked: output.response.lockDetail.locked, lockReason: output.response.lockDetail.reason, imagePaywalled: message.imageUrl