Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d318e49410 | |||
| c187f0b817 | |||
| 1e13f94b5d |
@@ -10,12 +10,13 @@ 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 = {
|
||||||
id: "history-msg-1",
|
displayId: "history-msg-1",
|
||||||
content: "history message",
|
content: "history message",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-07",
|
date: "2026-07-07",
|
||||||
@@ -28,12 +29,34 @@ describe("chat area render items", () => {
|
|||||||
getMessageKey,
|
getMessageKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(findMessageKey(firstItems, localMessage)).toBe("local-msg-1");
|
expect(findMessageKey(firstItems, localMessage)).toBe(
|
||||||
expect(findMessageKey(secondItems, localMessage)).toBe("local-msg-1");
|
"msg-client:message:local-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,6 +9,7 @@ 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";
|
||||||
|
|
||||||
@@ -31,11 +32,15 @@ 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: "message-1",
|
displayMessageId: "server:message-1:assistant",
|
||||||
messageId: "message-1",
|
messageId: "message-1",
|
||||||
kind: "private",
|
kind: "private",
|
||||||
returnUrl: "/chat",
|
returnUrl: "/chat",
|
||||||
@@ -51,7 +56,11 @@ 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");
|
||||||
@@ -62,7 +71,10 @@ 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({
|
||||||
@@ -83,7 +95,11 @@ 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");
|
||||||
@@ -103,6 +119,40 @@ 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",
|
||||||
@@ -136,7 +186,7 @@ describe("chat unlock coordinator", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveMessageUnlockRequest(
|
resolveMessageUnlockRequest(
|
||||||
{ messageId: imageMessageId, kind: "image" },
|
{ displayMessageId: imageMessageId, kind: "image" },
|
||||||
{
|
{
|
||||||
...imageScope,
|
...imageScope,
|
||||||
promotion,
|
promotion,
|
||||||
|
|||||||
@@ -7,20 +7,7 @@ export type ChatRenderItem =
|
|||||||
| { type: "msg"; message: UiMessage; key: string };
|
| { type: "msg"; message: UiMessage; key: string };
|
||||||
|
|
||||||
export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
|
export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
|
||||||
const localMessageKeys = new WeakMap<UiMessage, string>();
|
return (message) => `msg-${message.displayId}`;
|
||||||
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(
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ export function ChatScreen() {
|
|||||||
() =>
|
() =>
|
||||||
imageMessageId
|
imageMessageId
|
||||||
? visibleMessages.find(
|
? visibleMessages.find(
|
||||||
(item) => item.id === imageMessageId && item.imageUrl,
|
(item) =>
|
||||||
|
(item.displayId === imageMessageId ||
|
||||||
|
item.remoteId === imageMessageId) &&
|
||||||
|
item.imageUrl,
|
||||||
) ?? null
|
) ?? null
|
||||||
: null,
|
: null,
|
||||||
[imageMessageId, visibleMessages],
|
[imageMessageId, visibleMessages],
|
||||||
@@ -175,24 +178,46 @@ export function ChatScreen() {
|
|||||||
state.characterErrorCode,
|
state.characterErrorCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function handleUnlockPrivateMessage(messageId: string): void {
|
function handleUnlockPrivateMessage(
|
||||||
unlockCoordinator.requestMessageUnlock(messageId, "private");
|
displayMessageId: string,
|
||||||
}
|
remoteMessageId?: string,
|
||||||
|
): void {
|
||||||
function handleUnlockVoiceMessage(messageId: string): void {
|
unlockCoordinator.requestMessageUnlock({
|
||||||
unlockCoordinator.requestMessageUnlock(messageId, "voice");
|
displayMessageId,
|
||||||
}
|
remoteMessageId,
|
||||||
|
kind: "private",
|
||||||
function handleUnlockImageMessage(messageId: string): void {
|
|
||||||
unlockCoordinator.requestMessageUnlock(messageId, "image");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenImage(messageId: string): void {
|
|
||||||
router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
|
|
||||||
scroll: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function handleCloseImageViewer(): void {
|
||||||
router.replace(
|
router.replace(
|
||||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||||
@@ -201,8 +226,12 @@ export function ChatScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockImagePaywall(): void {
|
function handleUnlockImagePaywall(): void {
|
||||||
if (!imageMessageId) return;
|
if (!selectedImageMessage) return;
|
||||||
unlockCoordinator.requestMessageUnlock(imageMessageId, "image");
|
unlockCoordinator.requestMessageUnlock({
|
||||||
|
displayMessageId: selectedImageMessage.displayId,
|
||||||
|
remoteMessageId: selectedImageMessage.remoteId,
|
||||||
|
kind: "image",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -279,12 +308,12 @@ export function ChatScreen() {
|
|||||||
{selectedImageMessage?.imageUrl ? (
|
{selectedImageMessage?.imageUrl ? (
|
||||||
<FullscreenImageViewer
|
<FullscreenImageViewer
|
||||||
characterId={state.characterId}
|
characterId={state.characterId}
|
||||||
messageId={selectedImageMessage.id}
|
remoteMessageId={selectedImageMessage.remoteId}
|
||||||
imageUrl={selectedImageMessage.imageUrl}
|
imageUrl={selectedImageMessage.imageUrl}
|
||||||
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
||||||
isUnlockingImagePaywall={
|
isUnlockingImagePaywall={
|
||||||
state.isUnlockingMessage &&
|
state.isUnlockingMessage &&
|
||||||
state.unlockingMessageId === selectedImageMessage.id
|
state.unlockingMessageId === selectedImageMessage.displayId
|
||||||
}
|
}
|
||||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||||
onClose={handleCloseImageViewer}
|
onClose={handleCloseImageViewer}
|
||||||
|
|||||||
@@ -265,19 +265,19 @@ function createTouchEvent(
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMessage(id: string): UiMessage {
|
function createMessage(displayId: string): UiMessage {
|
||||||
return {
|
return {
|
||||||
id,
|
displayId,
|
||||||
content: `Message ${id}`,
|
content: `Message ${displayId}`,
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-15",
|
date: "2026-07-15",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUserMessage(id: string): UiMessage {
|
function createUserMessage(displayId: string): UiMessage {
|
||||||
return {
|
return {
|
||||||
id,
|
displayId,
|
||||||
content: `Message ${id}`,
|
content: `Message ${displayId}`,
|
||||||
isFromAI: false,
|
isFromAI: false,
|
||||||
date: "2026-07-15",
|
date: "2026-07-15",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ describe("chat Tailwind components", () => {
|
|||||||
const openableHtml = renderToStaticMarkup(
|
const openableHtml = renderToStaticMarkup(
|
||||||
<ImageBubble
|
<ImageBubble
|
||||||
characterId="elio"
|
characterId="elio"
|
||||||
messageId="message-1"
|
displayMessageId="server:message-1:assistant"
|
||||||
|
remoteMessageId="message-1"
|
||||||
imageUrl="/chat-image.png"
|
imageUrl="/chat-image.png"
|
||||||
onOpenImage={() => undefined}
|
onOpenImage={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
@@ -136,6 +137,7 @@ 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
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -54,13 +54,18 @@ export interface ChatAreaProps {
|
|||||||
isLoadingMoreHistory?: boolean;
|
isLoadingMoreHistory?: boolean;
|
||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
unlockingMessageId?: string | null;
|
unlockingMessageId?: string | null;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: ChatMessageAction;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: ChatMessageAction;
|
||||||
onUnlockImageMessage?: (messageId: string) => void;
|
onUnlockImageMessage?: ChatMessageAction;
|
||||||
onOpenImage?: (messageId: string) => void;
|
onOpenImage?: (displayMessageId: string) => void;
|
||||||
onLoadMoreHistory?: () => void;
|
onLoadMoreHistory?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatMessageAction = (
|
||||||
|
displayMessageId: string,
|
||||||
|
remoteMessageId?: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
export function ChatArea({
|
export function ChatArea({
|
||||||
characterId,
|
characterId,
|
||||||
messages,
|
messages,
|
||||||
@@ -278,10 +283,10 @@ function renderMessagesWithDateHeaders(
|
|||||||
getMessageKey: ChatMessageKeyResolver,
|
getMessageKey: ChatMessageKeyResolver,
|
||||||
isUnlockingMessage?: boolean,
|
isUnlockingMessage?: boolean,
|
||||||
unlockingMessageId?: string | null,
|
unlockingMessageId?: string | null,
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
onUnlockPrivateMessage?: ChatMessageAction,
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
onUnlockVoiceMessage?: ChatMessageAction,
|
||||||
onUnlockImageMessage?: (messageId: string) => void,
|
onUnlockImageMessage?: ChatMessageAction,
|
||||||
onOpenImage?: (messageId: string) => void,
|
onOpenImage?: (displayMessageId: string) => void,
|
||||||
) {
|
) {
|
||||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||||
item.type === "date" ? (
|
item.type === "date" ? (
|
||||||
@@ -290,7 +295,8 @@ function renderMessagesWithDateHeaders(
|
|||||||
<MessageBubble
|
<MessageBubble
|
||||||
characterId={characterId}
|
characterId={characterId}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
messageId={item.message.id}
|
displayMessageId={item.message.displayId}
|
||||||
|
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}
|
||||||
@@ -302,7 +308,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
privateMessageHint={item.message.privateMessageHint}
|
privateMessageHint={item.message.privateMessageHint}
|
||||||
isUnlockingMessage={
|
isUnlockingMessage={
|
||||||
isUnlockingMessage === true &&
|
isUnlockingMessage === true &&
|
||||||
item.message.id === unlockingMessageId
|
item.message.displayId === unlockingMessageId
|
||||||
}
|
}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
|||||||
|
|
||||||
export interface ChatMediaImageProps {
|
export interface ChatMediaImageProps {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
messageId?: string;
|
remoteMessageId?: 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,
|
||||||
messageId,
|
remoteMessageId,
|
||||||
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,
|
messageId: remoteMessageId,
|
||||||
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;
|
||||||
messageId?: string;
|
remoteMessageId?: 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,
|
||||||
messageId,
|
remoteMessageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
isUnlockingImagePaywall = false,
|
isUnlockingImagePaywall = false,
|
||||||
@@ -54,7 +54,7 @@ export function FullscreenImageViewer({
|
|||||||
>
|
>
|
||||||
<ChatMediaImage
|
<ChatMediaImage
|
||||||
characterId={characterId}
|
characterId={characterId}
|
||||||
messageId={messageId}
|
remoteMessageId={remoteMessageId}
|
||||||
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}
|
||||||
messageId={messageId}
|
remoteMessageId={remoteMessageId}
|
||||||
remoteUrl={imageUrl}
|
remoteUrl={imageUrl}
|
||||||
className={styles.viewerImage}
|
className={styles.viewerImage}
|
||||||
errorClassName="error"
|
errorClassName="error"
|
||||||
|
|||||||
@@ -12,20 +12,22 @@ import { ChatMediaImage } from "./chat-media-image";
|
|||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
messageId?: string;
|
displayMessageId: string;
|
||||||
|
remoteMessageId?: string;
|
||||||
imageUrl: string; // base64 data URI 或 URL
|
imageUrl: string; // base64 data URI 或 URL
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
onOpenImage?: (messageId: string) => void;
|
onOpenImage?: (displayMessageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageBubble({
|
export function ImageBubble({
|
||||||
characterId,
|
characterId,
|
||||||
messageId,
|
displayMessageId,
|
||||||
|
remoteMessageId,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled = false,
|
imagePaywalled = false,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
}: ImageBubbleProps) {
|
}: ImageBubbleProps) {
|
||||||
const canOpen = Boolean(messageId && onOpenImage);
|
const canOpen = Boolean(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" : "",
|
||||||
@@ -34,8 +36,8 @@ export function ImageBubble({
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const openImage = () => {
|
const openImage = () => {
|
||||||
if (!messageId || !onOpenImage) return;
|
if (!onOpenImage) return;
|
||||||
onOpenImage(messageId);
|
onOpenImage(displayMessageId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,7 +57,7 @@ export function ImageBubble({
|
|||||||
>
|
>
|
||||||
<ChatMediaImage
|
<ChatMediaImage
|
||||||
characterId={characterId}
|
characterId={characterId}
|
||||||
messageId={messageId}
|
remoteMessageId={remoteMessageId}
|
||||||
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)"
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import styles from "./chat-area.module.css";
|
|||||||
|
|
||||||
export interface MessageBubbleProps {
|
export interface MessageBubbleProps {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
messageId?: string;
|
displayMessageId: string;
|
||||||
|
remoteMessageId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
@@ -27,15 +28,21 @@ export interface MessageBubbleProps {
|
|||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: ChatMessageAction;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: ChatMessageAction;
|
||||||
onUnlockImageMessage?: (messageId: string) => void;
|
onUnlockImageMessage?: ChatMessageAction;
|
||||||
onOpenImage?: (messageId: string) => void;
|
onOpenImage?: (displayMessageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatMessageAction = (
|
||||||
|
displayMessageId: string,
|
||||||
|
remoteMessageId?: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
characterId,
|
characterId,
|
||||||
messageId,
|
displayMessageId,
|
||||||
|
remoteMessageId,
|
||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
@@ -57,18 +64,19 @@ export function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.bubbleRowAi}
|
className={styles.bubbleRowAi}
|
||||||
data-chat-message-id={messageId}
|
data-chat-message-id={displayMessageId}
|
||||||
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}
|
||||||
@@ -88,17 +96,18 @@ export function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.bubbleRowUser}
|
className={styles.bubbleRowUser}
|
||||||
data-chat-message-id={messageId}
|
data-chat-message-id={displayMessageId}
|
||||||
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}
|
||||||
|
|||||||
@@ -8,32 +8,39 @@ 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?: (messageId: string) => void;
|
onUnlockPrivateMessage?: ChatMessageAction;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: ChatMessageAction;
|
||||||
onUnlockImageMessage?: (messageId: string) => void;
|
onUnlockImageMessage?: ChatMessageAction;
|
||||||
onOpenImage?: (messageId: string) => void;
|
onOpenImage?: (displayMessageId: 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,
|
||||||
@@ -57,16 +64,17 @@ 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 =
|
||||||
messageId && onUnlockPrivateMessage
|
onUnlockPrivateMessage
|
||||||
? () => onUnlockPrivateMessage(messageId)
|
? () =>
|
||||||
|
onUnlockPrivateMessage(displayMessageId, remoteMessageId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const handleUnlockVoiceMessage =
|
const handleUnlockVoiceMessage =
|
||||||
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
|
isLockedVoiceMessage && onUnlockVoiceMessage
|
||||||
? () => onUnlockVoiceMessage(messageId)
|
? () => onUnlockVoiceMessage(displayMessageId, remoteMessageId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const handleUnlockImageMessage =
|
const handleUnlockImageMessage =
|
||||||
isLockedImageMessage && messageId && onUnlockImageMessage
|
isLockedImageMessage && onUnlockImageMessage
|
||||||
? () => onUnlockImageMessage(messageId)
|
? () => onUnlockImageMessage(displayMessageId, remoteMessageId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,7 +101,8 @@ export function MessageContent({
|
|||||||
{hasImage && imageUrl && (
|
{hasImage && imageUrl && (
|
||||||
<ImageBubble
|
<ImageBubble
|
||||||
characterId={characterId}
|
characterId={characterId}
|
||||||
messageId={messageId}
|
displayMessageId={displayMessageId}
|
||||||
|
remoteMessageId={remoteMessageId}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
onOpenImage={onOpenImage}
|
onOpenImage={onOpenImage}
|
||||||
@@ -102,7 +111,7 @@ export function MessageContent({
|
|||||||
{shouldRenderVoiceMessage ? (
|
{shouldRenderVoiceMessage ? (
|
||||||
<VoiceBubble
|
<VoiceBubble
|
||||||
characterId={characterId}
|
characterId={characterId}
|
||||||
messageId={messageId}
|
remoteMessageId={remoteMessageId}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
isFromAI={isFromAI}
|
isFromAI={isFromAI}
|
||||||
locked={isLockedVoiceMessage}
|
locked={isLockedVoiceMessage}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import styles from "./voice-bubble.module.css";
|
|||||||
|
|
||||||
export interface VoiceBubbleProps {
|
export interface VoiceBubbleProps {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
messageId?: string;
|
remoteMessageId?: 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,
|
||||||
messageId,
|
remoteMessageId,
|
||||||
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,
|
messageId: remoteMessageId,
|
||||||
remoteUrl: audioUrl,
|
remoteUrl: audioUrl,
|
||||||
kind: "audio",
|
kind: "audio",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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";
|
||||||
@@ -60,10 +61,11 @@ export interface UseChatUnlockCoordinatorInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UseChatUnlockCoordinatorOutput {
|
export interface UseChatUnlockCoordinatorOutput {
|
||||||
requestMessageUnlock: (
|
requestMessageUnlock: (input: {
|
||||||
messageId: string,
|
displayMessageId: string;
|
||||||
kind: PendingChatUnlockKind,
|
remoteMessageId?: string;
|
||||||
) => void;
|
kind: PendingChatUnlockKind;
|
||||||
|
}) => void;
|
||||||
dialogs: ChatUnlockDialogModel;
|
dialogs: ChatUnlockDialogModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ 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,
|
||||||
@@ -141,10 +144,15 @@ 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",
|
||||||
messageId: consumed.displayMessageId,
|
displayMessageId,
|
||||||
remoteMessageId: consumed.messageId,
|
remoteMessageId: consumed.messageId,
|
||||||
kind: consumed.kind,
|
kind: consumed.kind,
|
||||||
lockType: consumed.lockType,
|
lockType: consumed.lockType,
|
||||||
@@ -165,14 +173,17 @@ export function useChatUnlockCoordinator({
|
|||||||
imageMessageId,
|
imageMessageId,
|
||||||
imageReturnUrl,
|
imageReturnUrl,
|
||||||
navigator.isAuthenticatedUser,
|
navigator.isAuthenticatedUser,
|
||||||
|
promotion,
|
||||||
|
chatState.messages,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function requestMessageUnlock(
|
function requestMessageUnlock(input: {
|
||||||
messageId: string,
|
displayMessageId: string;
|
||||||
kind: PendingChatUnlockKind,
|
remoteMessageId?: string;
|
||||||
): void {
|
kind: PendingChatUnlockKind;
|
||||||
|
}): void {
|
||||||
const request = resolveMessageUnlockRequest(
|
const request = resolveMessageUnlockRequest(
|
||||||
{ messageId, kind },
|
input,
|
||||||
{
|
{
|
||||||
defaultReturnUrl,
|
defaultReturnUrl,
|
||||||
imageMessageId,
|
imageMessageId,
|
||||||
@@ -186,7 +197,7 @@ export function useChatUnlockCoordinator({
|
|||||||
onAuthenticated: () => {
|
onAuthenticated: () => {
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: request.displayMessageId,
|
displayMessageId: request.displayMessageId,
|
||||||
remoteMessageId: request.messageId,
|
remoteMessageId: request.messageId,
|
||||||
kind: request.kind,
|
kind: request.kind,
|
||||||
lockType: request.lockType,
|
lockType: request.lockType,
|
||||||
@@ -258,23 +269,21 @@ export function useChatUnlockCoordinator({
|
|||||||
|
|
||||||
export function resolveMessageUnlockRequest(
|
export function resolveMessageUnlockRequest(
|
||||||
input: {
|
input: {
|
||||||
messageId: string;
|
displayMessageId: string;
|
||||||
|
remoteMessageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
},
|
},
|
||||||
scope: ChatUnlockCoordinatorScope,
|
scope: ChatUnlockCoordinatorScope,
|
||||||
): CoordinatedMessageUnlockRequest {
|
): CoordinatedMessageUnlockRequest {
|
||||||
const matchedPromotion =
|
const matchedPromotion =
|
||||||
scope.promotion?.message.id === input.messageId
|
scope.promotion?.message.displayId === input.displayMessageId
|
||||||
? scope.promotion
|
? scope.promotion
|
||||||
: null;
|
: null;
|
||||||
const temporaryPromotionMessageId = matchedPromotion
|
const remoteMessageId =
|
||||||
? `promotion:${matchedPromotion.session.clientLockId}`
|
input.remoteMessageId ?? matchedPromotion?.message.remoteId;
|
||||||
: null;
|
|
||||||
const target = {
|
const target = {
|
||||||
displayMessageId: input.messageId,
|
displayMessageId: input.displayMessageId,
|
||||||
...(input.messageId !== temporaryPromotionMessageId
|
...(remoteMessageId ? { messageId: remoteMessageId } : {}),
|
||||||
? { messageId: input.messageId }
|
|
||||||
: {}),
|
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
...(matchedPromotion
|
...(matchedPromotion
|
||||||
? {
|
? {
|
||||||
@@ -300,6 +309,40 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/* @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,7 +6,6 @@ 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 {
|
||||||
@@ -74,10 +73,4 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -25,7 +24,6 @@ export interface TipCheckoutButtonProps {
|
|||||||
export function TipCheckoutButton({
|
export function TipCheckoutButton({
|
||||||
coffeeType,
|
coffeeType,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isAuthLoading = false,
|
|
||||||
onOrder,
|
onOrder,
|
||||||
returnPath,
|
returnPath,
|
||||||
}: TipCheckoutButtonProps) {
|
}: TipCheckoutButtonProps) {
|
||||||
@@ -42,8 +40,7 @@ export function TipCheckoutButton({
|
|||||||
characterSlug: character.slug,
|
characterSlug: character.slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading =
|
const isLoading = payment.isCreatingOrder || payment.isPollingOrder;
|
||||||
isAuthLoading || payment.isCreatingOrder || payment.isPollingOrder;
|
|
||||||
const label = payment.isPollingOrder
|
const label = payment.isPollingOrder
|
||||||
? "Processing payment..."
|
? "Processing payment..."
|
||||||
: payment.isCreatingOrder
|
: payment.isCreatingOrder
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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,
|
||||||
@@ -30,7 +29,3 @@ 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";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ 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";
|
||||||
@@ -39,7 +37,6 @@ 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";
|
||||||
|
|
||||||
@@ -59,10 +56,8 @@ 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,
|
||||||
@@ -125,7 +120,6 @@ 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({
|
||||||
@@ -176,16 +170,9 @@ 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,
|
||||||
@@ -249,7 +236,8 @@ export function TipScreen({
|
|||||||
{character.copy.tipTitle}
|
{character.copy.tipTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Send a warm coffee tip and keep the private moments glowing.
|
❤️ Thank you for being here. If you'd ever like to treat me to a
|
||||||
|
little coffee, I'd really appreciate it. ☕
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -322,11 +310,7 @@ export function TipScreen({
|
|||||||
<div className={styles.checkoutSlot}>
|
<div className={styles.checkoutSlot}>
|
||||||
<TipCheckoutButton
|
<TipCheckoutButton
|
||||||
coffeeType={selectedCoffeeType}
|
coffeeType={selectedCoffeeType}
|
||||||
disabled={
|
disabled={showMissingPlan || !canCreateOrder}
|
||||||
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?.[3]).toBe("Hello from Elio");
|
expect(historyCall?.[4]).toBe("Hello from Elio");
|
||||||
const signal = getSignal(historyCall?.[4]);
|
const signal = getSignal(historyCall?.[5]);
|
||||||
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,6 +6,7 @@ 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";
|
||||||
@@ -13,6 +14,7 @@ import type { ChatState } from "@/stores/chat/chat-state";
|
|||||||
import {
|
import {
|
||||||
applyHttpSendOutput,
|
applyHttpSendOutput,
|
||||||
applyNetworkHistoryLoadedOutput,
|
applyNetworkHistoryLoadedOutput,
|
||||||
|
applySingleUnlockOutput,
|
||||||
countLockedHistoryMessages,
|
countLockedHistoryMessages,
|
||||||
localMessagesToUi,
|
localMessagesToUi,
|
||||||
sendResponseToUiMessage,
|
sendResponseToUiMessage,
|
||||||
@@ -178,6 +180,7 @@ 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",
|
||||||
@@ -215,6 +218,7 @@ 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",
|
||||||
@@ -257,6 +261,7 @@ 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,
|
||||||
@@ -266,9 +271,115 @@ 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([
|
||||||
{
|
{
|
||||||
@@ -320,11 +431,53 @@ 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",
|
||||||
@@ -332,6 +485,7 @@ 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",
|
||||||
@@ -339,6 +493,7 @@ 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",
|
||||||
@@ -348,6 +503,7 @@ describe("countLockedHistoryMessages", () => {
|
|||||||
lockReason: "image",
|
lockReason: "image",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
displayId: "other-1",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
@@ -355,6 +511,7 @@ 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 = {
|
||||||
id: "local-msg",
|
displayId: "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 = {
|
||||||
id: "network-msg",
|
displayId: "network-msg",
|
||||||
content: "fresh network message",
|
content: "fresh network message",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-02",
|
date: "2026-07-02",
|
||||||
@@ -67,6 +67,7 @@ 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,
|
||||||
@@ -89,17 +90,17 @@ describe("chat history flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{ id: "local-msg", content: "cached local message" },
|
{ displayId: "local-msg", content: "cached local message" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
resolveNetwork();
|
resolveNetwork();
|
||||||
await waitFor(
|
await waitFor(
|
||||||
actor,
|
actor,
|
||||||
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
|
(snapshot) => snapshot.context.messages[0]?.displayId === "network-msg",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{ id: "network-msg", content: "fresh network message" },
|
{ displayId: "network-msg", content: "fresh network message" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
actor.stop();
|
actor.stop();
|
||||||
@@ -108,7 +109,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 = {
|
||||||
id: "latest",
|
displayId: "latest",
|
||||||
content: "current unlocked message",
|
content: "current unlocked message",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-15",
|
date: "2026-07-15",
|
||||||
@@ -173,8 +174,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([
|
||||||
{ id: "older-50" },
|
{ displayId: "older-50" },
|
||||||
{ id: "latest", content: "current unlocked message" },
|
{ displayId: "latest", content: "current unlocked message" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
actor.send({ type: "ChatLoadMoreHistoryRequested" });
|
actor.send({ type: "ChatLoadMoreHistoryRequested" });
|
||||||
@@ -290,10 +291,10 @@ describe("chat history flow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createHistoryMessage(id: string): UiMessage {
|
function createHistoryMessage(displayId: string): UiMessage {
|
||||||
return {
|
return {
|
||||||
id,
|
displayId,
|
||||||
content: `Message ${id}`,
|
content: `Message ${displayId}`,
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-15",
|
date: "2026-07-15",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ 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(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: "history-1",
|
displayId: "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.id)).toEqual([
|
expect(messages.map((message) => message.displayId)).toEqual([
|
||||||
"history-1",
|
"history-1",
|
||||||
"promotion:promotion-1",
|
"promotion:promotion-1",
|
||||||
]);
|
]);
|
||||||
@@ -62,7 +62,8 @@ describe("chat promotion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(next?.message).toMatchObject({
|
expect(next?.message).toMatchObject({
|
||||||
id: "backend-1",
|
displayId: "promotion:promotion-1",
|
||||||
|
remoteId: "backend-1",
|
||||||
imageUrl: "https://example.com/unlocked.jpg",
|
imageUrl: "https://example.com/unlocked.jpg",
|
||||||
imagePaywalled: false,
|
imagePaywalled: false,
|
||||||
locked: false,
|
locked: false,
|
||||||
@@ -71,10 +72,19 @@ 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,
|
||||||
{
|
{
|
||||||
id: "backend-1",
|
displayId: "server:backend-1:assistant",
|
||||||
|
remoteId: "backend-1",
|
||||||
content: "Stale history copy",
|
content: "Stale history copy",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-13",
|
date: "2026-07-13",
|
||||||
@@ -83,6 +93,6 @@ describe("chat promotion", () => {
|
|||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(messages).toEqual([state.message]);
|
expect(messages).toEqual([userMessage, state.message]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ 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: " " });
|
||||||
@@ -94,6 +98,9 @@ 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: [
|
||||||
{
|
{
|
||||||
id: "history-1",
|
displayId: "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({
|
||||||
id: "promotion:promotion-1",
|
displayId: "promotion:promotion-1",
|
||||||
locked: true,
|
locked: true,
|
||||||
lockReason: "voice_message",
|
lockReason: "voice_message",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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",
|
||||||
@@ -27,6 +28,7 @@ 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",
|
||||||
@@ -56,7 +58,7 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-image-locked",
|
displayId: "msg-image-locked",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
@@ -72,7 +74,7 @@ describe("chat unlock flow", () => {
|
|||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: "msg-image-locked",
|
displayId: "msg-image-locked",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
@@ -97,7 +99,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([
|
||||||
{
|
{
|
||||||
id: "msg-image-locked",
|
displayId: "msg-image-locked",
|
||||||
imageUrl: "https://example.com/locked.jpg",
|
imageUrl: "https://example.com/locked.jpg",
|
||||||
imagePaywalled: true,
|
imagePaywalled: true,
|
||||||
locked: true,
|
locked: true,
|
||||||
@@ -112,6 +114,7 @@ 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",
|
||||||
@@ -119,6 +122,7 @@ 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",
|
||||||
@@ -132,6 +136,7 @@ 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",
|
||||||
@@ -168,6 +173,7 @@ 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",
|
||||||
@@ -175,6 +181,7 @@ 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",
|
||||||
@@ -211,7 +218,7 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-private-locked",
|
displayId: "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",
|
||||||
@@ -238,7 +245,8 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "msg-private-locked",
|
displayMessageId: "msg-private-locked",
|
||||||
|
remoteMessageId: "msg-private-locked",
|
||||||
kind: "private",
|
kind: "private",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +258,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([
|
||||||
{
|
{
|
||||||
id: "msg-private-locked",
|
displayId: "msg-private-locked",
|
||||||
content: "Unlocked private message content.",
|
content: "Unlocked private message content.",
|
||||||
locked: false,
|
locked: false,
|
||||||
lockReason: null,
|
lockReason: null,
|
||||||
@@ -267,13 +275,15 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-shared-id",
|
displayId: "server:msg-shared-id:user",
|
||||||
|
remoteId: "msg-shared-id",
|
||||||
content: "User original question",
|
content: "User original question",
|
||||||
isFromAI: false,
|
isFromAI: false,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "msg-shared-id",
|
displayId: "server:msg-shared-id:assistant",
|
||||||
|
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",
|
||||||
@@ -300,7 +310,8 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "msg-shared-id",
|
displayMessageId: "server:msg-shared-id:assistant",
|
||||||
|
remoteMessageId: "msg-shared-id",
|
||||||
kind: "private",
|
kind: "private",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,12 +321,14 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
id: "msg-shared-id",
|
displayId: "server:msg-shared-id:user",
|
||||||
|
remoteId: "msg-shared-id",
|
||||||
content: "User original question",
|
content: "User original question",
|
||||||
isFromAI: false,
|
isFromAI: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "msg-shared-id",
|
displayId: "server:msg-shared-id:assistant",
|
||||||
|
remoteId: "msg-shared-id",
|
||||||
content: "Unlocked private AI message.",
|
content: "Unlocked private AI message.",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
locked: false,
|
locked: false,
|
||||||
@@ -333,7 +346,7 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-image-locked",
|
displayId: "msg-image-locked",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
@@ -360,7 +373,8 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "msg-image-locked",
|
displayMessageId: "msg-image-locked",
|
||||||
|
remoteMessageId: "msg-image-locked",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -370,7 +384,7 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
id: "msg-image-locked",
|
displayId: "msg-image-locked",
|
||||||
content: "",
|
content: "",
|
||||||
imageUrl: "https://example.com/locked.jpg",
|
imageUrl: "https://example.com/locked.jpg",
|
||||||
imagePaywalled: false,
|
imagePaywalled: false,
|
||||||
@@ -387,7 +401,7 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-voice-locked",
|
displayId: "msg-voice-locked",
|
||||||
content: "Original voice transcript.",
|
content: "Original voice transcript.",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
@@ -414,7 +428,8 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "msg-voice-locked",
|
displayMessageId: "msg-voice-locked",
|
||||||
|
remoteMessageId: "msg-voice-locked",
|
||||||
kind: "voice",
|
kind: "voice",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,7 +439,7 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
id: "msg-voice-locked",
|
displayId: "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,
|
||||||
@@ -441,7 +456,7 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-voice-locked",
|
displayId: "msg-voice-locked",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
@@ -473,7 +488,8 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "msg-voice-locked",
|
displayMessageId: "msg-voice-locked",
|
||||||
|
remoteMessageId: "msg-voice-locked",
|
||||||
kind: "voice",
|
kind: "voice",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,7 +509,7 @@ describe("chat unlock flow", () => {
|
|||||||
});
|
});
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
id: "msg-voice-locked",
|
displayId: "msg-voice-locked",
|
||||||
locked: true,
|
locked: true,
|
||||||
lockReason: "voice_message",
|
lockReason: "voice_message",
|
||||||
},
|
},
|
||||||
@@ -509,7 +525,7 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-missing",
|
displayId: "msg-missing",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
@@ -541,7 +557,8 @@ describe("chat unlock flow", () => {
|
|||||||
|
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "msg-missing",
|
displayMessageId: "msg-missing",
|
||||||
|
remoteMessageId: "msg-missing",
|
||||||
kind: "private",
|
kind: "private",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -557,7 +574,7 @@ describe("chat unlock flow", () => {
|
|||||||
});
|
});
|
||||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
id: "msg-missing",
|
displayId: "msg-missing",
|
||||||
locked: true,
|
locked: true,
|
||||||
lockReason: "private_message",
|
lockReason: "private_message",
|
||||||
},
|
},
|
||||||
@@ -571,7 +588,7 @@ describe("chat unlock flow", () => {
|
|||||||
createTestChatMachine({
|
createTestChatMachine({
|
||||||
historyMessages: [
|
historyMessages: [
|
||||||
{
|
{
|
||||||
id: "msg-mismatch",
|
displayId: "msg-mismatch",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-20",
|
date: "2026-07-20",
|
||||||
@@ -592,7 +609,8 @@ describe("chat unlock flow", () => {
|
|||||||
);
|
);
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "msg-mismatch",
|
displayMessageId: "msg-mismatch",
|
||||||
|
remoteMessageId: "msg-mismatch",
|
||||||
kind: "voice",
|
kind: "voice",
|
||||||
});
|
});
|
||||||
await waitFor(
|
await waitFor(
|
||||||
@@ -617,7 +635,7 @@ describe("chat unlock flow", () => {
|
|||||||
resolveUnlock = resolve;
|
resolveUnlock = resolve;
|
||||||
});
|
});
|
||||||
const originalMessage = {
|
const originalMessage = {
|
||||||
id: "promotion:lock-1",
|
displayId: "promotion:lock-1",
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: "2026-07-16",
|
date: "2026-07-16",
|
||||||
@@ -648,7 +666,7 @@ describe("chat unlock flow", () => {
|
|||||||
);
|
);
|
||||||
actor.send({
|
actor.send({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: "promotion:lock-1",
|
displayMessageId: "promotion:lock-1",
|
||||||
remoteMessageId: "remote-1",
|
remoteMessageId: "remote-1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
lockType: "image_paywall",
|
lockType: "image_paywall",
|
||||||
@@ -669,6 +687,7 @@ 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,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export type ChatEvent =
|
|||||||
| { type: "ChatPromotionCleared" }
|
| { type: "ChatPromotionCleared" }
|
||||||
| {
|
| {
|
||||||
type: "ChatUnlockMessageRequested";
|
type: "ChatUnlockMessageRequested";
|
||||||
messageId: string;
|
displayMessageId: string;
|
||||||
remoteMessageId?: string;
|
remoteMessageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
lockType?: ChatLockType;
|
lockType?: ChatLockType;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ 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;
|
||||||
@@ -31,8 +33,12 @@ export type LocalHistorySnapshotOutput = {
|
|||||||
|
|
||||||
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
|
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
|
||||||
|
|
||||||
export function createGreetingMessage(content: string): UiMessage {
|
export function createGreetingMessage(
|
||||||
|
characterId: string,
|
||||||
|
content: string,
|
||||||
|
): UiMessage {
|
||||||
return {
|
return {
|
||||||
|
displayId: `greeting:${encodeURIComponent(characterId)}`,
|
||||||
content,
|
content,
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: todayString(),
|
date: todayString(),
|
||||||
@@ -49,10 +55,14 @@ 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(emptyChatGreeting);
|
const greetingMessage = createGreetingMessage(
|
||||||
|
characterId,
|
||||||
|
emptyChatGreeting,
|
||||||
|
);
|
||||||
|
|
||||||
const localResult = cacheIdentity
|
const localResult = cacheIdentity
|
||||||
? await chatRepo.getLocalMessages(cacheIdentity)
|
? await chatRepo.getLocalMessages(cacheIdentity)
|
||||||
@@ -80,12 +90,16 @@ 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(emptyChatGreeting);
|
const greetingMessage = createGreetingMessage(
|
||||||
|
characterId,
|
||||||
|
emptyChatGreeting,
|
||||||
|
);
|
||||||
|
|
||||||
const networkResult = await chatRepo.getHistory(
|
const networkResult = await chatRepo.getHistory(
|
||||||
characterId,
|
characterId,
|
||||||
@@ -134,6 +148,7 @@ export async function syncNetworkHistory(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
messages: finalMessages,
|
messages: finalMessages,
|
||||||
|
localDisplayIds,
|
||||||
localOverwritten,
|
localOverwritten,
|
||||||
localCount,
|
localCount,
|
||||||
networkCount: networkUi.length,
|
networkCount: networkUi.length,
|
||||||
@@ -156,11 +171,13 @@ 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,
|
||||||
@@ -169,6 +186,9 @@ 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,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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;
|
||||||
@@ -33,8 +34,15 @@ export function applyNetworkHistoryLoadedOutput(
|
|||||||
| "nextHistoryOffset"
|
| "nextHistoryOffset"
|
||||||
| "isLoadingMoreHistory"
|
| "isLoadingMoreHistory"
|
||||||
> {
|
> {
|
||||||
const localSnapshotSize = output.localCount === 0 ? 1 : output.localCount;
|
const localDisplayIds = new Set(output.localDisplayIds);
|
||||||
const optimisticTail = context.messages.slice(localSnapshotSize);
|
const networkDisplayIds = new Set(
|
||||||
|
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],
|
||||||
@@ -95,13 +103,17 @@ export function prependUniqueHistoryMessages(
|
|||||||
olderMessages: readonly UiMessage[],
|
olderMessages: readonly UiMessage[],
|
||||||
): UiMessage[] {
|
): UiMessage[] {
|
||||||
const existingIds = new Set(
|
const existingIds = new Set(
|
||||||
currentMessages.flatMap((message) => (message.id ? [message.id] : [])),
|
currentMessages.map((message) => message.displayId),
|
||||||
);
|
);
|
||||||
const pageIds = new Set<string>();
|
const pageIds = new Set<string>();
|
||||||
const uniqueOlderMessages = olderMessages.filter((message) => {
|
const uniqueOlderMessages = olderMessages.filter((message) => {
|
||||||
if (!message.id) return true;
|
if (
|
||||||
if (existingIds.has(message.id) || pageIds.has(message.id)) return false;
|
existingIds.has(message.displayId) ||
|
||||||
pageIds.add(message.id);
|
pageIds.has(message.displayId)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pageIds.add(message.displayId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return [...uniqueOlderMessages, ...currentMessages];
|
return [...uniqueOlderMessages, ...currentMessages];
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import type {
|
|||||||
ChatLockDetailData,
|
ChatLockDetailData,
|
||||||
ChatSendResponse,
|
ChatSendResponse,
|
||||||
} from "@/data/schemas/chat";
|
} from "@/data/schemas/chat";
|
||||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
import {
|
||||||
|
createClientUiMessageIdentity,
|
||||||
|
createLegacyUiMessageIdentity,
|
||||||
|
createRemoteUiMessageIdentity,
|
||||||
|
type UiMessage,
|
||||||
|
} from "@/stores/chat/ui-message";
|
||||||
import { todayString } from "@/utils/date";
|
import { todayString } from "@/utils/date";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,20 +26,33 @@ export function localMessagesToUi(
|
|||||||
lockDetail?: ChatLockDetailData;
|
lockDetail?: ChatLockDetailData;
|
||||||
}[],
|
}[],
|
||||||
): UiMessage[] {
|
): UiMessage[] {
|
||||||
return records.map((m) => ({
|
const occurrences = new Map<string, number>();
|
||||||
...(m.id ? { id: m.id } : {}),
|
return records.map((m) => {
|
||||||
|
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: m.role === "assistant",
|
isFromAI,
|
||||||
hasImage: Boolean(m.image?.url),
|
hasImage: Boolean(m.image?.url),
|
||||||
}),
|
}),
|
||||||
isFromAI: m.role === "assistant",
|
isFromAI,
|
||||||
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),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +61,9 @@ export function localMessagesToUi(
|
|||||||
*/
|
*/
|
||||||
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||||
return {
|
return {
|
||||||
...(response.messageId ? { id: response.messageId } : {}),
|
...(response.messageId
|
||||||
|
? createRemoteUiMessageIdentity(response.messageId, true)
|
||||||
|
: createClientUiMessageIdentity("reply")),
|
||||||
content: getAiMessageDisplayContent({
|
content: getAiMessageDisplayContent({
|
||||||
content: response.reply,
|
content: response.reply,
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
@@ -58,6 +78,28 @@ 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();
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export function createChatPromotionState(
|
|||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
message: {
|
message: {
|
||||||
id: messageId || `promotion:${session.clientLockId}`,
|
displayId: `promotion:${session.clientLockId}`,
|
||||||
|
...(messageId ? { remoteId: messageId } : {}),
|
||||||
content: "",
|
content: "",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: todayString(),
|
date: todayString(),
|
||||||
@@ -47,7 +48,13 @@ export function appendPromotionMessage(
|
|||||||
): UiMessage[] {
|
): UiMessage[] {
|
||||||
if (!promotion) return [...messages];
|
if (!promotion) return [...messages];
|
||||||
return [
|
return [
|
||||||
...messages.filter((message) => message.id !== promotion.message.id),
|
...messages.filter(
|
||||||
|
(message) =>
|
||||||
|
message.displayId !== promotion.message.displayId &&
|
||||||
|
(!promotion.message.remoteId ||
|
||||||
|
!message.isFromAI ||
|
||||||
|
message.remoteId !== promotion.message.remoteId),
|
||||||
|
),
|
||||||
promotion.message,
|
promotion.message,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -56,7 +63,10 @@ export function applyPromotionUnlockOutput(
|
|||||||
promotion: ChatPromotionState | null,
|
promotion: ChatPromotionState | null,
|
||||||
output: UnlockMessageOutput,
|
output: UnlockMessageOutput,
|
||||||
): ChatPromotionState | null {
|
): ChatPromotionState | null {
|
||||||
if (!promotion || promotion.message.id !== output.displayMessageId) {
|
if (
|
||||||
|
!promotion ||
|
||||||
|
promotion.message.displayId !== output.displayMessageId
|
||||||
|
) {
|
||||||
return promotion;
|
return promotion;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export function applySingleUnlockOutput(
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedId = output.response.messageId || message.id;
|
const remoteId = output.response.messageId || message.remoteId;
|
||||||
if (!output.response.unlocked) {
|
if (!output.response.unlocked) {
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
id: resolvedId,
|
...(remoteId ? { remoteId } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export function applySingleUnlockOutput(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
id: resolvedId,
|
...(remoteId ? { remoteId } : {}),
|
||||||
content: resolvedContent,
|
content: resolvedContent,
|
||||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||||
imageUrl: resolvedImageUrl,
|
imageUrl: resolvedImageUrl,
|
||||||
@@ -69,8 +69,11 @@ function getUnlockedAudioUrl(
|
|||||||
return response.audioUrl;
|
return response.audioUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean {
|
function shouldApplySingleUnlock(
|
||||||
if (message.id !== messageId) return false;
|
message: UiMessage,
|
||||||
|
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,6 +46,7 @@ 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;
|
||||||
@@ -57,6 +58,7 @@ 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,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ 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,
|
||||||
@@ -63,6 +65,7 @@ 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,
|
||||||
@@ -83,6 +86,7 @@ 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?",
|
||||||
@@ -135,6 +139,7 @@ const appendGuestUserImageAction = historyMachineSetup.assign(
|
|||||||
messages: [
|
messages: [
|
||||||
...context.messages,
|
...context.messages,
|
||||||
{
|
{
|
||||||
|
...createClientUiMessageIdentity("image"),
|
||||||
content: "[Image]",
|
content: "[Image]",
|
||||||
isFromAI: false,
|
isFromAI: false,
|
||||||
date: today,
|
date: today,
|
||||||
@@ -162,6 +167,7 @@ const appendUserImageAction = historyMachineSetup.assign(
|
|||||||
messages: [
|
messages: [
|
||||||
...context.messages,
|
...context.messages,
|
||||||
{
|
{
|
||||||
|
...createClientUiMessageIdentity("image"),
|
||||||
content: "[Image]",
|
content: "[Image]",
|
||||||
isFromAI: false,
|
isFromAI: false,
|
||||||
date: today,
|
date: today,
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ const userReadyState = chatMachineSetup.createStateConfig({
|
|||||||
actions: "appendUserImage",
|
actions: "appendUserImage",
|
||||||
},
|
},
|
||||||
ChatUnlockMessageRequested: {
|
ChatUnlockMessageRequested: {
|
||||||
guard: ({ event }) => event.messageId.trim().length > 0,
|
guard: ({ event }) =>
|
||||||
|
event.displayMessageId.trim().length > 0,
|
||||||
target: "unlockingMessage",
|
target: "unlockingMessage",
|
||||||
actions: "markUnlockMessageStarted",
|
actions: "markUnlockMessageStarted",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.messageId,
|
displayMessageId: event.displayMessageId,
|
||||||
...(remoteMessageId ? { messageId: remoteMessageId } : {}),
|
...(event.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,8 +90,7 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
|
|||||||
unlockMessageError: output.response.reason,
|
unlockMessageError: output.response.reason,
|
||||||
unlockPaywallRequest: shouldOpenPaywall
|
unlockPaywallRequest: shouldOpenPaywall
|
||||||
? {
|
? {
|
||||||
displayMessageId:
|
displayMessageId: output.displayMessageId,
|
||||||
output.response.messageId || output.displayMessageId,
|
|
||||||
...(output.response.messageId || output.request.messageId
|
...(output.response.messageId || output.request.messageId
|
||||||
? {
|
? {
|
||||||
messageId:
|
messageId:
|
||||||
@@ -105,7 +104,8 @@ const requestUnlockPaymentFromOutputAction = sendMachineSetup.assign(
|
|||||||
...(output.request.clientLockId
|
...(output.request.clientLockId
|
||||||
? { clientLockId: output.request.clientLockId }
|
? { clientLockId: output.request.clientLockId }
|
||||||
: {}),
|
: {}),
|
||||||
...(context.promotion?.message.id === output.displayMessageId
|
...(context.promotion?.message.displayId ===
|
||||||
|
output.displayMessageId
|
||||||
? { promotion: context.promotion.session }
|
? { promotion: context.promotion.session }
|
||||||
: {}),
|
: {}),
|
||||||
reason: output.response.reason,
|
reason: output.response.reason,
|
||||||
@@ -130,7 +130,8 @@ const requestUnlockPaymentFromErrorAction = sendMachineSetup.assign(
|
|||||||
unlockPaywallRequest: request
|
unlockPaywallRequest: request
|
||||||
? {
|
? {
|
||||||
...request,
|
...request,
|
||||||
...(context.promotion?.message.id === request.displayMessageId
|
...(context.promotion?.message.displayId ===
|
||||||
|
request.displayMessageId
|
||||||
? { promotion: context.promotion.session }
|
? { promotion: context.promotion.session }
|
||||||
: {}),
|
: {}),
|
||||||
reason: "unlock_failed",
|
reason: "unlock_failed",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const UiMessageSchema = z.object({
|
export const UiMessageSchema = z.object({
|
||||||
id: z.string().optional(),
|
displayId: z.string().min(1),
|
||||||
|
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(),
|
||||||
@@ -18,6 +20,41 @@ 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({
|
||||||
@@ -31,3 +68,20 @@ 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user