fix(chat): render locked voice messages correctly

This commit is contained in:
2026-06-25 12:04:38 +08:00
parent f83e04ed6f
commit 8d956bc95d
8 changed files with 115 additions and 18 deletions
+2
View File
@@ -96,6 +96,8 @@ function renderMessagesWithDateHeaders(
imagePaywalled={item.message.imagePaywalled}
audioUrl={item.message.audioUrl}
isFromAI={item.message.isFromAI}
locked={item.message.locked}
lockReason={item.message.lockReason}
lockedPrivate={item.message.lockedPrivate}
privateMessageHint={item.message.privateMessageHint}
isUnlockingPrivate={
@@ -21,6 +21,8 @@ export interface MessageBubbleProps {
imagePaywalled?: boolean;
audioUrl?: string | null;
isFromAI: boolean;
locked?: boolean | null;
lockReason?: string | null;
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
isUnlockingPrivate?: boolean;
@@ -35,6 +37,8 @@ export function MessageBubble({
imagePaywalled,
audioUrl,
isFromAI,
locked,
lockReason,
lockedPrivate,
privateMessageHint,
isUnlockingPrivate,
@@ -55,6 +59,8 @@ export function MessageBubble({
imagePaywalled={imagePaywalled}
audioUrl={audioUrl}
isFromAI={true}
locked={locked}
lockReason={lockReason}
lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint}
isUnlockingPrivate={isUnlockingPrivate}
@@ -76,6 +82,8 @@ export function MessageBubble({
imagePaywalled={imagePaywalled}
audioUrl={audioUrl}
isFromAI={false}
locked={locked}
lockReason={lockReason}
lockedPrivate={lockedPrivate}
privateMessageHint={privateMessageHint}
isUnlockingPrivate={isUnlockingPrivate}
+16 -9
View File
@@ -1,12 +1,4 @@
"use client";
/**
* MessageContent 消息内容容器
*
* 决定渲染 ImageBubble 还是 TextBubble
* - 有 imageUrl:渲染 ImageBubble(图片)
* - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
* - 两者都有:图片在上,文字在下
*/
import { ImageBubble } from "./image-bubble";
import { PrivateMessageCard } from "./private-message-card";
import { TextBubble } from "./text-bubble";
@@ -19,6 +11,8 @@ export interface MessageContentProps {
imagePaywalled?: boolean;
audioUrl?: string | null;
isFromAI: boolean;
locked?: boolean | null;
lockReason?: string | null;
lockedPrivate?: boolean | null;
privateMessageHint?: string | null;
isUnlockingPrivate?: boolean;
@@ -35,6 +29,8 @@ export function MessageContent({
imagePaywalled,
audioUrl,
isFromAI,
locked,
lockReason,
lockedPrivate,
privateMessageHint,
isUnlockingPrivate = false,
@@ -44,7 +40,10 @@ export function MessageContent({
const hasImage = imageUrl != null && imageUrl.length > 0;
const hasAudio = audioUrl != null && audioUrl.length > 0;
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
const isLockedPrivateMessage = lockedPrivate === true;
const isLockedPrivateMessage =
lockedPrivate === true ||
(locked === true && lockReason === "private_message");
const isLockedVoiceMessage = locked === true && lockReason === "voice_message";
return (
<div
@@ -82,6 +81,14 @@ export function MessageContent({
<VoiceBubble
audioUrl={audioUrl}
isFromAI={isFromAI}
locked={isLockedVoiceMessage}
hint={privateMessageHint}
isUnlocking={isUnlockingPrivate}
onUnlock={
isLockedVoiceMessage && messageId
? () => onUnlockPrivateMessage?.(messageId)
: undefined
}
/>
) : null}
{hasText && !hasAudio ? (
@@ -24,6 +24,14 @@
color: #ffffff;
}
.locked {
border: 1px solid rgba(246, 87, 160, 0.2);
background:
linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)),
#ffffff;
color: #3c3b3b;
}
.playButton {
display: inline-flex;
flex: 0 0 auto;
@@ -43,6 +51,11 @@
color: #ffffff;
}
.locked .playButton {
background: linear-gradient(135deg, #ff8fc7 0%, #f657a0 100%);
color: #ffffff;
}
.playButton:disabled {
cursor: not-allowed;
opacity: 0.6;
@@ -106,3 +119,33 @@
.bubbleUser .error {
color: #ffffff;
}
.hint {
margin: 0;
color: #3c3b3b;
font-size: 14px;
line-height: 1.4;
}
.unlockButton {
align-self: flex-start;
margin-top: 2px;
padding: 9px 14px;
border: 0;
border-radius: 999px;
background: linear-gradient(90deg, #ff67e0, #ff52a2);
color: #ffffff;
cursor: pointer;
font-size: 14px;
font-weight: 700;
}
.unlockButton:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.unlockButton:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
+38 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { Pause, Play } from "lucide-react";
import { LockKeyhole, Pause, Play } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import styles from "./voice-bubble.module.css";
@@ -8,11 +8,19 @@ import styles from "./voice-bubble.module.css";
export interface VoiceBubbleProps {
audioUrl: string;
isFromAI: boolean;
locked?: boolean;
hint?: string | null;
isUnlocking?: boolean;
onUnlock?: () => void;
}
export function VoiceBubble({
audioUrl,
isFromAI,
locked = false,
hint,
isUnlocking = false,
onUnlock,
}: VoiceBubbleProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -43,6 +51,8 @@ export function VoiceBubble({
}, []);
const handleToggle = () => {
if (locked) return;
const audio = audioRef.current;
if (!audio || hasError) return;
@@ -61,16 +71,22 @@ export function VoiceBubble({
<div
className={`${styles.bubble} ${
isFromAI ? styles.bubbleAi : styles.bubbleUser
}`}
} ${locked ? styles.locked : ""}`}
>
<button
type="button"
className={styles.playButton}
onClick={handleToggle}
disabled={hasError}
disabled={locked || hasError}
aria-label={isPlaying ? "Pause voice message" : "Play voice message"}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
{locked ? (
<LockKeyhole size={18} />
) : isPlaying ? (
<Pause size={18} />
) : (
<Play size={18} />
)}
</button>
<div className={styles.body}>
<div className={styles.wave} aria-hidden="true">
@@ -83,8 +99,25 @@ export function VoiceBubble({
{hasError ? (
<p className={styles.error}>Voice message failed to load.</p>
) : null}
{locked ? (
<>
<p className={styles.hint}>
{hint && hint.length > 0
? hint
: "Elio has a locked voice message for you."}
</p>
<button
type="button"
className={styles.unlockButton}
disabled={isUnlocking || !onUnlock}
onClick={onUnlock}
>
{isUnlocking ? "Unlocking..." : "Unlock voice message"}
</button>
</>
) : null}
</div>
<audio ref={audioRef} src={audioUrl} preload="metadata" />
{!locked ? <audio ref={audioRef} src={audioUrl} preload="metadata" /> : null}
</div>
);
}
+2
View File
@@ -19,6 +19,8 @@ export const UiMessageSchema = z.object({
imagePaywalled: z.boolean().optional(),
/** 语音 URL */
audioUrl: z.string().optional(),
locked: z.boolean().nullable().optional(),
lockReason: z.string().nullable().optional(),
isPrivate: z.boolean().nullable().optional(),
lockedPrivate: z.boolean().nullable().optional(),
privateMessageHint: z.string().nullable().optional(),
+5 -4
View File
@@ -114,17 +114,18 @@ function deriveUiLockFields(
| undefined,
imageUrl?: string | null,
): Partial<UiMessage> {
const isPrivateMessage =
lockDetail?.reason === "private_message" ||
lockDetail?.reason === "voice_message";
const lockReason = lockDetail?.reason ?? null;
const isPrivateMessage = lockReason === "private_message";
const isVoiceMessage = lockReason === "voice_message";
return {
...(imageUrl ? { imageUrl } : {}),
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
...(isPrivateMessage ? { isPrivate: true } : {}),
...(isPrivateMessage && lockDetail?.locked
? { lockedPrivate: true }
: {}),
...(isPrivateMessage && lockDetail?.hint
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
? { privateMessageHint: lockDetail.hint }
: {}),
};
+1
View File
@@ -323,6 +323,7 @@ export const chatMachine = setup({
? {
...message,
content: response.content ?? message.content,
locked: false,
lockedPrivate: false,
privateMessageHint: null,
isPrivate: message.isPrivate ?? true,