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
+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>
);
}