diff --git a/src/app/chat/__tests__/chat-area.helpers.test.ts b/src/app/chat/__tests__/chat-area.helpers.test.ts index 04d44b81..6a014e0d 100644 --- a/src/app/chat/__tests__/chat-area.helpers.test.ts +++ b/src/app/chat/__tests__/chat-area.helpers.test.ts @@ -10,12 +10,13 @@ import { describe("chat area render items", () => { it("keeps local message keys stable when history is prepended", () => { const localMessage: UiMessage = { + displayId: "client:message:local-1", content: "optimistic message", isFromAI: false, date: "2026-07-08", }; const historyMessage: UiMessage = { - id: "history-msg-1", + displayId: "history-msg-1", content: "history message", isFromAI: true, date: "2026-07-07", @@ -28,12 +29,34 @@ describe("chat area render items", () => { getMessageKey, ); - expect(findMessageKey(firstItems, localMessage)).toBe("local-msg-1"); - expect(findMessageKey(secondItems, localMessage)).toBe("local-msg-1"); + expect(findMessageKey(firstItems, localMessage)).toBe( + "msg-client:message:local-1", + ); + expect(findMessageKey(secondItems, localMessage)).toBe( + "msg-client:message:local-1", + ); expect(findMessageKey(secondItems, historyMessage)).toBe( "msg-history-msg-1", ); }); + + it("derives the same key after a message object is recreated", () => { + const getMessageKey = createChatMessageKeyResolver(); + const original: UiMessage = { + displayId: "server:message-1:assistant", + remoteId: "message-1", + content: "Original", + isFromAI: true, + date: "2026-07-20", + }; + const updated: UiMessage = { + ...original, + content: "Unlocked", + locked: false, + }; + + expect(getMessageKey(updated)).toBe(getMessageKey(original)); + }); }); function findMessageKey( diff --git a/src/app/chat/__tests__/chat-unlock-coordinator.test.ts b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts index 4b5ab8a8..8c7de576 100644 --- a/src/app/chat/__tests__/chat-unlock-coordinator.test.ts +++ b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts @@ -9,6 +9,7 @@ import { createChatPromotionState } from "@/stores/chat/helper/promotion"; import { resolveChatUnlockReturnUrl, resolveMessageUnlockRequest, + resolvePendingUnlockDisplayMessageId, shouldResumePendingChatUnlock, } from "../hooks/use-chat-unlock-coordinator"; @@ -31,11 +32,15 @@ describe("chat unlock coordinator", () => { it("creates a regular message unlock request with its remote id", () => { expect( resolveMessageUnlockRequest( - { messageId: "message-1", kind: "private" }, + { + displayMessageId: "server:message-1:assistant", + remoteMessageId: "message-1", + kind: "private", + }, defaultScope, ), ).toEqual({ - displayMessageId: "message-1", + displayMessageId: "server:message-1:assistant", messageId: "message-1", kind: "private", returnUrl: "/chat", @@ -51,7 +56,11 @@ describe("chat unlock coordinator", () => { expect( resolveMessageUnlockRequest( - { messageId: "image-1", kind: "image" }, + { + displayMessageId: "image-1", + remoteMessageId: "image-1", + kind: "image", + }, imageScope, ).returnUrl, ).toBe("/chat?image=image-1"); @@ -62,7 +71,10 @@ describe("chat unlock coordinator", () => { expect( resolveMessageUnlockRequest( - { messageId: "promotion:promotion-1", kind: "image" }, + { + displayMessageId: "promotion:promotion-1", + kind: "image", + }, { ...defaultScope, promotion }, ), ).toEqual({ @@ -83,7 +95,11 @@ describe("chat unlock coordinator", () => { expect( resolveMessageUnlockRequest( - { messageId: "promotion-message-1", kind: "image" }, + { + displayMessageId: "promotion:promotion-1", + remoteMessageId: "promotion-message-1", + kind: "image", + }, { ...defaultScope, promotion }, ).messageId, ).toBe("promotion-message-1"); @@ -103,6 +119,40 @@ describe("chat unlock coordinator", () => { ).toBe(true); }); + it("maps a legacy pending remote id to the current display identity", () => { + const pending = createPendingUnlock({ + displayMessageId: "message-1", + messageId: "message-1", + kind: "private", + returnUrl: "/chat", + }); + + expect( + resolvePendingUnlockDisplayMessageId( + pending, + [ + { + displayId: "server:message-1:user", + remoteId: "message-1", + content: "Question", + isFromAI: false, + date: "2026-07-20", + }, + { + displayId: "server:message-1:assistant", + remoteId: "message-1", + content: "Locked reply", + isFromAI: true, + date: "2026-07-20", + locked: true, + lockReason: "private_message", + }, + ], + null, + ), + ).toBe("server:message-1:assistant"); + }); + it("only resumes an image return when its message matches the overlay", () => { const imageScope = { defaultReturnUrl: "/chat", @@ -136,7 +186,7 @@ describe("chat unlock coordinator", () => { expect( resolveMessageUnlockRequest( - { messageId: imageMessageId, kind: "image" }, + { displayMessageId: imageMessageId, kind: "image" }, { ...imageScope, promotion, diff --git a/src/app/chat/chat-render-items.ts b/src/app/chat/chat-render-items.ts index 4dd4f9d9..daa565b0 100644 --- a/src/app/chat/chat-render-items.ts +++ b/src/app/chat/chat-render-items.ts @@ -7,20 +7,7 @@ export type ChatRenderItem = | { type: "msg"; message: UiMessage; key: string }; export function createChatMessageKeyResolver(): ChatMessageKeyResolver { - const localMessageKeys = new WeakMap(); - let nextLocalMessageKey = 0; - - return (message) => { - if (message.id && message.id.length > 0) return `msg-${message.id}`; - - const existing = localMessageKeys.get(message); - if (existing) return existing; - - nextLocalMessageKey += 1; - const next = `local-msg-${nextLocalMessageKey}`; - localMessageKeys.set(message, next); - return next; - }; + return (message) => `msg-${message.displayId}`; } export function buildChatRenderItems( diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 196d59e3..3b3030bf 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -89,7 +89,10 @@ export function ChatScreen() { () => imageMessageId ? visibleMessages.find( - (item) => item.id === imageMessageId && item.imageUrl, + (item) => + (item.displayId === imageMessageId || + item.remoteId === imageMessageId) && + item.imageUrl, ) ?? null : null, [imageMessageId, visibleMessages], @@ -175,24 +178,46 @@ export function ChatScreen() { state.characterErrorCode, ]); - function handleUnlockPrivateMessage(messageId: string): void { - unlockCoordinator.requestMessageUnlock(messageId, "private"); - } - - function handleUnlockVoiceMessage(messageId: string): void { - unlockCoordinator.requestMessageUnlock(messageId, "voice"); - } - - function handleUnlockImageMessage(messageId: string): void { - unlockCoordinator.requestMessageUnlock(messageId, "image"); - } - - function handleOpenImage(messageId: string): void { - router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), { - scroll: false, + function handleUnlockPrivateMessage( + displayMessageId: string, + remoteMessageId?: string, + ): void { + unlockCoordinator.requestMessageUnlock({ + displayMessageId, + remoteMessageId, + kind: "private", }); } + function handleUnlockVoiceMessage( + displayMessageId: string, + remoteMessageId?: string, + ): void { + unlockCoordinator.requestMessageUnlock({ + displayMessageId, + remoteMessageId, + kind: "voice", + }); + } + + function handleUnlockImageMessage( + displayMessageId: string, + remoteMessageId?: string, + ): void { + unlockCoordinator.requestMessageUnlock({ + displayMessageId, + remoteMessageId, + kind: "image", + }); + } + + function handleOpenImage(displayMessageId: string): void { + router.push( + buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat), + { scroll: false }, + ); + } + function handleCloseImageViewer(): void { router.replace( buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat), @@ -201,8 +226,12 @@ export function ChatScreen() { } function handleUnlockImagePaywall(): void { - if (!imageMessageId) return; - unlockCoordinator.requestMessageUnlock(imageMessageId, "image"); + if (!selectedImageMessage) return; + unlockCoordinator.requestMessageUnlock({ + displayMessageId: selectedImageMessage.displayId, + remoteMessageId: selectedImageMessage.remoteId, + kind: "image", + }); } return ( @@ -279,12 +308,12 @@ export function ChatScreen() { {selectedImageMessage?.imageUrl ? ( { const openableHtml = renderToStaticMarkup( undefined} />, @@ -136,6 +137,7 @@ describe("chat Tailwind components", () => { const paywalledHtml = renderToStaticMarkup( , diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index e52053dd..83eda484 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -54,13 +54,18 @@ export interface ChatAreaProps { isLoadingMoreHistory?: boolean; isUnlockingMessage?: boolean; unlockingMessageId?: string | null; - onUnlockPrivateMessage?: (messageId: string) => void; - onUnlockVoiceMessage?: (messageId: string) => void; - onUnlockImageMessage?: (messageId: string) => void; - onOpenImage?: (messageId: string) => void; + onUnlockPrivateMessage?: ChatMessageAction; + onUnlockVoiceMessage?: ChatMessageAction; + onUnlockImageMessage?: ChatMessageAction; + onOpenImage?: (displayMessageId: string) => void; onLoadMoreHistory?: () => void; } +type ChatMessageAction = ( + displayMessageId: string, + remoteMessageId?: string, +) => void; + export function ChatArea({ characterId, messages, @@ -278,10 +283,10 @@ function renderMessagesWithDateHeaders( getMessageKey: ChatMessageKeyResolver, isUnlockingMessage?: boolean, unlockingMessageId?: string | null, - onUnlockPrivateMessage?: (messageId: string) => void, - onUnlockVoiceMessage?: (messageId: string) => void, - onUnlockImageMessage?: (messageId: string) => void, - onOpenImage?: (messageId: string) => void, + onUnlockPrivateMessage?: ChatMessageAction, + onUnlockVoiceMessage?: ChatMessageAction, + onUnlockImageMessage?: ChatMessageAction, + onOpenImage?: (displayMessageId: string) => void, ) { return buildChatRenderItems(messages, getMessageKey).map((item) => item.type === "date" ? ( @@ -290,7 +295,8 @@ function renderMessagesWithDateHeaders( void; + onOpenImage?: (displayMessageId: string) => void; } export function ImageBubble({ characterId, - messageId, + displayMessageId, + remoteMessageId, imageUrl, imagePaywalled = false, onOpenImage, }: ImageBubbleProps) { - const canOpen = Boolean(messageId && onOpenImage); + const canOpen = Boolean(onOpenImage); const imageClassName = [ "block h-auto w-full object-cover", imagePaywalled ? "scale-104 blur-sm" : "", @@ -34,8 +36,8 @@ export function ImageBubble({ .join(" "); const openImage = () => { - if (!messageId || !onOpenImage) return; - onOpenImage(messageId); + if (!onOpenImage) return; + onOpenImage(displayMessageId); }; return ( @@ -55,7 +57,7 @@ export function ImageBubble({ > void; - onUnlockVoiceMessage?: (messageId: string) => void; - onUnlockImageMessage?: (messageId: string) => void; - onOpenImage?: (messageId: string) => void; + onUnlockPrivateMessage?: ChatMessageAction; + onUnlockVoiceMessage?: ChatMessageAction; + onUnlockImageMessage?: ChatMessageAction; + onOpenImage?: (displayMessageId: string) => void; } +type ChatMessageAction = ( + displayMessageId: string, + remoteMessageId?: string, +) => void; + export function MessageBubble({ characterId, - messageId, + displayMessageId, + remoteMessageId, content, imageUrl, imagePaywalled, @@ -57,18 +64,19 @@ export function MessageBubble({ return (