fix(chat): render locked voice messages correctly
This commit is contained in:
@@ -96,6 +96,8 @@ function renderMessagesWithDateHeaders(
|
|||||||
imagePaywalled={item.message.imagePaywalled}
|
imagePaywalled={item.message.imagePaywalled}
|
||||||
audioUrl={item.message.audioUrl}
|
audioUrl={item.message.audioUrl}
|
||||||
isFromAI={item.message.isFromAI}
|
isFromAI={item.message.isFromAI}
|
||||||
|
locked={item.message.locked}
|
||||||
|
lockReason={item.message.lockReason}
|
||||||
lockedPrivate={item.message.lockedPrivate}
|
lockedPrivate={item.message.lockedPrivate}
|
||||||
privateMessageHint={item.message.privateMessageHint}
|
privateMessageHint={item.message.privateMessageHint}
|
||||||
isUnlockingPrivate={
|
isUnlockingPrivate={
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface MessageBubbleProps {
|
|||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
audioUrl?: string | null;
|
audioUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
|
locked?: boolean | null;
|
||||||
|
lockReason?: string | null;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
isUnlockingPrivate?: boolean;
|
isUnlockingPrivate?: boolean;
|
||||||
@@ -35,6 +37,8 @@ export function MessageBubble({
|
|||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
|
locked,
|
||||||
|
lockReason,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
isUnlockingPrivate,
|
isUnlockingPrivate,
|
||||||
@@ -55,6 +59,8 @@ export function MessageBubble({
|
|||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
isFromAI={true}
|
isFromAI={true}
|
||||||
|
locked={locked}
|
||||||
|
lockReason={lockReason}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
isUnlockingPrivate={isUnlockingPrivate}
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
@@ -76,6 +82,8 @@ export function MessageBubble({
|
|||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
isFromAI={false}
|
isFromAI={false}
|
||||||
|
locked={locked}
|
||||||
|
lockReason={lockReason}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
isUnlockingPrivate={isUnlockingPrivate}
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
|
||||||
* MessageContent 消息内容容器
|
|
||||||
*
|
|
||||||
* 决定渲染 ImageBubble 还是 TextBubble:
|
|
||||||
* - 有 imageUrl:渲染 ImageBubble(图片)
|
|
||||||
* - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
|
|
||||||
* - 两者都有:图片在上,文字在下
|
|
||||||
*/
|
|
||||||
import { ImageBubble } from "./image-bubble";
|
import { ImageBubble } from "./image-bubble";
|
||||||
import { PrivateMessageCard } from "./private-message-card";
|
import { PrivateMessageCard } from "./private-message-card";
|
||||||
import { TextBubble } from "./text-bubble";
|
import { TextBubble } from "./text-bubble";
|
||||||
@@ -19,6 +11,8 @@ export interface MessageContentProps {
|
|||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
audioUrl?: string | null;
|
audioUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
|
locked?: boolean | null;
|
||||||
|
lockReason?: string | null;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
isUnlockingPrivate?: boolean;
|
isUnlockingPrivate?: boolean;
|
||||||
@@ -35,6 +29,8 @@ export function MessageContent({
|
|||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
|
locked,
|
||||||
|
lockReason,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
isUnlockingPrivate = false,
|
isUnlockingPrivate = false,
|
||||||
@@ -44,7 +40,10 @@ export function MessageContent({
|
|||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||||
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -82,6 +81,14 @@ export function MessageContent({
|
|||||||
<VoiceBubble
|
<VoiceBubble
|
||||||
audioUrl={audioUrl}
|
audioUrl={audioUrl}
|
||||||
isFromAI={isFromAI}
|
isFromAI={isFromAI}
|
||||||
|
locked={isLockedVoiceMessage}
|
||||||
|
hint={privateMessageHint}
|
||||||
|
isUnlocking={isUnlockingPrivate}
|
||||||
|
onUnlock={
|
||||||
|
isLockedVoiceMessage && messageId
|
||||||
|
? () => onUnlockPrivateMessage?.(messageId)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{hasText && !hasAudio ? (
|
{hasText && !hasAudio ? (
|
||||||
|
|||||||
@@ -24,6 +24,14 @@
|
|||||||
color: #ffffff;
|
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 {
|
.playButton {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -43,6 +51,11 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locked .playButton {
|
||||||
|
background: linear-gradient(135deg, #ff8fc7 0%, #f657a0 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.playButton:disabled {
|
.playButton:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -106,3 +119,33 @@
|
|||||||
.bubbleUser .error {
|
.bubbleUser .error {
|
||||||
color: #ffffff;
|
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";
|
"use client";
|
||||||
|
|
||||||
import { Pause, Play } from "lucide-react";
|
import { LockKeyhole, Pause, Play } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import styles from "./voice-bubble.module.css";
|
import styles from "./voice-bubble.module.css";
|
||||||
@@ -8,11 +8,19 @@ import styles from "./voice-bubble.module.css";
|
|||||||
export interface VoiceBubbleProps {
|
export interface VoiceBubbleProps {
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
hint?: string | null;
|
||||||
|
isUnlocking?: boolean;
|
||||||
|
onUnlock?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceBubble({
|
export function VoiceBubble({
|
||||||
audioUrl,
|
audioUrl,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
|
locked = false,
|
||||||
|
hint,
|
||||||
|
isUnlocking = false,
|
||||||
|
onUnlock,
|
||||||
}: VoiceBubbleProps) {
|
}: VoiceBubbleProps) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -43,6 +51,8 @@ export function VoiceBubble({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
if (locked) return;
|
||||||
|
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio || hasError) return;
|
if (!audio || hasError) return;
|
||||||
|
|
||||||
@@ -61,16 +71,22 @@ export function VoiceBubble({
|
|||||||
<div
|
<div
|
||||||
className={`${styles.bubble} ${
|
className={`${styles.bubble} ${
|
||||||
isFromAI ? styles.bubbleAi : styles.bubbleUser
|
isFromAI ? styles.bubbleAi : styles.bubbleUser
|
||||||
}`}
|
} ${locked ? styles.locked : ""}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.playButton}
|
className={styles.playButton}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={hasError}
|
disabled={locked || hasError}
|
||||||
aria-label={isPlaying ? "Pause voice message" : "Play voice message"}
|
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>
|
</button>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.wave} aria-hidden="true">
|
<div className={styles.wave} aria-hidden="true">
|
||||||
@@ -83,8 +99,25 @@ export function VoiceBubble({
|
|||||||
{hasError ? (
|
{hasError ? (
|
||||||
<p className={styles.error}>Voice message failed to load.</p>
|
<p className={styles.error}>Voice message failed to load.</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
{!locked ? <audio ref={audioRef} src={audioUrl} preload="metadata" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const UiMessageSchema = z.object({
|
|||||||
imagePaywalled: z.boolean().optional(),
|
imagePaywalled: z.boolean().optional(),
|
||||||
/** 语音 URL */
|
/** 语音 URL */
|
||||||
audioUrl: z.string().optional(),
|
audioUrl: z.string().optional(),
|
||||||
|
locked: z.boolean().nullable().optional(),
|
||||||
|
lockReason: z.string().nullable().optional(),
|
||||||
isPrivate: z.boolean().nullable().optional(),
|
isPrivate: z.boolean().nullable().optional(),
|
||||||
lockedPrivate: z.boolean().nullable().optional(),
|
lockedPrivate: z.boolean().nullable().optional(),
|
||||||
privateMessageHint: z.string().nullable().optional(),
|
privateMessageHint: z.string().nullable().optional(),
|
||||||
|
|||||||
@@ -114,17 +114,18 @@ function deriveUiLockFields(
|
|||||||
| undefined,
|
| undefined,
|
||||||
imageUrl?: string | null,
|
imageUrl?: string | null,
|
||||||
): Partial<UiMessage> {
|
): Partial<UiMessage> {
|
||||||
const isPrivateMessage =
|
const lockReason = lockDetail?.reason ?? null;
|
||||||
lockDetail?.reason === "private_message" ||
|
const isPrivateMessage = lockReason === "private_message";
|
||||||
lockDetail?.reason === "voice_message";
|
const isVoiceMessage = lockReason === "voice_message";
|
||||||
return {
|
return {
|
||||||
...(imageUrl ? { imageUrl } : {}),
|
...(imageUrl ? { imageUrl } : {}),
|
||||||
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
||||||
|
...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}),
|
||||||
...(isPrivateMessage ? { isPrivate: true } : {}),
|
...(isPrivateMessage ? { isPrivate: true } : {}),
|
||||||
...(isPrivateMessage && lockDetail?.locked
|
...(isPrivateMessage && lockDetail?.locked
|
||||||
? { lockedPrivate: true }
|
? { lockedPrivate: true }
|
||||||
: {}),
|
: {}),
|
||||||
...(isPrivateMessage && lockDetail?.hint
|
...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint
|
||||||
? { privateMessageHint: lockDetail.hint }
|
? { privateMessageHint: lockDetail.hint }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ export const chatMachine = setup({
|
|||||||
? {
|
? {
|
||||||
...message,
|
...message,
|
||||||
content: response.content ?? message.content,
|
content: response.content ?? message.content,
|
||||||
|
locked: false,
|
||||||
lockedPrivate: false,
|
lockedPrivate: false,
|
||||||
privateMessageHint: null,
|
privateMessageHint: null,
|
||||||
isPrivate: message.isPrivate ?? true,
|
isPrivate: message.isPrivate ?? true,
|
||||||
|
|||||||
Reference in New Issue
Block a user