fix(chat): render locked voice messages correctly
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -323,6 +323,7 @@ export const chatMachine = setup({
|
||||
? {
|
||||
...message,
|
||||
content: response.content ?? message.content,
|
||||
locked: false,
|
||||
lockedPrivate: false,
|
||||
privateMessageHint: null,
|
||||
isPrivate: message.isPrivate ?? true,
|
||||
|
||||
Reference in New Issue
Block a user