fix(chat): stabilize message identities
This commit is contained in:
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user