diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx
index 59feb36d..fc9d1f7b 100644
--- a/src/app/chat/components/chat-area.tsx
+++ b/src/app/chat/components/chat-area.tsx
@@ -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={
diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx
index 554e34a5..1f3d29fe 100644
--- a/src/app/chat/components/message-bubble.tsx
+++ b/src/app/chat/components/message-bubble.tsx
@@ -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}
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx
index f4778ecf..d3fecb49 100644
--- a/src/app/chat/components/message-content.tsx
+++ b/src/app/chat/components/message-content.tsx
@@ -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 (
onUnlockPrivateMessage?.(messageId)
+ : undefined
+ }
/>
) : null}
{hasText && !hasAudio ? (
diff --git a/src/app/chat/components/voice-bubble.module.css b/src/app/chat/components/voice-bubble.module.css
index 590c8061..c2415cb2 100644
--- a/src/app/chat/components/voice-bubble.module.css
+++ b/src/app/chat/components/voice-bubble.module.css
@@ -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;
+}
diff --git a/src/app/chat/components/voice-bubble.tsx b/src/app/chat/components/voice-bubble.tsx
index 0b4133f3..fa71a0d0 100644
--- a/src/app/chat/components/voice-bubble.tsx
+++ b/src/app/chat/components/voice-bubble.tsx
@@ -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
(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({
@@ -83,8 +99,25 @@ export function VoiceBubble({
{hasError ? (
Voice message failed to load.
) : null}
+ {locked ? (
+ <>
+
+ {hint && hint.length > 0
+ ? hint
+ : "Elio has a locked voice message for you."}
+
+
+ >
+ ) : null}
-
+ {!locked ?
: null}
);
}
diff --git a/src/data/dto/chat/ui_message.ts b/src/data/dto/chat/ui_message.ts
index 9820ba11..e9670ff5 100644
--- a/src/data/dto/chat/ui_message.ts
+++ b/src/data/dto/chat/ui_message.ts
@@ -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(),
diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts
index 2ec44609..4250e532 100644
--- a/src/stores/chat/chat-machine.helpers.ts
+++ b/src/stores/chat/chat-machine.helpers.ts
@@ -114,17 +114,18 @@ function deriveUiLockFields(
| undefined,
imageUrl?: string | null,
): Partial
{
- 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 }
: {}),
};
diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts
index 0467dbd0..a9a08a23 100644
--- a/src/stores/chat/chat-machine.ts
+++ b/src/stores/chat/chat-machine.ts
@@ -323,6 +323,7 @@ export const chatMachine = setup({
? {
...message,
content: response.content ?? message.content,
+ locked: false,
lockedPrivate: false,
privateMessageHint: null,
isPrivate: message.isPrivate ?? true,