fix(chat): preserve locked message content on unlock

This commit is contained in:
2026-07-01 10:47:55 +08:00
parent 9c45c7eb69
commit 617f1d4777
8 changed files with 130 additions and 29 deletions
@@ -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"
);
}
-2
View File
@@ -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({
-7
View File
@@ -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;
} }
-1
View File
@@ -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)) {
-6
View File
@@ -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