fix(chat): stabilize message identities
This commit is contained in:
@@ -10,12 +10,13 @@ import {
|
||||
describe("chat area render items", () => {
|
||||
it("keeps local message keys stable when history is prepended", () => {
|
||||
const localMessage: UiMessage = {
|
||||
displayId: "client:message:local-1",
|
||||
content: "optimistic message",
|
||||
isFromAI: false,
|
||||
date: "2026-07-08",
|
||||
};
|
||||
const historyMessage: UiMessage = {
|
||||
id: "history-msg-1",
|
||||
displayId: "history-msg-1",
|
||||
content: "history message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-07",
|
||||
@@ -28,12 +29,34 @@ describe("chat area render items", () => {
|
||||
getMessageKey,
|
||||
);
|
||||
|
||||
expect(findMessageKey(firstItems, localMessage)).toBe("local-msg-1");
|
||||
expect(findMessageKey(secondItems, localMessage)).toBe("local-msg-1");
|
||||
expect(findMessageKey(firstItems, localMessage)).toBe(
|
||||
"msg-client:message:local-1",
|
||||
);
|
||||
expect(findMessageKey(secondItems, localMessage)).toBe(
|
||||
"msg-client:message:local-1",
|
||||
);
|
||||
expect(findMessageKey(secondItems, historyMessage)).toBe(
|
||||
"msg-history-msg-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the same key after a message object is recreated", () => {
|
||||
const getMessageKey = createChatMessageKeyResolver();
|
||||
const original: UiMessage = {
|
||||
displayId: "server:message-1:assistant",
|
||||
remoteId: "message-1",
|
||||
content: "Original",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
};
|
||||
const updated: UiMessage = {
|
||||
...original,
|
||||
content: "Unlocked",
|
||||
locked: false,
|
||||
};
|
||||
|
||||
expect(getMessageKey(updated)).toBe(getMessageKey(original));
|
||||
});
|
||||
});
|
||||
|
||||
function findMessageKey(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createChatPromotionState } from "@/stores/chat/helper/promotion";
|
||||
import {
|
||||
resolveChatUnlockReturnUrl,
|
||||
resolveMessageUnlockRequest,
|
||||
resolvePendingUnlockDisplayMessageId,
|
||||
shouldResumePendingChatUnlock,
|
||||
} from "../hooks/use-chat-unlock-coordinator";
|
||||
|
||||
@@ -31,11 +32,15 @@ describe("chat unlock coordinator", () => {
|
||||
it("creates a regular message unlock request with its remote id", () => {
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{ messageId: "message-1", kind: "private" },
|
||||
{
|
||||
displayMessageId: "server:message-1:assistant",
|
||||
remoteMessageId: "message-1",
|
||||
kind: "private",
|
||||
},
|
||||
defaultScope,
|
||||
),
|
||||
).toEqual({
|
||||
displayMessageId: "message-1",
|
||||
displayMessageId: "server:message-1:assistant",
|
||||
messageId: "message-1",
|
||||
kind: "private",
|
||||
returnUrl: "/chat",
|
||||
@@ -51,7 +56,11 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{ messageId: "image-1", kind: "image" },
|
||||
{
|
||||
displayMessageId: "image-1",
|
||||
remoteMessageId: "image-1",
|
||||
kind: "image",
|
||||
},
|
||||
imageScope,
|
||||
).returnUrl,
|
||||
).toBe("/chat?image=image-1");
|
||||
@@ -62,7 +71,10 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{ messageId: "promotion:promotion-1", kind: "image" },
|
||||
{
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
kind: "image",
|
||||
},
|
||||
{ ...defaultScope, promotion },
|
||||
),
|
||||
).toEqual({
|
||||
@@ -83,7 +95,11 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{ messageId: "promotion-message-1", kind: "image" },
|
||||
{
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
remoteMessageId: "promotion-message-1",
|
||||
kind: "image",
|
||||
},
|
||||
{ ...defaultScope, promotion },
|
||||
).messageId,
|
||||
).toBe("promotion-message-1");
|
||||
@@ -103,6 +119,40 @@ describe("chat unlock coordinator", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("maps a legacy pending remote id to the current display identity", () => {
|
||||
const pending = createPendingUnlock({
|
||||
displayMessageId: "message-1",
|
||||
messageId: "message-1",
|
||||
kind: "private",
|
||||
returnUrl: "/chat",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePendingUnlockDisplayMessageId(
|
||||
pending,
|
||||
[
|
||||
{
|
||||
displayId: "server:message-1:user",
|
||||
remoteId: "message-1",
|
||||
content: "Question",
|
||||
isFromAI: false,
|
||||
date: "2026-07-20",
|
||||
},
|
||||
{
|
||||
displayId: "server:message-1:assistant",
|
||||
remoteId: "message-1",
|
||||
content: "Locked reply",
|
||||
isFromAI: true,
|
||||
date: "2026-07-20",
|
||||
locked: true,
|
||||
lockReason: "private_message",
|
||||
},
|
||||
],
|
||||
null,
|
||||
),
|
||||
).toBe("server:message-1:assistant");
|
||||
});
|
||||
|
||||
it("only resumes an image return when its message matches the overlay", () => {
|
||||
const imageScope = {
|
||||
defaultReturnUrl: "/chat",
|
||||
@@ -136,7 +186,7 @@ describe("chat unlock coordinator", () => {
|
||||
|
||||
expect(
|
||||
resolveMessageUnlockRequest(
|
||||
{ messageId: imageMessageId, kind: "image" },
|
||||
{ displayMessageId: imageMessageId, kind: "image" },
|
||||
{
|
||||
...imageScope,
|
||||
promotion,
|
||||
|
||||
@@ -7,20 +7,7 @@ export type ChatRenderItem =
|
||||
| { type: "msg"; message: UiMessage; key: string };
|
||||
|
||||
export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
|
||||
const localMessageKeys = new WeakMap<UiMessage, string>();
|
||||
let nextLocalMessageKey = 0;
|
||||
|
||||
return (message) => {
|
||||
if (message.id && message.id.length > 0) return `msg-${message.id}`;
|
||||
|
||||
const existing = localMessageKeys.get(message);
|
||||
if (existing) return existing;
|
||||
|
||||
nextLocalMessageKey += 1;
|
||||
const next = `local-msg-${nextLocalMessageKey}`;
|
||||
localMessageKeys.set(message, next);
|
||||
return next;
|
||||
};
|
||||
return (message) => `msg-${message.displayId}`;
|
||||
}
|
||||
|
||||
export function buildChatRenderItems(
|
||||
|
||||
@@ -89,7 +89,10 @@ export function ChatScreen() {
|
||||
() =>
|
||||
imageMessageId
|
||||
? visibleMessages.find(
|
||||
(item) => item.id === imageMessageId && item.imageUrl,
|
||||
(item) =>
|
||||
(item.displayId === imageMessageId ||
|
||||
item.remoteId === imageMessageId) &&
|
||||
item.imageUrl,
|
||||
) ?? null
|
||||
: null,
|
||||
[imageMessageId, visibleMessages],
|
||||
@@ -175,24 +178,46 @@ export function ChatScreen() {
|
||||
state.characterErrorCode,
|
||||
]);
|
||||
|
||||
function handleUnlockPrivateMessage(messageId: string): void {
|
||||
unlockCoordinator.requestMessageUnlock(messageId, "private");
|
||||
}
|
||||
|
||||
function handleUnlockVoiceMessage(messageId: string): void {
|
||||
unlockCoordinator.requestMessageUnlock(messageId, "voice");
|
||||
}
|
||||
|
||||
function handleUnlockImageMessage(messageId: string): void {
|
||||
unlockCoordinator.requestMessageUnlock(messageId, "image");
|
||||
}
|
||||
|
||||
function handleOpenImage(messageId: string): void {
|
||||
router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
|
||||
scroll: false,
|
||||
function handleUnlockPrivateMessage(
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
): void {
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
kind: "private",
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnlockVoiceMessage(
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
): void {
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
kind: "voice",
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnlockImageMessage(
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
): void {
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
kind: "image",
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenImage(displayMessageId: string): void {
|
||||
router.push(
|
||||
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
||||
{ scroll: false },
|
||||
);
|
||||
}
|
||||
|
||||
function handleCloseImageViewer(): void {
|
||||
router.replace(
|
||||
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
||||
@@ -201,8 +226,12 @@ export function ChatScreen() {
|
||||
}
|
||||
|
||||
function handleUnlockImagePaywall(): void {
|
||||
if (!imageMessageId) return;
|
||||
unlockCoordinator.requestMessageUnlock(imageMessageId, "image");
|
||||
if (!selectedImageMessage) return;
|
||||
unlockCoordinator.requestMessageUnlock({
|
||||
displayMessageId: selectedImageMessage.displayId,
|
||||
remoteMessageId: selectedImageMessage.remoteId,
|
||||
kind: "image",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -279,12 +308,12 @@ export function ChatScreen() {
|
||||
{selectedImageMessage?.imageUrl ? (
|
||||
<FullscreenImageViewer
|
||||
characterId={state.characterId}
|
||||
messageId={selectedImageMessage.id}
|
||||
remoteMessageId={selectedImageMessage.remoteId}
|
||||
imageUrl={selectedImageMessage.imageUrl}
|
||||
imagePaywalled={selectedImageMessage.imagePaywalled === true}
|
||||
isUnlockingImagePaywall={
|
||||
state.isUnlockingMessage &&
|
||||
state.unlockingMessageId === selectedImageMessage.id
|
||||
state.unlockingMessageId === selectedImageMessage.displayId
|
||||
}
|
||||
onUnlockImagePaywall={handleUnlockImagePaywall}
|
||||
onClose={handleCloseImageViewer}
|
||||
|
||||
@@ -265,19 +265,19 @@ function createTouchEvent(
|
||||
return event;
|
||||
}
|
||||
|
||||
function createMessage(id: string): UiMessage {
|
||||
function createMessage(displayId: string): UiMessage {
|
||||
return {
|
||||
id,
|
||||
content: `Message ${id}`,
|
||||
displayId,
|
||||
content: `Message ${displayId}`,
|
||||
isFromAI: true,
|
||||
date: "2026-07-15",
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(id: string): UiMessage {
|
||||
function createUserMessage(displayId: string): UiMessage {
|
||||
return {
|
||||
id,
|
||||
content: `Message ${id}`,
|
||||
displayId,
|
||||
content: `Message ${displayId}`,
|
||||
isFromAI: false,
|
||||
date: "2026-07-15",
|
||||
};
|
||||
|
||||
@@ -128,7 +128,8 @@ describe("chat Tailwind components", () => {
|
||||
const openableHtml = renderToStaticMarkup(
|
||||
<ImageBubble
|
||||
characterId="elio"
|
||||
messageId="message-1"
|
||||
displayMessageId="server:message-1:assistant"
|
||||
remoteMessageId="message-1"
|
||||
imageUrl="/chat-image.png"
|
||||
onOpenImage={() => undefined}
|
||||
/>,
|
||||
@@ -136,6 +137,7 @@ describe("chat Tailwind components", () => {
|
||||
const paywalledHtml = renderToStaticMarkup(
|
||||
<ImageBubble
|
||||
characterId="elio"
|
||||
displayMessageId="server:message-2:assistant"
|
||||
imageUrl="/locked-image.png"
|
||||
imagePaywalled
|
||||
/>,
|
||||
|
||||
@@ -54,13 +54,18 @@ export interface ChatAreaProps {
|
||||
isLoadingMoreHistory?: boolean;
|
||||
isUnlockingMessage?: boolean;
|
||||
unlockingMessageId?: string | null;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
onLoadMoreHistory?: () => void;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
) => void;
|
||||
|
||||
export function ChatArea({
|
||||
characterId,
|
||||
messages,
|
||||
@@ -278,10 +283,10 @@ function renderMessagesWithDateHeaders(
|
||||
getMessageKey: ChatMessageKeyResolver,
|
||||
isUnlockingMessage?: boolean,
|
||||
unlockingMessageId?: string | null,
|
||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||
onUnlockImageMessage?: (messageId: string) => void,
|
||||
onOpenImage?: (messageId: string) => void,
|
||||
onUnlockPrivateMessage?: ChatMessageAction,
|
||||
onUnlockVoiceMessage?: ChatMessageAction,
|
||||
onUnlockImageMessage?: ChatMessageAction,
|
||||
onOpenImage?: (displayMessageId: string) => void,
|
||||
) {
|
||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||
item.type === "date" ? (
|
||||
@@ -290,7 +295,8 @@ function renderMessagesWithDateHeaders(
|
||||
<MessageBubble
|
||||
characterId={characterId}
|
||||
key={item.key}
|
||||
messageId={item.message.id}
|
||||
displayMessageId={item.message.displayId}
|
||||
remoteMessageId={item.message.remoteId}
|
||||
content={item.message.content}
|
||||
imageUrl={item.message.imageUrl}
|
||||
imagePaywalled={item.message.imagePaywalled}
|
||||
@@ -302,7 +308,7 @@ function renderMessagesWithDateHeaders(
|
||||
privateMessageHint={item.message.privateMessageHint}
|
||||
isUnlockingMessage={
|
||||
isUnlockingMessage === true &&
|
||||
item.message.id === unlockingMessageId
|
||||
item.message.displayId === unlockingMessageId
|
||||
}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCachedChatMediaUrl } from "@/lib/chat/use_cached_chat_media_url";
|
||||
|
||||
export interface ChatMediaImageProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
remoteMessageId?: string;
|
||||
remoteUrl: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
@@ -30,7 +30,7 @@ export interface ChatMediaImageProps {
|
||||
|
||||
export function ChatMediaImage({
|
||||
characterId,
|
||||
messageId,
|
||||
remoteMessageId,
|
||||
remoteUrl,
|
||||
alt = "",
|
||||
className,
|
||||
@@ -48,7 +48,7 @@ export function ChatMediaImage({
|
||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||
useCachedChatMediaUrl({
|
||||
characterId,
|
||||
messageId,
|
||||
messageId: remoteMessageId,
|
||||
remoteUrl,
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import styles from "./fullscreen-image-viewer.module.css";
|
||||
|
||||
export interface FullscreenImageViewerProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
remoteMessageId?: string;
|
||||
imageUrl: string;
|
||||
imagePaywalled?: boolean;
|
||||
isUnlockingImagePaywall?: boolean;
|
||||
@@ -29,7 +29,7 @@ export interface FullscreenImageViewerProps {
|
||||
|
||||
export function FullscreenImageViewer({
|
||||
characterId,
|
||||
messageId,
|
||||
remoteMessageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
isUnlockingImagePaywall = false,
|
||||
@@ -54,7 +54,7 @@ export function FullscreenImageViewer({
|
||||
>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.paywallImage}
|
||||
nativeClassName={`${styles.nativePaywallImage} ${styles.paywallImage}`}
|
||||
@@ -104,7 +104,7 @@ export function FullscreenImageViewer({
|
||||
/>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
remoteUrl={imageUrl}
|
||||
className={styles.viewerImage}
|
||||
errorClassName="error"
|
||||
|
||||
@@ -12,20 +12,22 @@ import { ChatMediaImage } from "./chat-media-image";
|
||||
|
||||
export interface ImageBubbleProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
imageUrl: string; // base64 data URI 或 URL
|
||||
imagePaywalled?: boolean;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
}
|
||||
|
||||
export function ImageBubble({
|
||||
characterId,
|
||||
messageId,
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
imageUrl,
|
||||
imagePaywalled = false,
|
||||
onOpenImage,
|
||||
}: ImageBubbleProps) {
|
||||
const canOpen = Boolean(messageId && onOpenImage);
|
||||
const canOpen = Boolean(onOpenImage);
|
||||
const imageClassName = [
|
||||
"block h-auto w-full object-cover",
|
||||
imagePaywalled ? "scale-104 blur-sm" : "",
|
||||
@@ -34,8 +36,8 @@ export function ImageBubble({
|
||||
.join(" ");
|
||||
|
||||
const openImage = () => {
|
||||
if (!messageId || !onOpenImage) return;
|
||||
onOpenImage(messageId);
|
||||
if (!onOpenImage) return;
|
||||
onOpenImage(displayMessageId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -55,7 +57,7 @@ export function ImageBubble({
|
||||
>
|
||||
<ChatMediaImage
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
remoteUrl={imageUrl}
|
||||
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)"
|
||||
|
||||
@@ -16,7 +16,8 @@ import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
imagePaywalled?: boolean;
|
||||
@@ -27,15 +28,21 @@ export interface MessageBubbleProps {
|
||||
lockedPrivate?: boolean | null;
|
||||
privateMessageHint?: string | null;
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
) => void;
|
||||
|
||||
export function MessageBubble({
|
||||
characterId,
|
||||
messageId,
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
content,
|
||||
imageUrl,
|
||||
imagePaywalled,
|
||||
@@ -57,18 +64,19 @@ export function MessageBubble({
|
||||
return (
|
||||
<div
|
||||
className={styles.bubbleRowAi}
|
||||
data-chat-message-id={messageId}
|
||||
data-chat-message-id={displayMessageId}
|
||||
aria-label="AI message"
|
||||
>
|
||||
<MessageAvatar isFromAI={true} />
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
characterId={characterId}
|
||||
displayMessageId={displayMessageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
audioUrl={audioUrl}
|
||||
messageId={messageId}
|
||||
isFromAI={true}
|
||||
locked={locked}
|
||||
lockReason={lockReason}
|
||||
@@ -88,17 +96,18 @@ export function MessageBubble({
|
||||
return (
|
||||
<div
|
||||
className={styles.bubbleRowUser}
|
||||
data-chat-message-id={messageId}
|
||||
data-chat-message-id={displayMessageId}
|
||||
aria-label="User message"
|
||||
>
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
<MessageContent
|
||||
characterId={characterId}
|
||||
displayMessageId={displayMessageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
content={content}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
audioUrl={audioUrl}
|
||||
messageId={messageId}
|
||||
isFromAI={false}
|
||||
locked={locked}
|
||||
lockReason={lockReason}
|
||||
|
||||
@@ -8,32 +8,39 @@ import styles from "./chat-area.module.css";
|
||||
|
||||
export interface MessageContentProps {
|
||||
characterId: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
content: string;
|
||||
imageUrl?: string | null;
|
||||
imagePaywalled?: boolean;
|
||||
audioUrl?: string | null;
|
||||
messageId?: string;
|
||||
isFromAI: boolean;
|
||||
locked?: boolean | null;
|
||||
lockReason?: string | null;
|
||||
lockedPrivate?: boolean | null;
|
||||
privateMessageHint?: string | null;
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
onUnlockPrivateMessage?: ChatMessageAction;
|
||||
onUnlockVoiceMessage?: ChatMessageAction;
|
||||
onUnlockImageMessage?: ChatMessageAction;
|
||||
onOpenImage?: (displayMessageId: string) => void;
|
||||
}
|
||||
|
||||
type ChatMessageAction = (
|
||||
displayMessageId: string,
|
||||
remoteMessageId?: string,
|
||||
) => void;
|
||||
|
||||
const IMAGE_PLACEHOLDER = "[图片]";
|
||||
|
||||
export function MessageContent({
|
||||
characterId,
|
||||
displayMessageId,
|
||||
remoteMessageId,
|
||||
content,
|
||||
imageUrl,
|
||||
imagePaywalled,
|
||||
audioUrl,
|
||||
messageId,
|
||||
isFromAI,
|
||||
locked,
|
||||
lockReason,
|
||||
@@ -57,16 +64,17 @@ export function MessageContent({
|
||||
(lockReason === "image_paywall" || lockReason === "image");
|
||||
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
|
||||
const handleUnlockPrivateMessage =
|
||||
messageId && onUnlockPrivateMessage
|
||||
? () => onUnlockPrivateMessage(messageId)
|
||||
onUnlockPrivateMessage
|
||||
? () =>
|
||||
onUnlockPrivateMessage(displayMessageId, remoteMessageId)
|
||||
: undefined;
|
||||
const handleUnlockVoiceMessage =
|
||||
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
|
||||
? () => onUnlockVoiceMessage(messageId)
|
||||
isLockedVoiceMessage && onUnlockVoiceMessage
|
||||
? () => onUnlockVoiceMessage(displayMessageId, remoteMessageId)
|
||||
: undefined;
|
||||
const handleUnlockImageMessage =
|
||||
isLockedImageMessage && messageId && onUnlockImageMessage
|
||||
? () => onUnlockImageMessage(messageId)
|
||||
isLockedImageMessage && onUnlockImageMessage
|
||||
? () => onUnlockImageMessage(displayMessageId, remoteMessageId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
@@ -93,7 +101,8 @@ export function MessageContent({
|
||||
{hasImage && imageUrl && (
|
||||
<ImageBubble
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
displayMessageId={displayMessageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
imageUrl={imageUrl}
|
||||
imagePaywalled={imagePaywalled}
|
||||
onOpenImage={onOpenImage}
|
||||
@@ -102,7 +111,7 @@ export function MessageContent({
|
||||
{shouldRenderVoiceMessage ? (
|
||||
<VoiceBubble
|
||||
characterId={characterId}
|
||||
messageId={messageId}
|
||||
remoteMessageId={remoteMessageId}
|
||||
audioUrl={audioUrl}
|
||||
isFromAI={isFromAI}
|
||||
locked={isLockedVoiceMessage}
|
||||
|
||||
@@ -9,7 +9,7 @@ import styles from "./voice-bubble.module.css";
|
||||
|
||||
export interface VoiceBubbleProps {
|
||||
characterId: string;
|
||||
messageId?: string;
|
||||
remoteMessageId?: string;
|
||||
audioUrl?: string | null;
|
||||
isFromAI: boolean;
|
||||
locked?: boolean;
|
||||
@@ -20,7 +20,7 @@ export interface VoiceBubbleProps {
|
||||
|
||||
export function VoiceBubble({
|
||||
characterId,
|
||||
messageId,
|
||||
remoteMessageId,
|
||||
audioUrl,
|
||||
isFromAI,
|
||||
locked = false,
|
||||
@@ -36,7 +36,7 @@ export function VoiceBubble({
|
||||
const { mediaUrl, isUsingCachedMedia, reportMediaError } =
|
||||
useCachedChatMediaUrl({
|
||||
characterId,
|
||||
messageId,
|
||||
messageId: remoteMessageId,
|
||||
remoteUrl: audioUrl,
|
||||
kind: "audio",
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/stores/chat/chat-context";
|
||||
import type { ChatPromotionState } from "@/stores/chat/helper/promotion";
|
||||
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
|
||||
import type { UiMessage } from "@/stores/chat/ui-message";
|
||||
import { useUserSelector } from "@/stores/user/user-context";
|
||||
|
||||
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
||||
@@ -60,10 +61,11 @@ export interface UseChatUnlockCoordinatorInput
|
||||
}
|
||||
|
||||
export interface UseChatUnlockCoordinatorOutput {
|
||||
requestMessageUnlock: (
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
) => void;
|
||||
requestMessageUnlock: (input: {
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
}) => void;
|
||||
dialogs: ChatUnlockDialogModel;
|
||||
}
|
||||
|
||||
@@ -81,6 +83,7 @@ export function useChatUnlockCoordinator({
|
||||
(state) => ({
|
||||
characterId: state.context.characterId,
|
||||
historyLoaded: state.context.historyLoaded,
|
||||
messages: state.context.messages,
|
||||
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
||||
lockedHistoryCount: state.context.lockedHistoryCount,
|
||||
unlockHistoryError: state.context.unlockHistoryError,
|
||||
@@ -141,10 +144,15 @@ export function useChatUnlockCoordinator({
|
||||
|
||||
const consumed = await consumePendingChatUnlock(chatState.characterId);
|
||||
if (cancelled || !consumed) return;
|
||||
const displayMessageId = resolvePendingUnlockDisplayMessageId(
|
||||
consumed,
|
||||
chatState.messages,
|
||||
promotion,
|
||||
);
|
||||
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: consumed.displayMessageId,
|
||||
displayMessageId,
|
||||
remoteMessageId: consumed.messageId,
|
||||
kind: consumed.kind,
|
||||
lockType: consumed.lockType,
|
||||
@@ -165,14 +173,17 @@ export function useChatUnlockCoordinator({
|
||||
imageMessageId,
|
||||
imageReturnUrl,
|
||||
navigator.isAuthenticatedUser,
|
||||
promotion,
|
||||
chatState.messages,
|
||||
]);
|
||||
|
||||
function requestMessageUnlock(
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
): void {
|
||||
function requestMessageUnlock(input: {
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
}): void {
|
||||
const request = resolveMessageUnlockRequest(
|
||||
{ messageId, kind },
|
||||
input,
|
||||
{
|
||||
defaultReturnUrl,
|
||||
imageMessageId,
|
||||
@@ -186,7 +197,7 @@ export function useChatUnlockCoordinator({
|
||||
onAuthenticated: () => {
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: request.displayMessageId,
|
||||
displayMessageId: request.displayMessageId,
|
||||
remoteMessageId: request.messageId,
|
||||
kind: request.kind,
|
||||
lockType: request.lockType,
|
||||
@@ -258,23 +269,21 @@ export function useChatUnlockCoordinator({
|
||||
|
||||
export function resolveMessageUnlockRequest(
|
||||
input: {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
},
|
||||
scope: ChatUnlockCoordinatorScope,
|
||||
): CoordinatedMessageUnlockRequest {
|
||||
const matchedPromotion =
|
||||
scope.promotion?.message.id === input.messageId
|
||||
scope.promotion?.message.displayId === input.displayMessageId
|
||||
? scope.promotion
|
||||
: null;
|
||||
const temporaryPromotionMessageId = matchedPromotion
|
||||
? `promotion:${matchedPromotion.session.clientLockId}`
|
||||
: null;
|
||||
const remoteMessageId =
|
||||
input.remoteMessageId ?? matchedPromotion?.message.remoteId;
|
||||
const target = {
|
||||
displayMessageId: input.messageId,
|
||||
...(input.messageId !== temporaryPromotionMessageId
|
||||
? { messageId: input.messageId }
|
||||
: {}),
|
||||
displayMessageId: input.displayMessageId,
|
||||
...(remoteMessageId ? { messageId: remoteMessageId } : {}),
|
||||
kind: input.kind,
|
||||
...(matchedPromotion
|
||||
? {
|
||||
@@ -300,6 +309,40 @@ export function shouldResumePendingChatUnlock(
|
||||
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(
|
||||
target: Pick<
|
||||
CoordinatedMessageUnlockRequest,
|
||||
|
||||
Reference in New Issue
Block a user