feat(chat): add voice message bubbles

This commit is contained in:
2026-06-25 11:13:25 +08:00
parent 038b207964
commit cd836cda43
7 changed files with 233 additions and 3 deletions
+1
View File
@@ -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}
+2 -1
View File
@@ -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";
@@ -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}
+14 -1
View File
@@ -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 && <TextBubble content={content} isFromAI={isFromAI} />}
{hasAudio && audioUrl ? (
<VoiceBubble
audioUrl={audioUrl}
content={content}
isFromAI={isFromAI}
/>
) : null}
{hasText && !hasAudio ? (
<TextBubble content={content} isFromAI={isFromAI} />
) : null}
</>
)}
</div>
@@ -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;
}
+93
View File
@@ -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<HTMLAudioElement>(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 (
<div
className={`${styles.bubble} ${
isFromAI ? styles.bubbleAi : styles.bubbleUser
}`}
>
<button
type="button"
className={styles.playButton}
onClick={handleToggle}
disabled={hasError}
aria-label={isPlaying ? "Pause voice message" : "Play voice message"}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<div className={styles.body}>
<div className={styles.wave} aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
</div>
{content.length > 0 ? <p className={styles.text}>{content}</p> : null}
{hasError ? (
<p className={styles.error}>Voice message failed to load.</p>
) : null}
</div>
<audio ref={audioRef} src={audioUrl} preload="metadata" />
</div>
);
}
+3 -1
View File
@@ -114,7 +114,9 @@ function deriveUiLockFields(
| undefined,
imageUrl?: string | null,
): Partial<UiMessage> {
const isPrivateMessage = lockDetail?.reason === "private_message";
const isPrivateMessage =
lockDetail?.reason === "private_message" ||
lockDetail?.reason === "voice_message";
return {
...(imageUrl ? { imageUrl } : {}),
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),