Compare commits

..

1 Commits

Author SHA1 Message Date
admin 4cdc4eafbb chore(favicon): 使用预发布环境的图标
Docker Image / Quality and Bundle Budgets (push) Successful in 3s
Docker Image / Build and Push Docker Image (push) Successful in 1m48s
2026-07-20 17:44:48 +08:00
38 changed files with 243 additions and 909 deletions
@@ -10,13 +10,12 @@ import {
describe("chat area render items", () => { describe("chat area render items", () => {
it("keeps local message keys stable when history is prepended", () => { it("keeps local message keys stable when history is prepended", () => {
const localMessage: UiMessage = { const localMessage: UiMessage = {
displayId: "client:message:local-1",
content: "optimistic message", content: "optimistic message",
isFromAI: false, isFromAI: false,
date: "2026-07-08", date: "2026-07-08",
}; };
const historyMessage: UiMessage = { const historyMessage: UiMessage = {
displayId: "history-msg-1", id: "history-msg-1",
content: "history message", content: "history message",
isFromAI: true, isFromAI: true,
date: "2026-07-07", date: "2026-07-07",
@@ -29,34 +28,12 @@ describe("chat area render items", () => {
getMessageKey, getMessageKey,
); );
expect(findMessageKey(firstItems, localMessage)).toBe( expect(findMessageKey(firstItems, localMessage)).toBe("local-msg-1");
"msg-client:message:local-1", expect(findMessageKey(secondItems, localMessage)).toBe("local-msg-1");
);
expect(findMessageKey(secondItems, localMessage)).toBe(
"msg-client:message:local-1",
);
expect(findMessageKey(secondItems, historyMessage)).toBe( expect(findMessageKey(secondItems, historyMessage)).toBe(
"msg-history-msg-1", "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( function findMessageKey(
@@ -9,7 +9,6 @@ import { createChatPromotionState } from "@/stores/chat/helper/promotion";
import { import {
resolveChatUnlockReturnUrl, resolveChatUnlockReturnUrl,
resolveMessageUnlockRequest, resolveMessageUnlockRequest,
resolvePendingUnlockDisplayMessageId,
shouldResumePendingChatUnlock, shouldResumePendingChatUnlock,
} from "../hooks/use-chat-unlock-coordinator"; } from "../hooks/use-chat-unlock-coordinator";
@@ -32,15 +31,11 @@ describe("chat unlock coordinator", () => {
it("creates a regular message unlock request with its remote id", () => { it("creates a regular message unlock request with its remote id", () => {
expect( expect(
resolveMessageUnlockRequest( resolveMessageUnlockRequest(
{ { messageId: "message-1", kind: "private" },
displayMessageId: "server:message-1:assistant",
remoteMessageId: "message-1",
kind: "private",
},
defaultScope, defaultScope,
), ),
).toEqual({ ).toEqual({
displayMessageId: "server:message-1:assistant", displayMessageId: "message-1",
messageId: "message-1", messageId: "message-1",
kind: "private", kind: "private",
returnUrl: "/chat", returnUrl: "/chat",
@@ -56,11 +51,7 @@ describe("chat unlock coordinator", () => {
expect( expect(
resolveMessageUnlockRequest( resolveMessageUnlockRequest(
{ { messageId: "image-1", kind: "image" },
displayMessageId: "image-1",
remoteMessageId: "image-1",
kind: "image",
},
imageScope, imageScope,
).returnUrl, ).returnUrl,
).toBe("/chat?image=image-1"); ).toBe("/chat?image=image-1");
@@ -71,10 +62,7 @@ describe("chat unlock coordinator", () => {
expect( expect(
resolveMessageUnlockRequest( resolveMessageUnlockRequest(
{ { messageId: "promotion:promotion-1", kind: "image" },
displayMessageId: "promotion:promotion-1",
kind: "image",
},
{ ...defaultScope, promotion }, { ...defaultScope, promotion },
), ),
).toEqual({ ).toEqual({
@@ -95,11 +83,7 @@ describe("chat unlock coordinator", () => {
expect( expect(
resolveMessageUnlockRequest( resolveMessageUnlockRequest(
{ { messageId: "promotion-message-1", kind: "image" },
displayMessageId: "promotion:promotion-1",
remoteMessageId: "promotion-message-1",
kind: "image",
},
{ ...defaultScope, promotion }, { ...defaultScope, promotion },
).messageId, ).messageId,
).toBe("promotion-message-1"); ).toBe("promotion-message-1");
@@ -119,40 +103,6 @@ describe("chat unlock coordinator", () => {
).toBe(true); ).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", () => { it("only resumes an image return when its message matches the overlay", () => {
const imageScope = { const imageScope = {
defaultReturnUrl: "/chat", defaultReturnUrl: "/chat",
@@ -186,7 +136,7 @@ describe("chat unlock coordinator", () => {
expect( expect(
resolveMessageUnlockRequest( resolveMessageUnlockRequest(
{ displayMessageId: imageMessageId, kind: "image" }, { messageId: imageMessageId, kind: "image" },
{ {
...imageScope, ...imageScope,
promotion, promotion,
+14 -1
View File
@@ -7,7 +7,20 @@ export type ChatRenderItem =
| { type: "msg"; message: UiMessage; key: string }; | { type: "msg"; message: UiMessage; key: string };
export function createChatMessageKeyResolver(): ChatMessageKeyResolver { export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
return (message) => `msg-${message.displayId}`; const localMessageKeys = new WeakMap<UiMessage, string>();
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;
};
} }
export function buildChatRenderItems( export function buildChatRenderItems(
+15 -44
View File
@@ -89,10 +89,7 @@ export function ChatScreen() {
() => () =>
imageMessageId imageMessageId
? visibleMessages.find( ? visibleMessages.find(
(item) => (item) => item.id === imageMessageId && item.imageUrl,
(item.displayId === imageMessageId ||
item.remoteId === imageMessageId) &&
item.imageUrl,
) ?? null ) ?? null
: null, : null,
[imageMessageId, visibleMessages], [imageMessageId, visibleMessages],
@@ -178,44 +175,22 @@ export function ChatScreen() {
state.characterErrorCode, state.characterErrorCode,
]); ]);
function handleUnlockPrivateMessage( function handleUnlockPrivateMessage(messageId: string): void {
displayMessageId: string, unlockCoordinator.requestMessageUnlock(messageId, "private");
remoteMessageId?: string,
): void {
unlockCoordinator.requestMessageUnlock({
displayMessageId,
remoteMessageId,
kind: "private",
});
} }
function handleUnlockVoiceMessage( function handleUnlockVoiceMessage(messageId: string): void {
displayMessageId: string, unlockCoordinator.requestMessageUnlock(messageId, "voice");
remoteMessageId?: string,
): void {
unlockCoordinator.requestMessageUnlock({
displayMessageId,
remoteMessageId,
kind: "voice",
});
} }
function handleUnlockImageMessage( function handleUnlockImageMessage(messageId: string): void {
displayMessageId: string, unlockCoordinator.requestMessageUnlock(messageId, "image");
remoteMessageId?: string,
): void {
unlockCoordinator.requestMessageUnlock({
displayMessageId,
remoteMessageId,
kind: "image",
});
} }
function handleOpenImage(displayMessageId: string): void { function handleOpenImage(messageId: string): void {
router.push( router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat), scroll: false,
{ scroll: false }, });
);
} }
function handleCloseImageViewer(): void { function handleCloseImageViewer(): void {
@@ -226,12 +201,8 @@ export function ChatScreen() {
} }
function handleUnlockImagePaywall(): void { function handleUnlockImagePaywall(): void {
if (!selectedImageMessage) return; if (!imageMessageId) return;
unlockCoordinator.requestMessageUnlock({ unlockCoordinator.requestMessageUnlock(imageMessageId, "image");
displayMessageId: selectedImageMessage.displayId,
remoteMessageId: selectedImageMessage.remoteId,
kind: "image",
});
} }
return ( return (
@@ -308,12 +279,12 @@ export function ChatScreen() {
{selectedImageMessage?.imageUrl ? ( {selectedImageMessage?.imageUrl ? (
<FullscreenImageViewer <FullscreenImageViewer
characterId={state.characterId} characterId={state.characterId}
remoteMessageId={selectedImageMessage.remoteId} messageId={selectedImageMessage.id}
imageUrl={selectedImageMessage.imageUrl} imageUrl={selectedImageMessage.imageUrl}
imagePaywalled={selectedImageMessage.imagePaywalled === true} imagePaywalled={selectedImageMessage.imagePaywalled === true}
isUnlockingImagePaywall={ isUnlockingImagePaywall={
state.isUnlockingMessage && state.isUnlockingMessage &&
state.unlockingMessageId === selectedImageMessage.displayId state.unlockingMessageId === selectedImageMessage.id
} }
onUnlockImagePaywall={handleUnlockImagePaywall} onUnlockImagePaywall={handleUnlockImagePaywall}
onClose={handleCloseImageViewer} onClose={handleCloseImageViewer}
@@ -265,19 +265,19 @@ function createTouchEvent(
return event; return event;
} }
function createMessage(displayId: string): UiMessage { function createMessage(id: string): UiMessage {
return { return {
displayId, id,
content: `Message ${displayId}`, content: `Message ${id}`,
isFromAI: true, isFromAI: true,
date: "2026-07-15", date: "2026-07-15",
}; };
} }
function createUserMessage(displayId: string): UiMessage { function createUserMessage(id: string): UiMessage {
return { return {
displayId, id,
content: `Message ${displayId}`, content: `Message ${id}`,
isFromAI: false, isFromAI: false,
date: "2026-07-15", date: "2026-07-15",
}; };
@@ -128,8 +128,7 @@ describe("chat Tailwind components", () => {
const openableHtml = renderToStaticMarkup( const openableHtml = renderToStaticMarkup(
<ImageBubble <ImageBubble
characterId="elio" characterId="elio"
displayMessageId="server:message-1:assistant" messageId="message-1"
remoteMessageId="message-1"
imageUrl="/chat-image.png" imageUrl="/chat-image.png"
onOpenImage={() => undefined} onOpenImage={() => undefined}
/>, />,
@@ -137,7 +136,6 @@ describe("chat Tailwind components", () => {
const paywalledHtml = renderToStaticMarkup( const paywalledHtml = renderToStaticMarkup(
<ImageBubble <ImageBubble
characterId="elio" characterId="elio"
displayMessageId="server:message-2:assistant"
imageUrl="/locked-image.png" imageUrl="/locked-image.png"
imagePaywalled imagePaywalled
/>, />,
+10 -16
View File
@@ -54,18 +54,13 @@ export interface ChatAreaProps {
isLoadingMoreHistory?: boolean; isLoadingMoreHistory?: boolean;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
unlockingMessageId?: string | null; unlockingMessageId?: string | null;
onUnlockPrivateMessage?: ChatMessageAction; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: ChatMessageAction; onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (messageId: string) => void;
onLoadMoreHistory?: () => void; onLoadMoreHistory?: () => void;
} }
type ChatMessageAction = (
displayMessageId: string,
remoteMessageId?: string,
) => void;
export function ChatArea({ export function ChatArea({
characterId, characterId,
messages, messages,
@@ -283,10 +278,10 @@ function renderMessagesWithDateHeaders(
getMessageKey: ChatMessageKeyResolver, getMessageKey: ChatMessageKeyResolver,
isUnlockingMessage?: boolean, isUnlockingMessage?: boolean,
unlockingMessageId?: string | null, unlockingMessageId?: string | null,
onUnlockPrivateMessage?: ChatMessageAction, onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockVoiceMessage?: ChatMessageAction, onUnlockVoiceMessage?: (messageId: string) => void,
onUnlockImageMessage?: ChatMessageAction, onUnlockImageMessage?: (messageId: string) => void,
onOpenImage?: (displayMessageId: string) => void, onOpenImage?: (messageId: string) => void,
) { ) {
return buildChatRenderItems(messages, getMessageKey).map((item) => return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? ( item.type === "date" ? (
@@ -295,8 +290,7 @@ function renderMessagesWithDateHeaders(
<MessageBubble <MessageBubble
characterId={characterId} characterId={characterId}
key={item.key} key={item.key}
displayMessageId={item.message.displayId} messageId={item.message.id}
remoteMessageId={item.message.remoteId}
content={item.message.content} content={item.message.content}
imageUrl={item.message.imageUrl} imageUrl={item.message.imageUrl}
imagePaywalled={item.message.imagePaywalled} imagePaywalled={item.message.imagePaywalled}
@@ -308,7 +302,7 @@ function renderMessagesWithDateHeaders(
privateMessageHint={item.message.privateMessageHint} privateMessageHint={item.message.privateMessageHint}
isUnlockingMessage={ isUnlockingMessage={
isUnlockingMessage === true && isUnlockingMessage === true &&
item.message.displayId === unlockingMessageId item.message.id === unlockingMessageId
} }
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
+3 -3
View File
@@ -13,7 +13,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
export interface ChatMediaImageProps { export interface ChatMediaImageProps {
characterId: string; characterId: string;
remoteMessageId?: string; messageId?: string;
remoteUrl: string; remoteUrl: string;
alt?: string; alt?: string;
className?: string; className?: string;
@@ -30,7 +30,7 @@ export interface ChatMediaImageProps {
export function ChatMediaImage({ export function ChatMediaImage({
characterId, characterId,
remoteMessageId, messageId,
remoteUrl, remoteUrl,
alt = "", alt = "",
className, className,
@@ -48,7 +48,7 @@ export function ChatMediaImage({
const { mediaUrl, isUsingCachedMedia, reportMediaError } = const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({ useCachedChatMediaUrl({
characterId, characterId,
messageId: remoteMessageId, messageId,
remoteUrl, remoteUrl,
kind: "image", kind: "image",
}); });
@@ -19,7 +19,7 @@ import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps { export interface FullscreenImageViewerProps {
characterId: string; characterId: string;
remoteMessageId?: string; messageId?: string;
imageUrl: string; imageUrl: string;
imagePaywalled?: boolean; imagePaywalled?: boolean;
isUnlockingImagePaywall?: boolean; isUnlockingImagePaywall?: boolean;
@@ -29,7 +29,7 @@ export interface FullscreenImageViewerProps {
export function FullscreenImageViewer({ export function FullscreenImageViewer({
characterId, characterId,
remoteMessageId, messageId,
imageUrl, imageUrl,
imagePaywalled = false, imagePaywalled = false,
isUnlockingImagePaywall = false, isUnlockingImagePaywall = false,
@@ -54,7 +54,7 @@ export function FullscreenImageViewer({
> >
<ChatMediaImage <ChatMediaImage
characterId={characterId} characterId={characterId}
remoteMessageId={remoteMessageId} messageId={messageId}
remoteUrl={imageUrl} remoteUrl={imageUrl}
className={styles.paywallImage} className={styles.paywallImage}
nativeClassName={`${styles.nativePaywallImage} ${styles.paywallImage}`} nativeClassName={`${styles.nativePaywallImage} ${styles.paywallImage}`}
@@ -104,7 +104,7 @@ export function FullscreenImageViewer({
/> />
<ChatMediaImage <ChatMediaImage
characterId={characterId} characterId={characterId}
remoteMessageId={remoteMessageId} messageId={messageId}
remoteUrl={imageUrl} remoteUrl={imageUrl}
className={styles.viewerImage} className={styles.viewerImage}
errorClassName="error" errorClassName="error"
+7 -9
View File
@@ -12,22 +12,20 @@ import { ChatMediaImage } from "./chat-media-image";
export interface ImageBubbleProps { export interface ImageBubbleProps {
characterId: string; characterId: string;
displayMessageId: string; messageId?: string;
remoteMessageId?: string;
imageUrl: string; // base64 data URI 或 URL imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean; imagePaywalled?: boolean;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (messageId: string) => void;
} }
export function ImageBubble({ export function ImageBubble({
characterId, characterId,
displayMessageId, messageId,
remoteMessageId,
imageUrl, imageUrl,
imagePaywalled = false, imagePaywalled = false,
onOpenImage, onOpenImage,
}: ImageBubbleProps) { }: ImageBubbleProps) {
const canOpen = Boolean(onOpenImage); const canOpen = Boolean(messageId && onOpenImage);
const imageClassName = [ const imageClassName = [
"block h-auto w-full object-cover", "block h-auto w-full object-cover",
imagePaywalled ? "scale-104 blur-sm" : "", imagePaywalled ? "scale-104 blur-sm" : "",
@@ -36,8 +34,8 @@ export function ImageBubble({
.join(" "); .join(" ");
const openImage = () => { const openImage = () => {
if (!onOpenImage) return; if (!messageId || !onOpenImage) return;
onOpenImage(displayMessageId); onOpenImage(messageId);
}; };
return ( return (
@@ -57,7 +55,7 @@ export function ImageBubble({
> >
<ChatMediaImage <ChatMediaImage
characterId={characterId} characterId={characterId}
remoteMessageId={remoteMessageId} messageId={messageId}
remoteUrl={imageUrl} remoteUrl={imageUrl}
className={imageClassName} className={imageClassName}
errorClassName="flex size-(--chat-media-size,220px) items-center justify-center bg-(--color-bubble-background,#fff) text-(length:--icon-size-xl,24px) text-(--color-text-secondary,#9e9e9e)" errorClassName="flex size-(--chat-media-size,220px) items-center justify-center bg-(--color-bubble-background,#fff) text-(length:--icon-size-xl,24px) text-(--color-text-secondary,#9e9e9e)"
+10 -19
View File
@@ -16,8 +16,7 @@ import styles from "./chat-area.module.css";
export interface MessageBubbleProps { export interface MessageBubbleProps {
characterId: string; characterId: string;
displayMessageId: string; messageId?: string;
remoteMessageId?: string;
content: string; content: string;
imageUrl?: string | null; imageUrl?: string | null;
imagePaywalled?: boolean; imagePaywalled?: boolean;
@@ -28,21 +27,15 @@ export interface MessageBubbleProps {
lockedPrivate?: boolean | null; lockedPrivate?: boolean | null;
privateMessageHint?: string | null; privateMessageHint?: string | null;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: ChatMessageAction; onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (messageId: string) => void;
} }
type ChatMessageAction = (
displayMessageId: string,
remoteMessageId?: string,
) => void;
export function MessageBubble({ export function MessageBubble({
characterId, characterId,
displayMessageId, messageId,
remoteMessageId,
content, content,
imageUrl, imageUrl,
imagePaywalled, imagePaywalled,
@@ -64,19 +57,18 @@ export function MessageBubble({
return ( return (
<div <div
className={styles.bubbleRowAi} className={styles.bubbleRowAi}
data-chat-message-id={displayMessageId} data-chat-message-id={messageId}
aria-label="AI message" aria-label="AI message"
> >
<MessageAvatar isFromAI={true} /> <MessageAvatar isFromAI={true} />
<div className={styles.bubbleInlineSpacer} aria-hidden="true" /> <div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent <MessageContent
characterId={characterId} characterId={characterId}
displayMessageId={displayMessageId}
remoteMessageId={remoteMessageId}
content={content} content={content}
imageUrl={imageUrl} imageUrl={imageUrl}
imagePaywalled={imagePaywalled} imagePaywalled={imagePaywalled}
audioUrl={audioUrl} audioUrl={audioUrl}
messageId={messageId}
isFromAI={true} isFromAI={true}
locked={locked} locked={locked}
lockReason={lockReason} lockReason={lockReason}
@@ -96,18 +88,17 @@ export function MessageBubble({
return ( return (
<div <div
className={styles.bubbleRowUser} className={styles.bubbleRowUser}
data-chat-message-id={displayMessageId} data-chat-message-id={messageId}
aria-label="User message" aria-label="User message"
> >
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" /> <div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
<MessageContent <MessageContent
characterId={characterId} characterId={characterId}
displayMessageId={displayMessageId}
remoteMessageId={remoteMessageId}
content={content} content={content}
imageUrl={imageUrl} imageUrl={imageUrl}
imagePaywalled={imagePaywalled} imagePaywalled={imagePaywalled}
audioUrl={audioUrl} audioUrl={audioUrl}
messageId={messageId}
isFromAI={false} isFromAI={false}
locked={locked} locked={locked}
lockReason={lockReason} lockReason={lockReason}
+14 -23
View File
@@ -8,39 +8,32 @@ import styles from "./chat-area.module.css";
export interface MessageContentProps { export interface MessageContentProps {
characterId: string; characterId: string;
displayMessageId: string;
remoteMessageId?: string;
content: string; content: string;
imageUrl?: string | null; imageUrl?: string | null;
imagePaywalled?: boolean; imagePaywalled?: boolean;
audioUrl?: string | null; audioUrl?: string | null;
messageId?: string;
isFromAI: boolean; isFromAI: boolean;
locked?: boolean | null; locked?: boolean | null;
lockReason?: string | null; lockReason?: string | null;
lockedPrivate?: boolean | null; lockedPrivate?: boolean | null;
privateMessageHint?: string | null; privateMessageHint?: string | null;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
onUnlockPrivateMessage?: ChatMessageAction; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockVoiceMessage?: ChatMessageAction; onUnlockVoiceMessage?: (messageId: string) => void;
onUnlockImageMessage?: ChatMessageAction; onUnlockImageMessage?: (messageId: string) => void;
onOpenImage?: (displayMessageId: string) => void; onOpenImage?: (messageId: string) => void;
} }
type ChatMessageAction = (
displayMessageId: string,
remoteMessageId?: string,
) => void;
const IMAGE_PLACEHOLDER = "[图片]"; const IMAGE_PLACEHOLDER = "[图片]";
export function MessageContent({ export function MessageContent({
characterId, characterId,
displayMessageId,
remoteMessageId,
content, content,
imageUrl, imageUrl,
imagePaywalled, imagePaywalled,
audioUrl, audioUrl,
messageId,
isFromAI, isFromAI,
locked, locked,
lockReason, lockReason,
@@ -64,17 +57,16 @@ export function MessageContent({
(lockReason === "image_paywall" || lockReason === "image"); (lockReason === "image_paywall" || lockReason === "image");
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage; const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
const handleUnlockPrivateMessage = const handleUnlockPrivateMessage =
onUnlockPrivateMessage messageId && onUnlockPrivateMessage
? () => ? () => onUnlockPrivateMessage(messageId)
onUnlockPrivateMessage(displayMessageId, remoteMessageId)
: undefined; : undefined;
const handleUnlockVoiceMessage = const handleUnlockVoiceMessage =
isLockedVoiceMessage && onUnlockVoiceMessage isLockedVoiceMessage && messageId && onUnlockVoiceMessage
? () => onUnlockVoiceMessage(displayMessageId, remoteMessageId) ? () => onUnlockVoiceMessage(messageId)
: undefined; : undefined;
const handleUnlockImageMessage = const handleUnlockImageMessage =
isLockedImageMessage && onUnlockImageMessage isLockedImageMessage && messageId && onUnlockImageMessage
? () => onUnlockImageMessage(displayMessageId, remoteMessageId) ? () => onUnlockImageMessage(messageId)
: undefined; : undefined;
return ( return (
@@ -101,8 +93,7 @@ export function MessageContent({
{hasImage && imageUrl && ( {hasImage && imageUrl && (
<ImageBubble <ImageBubble
characterId={characterId} characterId={characterId}
displayMessageId={displayMessageId} messageId={messageId}
remoteMessageId={remoteMessageId}
imageUrl={imageUrl} imageUrl={imageUrl}
imagePaywalled={imagePaywalled} imagePaywalled={imagePaywalled}
onOpenImage={onOpenImage} onOpenImage={onOpenImage}
@@ -111,7 +102,7 @@ export function MessageContent({
{shouldRenderVoiceMessage ? ( {shouldRenderVoiceMessage ? (
<VoiceBubble <VoiceBubble
characterId={characterId} characterId={characterId}
remoteMessageId={remoteMessageId} messageId={messageId}
audioUrl={audioUrl} audioUrl={audioUrl}
isFromAI={isFromAI} isFromAI={isFromAI}
locked={isLockedVoiceMessage} locked={isLockedVoiceMessage}
+3 -3
View File
@@ -9,7 +9,7 @@ import styles from "./voice-bubble.module.css";
export interface VoiceBubbleProps { export interface VoiceBubbleProps {
characterId: string; characterId: string;
remoteMessageId?: string; messageId?: string;
audioUrl?: string | null; audioUrl?: string | null;
isFromAI: boolean; isFromAI: boolean;
locked?: boolean; locked?: boolean;
@@ -20,7 +20,7 @@ export interface VoiceBubbleProps {
export function VoiceBubble({ export function VoiceBubble({
characterId, characterId,
remoteMessageId, messageId,
audioUrl, audioUrl,
isFromAI, isFromAI,
locked = false, locked = false,
@@ -36,7 +36,7 @@ export function VoiceBubble({
const { mediaUrl, isUsingCachedMedia, reportMediaError } = const { mediaUrl, isUsingCachedMedia, reportMediaError } =
useCachedChatMediaUrl({ useCachedChatMediaUrl({
characterId, characterId,
messageId: remoteMessageId, messageId,
remoteUrl: audioUrl, remoteUrl: audioUrl,
kind: "audio", kind: "audio",
}); });
@@ -19,7 +19,6 @@ import {
} from "@/stores/chat/chat-context"; } from "@/stores/chat/chat-context";
import type { ChatPromotionState } from "@/stores/chat/helper/promotion"; import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import type { UiMessage } from "@/stores/chat/ui-message";
import { useUserSelector } from "@/stores/user/user-context"; import { useUserSelector } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
@@ -61,11 +60,10 @@ export interface UseChatUnlockCoordinatorInput
} }
export interface UseChatUnlockCoordinatorOutput { export interface UseChatUnlockCoordinatorOutput {
requestMessageUnlock: (input: { requestMessageUnlock: (
displayMessageId: string; messageId: string,
remoteMessageId?: string; kind: PendingChatUnlockKind,
kind: PendingChatUnlockKind; ) => void;
}) => void;
dialogs: ChatUnlockDialogModel; dialogs: ChatUnlockDialogModel;
} }
@@ -83,7 +81,6 @@ export function useChatUnlockCoordinator({
(state) => ({ (state) => ({
characterId: state.context.characterId, characterId: state.context.characterId,
historyLoaded: state.context.historyLoaded, historyLoaded: state.context.historyLoaded,
messages: state.context.messages,
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }), isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
lockedHistoryCount: state.context.lockedHistoryCount, lockedHistoryCount: state.context.lockedHistoryCount,
unlockHistoryError: state.context.unlockHistoryError, unlockHistoryError: state.context.unlockHistoryError,
@@ -144,15 +141,10 @@ export function useChatUnlockCoordinator({
const consumed = await consumePendingChatUnlock(chatState.characterId); const consumed = await consumePendingChatUnlock(chatState.characterId);
if (cancelled || !consumed) return; if (cancelled || !consumed) return;
const displayMessageId = resolvePendingUnlockDisplayMessageId(
consumed,
chatState.messages,
promotion,
);
chatDispatch({ chatDispatch({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId, messageId: consumed.displayMessageId,
remoteMessageId: consumed.messageId, remoteMessageId: consumed.messageId,
kind: consumed.kind, kind: consumed.kind,
lockType: consumed.lockType, lockType: consumed.lockType,
@@ -173,17 +165,14 @@ export function useChatUnlockCoordinator({
imageMessageId, imageMessageId,
imageReturnUrl, imageReturnUrl,
navigator.isAuthenticatedUser, navigator.isAuthenticatedUser,
promotion,
chatState.messages,
]); ]);
function requestMessageUnlock(input: { function requestMessageUnlock(
displayMessageId: string; messageId: string,
remoteMessageId?: string; kind: PendingChatUnlockKind,
kind: PendingChatUnlockKind; ): void {
}): void {
const request = resolveMessageUnlockRequest( const request = resolveMessageUnlockRequest(
input, { messageId, kind },
{ {
defaultReturnUrl, defaultReturnUrl,
imageMessageId, imageMessageId,
@@ -197,7 +186,7 @@ export function useChatUnlockCoordinator({
onAuthenticated: () => { onAuthenticated: () => {
chatDispatch({ chatDispatch({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: request.displayMessageId, messageId: request.displayMessageId,
remoteMessageId: request.messageId, remoteMessageId: request.messageId,
kind: request.kind, kind: request.kind,
lockType: request.lockType, lockType: request.lockType,
@@ -269,21 +258,23 @@ export function useChatUnlockCoordinator({
export function resolveMessageUnlockRequest( export function resolveMessageUnlockRequest(
input: { input: {
displayMessageId: string; messageId: string;
remoteMessageId?: string;
kind: PendingChatUnlockKind; kind: PendingChatUnlockKind;
}, },
scope: ChatUnlockCoordinatorScope, scope: ChatUnlockCoordinatorScope,
): CoordinatedMessageUnlockRequest { ): CoordinatedMessageUnlockRequest {
const matchedPromotion = const matchedPromotion =
scope.promotion?.message.displayId === input.displayMessageId scope.promotion?.message.id === input.messageId
? scope.promotion ? scope.promotion
: null; : null;
const remoteMessageId = const temporaryPromotionMessageId = matchedPromotion
input.remoteMessageId ?? matchedPromotion?.message.remoteId; ? `promotion:${matchedPromotion.session.clientLockId}`
: null;
const target = { const target = {
displayMessageId: input.displayMessageId, displayMessageId: input.messageId,
...(remoteMessageId ? { messageId: remoteMessageId } : {}), ...(input.messageId !== temporaryPromotionMessageId
? { messageId: input.messageId }
: {}),
kind: input.kind, kind: input.kind,
...(matchedPromotion ...(matchedPromotion
? { ? {
@@ -309,40 +300,6 @@ export function shouldResumePendingChatUnlock(
return isCurrentImageUnlock(pending, scope.imageMessageId); return isCurrentImageUnlock(pending, scope.imageMessageId);
} }
export function resolvePendingUnlockDisplayMessageId(
pending: PendingChatUnlock,
messages: readonly UiMessage[],
promotion: ChatPromotionState | null,
): string {
if (
promotion &&
(promotion.message.displayId === pending.displayMessageId ||
(pending.messageId &&
promotion.message.remoteId === pending.messageId))
) {
return promotion.message.displayId;
}
const exactMessage = messages.find(
(message) => message.displayId === pending.displayMessageId,
);
if (exactMessage) return exactMessage.displayId;
if (!pending.messageId) return pending.displayMessageId;
const remoteMessage =
messages.find(
(message) =>
message.isFromAI &&
message.locked === true &&
message.remoteId === pending.messageId,
) ??
messages.find(
(message) =>
message.isFromAI && message.remoteId === pending.messageId,
);
return remoteMessage?.displayId ?? pending.displayMessageId;
}
export function resolveChatUnlockReturnUrl( export function resolveChatUnlockReturnUrl(
target: Pick< target: Pick<
CoordinatedMessageUnlockRequest, CoordinatedMessageUnlockRequest,
@@ -1,191 +0,0 @@
/* @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
PaymentPlanSchema,
type PaymentPlan,
} from "@/data/schemas/payment";
import type { PaymentContextState } from "@/stores/payment/payment-context";
const mocks = vi.hoisted(() => ({
payment: null as unknown as PaymentContextState,
paymentDispatch: vi.fn(),
planClick: vi.fn(),
}));
vi.mock("@/app/_components", () => ({
BackButton: () => null,
CharacterAvatar: () => null,
}));
vi.mock("@/app/_components/core", () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/app/_components/payment/payment-method-selector", () => ({
PaymentMethodSelector: () => null,
}));
vi.mock("@/app/_hooks/use-payment-method-selection", () => ({
usePaymentMethodSelection: () => undefined,
}));
vi.mock("@/app/_hooks/use-payment-plan-analytics", () => ({
usePaymentPlanAnalytics: () => undefined,
}));
vi.mock("@/app/_hooks/use-payment-route-flow", () => ({
usePaymentRouteFlow: () => ({
payment: mocks.payment,
paymentDispatch: mocks.paymentDispatch,
}),
}));
vi.mock("@/lib/analytics", () => ({
behaviorAnalytics: { planClick: mocks.planClick },
}));
vi.mock("@/providers/character-provider", () => ({
useActiveCharacter: () => ({
id: "maya-tan",
displayName: "Maya Tan",
assets: {
avatar: "/images/avatar/maya.png",
cover: "/images/cover/maya.png",
},
copy: {
tipHeader: "Tip Maya",
tipTitle: "Buy Maya a coffee",
},
}),
useActiveCharacterRoutes: () => ({
splash: "/characters/maya/splash",
tip: "/characters/maya/tip",
}),
}));
vi.mock("@/stores/user/user-context", () => ({
useUserState: () => ({ currentUser: null }),
}));
vi.mock("../tip-checkout-button", () => ({
TipCheckoutButton: ({
disabled,
onOrder,
}: {
disabled?: boolean;
onOrder: () => void;
}) => (
<button
type="button"
data-testid="tip-checkout"
disabled={disabled}
onClick={onOrder}
>
Checkout
</button>
),
}));
vi.mock("../tip-coffee-tier-selector", () => ({
TipCoffeeTierSelector: () => null,
}));
import { TipScreen } from "../tip-screen";
const mediumPlan: PaymentPlan = PaymentPlanSchema.parse({
planId: "tip_coffee_usd_9_99",
planName: "Gilded Heart",
orderType: "tip",
vipDays: null,
dolAmount: null,
creditBalance: 0,
amountCents: 999,
originalAmountCents: null,
dailyPriceCents: null,
currency: "USD",
isFirstRechargeOffer: false,
mostPopular: false,
firstRechargeDiscountPercent: null,
promotionType: null,
});
describe("TipScreen checkout", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
mocks.payment = makePaymentState();
mocks.paymentDispatch.mockReset();
mocks.planClick.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("creates a character-attributed order without an AuthProvider", () => {
renderScreen();
const checkout = getCheckoutButton();
expect(checkout.disabled).toBe(false);
act(() => checkout.click());
expect(mocks.planClick).toHaveBeenCalledWith(
mediumPlan,
expect.objectContaining({ entryPoint: "tip_page" }),
);
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
recipientCharacterId: "maya-tan",
});
});
it.each([
["plans are loading", { isLoadingPlans: true }],
["the selected plan is missing", { plans: [], selectedPlanId: "" }],
["an order is being created", { isCreatingOrder: true }],
["an order is being polled", { isPollingOrder: true }],
["the order is paid", { isPaid: true }],
] as const)("disables checkout when %s", (_label, overrides) => {
mocks.payment = makePaymentState(overrides);
renderScreen();
expect(getCheckoutButton().disabled).toBe(true);
});
function renderScreen(): void {
act(() => root.render(<TipScreen />));
}
function getCheckoutButton(): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(
'[data-testid="tip-checkout"]',
);
if (!button) throw new Error("Missing Tip checkout button");
return button;
}
});
function makePaymentState(
overrides: Partial<PaymentContextState> = {},
): PaymentContextState {
return {
status: "ready",
plans: [mediumPlan],
isFirstRecharge: false,
selectedPlanId: mediumPlan.planId,
payChannel: "stripe",
autoRenew: false,
agreed: true,
currentOrderId: null,
payParams: null,
orderStatus: null,
errorMessage: null,
launchNonce: 0,
isLoadingPlans: false,
isCreatingOrder: false,
isPollingOrder: false,
isPaid: false,
...overrides,
};
}
@@ -6,6 +6,7 @@ import type { PaymentPlanInput } from "@/data/schemas/payment/payment_plan";
import { import {
findTipCoffeePlan, findTipCoffeePlan,
formatTipPrice, formatTipPrice,
isRealLoginStatus,
} from "../tip-screen.helpers"; } from "../tip-screen.helpers";
function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan { function makePlan(input: Partial<PaymentPlanInput>): PaymentPlan {
@@ -73,4 +74,10 @@ describe("tip screen helpers", () => {
expect(formatTipPrice(null, "medium")).toBe("US$ 9.99"); expect(formatTipPrice(null, "medium")).toBe("US$ 9.99");
expect(formatTipPrice(null, "large")).toBe("US$ 19.99"); expect(formatTipPrice(null, "large")).toBe("US$ 19.99");
}); });
it("treats guest and notLoggedIn as non-real login states", () => {
expect(isRealLoginStatus("guest")).toBe(false);
expect(isRealLoginStatus("notLoggedIn")).toBe(false);
expect(isRealLoginStatus("facebook")).toBe(true);
});
}); });
+4 -1
View File
@@ -17,6 +17,7 @@ const log = new Logger("TipCheckoutButton");
export interface TipCheckoutButtonProps { export interface TipCheckoutButtonProps {
coffeeType: TipCoffeeType; coffeeType: TipCoffeeType;
disabled?: boolean; disabled?: boolean;
isAuthLoading?: boolean;
onOrder: () => void; onOrder: () => void;
returnPath: string; returnPath: string;
} }
@@ -24,6 +25,7 @@ export interface TipCheckoutButtonProps {
export function TipCheckoutButton({ export function TipCheckoutButton({
coffeeType, coffeeType,
disabled = false, disabled = false,
isAuthLoading = false,
onOrder, onOrder,
returnPath, returnPath,
}: TipCheckoutButtonProps) { }: TipCheckoutButtonProps) {
@@ -40,7 +42,8 @@ export function TipCheckoutButton({
characterSlug: character.slug, characterSlug: character.slug,
}); });
const isLoading = payment.isCreatingOrder || payment.isPollingOrder; const isLoading =
isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder;
const label = payment.isPollingOrder const label = payment.isPollingOrder
? "Processing payment..." ? "Processing payment..."
: payment.isCreatingOrder : payment.isCreatingOrder
+5
View File
@@ -1,3 +1,4 @@
import type { LoginStatus } from "@/data/schemas/auth";
import type { PaymentPlan } from "@/data/schemas/payment"; import type { PaymentPlan } from "@/data/schemas/payment";
import { import {
getTipCoffeeOption, getTipCoffeeOption,
@@ -29,3 +30,7 @@ export function formatTipPrice(
if (currency.length > 0) return `${currency} ${formattedAmount}`; if (currency.length > 0) return `${currency} ${formattedAmount}`;
return formattedAmount; return formattedAmount;
} }
export function isRealLoginStatus(loginStatus: LoginStatus): boolean {
return loginStatus !== "notLoggedIn" && loginStatus !== "guest";
}
+20 -4
View File
@@ -23,10 +23,12 @@ import {
TIP_COFFEE_OPTIONS, TIP_COFFEE_OPTIONS,
type TipCoffeeType, type TipCoffeeType,
} from "@/lib/tip/tip_coffee"; } from "@/lib/tip/tip_coffee";
import { useAppNavigator } from "@/router/use-app-navigator";
import { import {
useActiveCharacter, useActiveCharacter,
useActiveCharacterRoutes, useActiveCharacterRoutes,
} from "@/providers/character-provider"; } from "@/providers/character-provider";
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { TipCheckoutButton } from "./tip-checkout-button"; import { TipCheckoutButton } from "./tip-checkout-button";
@@ -37,6 +39,7 @@ import {
import { import {
findTipCoffeePlan, findTipCoffeePlan,
formatTipPrice, formatTipPrice,
isRealLoginStatus,
} from "./tip-screen.helpers"; } from "./tip-screen.helpers";
import styles from "./tip-screen.module.css"; import styles from "./tip-screen.module.css";
@@ -56,8 +59,10 @@ export function TipScreen({
shouldResumePendingOrder = false, shouldResumePendingOrder = false,
initialPayChannel = null, initialPayChannel = null,
}: TipScreenProps) { }: TipScreenProps) {
const navigator = useAppNavigator();
const character = useActiveCharacter(); const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const authState = useAuthState();
const userState = useUserState(); const userState = useUserState();
const paymentMethodConfig = getPaymentMethodConfig({ const paymentMethodConfig = getPaymentMethodConfig({
countryCode: userState.currentUser?.countryCode, countryCode: userState.currentUser?.countryCode,
@@ -120,6 +125,7 @@ export function TipScreen({
payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null; payment.status === "ready" && !payment.isLoadingPlans && coffeePlan === null;
const isTierSelectionDisabled = const isTierSelectionDisabled =
payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy; payment.status !== "ready" || payment.isLoadingPlans || isPaymentBusy;
const isAuthLoading = !authState.hasInitialized || authState.isLoading;
const handlePaymentMethodChange = (payChannel: PayChannel) => { const handlePaymentMethodChange = (payChannel: PayChannel) => {
paymentDispatch({ paymentDispatch({
@@ -170,9 +176,16 @@ export function TipScreen({
]); ]);
const handleOrder = () => { const handleOrder = () => {
if (isAuthLoading) return;
if (coffeePlan) {
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
}
if (!isRealLoginStatus(authState.loginStatus)) {
navigator.openAuth(returnPath);
return;
}
if (!canCreateOrder) return; if (!canCreateOrder) return;
behaviorAnalytics.planClick(coffeePlan, TIP_ANALYTICS_CONTEXT);
paymentDispatch({ paymentDispatch({
type: "PaymentCreateOrderSubmitted", type: "PaymentCreateOrderSubmitted",
recipientCharacterId: character.id, recipientCharacterId: character.id,
@@ -236,8 +249,7 @@ export function TipScreen({
{character.copy.tipTitle} {character.copy.tipTitle}
</h1> </h1>
<p className={styles.subtitle}> <p className={styles.subtitle}>
Thank you for being here. If you&apos;d ever like to treat me to a Send a warm coffee tip and keep the private moments glowing.
little coffee, I&apos;d really appreciate it.
</p> </p>
</section> </section>
@@ -310,7 +322,11 @@ export function TipScreen({
<div className={styles.checkoutSlot}> <div className={styles.checkoutSlot}>
<TipCheckoutButton <TipCheckoutButton
coffeeType={selectedCoffeeType} coffeeType={selectedCoffeeType}
disabled={showMissingPlan || !canCreateOrder} disabled={
showMissingPlan ||
(!canCreateOrder && isRealLoginStatus(authState.loginStatus))
}
isAuthLoading={isAuthLoading}
onOrder={handleOrder} onOrder={handleOrder}
returnPath={returnPath} returnPath={returnPath}
/> />
@@ -72,8 +72,8 @@ describe("chat actor request cancellation", () => {
); );
const historyCall = historySync.syncNetworkHistory.mock.calls[0]; const historyCall = historySync.syncNetworkHistory.mock.calls[0];
expect(historyCall?.[4]).toBe("Hello from Elio"); expect(historyCall?.[3]).toBe("Hello from Elio");
const signal = getSignal(historyCall?.[5]); const signal = getSignal(historyCall?.[4]);
expect(signal.aborted).toBe(false); expect(signal.aborted).toBe(false);
actor.stop(); actor.stop();
expect(signal.aborted).toBe(true); expect(signal.aborted).toBe(true);
@@ -6,7 +6,6 @@ import {
} from "@/data/constants/character"; } from "@/data/constants/character";
import { import {
ChatSendResponseSchema, ChatSendResponseSchema,
UnlockPrivateResponseSchema,
type ChatSendResponse, type ChatSendResponse,
type ChatSendResponseInput, type ChatSendResponseInput,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
@@ -14,7 +13,6 @@ import type { ChatState } from "@/stores/chat/chat-state";
import { import {
applyHttpSendOutput, applyHttpSendOutput,
applyNetworkHistoryLoadedOutput, applyNetworkHistoryLoadedOutput,
applySingleUnlockOutput,
countLockedHistoryMessages, countLockedHistoryMessages,
localMessagesToUi, localMessagesToUi,
sendResponseToUiMessage, sendResponseToUiMessage,
@@ -180,7 +178,6 @@ describe("applyHttpSendOutput", () => {
const context = makeChatState({ const context = makeChatState({
messages: [ messages: [
{ {
displayId: "client:message:failed",
content: "hello", content: "hello",
isFromAI: false, isFromAI: false,
date: "2026-06-25", date: "2026-06-25",
@@ -218,7 +215,6 @@ describe("applyHttpSendOutput", () => {
const context = makeChatState({ const context = makeChatState({
messages: [ messages: [
{ {
displayId: "client:message:successful",
content: "hello", content: "hello",
isFromAI: false, isFromAI: false,
date: "2026-06-25", date: "2026-06-25",
@@ -261,7 +257,6 @@ describe("chat history pagination helpers", () => {
it("falls back to the bounded history limit when the backend limit is zero", () => { it("falls back to the bounded history limit when the backend limit is zero", () => {
const nextState = applyNetworkHistoryLoadedOutput(makeChatState(), { const nextState = applyNetworkHistoryLoadedOutput(makeChatState(), {
messages: [], messages: [],
localDisplayIds: [],
localCount: 0, localCount: 0,
total: 120, total: 120,
limit: 0, limit: 0,
@@ -271,115 +266,9 @@ describe("chat history pagination helpers", () => {
expect(nextState.nextHistoryOffset).toBe(50); expect(nextState.nextHistoryOffset).toBe(50);
expect(nextState.historyTotal).toBe(120); expect(nextState.historyTotal).toBe(120);
}); });
it("preserves messages added after the local history snapshot", () => {
const localDisplayId = "server:history-1:assistant";
const optimisticMessage = {
displayId: "client:message:optimistic-1",
clientId: "optimistic-1",
content: "Sent while refreshing",
isFromAI: false,
date: "2026-07-20",
};
const nextState = applyNetworkHistoryLoadedOutput(
makeChatState({
messages: [
{
displayId: localDisplayId,
remoteId: "history-1",
content: "Cached",
isFromAI: true,
date: "2026-07-20",
},
optimisticMessage,
],
}),
{
messages: [
{
displayId: localDisplayId,
remoteId: "history-1",
content: "Fresh",
isFromAI: true,
date: "2026-07-20",
},
],
localDisplayIds: [localDisplayId],
localCount: 1,
total: 1,
limit: 50,
},
);
expect(nextState.messages).toEqual([
expect.objectContaining({
displayId: localDisplayId,
content: "Fresh",
}),
optimisticMessage,
]);
});
}); });
describe("localMessagesToUi", () => { describe("localMessagesToUi", () => {
it("uses distinct stable display ids for user and assistant records sharing a remote id", () => {
const records = [
{
id: "shared-1",
role: "user",
content: "Hello",
createdAt: "2026-06-25T12:00:00.000Z",
},
{
id: "shared-1",
role: "assistant",
content: "Hi",
createdAt: "2026-06-25T12:00:01.000Z",
},
{
id: "shared-1",
role: "assistant",
content: "A duplicated backend id",
createdAt: "2026-06-25T12:00:02.000Z",
},
];
const first = localMessagesToUi(records);
const second = localMessagesToUi(records);
expect(first.map((message) => message.displayId)).toEqual([
"server:shared-1:user",
"server:shared-1:assistant",
"server:shared-1:assistant:1",
]);
expect(first.map((message) => message.remoteId)).toEqual([
"shared-1",
"shared-1",
"shared-1",
]);
expect(second.map((message) => message.displayId)).toEqual(
first.map((message) => message.displayId),
);
});
it("creates a stable legacy display id when history has no remote id", () => {
const records = [
{
role: "assistant",
type: "text",
content: "Legacy reply",
createdAt: "2026-06-25T12:00:00.000Z",
},
];
const first = localMessagesToUi(records)[0];
const second = localMessagesToUi(records)[0];
expect(first?.displayId).toMatch(/^legacy:/);
expect(second?.displayId).toBe(first?.displayId);
expect(first?.remoteId).toBeUndefined();
});
it("maps locked voice messages from history", () => { it("maps locked voice messages from history", () => {
const [message] = localMessagesToUi([ const [message] = localMessagesToUi([
{ {
@@ -431,53 +320,11 @@ describe("localMessagesToUi", () => {
}); });
}); });
describe("applySingleUnlockOutput", () => {
it("preserves display identity while adding the backend id", () => {
const displayId = "promotion:lock-1";
const [message] = applySingleUnlockOutput(
[
{
displayId,
content: "",
isFromAI: true,
date: "2026-07-20",
locked: true,
lockReason: "image_paywall",
imagePaywalled: true,
},
],
{
displayMessageId: displayId,
request: {
displayMessageId: displayId,
lockType: "image_paywall",
clientLockId: "lock-1",
},
response: UnlockPrivateResponseSchema.parse({
unlocked: true,
messageId: "remote-1",
image: {
type: "promotion",
url: "https://example.com/unlocked.jpg",
},
}),
},
);
expect(message).toMatchObject({
displayId,
remoteId: "remote-1",
locked: false,
});
});
});
describe("countLockedHistoryMessages", () => { describe("countLockedHistoryMessages", () => {
it("counts only unlockable locked AI messages", () => { it("counts only unlockable locked AI messages", () => {
expect( expect(
countLockedHistoryMessages([ countLockedHistoryMessages([
{ {
displayId: "private-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -485,7 +332,6 @@ describe("countLockedHistoryMessages", () => {
lockReason: "private_message", lockReason: "private_message",
}, },
{ {
displayId: "voice-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -493,7 +339,6 @@ describe("countLockedHistoryMessages", () => {
lockReason: "voice_message", lockReason: "voice_message",
}, },
{ {
displayId: "image-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -503,7 +348,6 @@ describe("countLockedHistoryMessages", () => {
lockReason: "image", lockReason: "image",
}, },
{ {
displayId: "other-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -511,7 +355,6 @@ describe("countLockedHistoryMessages", () => {
lockReason: "insufficient_credits", lockReason: "insufficient_credits",
}, },
{ {
displayId: "user-1",
content: "user text", content: "user text",
isFromAI: false, isFromAI: false,
date: "2026-06-29", date: "2026-06-29",
@@ -38,13 +38,13 @@ describe("chat history flow", () => {
resolveNetwork = resolve; resolveNetwork = resolve;
}); });
const localMessage: UiMessage = { const localMessage: UiMessage = {
displayId: "local-msg", id: "local-msg",
content: "cached local message", content: "cached local message",
isFromAI: true, isFromAI: true,
date: "2026-07-02", date: "2026-07-02",
}; };
const networkMessage: UiMessage = { const networkMessage: UiMessage = {
displayId: "network-msg", id: "network-msg",
content: "fresh network message", content: "fresh network message",
isFromAI: true, isFromAI: true,
date: "2026-07-02", date: "2026-07-02",
@@ -67,7 +67,6 @@ describe("chat history flow", () => {
type: "ChatNetworkHistoryLoaded", type: "ChatNetworkHistoryLoaded",
output: { output: {
messages: [networkMessage], messages: [networkMessage],
localDisplayIds: [localMessage.displayId],
localOverwritten: true, localOverwritten: true,
localCount: 1, localCount: 1,
networkCount: 1, networkCount: 1,
@@ -90,17 +89,17 @@ describe("chat history flow", () => {
); );
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ displayId: "local-msg", content: "cached local message" }, { id: "local-msg", content: "cached local message" },
]); ]);
resolveNetwork(); resolveNetwork();
await waitFor( await waitFor(
actor, actor,
(snapshot) => snapshot.context.messages[0]?.displayId === "network-msg", (snapshot) => snapshot.context.messages[0]?.id === "network-msg",
); );
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ displayId: "network-msg", content: "fresh network message" }, { id: "network-msg", content: "fresh network message" },
]); ]);
actor.stop(); actor.stop();
@@ -109,7 +108,7 @@ describe("chat history flow", () => {
it("loads older pages until total is exhausted and keeps existing messages", async () => { it("loads older pages until total is exhausted and keeps existing messages", async () => {
const requests: LoadMoreHistoryActorEvent[] = []; const requests: LoadMoreHistoryActorEvent[] = [];
const latestMessage: UiMessage = { const latestMessage: UiMessage = {
displayId: "latest", id: "latest",
content: "current unlocked message", content: "current unlocked message",
isFromAI: true, isFromAI: true,
date: "2026-07-15", date: "2026-07-15",
@@ -174,8 +173,8 @@ describe("chat history flow", () => {
); );
expect(requests[0]).toMatchObject({ offset: 50, limit: 50 }); expect(requests[0]).toMatchObject({ offset: 50, limit: 50 });
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ displayId: "older-50" }, { id: "older-50" },
{ displayId: "latest", content: "current unlocked message" }, { id: "latest", content: "current unlocked message" },
]); ]);
actor.send({ type: "ChatLoadMoreHistoryRequested" }); actor.send({ type: "ChatLoadMoreHistoryRequested" });
@@ -291,10 +290,10 @@ describe("chat history flow", () => {
}); });
}); });
function createHistoryMessage(displayId: string): UiMessage { function createHistoryMessage(id: string): UiMessage {
return { return {
displayId, id,
content: `Message ${displayId}`, content: `Message ${id}`,
isFromAI: true, isFromAI: true,
date: "2026-07-15", date: "2026-07-15",
}; };
@@ -170,7 +170,6 @@ export function createLoadHistoryCallback(
type: "ChatNetworkHistoryLoaded", type: "ChatNetworkHistoryLoaded",
output: { output: {
messages: networkMessages, messages: networkMessages,
localDisplayIds: messages.map((message) => message.displayId),
localOverwritten: true, localOverwritten: true,
localCount: messages.length, localCount: messages.length,
networkCount: networkMessages.length, networkCount: networkMessages.length,
@@ -22,7 +22,7 @@ describe("chat promotion", () => {
const messages = appendPromotionMessage( const messages = appendPromotionMessage(
[ [
{ {
displayId: "history-1", id: "history-1",
content: "History", content: "History",
isFromAI: true, isFromAI: true,
date: "2026-07-13", date: "2026-07-13",
@@ -31,7 +31,7 @@ describe("chat promotion", () => {
state, state,
); );
expect(messages.map((message) => message.displayId)).toEqual([ expect(messages.map((message) => message.id)).toEqual([
"history-1", "history-1",
"promotion:promotion-1", "promotion:promotion-1",
]); ]);
@@ -62,8 +62,7 @@ describe("chat promotion", () => {
}); });
expect(next?.message).toMatchObject({ expect(next?.message).toMatchObject({
displayId: "promotion:promotion-1", id: "backend-1",
remoteId: "backend-1",
imageUrl: "https://example.com/unlocked.jpg", imageUrl: "https://example.com/unlocked.jpg",
imagePaywalled: false, imagePaywalled: false,
locked: false, locked: false,
@@ -72,19 +71,10 @@ describe("chat promotion", () => {
it("keeps the promotion last and removes matching history duplicates", () => { it("keeps the promotion last and removes matching history duplicates", () => {
const state = createChatPromotionState(promotion, "backend-1"); const state = createChatPromotionState(promotion, "backend-1");
const userMessage = {
displayId: "server:backend-1:user",
remoteId: "backend-1",
content: "User message with the shared remote id",
isFromAI: false,
date: "2026-07-13",
};
const messages = appendPromotionMessage( const messages = appendPromotionMessage(
[ [
userMessage,
{ {
displayId: "server:backend-1:assistant", id: "backend-1",
remoteId: "backend-1",
content: "Stale history copy", content: "Stale history copy",
isFromAI: true, isFromAI: true,
date: "2026-07-13", date: "2026-07-13",
@@ -93,6 +83,6 @@ describe("chat promotion", () => {
state, state,
); );
expect(messages).toEqual([userMessage, state.message]); expect(messages).toEqual([state.message]);
}); });
}); });
@@ -32,10 +32,6 @@ describe("chat send flow", () => {
{ content: "hello", isFromAI: false }, { content: "hello", isFromAI: false },
{ content: "still there?", isFromAI: false }, { content: "still there?", isFromAI: false },
]); ]);
for (const message of actor.getSnapshot().context.messages) {
expect(message.displayId).toMatch(/^client:message:/);
expect(message.clientId).toBeTruthy();
}
expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(2); expect(actor.getSnapshot().context.outgoingMessageRevision).toBe(2);
actor.send({ type: "ChatSendMessage", content: " " }); actor.send({ type: "ChatSendMessage", content: " " });
@@ -98,9 +94,6 @@ describe("chat send flow", () => {
content: "[Image]", content: "[Image]",
isFromAI: false, isFromAI: false,
}); });
expect(
actor.getSnapshot().context.messages.at(-1)?.displayId,
).toMatch(/^client:image:/);
actor.stop(); actor.stop();
}); });
@@ -96,7 +96,7 @@ describe("chat session flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "history-1", id: "history-1",
content: "Existing history", content: "Existing history",
isFromAI: true, isFromAI: true,
date: "2026-07-13", date: "2026-07-13",
@@ -124,7 +124,7 @@ describe("chat session flow", () => {
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.messages).toHaveLength(1); expect(context.messages).toHaveLength(1);
expect(context.promotion?.message).toMatchObject({ expect(context.promotion?.message).toMatchObject({
displayId: "promotion:promotion-1", id: "promotion:promotion-1",
locked: true, locked: true,
lockReason: "voice_message", lockReason: "voice_message",
}); });
@@ -20,7 +20,6 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "private-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -28,7 +27,6 @@ describe("chat unlock flow", () => {
lockReason: "private_message", lockReason: "private_message",
}, },
{ {
displayId: "voice-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -58,7 +56,7 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "msg-image-locked", id: "msg-image-locked",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -74,7 +72,7 @@ describe("chat unlock flow", () => {
shortfallCredits: 0, shortfallCredits: 0,
messages: [ messages: [
{ {
displayId: "msg-image-locked", id: "msg-image-locked",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -99,7 +97,7 @@ describe("chat unlock flow", () => {
expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false); expect(actor.getSnapshot().context.unlockHistoryPromptVisible).toBe(false);
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
displayId: "msg-image-locked", id: "msg-image-locked",
imageUrl: "https://example.com/locked.jpg", imageUrl: "https://example.com/locked.jpg",
imagePaywalled: true, imagePaywalled: true,
locked: true, locked: true,
@@ -114,7 +112,6 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "private-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -122,7 +119,6 @@ describe("chat unlock flow", () => {
lockReason: "private_message", lockReason: "private_message",
}, },
{ {
displayId: "voice-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -136,7 +132,6 @@ describe("chat unlock flow", () => {
shortfallCredits: 0, shortfallCredits: 0,
messages: [ messages: [
{ {
displayId: "unlocked-1",
content: "unlocked", content: "unlocked",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -173,7 +168,6 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "private-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -181,7 +175,6 @@ describe("chat unlock flow", () => {
lockReason: "private_message", lockReason: "private_message",
}, },
{ {
displayId: "voice-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -218,7 +211,7 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "msg-private-locked", id: "msg-private-locked",
content: "Original private message content.", content: "Original private message content.",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -245,8 +238,7 @@ describe("chat unlock flow", () => {
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "msg-private-locked", messageId: "msg-private-locked",
remoteMessageId: "msg-private-locked",
kind: "private", kind: "private",
}); });
@@ -258,7 +250,7 @@ describe("chat unlock flow", () => {
expect(actor.getSnapshot().context.unlockingMessage).toBeNull(); expect(actor.getSnapshot().context.unlockingMessage).toBeNull();
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
displayId: "msg-private-locked", id: "msg-private-locked",
content: "Unlocked private message content.", content: "Unlocked private message content.",
locked: false, locked: false,
lockReason: null, lockReason: null,
@@ -275,15 +267,13 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "server:msg-shared-id:user", id: "msg-shared-id",
remoteId: "msg-shared-id",
content: "User original question", content: "User original question",
isFromAI: false, isFromAI: false,
date: "2026-06-29", date: "2026-06-29",
}, },
{ {
displayId: "server:msg-shared-id:assistant", id: "msg-shared-id",
remoteId: "msg-shared-id",
content: "Original AI private message", content: "Original AI private message",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -310,8 +300,7 @@ describe("chat unlock flow", () => {
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "server:msg-shared-id:assistant", messageId: "msg-shared-id",
remoteMessageId: "msg-shared-id",
kind: "private", kind: "private",
}); });
@@ -321,14 +310,12 @@ describe("chat unlock flow", () => {
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
displayId: "server:msg-shared-id:user", id: "msg-shared-id",
remoteId: "msg-shared-id",
content: "User original question", content: "User original question",
isFromAI: false, isFromAI: false,
}, },
{ {
displayId: "server:msg-shared-id:assistant", id: "msg-shared-id",
remoteId: "msg-shared-id",
content: "Unlocked private AI message.", content: "Unlocked private AI message.",
isFromAI: true, isFromAI: true,
locked: false, locked: false,
@@ -346,7 +333,7 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "msg-image-locked", id: "msg-image-locked",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -373,8 +360,7 @@ describe("chat unlock flow", () => {
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "msg-image-locked", messageId: "msg-image-locked",
remoteMessageId: "msg-image-locked",
kind: "image", kind: "image",
}); });
@@ -384,7 +370,7 @@ describe("chat unlock flow", () => {
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
displayId: "msg-image-locked", id: "msg-image-locked",
content: "", content: "",
imageUrl: "https://example.com/locked.jpg", imageUrl: "https://example.com/locked.jpg",
imagePaywalled: false, imagePaywalled: false,
@@ -401,7 +387,7 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "msg-voice-locked", id: "msg-voice-locked",
content: "Original voice transcript.", content: "Original voice transcript.",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -428,8 +414,7 @@ describe("chat unlock flow", () => {
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "msg-voice-locked", messageId: "msg-voice-locked",
remoteMessageId: "msg-voice-locked",
kind: "voice", kind: "voice",
}); });
@@ -439,7 +424,7 @@ describe("chat unlock flow", () => {
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
displayId: "msg-voice-locked", id: "msg-voice-locked",
content: "Original voice transcript.", content: "Original voice transcript.",
audioUrl: "https://example.com/unlocked-voice.mp3", audioUrl: "https://example.com/unlocked-voice.mp3",
locked: false, locked: false,
@@ -456,7 +441,7 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "msg-voice-locked", id: "msg-voice-locked",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -488,8 +473,7 @@ describe("chat unlock flow", () => {
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "msg-voice-locked", messageId: "msg-voice-locked",
remoteMessageId: "msg-voice-locked",
kind: "voice", kind: "voice",
}); });
@@ -509,7 +493,7 @@ describe("chat unlock flow", () => {
}); });
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
displayId: "msg-voice-locked", id: "msg-voice-locked",
locked: true, locked: true,
lockReason: "voice_message", lockReason: "voice_message",
}, },
@@ -525,7 +509,7 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "msg-missing", id: "msg-missing",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-06-29", date: "2026-06-29",
@@ -557,8 +541,7 @@ describe("chat unlock flow", () => {
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "msg-missing", messageId: "msg-missing",
remoteMessageId: "msg-missing",
kind: "private", kind: "private",
}); });
@@ -574,7 +557,7 @@ describe("chat unlock flow", () => {
}); });
expect(actor.getSnapshot().context.messages).toMatchObject([ expect(actor.getSnapshot().context.messages).toMatchObject([
{ {
displayId: "msg-missing", id: "msg-missing",
locked: true, locked: true,
lockReason: "private_message", lockReason: "private_message",
}, },
@@ -588,7 +571,7 @@ describe("chat unlock flow", () => {
createTestChatMachine({ createTestChatMachine({
historyMessages: [ historyMessages: [
{ {
displayId: "msg-mismatch", id: "msg-mismatch",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-07-20", date: "2026-07-20",
@@ -609,8 +592,7 @@ describe("chat unlock flow", () => {
); );
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "msg-mismatch", messageId: "msg-mismatch",
remoteMessageId: "msg-mismatch",
kind: "voice", kind: "voice",
}); });
await waitFor( await waitFor(
@@ -635,7 +617,7 @@ describe("chat unlock flow", () => {
resolveUnlock = resolve; resolveUnlock = resolve;
}); });
const originalMessage = { const originalMessage = {
displayId: "promotion:lock-1", id: "promotion:lock-1",
content: "", content: "",
isFromAI: true, isFromAI: true,
date: "2026-07-16", date: "2026-07-16",
@@ -666,7 +648,7 @@ describe("chat unlock flow", () => {
); );
actor.send({ actor.send({
type: "ChatUnlockMessageRequested", type: "ChatUnlockMessageRequested",
displayMessageId: "promotion:lock-1", messageId: "promotion:lock-1",
remoteMessageId: "remote-1", remoteMessageId: "remote-1",
kind: "image", kind: "image",
lockType: "image_paywall", lockType: "image_paywall",
@@ -687,7 +669,6 @@ describe("chat unlock flow", () => {
type: "ChatNetworkHistoryLoaded", type: "ChatNetworkHistoryLoaded",
output: { output: {
messages: [], messages: [],
localDisplayIds: [],
localOverwritten: true, localOverwritten: true,
localCount: 0, localCount: 0,
networkCount: 0, networkCount: 0,
+1 -1
View File
@@ -51,7 +51,7 @@ export type ChatEvent =
| { type: "ChatPromotionCleared" } | { type: "ChatPromotionCleared" }
| { | {
type: "ChatUnlockMessageRequested"; type: "ChatUnlockMessageRequested";
displayMessageId: string; messageId: string;
remoteMessageId?: string; remoteMessageId?: string;
kind: PendingChatUnlockKind; kind: PendingChatUnlockKind;
lockType?: ChatLockType; lockType?: ChatLockType;
+3 -23
View File
@@ -15,8 +15,6 @@ const log = new Logger("StoresChatChatHistorySync");
export type ReadAndSyncHistoryOutput = { export type ReadAndSyncHistoryOutput = {
/** Network-authoritative messages. Empty network history includes a UI greeting. */ /** Network-authoritative messages. Empty network history includes a UI greeting. */
messages: UiMessage[]; messages: UiMessage[];
/** Display identities present in the local snapshot before the request. */
localDisplayIds: readonly string[];
/** True when network history was written back to local storage. */ /** True when network history was written back to local storage. */
localOverwritten: boolean; localOverwritten: boolean;
localCount: number; localCount: number;
@@ -33,12 +31,8 @@ export type LocalHistorySnapshotOutput = {
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput; export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
export function createGreetingMessage( export function createGreetingMessage(content: string): UiMessage {
characterId: string,
content: string,
): UiMessage {
return { return {
displayId: `greeting:${encodeURIComponent(characterId)}`,
content, content,
isFromAI: true, isFromAI: true,
date: todayString(), date: todayString(),
@@ -55,14 +49,10 @@ export async function resolveHistoryCacheIdentity(
export async function readLocalHistorySnapshot( export async function readLocalHistorySnapshot(
cacheIdentity: string | null, cacheIdentity: string | null,
characterId: string,
emptyChatGreeting: string, emptyChatGreeting: string,
): Promise<LocalHistorySnapshotOutput> { ): Promise<LocalHistorySnapshotOutput> {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage( const greetingMessage = createGreetingMessage(emptyChatGreeting);
characterId,
emptyChatGreeting,
);
const localResult = cacheIdentity const localResult = cacheIdentity
? await chatRepo.getLocalMessages(cacheIdentity) ? await chatRepo.getLocalMessages(cacheIdentity)
@@ -90,16 +80,12 @@ export async function readLocalHistorySnapshot(
export async function syncNetworkHistory( export async function syncNetworkHistory(
characterId: string, characterId: string,
localCount: number, localCount: number,
localDisplayIds: readonly string[],
cacheIdentity: string | null, cacheIdentity: string | null,
emptyChatGreeting: string, emptyChatGreeting: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<NetworkHistorySyncOutput | null> { ): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = await loadChatRepository(); const chatRepo = await loadChatRepository();
const greetingMessage = createGreetingMessage( const greetingMessage = createGreetingMessage(emptyChatGreeting);
characterId,
emptyChatGreeting,
);
const networkResult = await chatRepo.getHistory( const networkResult = await chatRepo.getHistory(
characterId, characterId,
@@ -148,7 +134,6 @@ export async function syncNetworkHistory(
return { return {
messages: finalMessages, messages: finalMessages,
localDisplayIds,
localOverwritten, localOverwritten,
localCount, localCount,
networkCount: networkUi.length, networkCount: networkUi.length,
@@ -171,13 +156,11 @@ export async function readAndSyncHistory(
const cacheIdentity = await resolveHistoryCacheIdentity(characterId); const cacheIdentity = await resolveHistoryCacheIdentity(characterId);
const localSnapshot = await readLocalHistorySnapshot( const localSnapshot = await readLocalHistorySnapshot(
cacheIdentity, cacheIdentity,
characterId,
emptyChatGreeting, emptyChatGreeting,
); );
const networkSnapshot = await syncNetworkHistory( const networkSnapshot = await syncNetworkHistory(
characterId, characterId,
localSnapshot.localCount, localSnapshot.localCount,
localSnapshot.messages.map((message) => message.displayId),
cacheIdentity, cacheIdentity,
emptyChatGreeting, emptyChatGreeting,
signal, signal,
@@ -186,9 +169,6 @@ export async function readAndSyncHistory(
return { return {
messages: localSnapshot.messages, messages: localSnapshot.messages,
localDisplayIds: localSnapshot.messages.map(
(message) => message.displayId,
),
localOverwritten: false, localOverwritten: false,
localCount: localSnapshot.localCount, localCount: localSnapshot.localCount,
networkCount: 0, networkCount: 0,
+6 -18
View File
@@ -20,7 +20,6 @@ export function applyNetworkHistoryLoadedOutput(
context: ChatState, context: ChatState,
output: { output: {
messages: UiMessage[]; messages: UiMessage[];
localDisplayIds: readonly string[];
localCount: number; localCount: number;
total: number; total: number;
limit: number; limit: number;
@@ -34,15 +33,8 @@ export function applyNetworkHistoryLoadedOutput(
| "nextHistoryOffset" | "nextHistoryOffset"
| "isLoadingMoreHistory" | "isLoadingMoreHistory"
> { > {
const localDisplayIds = new Set(output.localDisplayIds); const localSnapshotSize = output.localCount === 0 ? 1 : output.localCount;
const networkDisplayIds = new Set( const optimisticTail = context.messages.slice(localSnapshotSize);
output.messages.map((message) => message.displayId),
);
const optimisticTail = context.messages.filter(
(message) =>
!localDisplayIds.has(message.displayId) &&
!networkDisplayIds.has(message.displayId),
);
const historyLimit = normalizeHistoryLimit(output.limit); const historyLimit = normalizeHistoryLimit(output.limit);
return { return {
messages: [...output.messages, ...optimisticTail], messages: [...output.messages, ...optimisticTail],
@@ -103,17 +95,13 @@ export function prependUniqueHistoryMessages(
olderMessages: readonly UiMessage[], olderMessages: readonly UiMessage[],
): UiMessage[] { ): UiMessage[] {
const existingIds = new Set( const existingIds = new Set(
currentMessages.map((message) => message.displayId), currentMessages.flatMap((message) => (message.id ? [message.id] : [])),
); );
const pageIds = new Set<string>(); const pageIds = new Set<string>();
const uniqueOlderMessages = olderMessages.filter((message) => { const uniqueOlderMessages = olderMessages.filter((message) => {
if ( if (!message.id) return true;
existingIds.has(message.displayId) || if (existingIds.has(message.id) || pageIds.has(message.id)) return false;
pageIds.has(message.displayId) pageIds.add(message.id);
) {
return false;
}
pageIds.add(message.displayId);
return true; return true;
}); });
return [...uniqueOlderMessages, ...currentMessages]; return [...uniqueOlderMessages, ...currentMessages];
+7 -49
View File
@@ -2,12 +2,7 @@ import type {
ChatLockDetailData, ChatLockDetailData,
ChatSendResponse, ChatSendResponse,
} from "@/data/schemas/chat"; } from "@/data/schemas/chat";
import { import type { UiMessage } from "@/stores/chat/ui-message";
createClientUiMessageIdentity,
createLegacyUiMessageIdentity,
createRemoteUiMessageIdentity,
type UiMessage,
} from "@/stores/chat/ui-message";
import { todayString } from "@/utils/date"; import { todayString } from "@/utils/date";
/** /**
@@ -26,33 +21,20 @@ export function localMessagesToUi(
lockDetail?: ChatLockDetailData; lockDetail?: ChatLockDetailData;
}[], }[],
): UiMessage[] { ): UiMessage[] {
const occurrences = new Map<string, number>(); return records.map((m) => ({
return records.map((m) => { ...(m.id ? { id: m.id } : {}),
const isFromAI = m.role === "assistant";
const identityBase = m.id
? `remote:${m.id}:${m.role}`
: `legacy:${createLegacyFingerprint(m)}`;
const occurrence = occurrences.get(identityBase) ?? 0;
occurrences.set(identityBase, occurrence + 1);
const identity = m.id
? createRemoteUiMessageIdentity(m.id, isFromAI, occurrence)
: createLegacyUiMessageIdentity(identityBase, occurrence);
return {
...identity,
content: getAiMessageDisplayContent({ content: getAiMessageDisplayContent({
content: m.content, content: m.content,
isFromAI, isFromAI: m.role === "assistant",
hasImage: Boolean(m.image?.url), hasImage: Boolean(m.image?.url),
}), }),
isFromAI, isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt), date: messageDateFromCreatedAt(m.createdAt),
...(m.audioUrl && m.lockDetail?.locked !== true ...(m.audioUrl && m.lockDetail?.locked !== true
? { audioUrl: m.audioUrl } ? { audioUrl: m.audioUrl }
: {}), : {}),
...deriveUiLockFields(m.lockDetail, m.image?.url), ...deriveUiLockFields(m.lockDetail, m.image?.url),
}; }));
});
} }
/** /**
@@ -61,9 +43,7 @@ export function localMessagesToUi(
*/ */
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
return { return {
...(response.messageId ...(response.messageId ? { id: response.messageId } : {}),
? createRemoteUiMessageIdentity(response.messageId, true)
: createClientUiMessageIdentity("reply")),
content: getAiMessageDisplayContent({ content: getAiMessageDisplayContent({
content: response.reply, content: response.reply,
isFromAI: true, isFromAI: true,
@@ -78,28 +58,6 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
}; };
} }
function createLegacyFingerprint(record: {
type?: string;
content: string;
role: string;
createdAt: string;
audioUrl?: string | null;
image?: { type: string | null; url: string | null };
lockDetail?: ChatLockDetailData;
}): string {
return JSON.stringify([
record.role,
record.type ?? "text",
record.content,
record.createdAt,
record.audioUrl ?? null,
record.image?.type ?? null,
record.image?.url ?? null,
record.lockDetail?.locked ?? null,
record.lockDetail?.reason ?? null,
]);
}
function messageDateFromCreatedAt(createdAt: string): string { function messageDateFromCreatedAt(createdAt: string): string {
const parsed = new Date(createdAt); const parsed = new Date(createdAt);
if (Number.isNaN(parsed.getTime())) return todayString(); if (Number.isNaN(parsed.getTime())) return todayString();
+3 -13
View File
@@ -28,8 +28,7 @@ export function createChatPromotionState(
return { return {
session, session,
message: { message: {
displayId: `promotion:${session.clientLockId}`, id: messageId || `promotion:${session.clientLockId}`,
...(messageId ? { remoteId: messageId } : {}),
content: "", content: "",
isFromAI: true, isFromAI: true,
date: todayString(), date: todayString(),
@@ -48,13 +47,7 @@ export function appendPromotionMessage(
): UiMessage[] { ): UiMessage[] {
if (!promotion) return [...messages]; if (!promotion) return [...messages];
return [ return [
...messages.filter( ...messages.filter((message) => message.id !== promotion.message.id),
(message) =>
message.displayId !== promotion.message.displayId &&
(!promotion.message.remoteId ||
!message.isFromAI ||
message.remoteId !== promotion.message.remoteId),
),
promotion.message, promotion.message,
]; ];
} }
@@ -63,10 +56,7 @@ export function applyPromotionUnlockOutput(
promotion: ChatPromotionState | null, promotion: ChatPromotionState | null,
output: UnlockMessageOutput, output: UnlockMessageOutput,
): ChatPromotionState | null { ): ChatPromotionState | null {
if ( if (!promotion || promotion.message.id !== output.displayMessageId) {
!promotion ||
promotion.message.displayId !== output.displayMessageId
) {
return promotion; return promotion;
} }
return { return {
+5 -8
View File
@@ -30,11 +30,11 @@ export function applySingleUnlockOutput(
return message; return message;
} }
const remoteId = output.response.messageId || message.remoteId; const resolvedId = output.response.messageId || message.id;
if (!output.response.unlocked) { if (!output.response.unlocked) {
return { return {
...message, ...message,
...(remoteId ? { remoteId } : {}), id: resolvedId,
}; };
} }
@@ -47,7 +47,7 @@ export function applySingleUnlockOutput(
return { return {
...message, ...message,
...(remoteId ? { remoteId } : {}), id: resolvedId,
content: resolvedContent, content: resolvedContent,
audioUrl: getUnlockedAudioUrl(message, output.response), audioUrl: getUnlockedAudioUrl(message, output.response),
imageUrl: resolvedImageUrl, imageUrl: resolvedImageUrl,
@@ -69,11 +69,8 @@ function getUnlockedAudioUrl(
return response.audioUrl; return response.audioUrl;
} }
function shouldApplySingleUnlock( function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean {
message: UiMessage, if (message.id !== messageId) return false;
displayMessageId: string,
): boolean {
if (message.displayId !== displayMessageId) return false;
if (!message.isFromAI) return false; if (!message.isFromAI) return false;
if (message.locked !== true) return false; if (message.locked !== true) return false;
return ( return (
@@ -46,7 +46,6 @@ export const loadHistoryActor = fromCallback<
const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId); const cacheIdentity = await resolveHistoryCacheIdentity(input.characterId);
const localSnapshot = await readLocalHistorySnapshot( const localSnapshot = await readLocalHistorySnapshot(
cacheIdentity, cacheIdentity,
input.characterId,
input.emptyChatGreeting, input.emptyChatGreeting,
); );
if (cancelled) return; if (cancelled) return;
@@ -58,7 +57,6 @@ export const loadHistoryActor = fromCallback<
const networkSnapshot = await syncNetworkHistory( const networkSnapshot = await syncNetworkHistory(
input.characterId, input.characterId,
localSnapshot.localCount, localSnapshot.localCount,
localSnapshot.messages.map((message) => message.displayId),
cacheIdentity, cacheIdentity,
input.emptyChatGreeting, input.emptyChatGreeting,
controller.signal, controller.signal,
-6
View File
@@ -9,7 +9,6 @@ import {
finishPendingReply, finishPendingReply,
type HttpSendOutput, type HttpSendOutput,
} from "../helper/send-state"; } from "../helper/send-state";
import { createClientUiMessageIdentity } from "../ui-message";
import { historyMachineSetup } from "./history-flow"; import { historyMachineSetup } from "./history-flow";
import { createChatActorActionSetup } from "./setup"; import { createChatActorActionSetup } from "./setup";
@@ -37,7 +36,6 @@ const appendGuestUserMessageAction = historyMachineSetup.assign(
messages: [ messages: [
...context.messages, ...context.messages,
{ {
...createClientUiMessageIdentity("message"),
content: event.content, content: event.content,
isFromAI: false, isFromAI: false,
date: today, date: today,
@@ -65,7 +63,6 @@ const appendUserMessageAction = historyMachineSetup.assign(
messages: [ messages: [
...context.messages, ...context.messages,
{ {
...createClientUiMessageIdentity("message"),
content: event.content, content: event.content,
isFromAI: false, isFromAI: false,
date: today, date: today,
@@ -86,7 +83,6 @@ const appendQueuedSendErrorMessageAction = historyMachineSetup.assign(
const messages = [ const messages = [
...context.messages, ...context.messages,
{ {
...createClientUiMessageIdentity("error"),
content: unavailable content: unavailable
? "This character is temporarily unavailable. Please try again shortly." ? "This character is temporarily unavailable. Please try again shortly."
: "Something went wrong. Try sending again?", : "Something went wrong. Try sending again?",
@@ -139,7 +135,6 @@ const appendGuestUserImageAction = historyMachineSetup.assign(
messages: [ messages: [
...context.messages, ...context.messages,
{ {
...createClientUiMessageIdentity("image"),
content: "[Image]", content: "[Image]",
isFromAI: false, isFromAI: false,
date: today, date: today,
@@ -167,7 +162,6 @@ const appendUserImageAction = historyMachineSetup.assign(
messages: [ messages: [
...context.messages, ...context.messages,
{ {
...createClientUiMessageIdentity("image"),
content: "[Image]", content: "[Image]",
isFromAI: false, isFromAI: false,
date: today, date: today,
+1 -2
View File
@@ -157,8 +157,7 @@ const userReadyState = chatMachineSetup.createStateConfig({
actions: "appendUserImage", actions: "appendUserImage",
}, },
ChatUnlockMessageRequested: { ChatUnlockMessageRequested: {
guard: ({ event }) => guard: ({ event }) => event.messageId.trim().length > 0,
event.displayMessageId.trim().length > 0,
target: "unlockingMessage", target: "unlockingMessage",
actions: "markUnlockMessageStarted", actions: "markUnlockMessageStarted",
}, },
+8 -9
View File
@@ -45,12 +45,12 @@ const markUnlockHistoryFailedAction = sendMachineSetup.assign(() => ({
const markUnlockMessageStartedAction = sendMachineSetup.assign( const markUnlockMessageStartedAction = sendMachineSetup.assign(
({ event }) => { ({ event }) => {
if (event.type !== "ChatUnlockMessageRequested") return {}; if (event.type !== "ChatUnlockMessageRequested") return {};
const remoteMessageId =
event.remoteMessageId ?? (event.lockType ? undefined : event.messageId);
return { return {
unlockingMessage: { unlockingMessage: {
displayMessageId: event.displayMessageId, displayMessageId: event.messageId,
...(event.remoteMessageId ...(remoteMessageId ? { messageId: remoteMessageId } : {}),
? { messageId: event.remoteMessageId }
: {}),
kind: event.kind, kind: event.kind,
...(event.lockType ? { lockType: event.lockType } : {}), ...(event.lockType ? { lockType: event.lockType } : {}),
...(event.clientLockId ...(event.clientLockId
@@ -90,7 +90,8 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
unlockMessageError: output.response.reason, unlockMessageError: output.response.reason,
unlockPaywallRequest: shouldOpenPaywall unlockPaywallRequest: shouldOpenPaywall
? { ? {
displayMessageId: output.displayMessageId, displayMessageId:
output.response.messageId || output.displayMessageId,
...(output.response.messageId || output.request.messageId ...(output.response.messageId || output.request.messageId
? { ? {
messageId: messageId:
@@ -104,8 +105,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
...(output.request.clientLockId ...(output.request.clientLockId
? { clientLockId: output.request.clientLockId } ? { clientLockId: output.request.clientLockId }
: {}), : {}),
...(context.promotion?.message.displayId === ...(context.promotion?.message.id === output.displayMessageId
output.displayMessageId
? { promotion: context.promotion.session } ? { promotion: context.promotion.session }
: {}), : {}),
reason: output.response.reason, reason: output.response.reason,
@@ -130,8 +130,7 @@ const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign(
unlockPaywallRequest: request unlockPaywallRequest: request
? { ? {
...request, ...request,
...(context.promotion?.message.displayId === ...(context.promotion?.message.id === request.displayMessageId
request.displayMessageId
? { promotion: context.promotion.session } ? { promotion: context.promotion.session }
: {}), : {}),
reason: "unlock_failed", reason: "unlock_failed",
+1 -55
View File
@@ -1,9 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const UiMessageSchema = z.object({ export const UiMessageSchema = z.object({
displayId: z.string().min(1), id: z.string().optional(),
remoteId: z.string().min(1).optional(),
clientId: z.string().min(1).optional(),
content: z.string(), content: z.string(),
isFromAI: z.boolean(), isFromAI: z.boolean(),
date: z.string(), date: z.string(),
@@ -20,41 +18,6 @@ export const UiMessageSchema = z.object({
export type UiMessage = z.infer<typeof UiMessageSchema>; export type UiMessage = z.infer<typeof UiMessageSchema>;
let fallbackClientId = 0;
export function createRemoteUiMessageIdentity(
remoteId: string,
isFromAI: boolean,
occurrence = 0,
): Pick<UiMessage, "displayId" | "remoteId"> {
const role = isFromAI ? "assistant" : "user";
const base = `server:${encodeURIComponent(remoteId)}:${role}`;
return {
displayId: occurrence > 0 ? `${base}:${occurrence}` : base,
remoteId,
};
}
export function createLegacyUiMessageIdentity(
fingerprint: string,
occurrence = 0,
): Pick<UiMessage, "displayId"> {
const base = `legacy:${hashIdentity(fingerprint)}`;
return {
displayId: occurrence > 0 ? `${base}:${occurrence}` : base,
};
}
export function createClientUiMessageIdentity(
kind: "message" | "image" | "reply" | "error",
): Pick<UiMessage, "displayId" | "clientId"> {
const clientId = createClientId();
return {
clientId,
displayId: `client:${kind}:${clientId}`,
};
}
export const UiMessage = { export const UiMessage = {
create(input: Omit<UiMessage, "date"> & { date?: string }): UiMessage { create(input: Omit<UiMessage, "date"> & { date?: string }): UiMessage {
return UiMessageSchema.parse({ return UiMessageSchema.parse({
@@ -68,20 +31,3 @@ export const UiMessage = {
}); });
}, },
}; };
function createClientId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
fallbackClientId += 1;
return `${Date.now().toString(36)}-${fallbackClientId.toString(36)}`;
}
function hashIdentity(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}