feat(chat): add voice message bubbles
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user