feat(chat): add voice message bubbles
This commit is contained in:
@@ -94,6 +94,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
content={item.message.content}
|
content={item.message.content}
|
||||||
imageUrl={item.message.imageUrl}
|
imageUrl={item.message.imageUrl}
|
||||||
imagePaywalled={item.message.imagePaywalled}
|
imagePaywalled={item.message.imagePaywalled}
|
||||||
|
audioUrl={item.message.audioUrl}
|
||||||
isFromAI={item.message.isFromAI}
|
isFromAI={item.message.isFromAI}
|
||||||
lockedPrivate={item.message.lockedPrivate}
|
lockedPrivate={item.message.lockedPrivate}
|
||||||
privateMessageHint={item.message.privateMessageHint}
|
privateMessageHint={item.message.privateMessageHint}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export * from "./lottie-message-bubble";
|
|||||||
export * from "./message-avatar";
|
export * from "./message-avatar";
|
||||||
export * from "./message-bubble";
|
export * from "./message-bubble";
|
||||||
export * from "./message-content";
|
export * from "./message-content";
|
||||||
|
export * from "./private-message-card";
|
||||||
export * from "./pwa-install-dialog";
|
export * from "./pwa-install-dialog";
|
||||||
export * from "./pwa-install-overlay";
|
export * from "./pwa-install-overlay";
|
||||||
export * from "./private-message-card";
|
|
||||||
export * from "./text-bubble";
|
export * from "./text-bubble";
|
||||||
export * from "./user-message-avatar";
|
export * from "./user-message-avatar";
|
||||||
|
export * from "./voice-bubble";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface MessageBubbleProps {
|
|||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
|
audioUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
@@ -32,6 +33,7 @@ export function MessageBubble({
|
|||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
|
audioUrl,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
@@ -51,6 +53,7 @@ export function MessageBubble({
|
|||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
|
audioUrl={audioUrl}
|
||||||
isFromAI={true}
|
isFromAI={true}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
@@ -71,6 +74,7 @@ export function MessageBubble({
|
|||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
imagePaywalled={imagePaywalled}
|
imagePaywalled={imagePaywalled}
|
||||||
|
audioUrl={audioUrl}
|
||||||
isFromAI={false}
|
isFromAI={false}
|
||||||
lockedPrivate={lockedPrivate}
|
lockedPrivate={lockedPrivate}
|
||||||
privateMessageHint={privateMessageHint}
|
privateMessageHint={privateMessageHint}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@
|
|||||||
import { ImageBubble } from "./image-bubble";
|
import { ImageBubble } from "./image-bubble";
|
||||||
import { PrivateMessageCard } from "./private-message-card";
|
import { PrivateMessageCard } from "./private-message-card";
|
||||||
import { TextBubble } from "./text-bubble";
|
import { TextBubble } from "./text-bubble";
|
||||||
|
import { VoiceBubble } from "./voice-bubble";
|
||||||
|
|
||||||
export interface MessageContentProps {
|
export interface MessageContentProps {
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
imagePaywalled?: boolean;
|
imagePaywalled?: boolean;
|
||||||
|
audioUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
lockedPrivate?: boolean | null;
|
lockedPrivate?: boolean | null;
|
||||||
privateMessageHint?: string | null;
|
privateMessageHint?: string | null;
|
||||||
@@ -31,6 +33,7 @@ export function MessageContent({
|
|||||||
content,
|
content,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePaywalled,
|
imagePaywalled,
|
||||||
|
audioUrl,
|
||||||
isFromAI,
|
isFromAI,
|
||||||
lockedPrivate,
|
lockedPrivate,
|
||||||
privateMessageHint,
|
privateMessageHint,
|
||||||
@@ -39,6 +42,7 @@ export function MessageContent({
|
|||||||
onUnlockImagePaywall,
|
onUnlockImagePaywall,
|
||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
|
const hasAudio = audioUrl != null && audioUrl.length > 0;
|
||||||
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
||||||
const isLockedPrivateMessage = lockedPrivate === true;
|
const isLockedPrivateMessage = lockedPrivate === true;
|
||||||
|
|
||||||
@@ -74,7 +78,16 @@ export function MessageContent({
|
|||||||
onUnlockImagePaywall={onUnlockImagePaywall}
|
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>
|
</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,
|
| undefined,
|
||||||
imageUrl?: string | null,
|
imageUrl?: string | null,
|
||||||
): Partial<UiMessage> {
|
): Partial<UiMessage> {
|
||||||
const isPrivateMessage = lockDetail?.reason === "private_message";
|
const isPrivateMessage =
|
||||||
|
lockDetail?.reason === "private_message" ||
|
||||||
|
lockDetail?.reason === "voice_message";
|
||||||
return {
|
return {
|
||||||
...(imageUrl ? { imageUrl } : {}),
|
...(imageUrl ? { imageUrl } : {}),
|
||||||
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user