From cd836cda43acf070f62fe1ad3694b089349ad7b2 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 25 Jun 2026 11:13:25 +0800 Subject: [PATCH] feat(chat): add voice message bubbles --- src/app/chat/components/chat-area.tsx | 1 + src/app/chat/components/index.ts | 3 +- src/app/chat/components/message-bubble.tsx | 4 + src/app/chat/components/message-content.tsx | 15 ++- .../chat/components/voice-bubble.module.css | 116 ++++++++++++++++++ src/app/chat/components/voice-bubble.tsx | 93 ++++++++++++++ src/stores/chat/chat-machine.helpers.ts | 4 +- 7 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 src/app/chat/components/voice-bubble.module.css create mode 100644 src/app/chat/components/voice-bubble.tsx diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index df68c514..59feb36d 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -94,6 +94,7 @@ function renderMessagesWithDateHeaders( content={item.message.content} imageUrl={item.message.imageUrl} imagePaywalled={item.message.imagePaywalled} + audioUrl={item.message.audioUrl} isFromAI={item.message.isFromAI} lockedPrivate={item.message.lockedPrivate} privateMessageHint={item.message.privateMessageHint} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index 56e09e75..c798d8b3 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -20,8 +20,9 @@ export * from "./lottie-message-bubble"; export * from "./message-avatar"; export * from "./message-bubble"; export * from "./message-content"; +export * from "./private-message-card"; export * from "./pwa-install-dialog"; export * from "./pwa-install-overlay"; -export * from "./private-message-card"; export * from "./text-bubble"; export * from "./user-message-avatar"; +export * from "./voice-bubble"; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index c1a43bb7..554e34a5 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -19,6 +19,7 @@ export interface MessageBubbleProps { content: string; imageUrl?: string | null; imagePaywalled?: boolean; + audioUrl?: string | null; isFromAI: boolean; lockedPrivate?: boolean | null; privateMessageHint?: string | null; @@ -32,6 +33,7 @@ export function MessageBubble({ content, imageUrl, imagePaywalled, + audioUrl, isFromAI, lockedPrivate, privateMessageHint, @@ -51,6 +53,7 @@ export function MessageBubble({ content={content} imageUrl={imageUrl} imagePaywalled={imagePaywalled} + audioUrl={audioUrl} isFromAI={true} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} @@ -71,6 +74,7 @@ export function MessageBubble({ content={content} imageUrl={imageUrl} imagePaywalled={imagePaywalled} + audioUrl={audioUrl} isFromAI={false} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index f13ad552..8d5ff775 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -10,12 +10,14 @@ import { ImageBubble } from "./image-bubble"; import { PrivateMessageCard } from "./private-message-card"; import { TextBubble } from "./text-bubble"; +import { VoiceBubble } from "./voice-bubble"; export interface MessageContentProps { messageId?: string; content: string; imageUrl?: string | null; imagePaywalled?: boolean; + audioUrl?: string | null; isFromAI: boolean; lockedPrivate?: boolean | null; privateMessageHint?: string | null; @@ -31,6 +33,7 @@ export function MessageContent({ content, imageUrl, imagePaywalled, + audioUrl, isFromAI, lockedPrivate, privateMessageHint, @@ -39,6 +42,7 @@ export function MessageContent({ onUnlockImagePaywall, }: MessageContentProps) { 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; @@ -74,7 +78,16 @@ export function MessageContent({ onUnlockImagePaywall={onUnlockImagePaywall} /> )} - {hasText && } + {hasAudio && audioUrl ? ( + + ) : null} + {hasText && !hasAudio ? ( + + ) : null} )} diff --git a/src/app/chat/components/voice-bubble.module.css b/src/app/chat/components/voice-bubble.module.css new file mode 100644 index 00000000..c0eedf57 --- /dev/null +++ b/src/app/chat/components/voice-bubble.module.css @@ -0,0 +1,116 @@ +.bubble { + display: flex; + align-items: flex-start; + gap: 10px; + width: min(280px, 100%); + padding: 12px 14px; + border-radius: 20px; + box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1)); +} + +.bubbleAi { + border-top-left-radius: 0; + background: #ffffff; + color: var(--color-text-foreground, #171717); +} + +.bubbleUser { + border-top-right-radius: 0; + background: linear-gradient( + to right, + var(--color-button-gradient-start, #ff67e0), + var(--color-button-gradient-end, #ff52a2) + ); + color: #ffffff; +} + +.playButton { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 0; + border-radius: 50%; + background: rgba(246, 87, 160, 0.14); + color: #f657a0; + cursor: pointer; +} + +.bubbleUser .playButton { + background: rgba(255, 255, 255, 0.22); + color: #ffffff; +} + +.playButton:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.playButton:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.body { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; +} + +.wave { + display: flex; + align-items: center; + gap: 4px; + height: 34px; +} + +.wave span { + display: block; + width: 4px; + border-radius: 999px; + background: currentColor; + opacity: 0.72; +} + +.wave span:nth-child(1) { + height: 12px; +} + +.wave span:nth-child(2) { + height: 22px; +} + +.wave span:nth-child(3) { + height: 30px; +} + +.wave span:nth-child(4) { + height: 18px; +} + +.wave span:nth-child(5) { + height: 26px; +} + +.text { + margin: 0; + font-size: var(--font-size-md, 14px); + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.error { + margin: 0; + color: var(--color-error, #ff4d4f); + font-size: var(--font-size-sm, 12px); + line-height: 1.4; +} + +.bubbleUser .error { + color: #ffffff; +} diff --git a/src/app/chat/components/voice-bubble.tsx b/src/app/chat/components/voice-bubble.tsx new file mode 100644 index 00000000..230be563 --- /dev/null +++ b/src/app/chat/components/voice-bubble.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Pause, Play } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import styles from "./voice-bubble.module.css"; + +export interface VoiceBubbleProps { + audioUrl: string; + content?: string; + isFromAI: boolean; +} + +export function VoiceBubble({ + audioUrl, + content = "", + isFromAI, +}: VoiceBubbleProps) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const handleEnded = () => setIsPlaying(false); + const handlePause = () => setIsPlaying(false); + const handlePlay = () => setIsPlaying(true); + const handleError = () => { + setHasError(true); + setIsPlaying(false); + }; + + audio.addEventListener("ended", handleEnded); + audio.addEventListener("pause", handlePause); + audio.addEventListener("play", handlePlay); + audio.addEventListener("error", handleError); + return () => { + audio.removeEventListener("ended", handleEnded); + audio.removeEventListener("pause", handlePause); + audio.removeEventListener("play", handlePlay); + audio.removeEventListener("error", handleError); + }; + }, []); + + const handleToggle = () => { + const audio = audioRef.current; + if (!audio || hasError) return; + + if (isPlaying) { + audio.pause(); + return; + } + + void audio.play().catch(() => { + setHasError(true); + setIsPlaying(false); + }); + }; + + return ( +
+ +
+ + {content.length > 0 ?

{content}

: null} + {hasError ? ( +

Voice message failed to load.

+ ) : null} +
+
+ ); +} diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index c899e99d..2ec44609 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -114,7 +114,9 @@ function deriveUiLockFields( | undefined, imageUrl?: string | null, ): Partial { - const isPrivateMessage = lockDetail?.reason === "private_message"; + const isPrivateMessage = + lockDetail?.reason === "private_message" || + lockDetail?.reason === "voice_message"; return { ...(imageUrl ? { imageUrl } : {}), ...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),