From ca55723e4822110442940a743eb4aacb8787701a Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 14 Jul 2026 15:20:56 +0800 Subject: [PATCH] fix(chat): sync paid voice audio unlock flow --- .../__tests__/unlock_private_response.test.ts | 47 ++----------------- .../chat/response/unlock_private_response.ts | 2 - .../chat_media_cache_helpers.test.ts | 28 ++++++++++- .../repositories/chat_media_cache_helpers.ts | 12 +++-- .../chat/response/unlock_private_response.ts | 3 +- .../__tests__/chat-machine.helpers.test.ts | 32 +++++++++++-- .../chat-machine.transitions.test.ts | 8 ---- .../chat/__tests__/chat-promotion.test.ts | 4 -- src/stores/chat/chat-message-mappers.ts | 8 +++- src/stores/chat/chat-send-flow.ts | 2 +- src/stores/chat/chat-unlock-flow.ts | 2 +- src/stores/chat/chat-unlock-helpers.ts | 18 ++----- 12 files changed, 78 insertions(+), 88 deletions(-) diff --git a/src/data/dto/chat/__tests__/unlock_private_response.test.ts b/src/data/dto/chat/__tests__/unlock_private_response.test.ts index bfd53544..71b49890 100644 --- a/src/data/dto/chat/__tests__/unlock_private_response.test.ts +++ b/src/data/dto/chat/__tests__/unlock_private_response.test.ts @@ -13,10 +13,6 @@ describe("UnlockPrivateResponse", () => { creditsCharged: 10, requiredCredits: 10, shortfallCredits: 0, - lockDetail: { - locked: false, - reason: null, - }, }); expect(response.unlocked).toBe(true); @@ -27,7 +23,6 @@ describe("UnlockPrivateResponse", () => { expect(response.creditsCharged).toBe(10); expect(response.requiredCredits).toBe(10); expect(response.shortfallCredits).toBe(0); - expect(response.lockDetail.locked).toBe(false); }); it("normalizes backend ids, audio aliases, and unlocked images", () => { @@ -43,10 +38,6 @@ describe("UnlockPrivateResponse", () => { type: "promotion", url: "https://example.com/image.jpg", }, - lockDetail: { - locked: false, - reason: null, - }, }); expect(response.messageId).toBe("backend-message-1"); @@ -66,10 +57,6 @@ describe("UnlockPrivateResponse", () => { creditsCharged: 0, requiredCredits: 10, shortfallCredits: 5, - lockDetail: { - locked: true, - reason: "voice_message", - }, }); expect(response.unlocked).toBe(false); @@ -78,10 +65,6 @@ describe("UnlockPrivateResponse", () => { expect(response.creditsCharged).toBe(0); expect(response.requiredCredits).toBe(10); expect(response.shortfallCredits).toBe(5); - expect(response.lockDetail).toEqual({ - locked: true, - reason: "voice_message", - }); }); it("defaults nullable content to an empty string", () => { @@ -90,10 +73,6 @@ describe("UnlockPrivateResponse", () => { content: null, audioUrl: null, reason: "insufficient_balance", - lockDetail: { - locked: true, - reason: "private_message", - }, }); expect(response.content).toBe(""); @@ -109,10 +88,6 @@ describe("UnlockPrivateResponse", () => { creditsCharged: null, requiredCredits: null, shortfallCredits: null, - lockDetail: { - locked: true, - reason: "image_paywall", - }, }); expect(response.creditBalance).toBe(0); @@ -121,21 +96,7 @@ describe("UnlockPrivateResponse", () => { expect(response.shortfallCredits).toBe(0); }); - it("defaults nullable lock detail through schema helpers", () => { - const response = UnlockPrivateResponse.from({ - unlocked: false, - content: "", - reason: "insufficient_balance", - lockDetail: null, - }); - - expect(response.lockDetail).toEqual({ - locked: false, - reason: null, - }); - }); - - it("strips legacy lock detail fields while preserving lock state and type", () => { + it("strips legacy lock detail fields", () => { const response = UnlockPrivateResponse.fromJson({ unlocked: false, lockDetail: { @@ -148,9 +109,7 @@ describe("UnlockPrivateResponse", () => { }, }); - expect(response.lockDetail).toEqual({ - locked: true, - reason: "private_message", - }); + expect(response.toJson()).not.toHaveProperty("lockDetail"); + expect(response).not.toHaveProperty("lockDetail"); }); }); diff --git a/src/data/dto/chat/response/unlock_private_response.ts b/src/data/dto/chat/response/unlock_private_response.ts index d3c9a509..bdb94b9c 100644 --- a/src/data/dto/chat/response/unlock_private_response.ts +++ b/src/data/dto/chat/response/unlock_private_response.ts @@ -4,7 +4,6 @@ import { type UnlockPrivateResponseData, type UnlockPrivateResponseInput, } from "@/data/schemas/chat/response/unlock_private_response"; -import type { ChatLockDetailData } from "@/data/schemas/chat"; import type { ChatImageData, ChatLockType } from "@/data/schemas/chat"; /** @@ -23,7 +22,6 @@ export class UnlockPrivateResponse { declare readonly creditsCharged: number; declare readonly requiredCredits: number; declare readonly shortfallCredits: number; - declare readonly lockDetail: ChatLockDetailData; private constructor(input: UnlockPrivateResponseInput) { const data = UnlockPrivateResponseSchema.parse(input); diff --git a/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts b/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts index 73ed6424..60481516 100644 --- a/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts +++ b/src/data/repositories/__tests__/chat_media_cache_helpers.test.ts @@ -34,7 +34,6 @@ function makeResponse( ): ChatSendResponse { return ChatSendResponse.from({ reply: "", - audioUrl: "", messageId: "msg-1", isGuest: false, timestamp: 1782356425363, @@ -92,6 +91,33 @@ describe("chat media cache helpers", () => { ).toEqual([]); }); + it("does not cache audio from a locked send response", () => { + expect( + getSendResponseMediaTargets( + makeResponse({ + audioUrl: "https://example.com/locked.mp3", + lockDetail: { locked: true, reason: "voice_message" }, + }), + ), + ).toEqual([]); + }); + + it("caches audio from an unlocked send response", () => { + expect( + getSendResponseMediaTargets( + makeResponse({ + audioUrl: "https://example.com/unlocked.mp3", + }), + ), + ).toEqual([ + { + messageId: "msg-1", + kind: "audio", + remoteUrl: "https://example.com/unlocked.mp3", + }, + ]); + }); + it("deduplicates media targets", () => { expect( uniqueMediaTargets([ diff --git a/src/data/repositories/chat_media_cache_helpers.ts b/src/data/repositories/chat_media_cache_helpers.ts index de7e1a23..865911a2 100644 --- a/src/data/repositories/chat_media_cache_helpers.ts +++ b/src/data/repositories/chat_media_cache_helpers.ts @@ -42,11 +42,13 @@ export function getSendResponseMediaTargets( kind: "image", remoteUrl: response.image.url, }); - pushMediaTarget(targets, { - messageId: response.messageId, - kind: "audio", - remoteUrl: response.audioUrl, - }); + if (!response.lockDetail.locked) { + pushMediaTarget(targets, { + messageId: response.messageId, + kind: "audio", + remoteUrl: response.audioUrl, + }); + } return targets; } diff --git a/src/data/schemas/chat/response/unlock_private_response.ts b/src/data/schemas/chat/response/unlock_private_response.ts index 24dedc9f..a76f2d87 100644 --- a/src/data/schemas/chat/response/unlock_private_response.ts +++ b/src/data/schemas/chat/response/unlock_private_response.ts @@ -6,7 +6,7 @@ import { stringOrEmpty, stringOrNull, } from "../../nullable-defaults"; -import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads"; +import { ChatImageSchema } from "../chat_payloads"; import { ChatLockTypeSchema } from "../chat_lock_type"; /** @@ -31,7 +31,6 @@ export const UnlockPrivateResponseSchema = z creditsCharged: numberOrZero, requiredCredits: numberOrZero, shortfallCredits: numberOrZero, - lockDetail: ChatLockDetailSchema, }) .transform(({ reply, message_id, audio_url, ...data }) => ({ ...data, diff --git a/src/stores/chat/__tests__/chat-machine.helpers.test.ts b/src/stores/chat/__tests__/chat-machine.helpers.test.ts index 4df4158e..ea5c4714 100644 --- a/src/stores/chat/__tests__/chat-machine.helpers.test.ts +++ b/src/stores/chat/__tests__/chat-machine.helpers.test.ts @@ -14,7 +14,6 @@ function makeResponse( ): ChatSendResponse { return ChatSendResponse.from({ reply: "AI reply", - audioUrl: "", messageId: "msg-1", isGuest: false, timestamp: 1782356425363, @@ -62,7 +61,6 @@ describe("sendResponseToUiMessage", () => { it("maps locked voice messages without treating them as private text", () => { const message = sendResponseToUiMessage( makeResponse({ - audioUrl: "", lockDetail: { locked: true, reason: "voice_message", @@ -78,6 +76,34 @@ describe("sendResponseToUiMessage", () => { expect(message.privateMessageHint).toBeUndefined(); }); + it("does not expose an audio URL from a locked send response", () => { + const message = sendResponseToUiMessage( + makeResponse({ + audioUrl: "https://example.com/locked-voice.mp3", + lockDetail: { + locked: true, + reason: "voice_message", + }, + }), + ); + + expect(message.audioUrl).toBeUndefined(); + expect(message.locked).toBe(true); + }); + + it("maps an audio URL from an unlocked send response", () => { + const message = sendResponseToUiMessage( + makeResponse({ + audioUrl: "https://example.com/unlocked-voice.mp3", + }), + ); + + expect(message.audioUrl).toBe( + "https://example.com/unlocked-voice.mp3", + ); + expect(message.locked).toBe(false); + }); + it("maps locked private text messages as private messages", () => { const message = sendResponseToUiMessage( makeResponse({ @@ -227,7 +253,7 @@ describe("localMessagesToUi", () => { type: "voice", content: "hidden voice transcript", createdAt: "2026-06-25T12:00:00.000Z", - audioUrl: null, + audioUrl: "https://example.com/locked-history-voice.mp3", image: { type: null, url: null }, lockDetail: { locked: true, diff --git a/src/stores/chat/__tests__/chat-machine.transitions.test.ts b/src/stores/chat/__tests__/chat-machine.transitions.test.ts index f980beeb..689e6c7c 100644 --- a/src/stores/chat/__tests__/chat-machine.transitions.test.ts +++ b/src/stores/chat/__tests__/chat-machine.transitions.test.ts @@ -57,10 +57,6 @@ function makeUnlockPrivateResponse( creditsCharged: 10, requiredCredits: 10, shortfallCredits: 0, - lockDetail: { - locked: false, - reason: null, - }, ...overrides, }); } @@ -822,10 +818,6 @@ describe("chatMachine transitions", () => { creditsCharged: 0, requiredCredits: 10, shortfallCredits: 7, - lockDetail: { - locked: true, - reason: "voice_message", - }, }), }, }), diff --git a/src/stores/chat/__tests__/chat-promotion.test.ts b/src/stores/chat/__tests__/chat-promotion.test.ts index 3d640ad1..06402167 100644 --- a/src/stores/chat/__tests__/chat-promotion.test.ts +++ b/src/stores/chat/__tests__/chat-promotion.test.ts @@ -57,10 +57,6 @@ describe("chat promotion", () => { type: "promotion", url: "https://example.com/unlocked.jpg", }, - lockDetail: { - locked: false, - reason: null, - }, }), }); diff --git a/src/stores/chat/chat-message-mappers.ts b/src/stores/chat/chat-message-mappers.ts index 4aa5c353..8d3cde56 100644 --- a/src/stores/chat/chat-message-mappers.ts +++ b/src/stores/chat/chat-message-mappers.ts @@ -27,7 +27,9 @@ export function localMessagesToUi( }), isFromAI: m.role === "assistant", date: messageDateFromCreatedAt(m.createdAt), - ...(m.audioUrl ? { audioUrl: m.audioUrl } : {}), + ...(m.audioUrl && m.lockDetail?.locked !== true + ? { audioUrl: m.audioUrl } + : {}), ...deriveUiLockFields(m.lockDetail, m.image?.url), })); } @@ -46,7 +48,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { }), isFromAI: true, date: todayString(new Date(response.timestamp)), - ...(response.audioUrl ? { audioUrl: response.audioUrl } : {}), + ...(response.audioUrl && !response.lockDetail.locked + ? { audioUrl: response.audioUrl } + : {}), ...deriveUiLockFields(response.lockDetail, response.image.url), }; } diff --git a/src/stores/chat/chat-send-flow.ts b/src/stores/chat/chat-send-flow.ts index 8546a159..74ac04b6 100644 --- a/src/stores/chat/chat-send-flow.ts +++ b/src/stores/chat/chat-send-flow.ts @@ -197,7 +197,7 @@ function hasRenderableSendResponse(response: ChatSendResponse): boolean { return ( response.reply.trim().length > 0 || - response.audioUrl.trim().length > 0 || + (!response.lockDetail.locked && response.audioUrl.trim().length > 0) || Boolean(response.image.url) || isLockedPaidMessage ); diff --git a/src/stores/chat/chat-unlock-flow.ts b/src/stores/chat/chat-unlock-flow.ts index 0f19e5e3..fc7e08a6 100644 --- a/src/stores/chat/chat-unlock-flow.ts +++ b/src/stores/chat/chat-unlock-flow.ts @@ -80,7 +80,7 @@ export const unlockMessageActor = fromPromise< ...(unlockResult.data.image.url ? { image: unlockResult.data.image } : {}), - lockDetail: unlockResult.data.lockDetail, + lockDetail: { locked: false, reason: null }, }, cacheIdentityResult.data, ); diff --git a/src/stores/chat/chat-unlock-helpers.ts b/src/stores/chat/chat-unlock-helpers.ts index 9760ed61..1909b16b 100644 --- a/src/stores/chat/chat-unlock-helpers.ts +++ b/src/stores/chat/chat-unlock-helpers.ts @@ -48,27 +48,15 @@ export function applySingleUnlockOutput( content: resolvedContent, audioUrl: getUnlockedAudioUrl(message, output.response), imageUrl: resolvedImageUrl, - locked: output.response.lockDetail.locked, - lockReason: output.response.lockDetail.reason, - imagePaywalled: resolvedImageUrl - ? isLockedImage(output.response.lockDetail) - : undefined, + locked: false, + lockReason: null, + imagePaywalled: resolvedImageUrl ? false : undefined, lockedPrivate: false, privateMessageHint: null, }; }); } -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,