← back to Dear Bubbe Nextjs
components/DesktopBubbe.tsx
463 lines
'use client'
import { useState, useEffect, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { guestGateBlocks, fetchBubbeReply, makeVoicePlayer } from '@/lib/bubbeChat'
export default function DesktopBubbe() {
const { data: session } = useSession()
const [messages, setMessages] = useState<Array<{id: string, role: 'user' | 'assistant', content: string}>>([])
const [textInput, setTextInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isListening, setIsListening] = useState(false)
const [isSpeaking, setIsSpeaking] = useState(false)
const [hasStarted, setHasStarted] = useState(false)
const [voiceEnabled, setVoiceEnabled] = useState(true)
const messagesEndRef = useRef<HTMLDivElement>(null)
const recognitionRef = useRef<WebSpeechRecognition | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const isSpeakingRef = useRef(false) // synchronous lock so voices never overlap
const isAutoStartedRef = useRef(false)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
// Initialize speech recognition
if (typeof window !== 'undefined' && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) {
const SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition
if (!SpeechRecognition) return
try {
recognitionRef.current = new SpeechRecognition()
recognitionRef.current.continuous = false
recognitionRef.current.interimResults = false
recognitionRef.current.lang = 'en-US'
recognitionRef.current.onresult = (event: any) => {
const transcript = event.results[0][0].transcript
handleSendMessage(transcript)
setIsListening(false)
}
recognitionRef.current.onerror = (event: any) => {
console.error('Speech error:', event.error)
setIsListening(false)
// Continue conversation on error
if (hasStarted && voiceEnabled && event.error !== 'aborted') {
setTimeout(() => {
if (!isSpeaking && !isLoading) {
startListening()
}
}, 1000)
}
}
recognitionRef.current.onend = () => {
setIsListening(false)
// Auto-restart listening if voice enabled
if (hasStarted && voiceEnabled && !isSpeaking && !isLoading) {
setTimeout(() => {
if (!isSpeaking && !isLoading) {
startListening()
}
}, 500)
}
}
} catch (e) {
console.error('Speech recognition setup failed:', e)
}
}
// Initialize the ONE audio element only once (recreating it on every state
// change spawned overlapping Audio elements that played over each other).
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.addEventListener('ended', () => {
isSpeakingRef.current = false
setIsSpeaking(false)
// Auto-start listening after speech ends
if (voiceEnabled && hasStarted) {
setTimeout(() => {
startListening()
}, 500)
}
})
}
}, [voiceEnabled, hasStarted, isSpeaking, isLoading])
// Auto-start conversation on page load
useEffect(() => {
if (!isAutoStartedRef.current) {
isAutoStartedRef.current = true
// Prevent duplicate auto-start with global flag
if (!(window as any).__bubbeDesktopAutoStarted) {
(window as any).__bubbeDesktopAutoStarted = true
// Start conversation after small delay
setTimeout(() => {
startConversation()
}, 1500)
}
}
}, [])
// Voice playback (stop-before-play + overlap lock) lives in lib/bubbeChat.
// Desktop gates on voiceEnabled and auto-restarts listening after speech.
const voicePlayer = makeVoicePlayer({ audioRef, isSpeakingRef, setIsSpeaking })
const playBubbeVoice = (text: string) => {
stopListening()
return voicePlayer(text, {
enabled: voiceEnabled,
onDone: () => { if (voiceEnabled && hasStarted) setTimeout(() => startListening(), 500) },
})
}
const handleSendMessage = async (text: string) => {
if (!text.trim()) return
// Free-chat gate: guests get a few free messages, then Google sign-in.
if (guestGateBlocks(session, setMessages)) return
const userMessage = {
id: Date.now().toString(),
role: 'user' as const,
content: text.trim()
}
setMessages(prev => [...prev, userMessage])
setTextInput('')
setIsLoading(true)
try {
const reply = await fetchBubbeReply(text)
const assistantMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant' as const,
content: reply
}
setMessages(prev => [...prev, assistantMessage])
// Play voice response if enabled
if (voiceEnabled) {
await playBubbeVoice(assistantMessage.content)
}
} catch (error) {
console.error('Error:', error)
const errorMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant' as const,
content: "Oy vey, the internet is meshuga! Try again!"
}
setMessages(prev => [...prev, errorMessage])
if (voiceEnabled) {
await playBubbeVoice(errorMessage.content)
}
} finally {
setIsLoading(false)
}
}
const startListening = () => {
if (!recognitionRef.current || !voiceEnabled) return
if (isListening || isSpeaking) return
try {
setIsListening(true)
recognitionRef.current.start()
} catch (error) {
console.error('Failed to start listening:', error)
setIsListening(false)
}
}
const stopListening = () => {
if (recognitionRef.current && isListening) {
try {
recognitionRef.current.stop()
} catch (e) {
console.error('Error stopping:', e)
}
}
setIsListening(false)
}
const startConversation = async () => {
if (hasStarted) return
setHasStarted(true)
// Start with Bubbe's greeting
const greeting = "Oy vey, look who finally showed up! Nu, what's the crisis today? And don't lie to me, I can see you're stressed from here!"
const greetingMessage = {
id: Date.now().toString(),
role: 'assistant' as const,
content: greeting
}
setMessages([greetingMessage])
// Play the greeting and then start listening
await playBubbeVoice(greeting)
// If voice didn't play, start listening anyway
if (!isSpeaking && voiceEnabled) {
setTimeout(() => startListening(), 500)
}
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
flexDirection: 'column',
fontFamily: 'system-ui, -apple-system, sans-serif'
}}>
{/* Header */}
<div style={{
background: 'rgba(0,0,0,0.2)',
padding: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
color: 'white',
borderBottom: '1px solid rgba(255,255,255,0.1)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<span style={{ fontSize: '36px' }}>🥯</span>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 'bold', margin: 0 }}>Dear Bubbe</h1>
<p style={{ fontSize: '14px', opacity: 0.8, margin: 0 }}>
Your Brutally Honest AI Jewish Grandmother
</p>
</div>
</div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
{/* Voice Status */}
{(isSpeaking || isListening) && (
<div style={{
background: isSpeaking ? '#f59e0b' : '#ef4444',
padding: '8px 16px',
borderRadius: '20px',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{isSpeaking ? '🔊 Speaking' : '🎤 Listening'}
</div>
)}
{/* Voice Toggle */}
<button
onClick={() => setVoiceEnabled(!voiceEnabled)}
style={{
padding: '10px 20px',
background: voiceEnabled ? '#10b981' : '#6b7280',
color: 'white',
border: 'none',
borderRadius: '20px',
fontSize: '14px',
fontWeight: 'bold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{voiceEnabled ? '🔊 Voice On' : '🔇 Voice Off'}
</button>
</div>
</div>
{/* Main Chat Area */}
<div style={{
flex: 1,
display: 'flex',
maxWidth: '1200px',
width: '100%',
margin: '0 auto',
padding: '20px',
gap: '20px'
}}>
{/* Messages Column */}
<div style={{
flex: 1,
background: 'rgba(255,255,255,0.1)',
borderRadius: '20px',
padding: '20px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column'
}}>
{messages.length === 0 && (
<div style={{
textAlign: 'center',
color: 'rgba(255,255,255,0.8)',
marginTop: '50px'
}}>
<p style={{ fontSize: '20px' }}>
Bubbe is getting ready to kvetch at you...
</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
style={{
alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '70%',
marginBottom: '15px'
}}
>
<div style={{
background: message.role === 'user' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.3)',
color: message.role === 'user' ? '#333' : 'white',
padding: '15px 20px',
borderRadius: '20px',
borderBottomRightRadius: message.role === 'user' ? '4px' : '20px',
borderBottomLeftRadius: message.role === 'user' ? '20px' : '4px',
fontSize: '16px',
lineHeight: '1.5'
}}>
{message.content}
</div>
</div>
))}
{isLoading && (
<div style={{ alignSelf: 'flex-start', maxWidth: '70%' }}>
<div style={{
background: 'rgba(0,0,0,0.3)',
color: 'white',
padding: '15px 20px',
borderRadius: '20px',
borderBottomLeftRadius: '4px'
}}>
<div style={{ display: 'flex', gap: '4px' }}>
<span style={{ animation: 'bounce 1.4s infinite' }}>●</span>
<span style={{ animation: 'bounce 1.4s infinite 0.2s' }}>●</span>
<span style={{ animation: 'bounce 1.4s infinite 0.4s' }}>●</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input Area */}
<div style={{
padding: '20px',
background: 'rgba(0,0,0,0.2)',
borderTop: '1px solid rgba(255,255,255,0.2)'
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'flex',
gap: '15px',
alignItems: 'center'
}}>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSendMessage(textInput)
}
}}
placeholder={voiceEnabled ? "Type or speak your message..." : "Type your message..."}
disabled={isLoading}
style={{
flex: 1,
padding: '15px 20px',
fontSize: '16px',
borderRadius: '30px',
border: 'none',
background: 'rgba(255,255,255,0.95)',
outline: 'none'
}}
/>
{voiceEnabled && (
<button
onClick={() => {
if (isSpeaking) {
if (audioRef.current) {
audioRef.current.pause()
setIsSpeaking(false)
}
} else if (isListening) {
stopListening()
} else if (!isLoading && hasStarted) {
startListening()
} else if (!hasStarted) {
startConversation()
}
}}
disabled={isLoading}
style={{
width: '60px',
height: '60px',
borderRadius: '50%',
background: isListening ? '#ef4444' : isSpeaking ? '#f59e0b' : 'white',
border: 'none',
fontSize: '24px',
cursor: isLoading ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
animation: isSpeaking || isListening ? 'pulse 1.5s infinite' : 'none'
}}
>
{isSpeaking ? '🔊' : isListening ? '⏹️' : isLoading ? '⏳' : '🎤'}
</button>
)}
<button
onClick={() => handleSendMessage(textInput)}
disabled={!textInput.trim() || isLoading}
style={{
padding: '15px 30px',
background: !textInput.trim() || isLoading ? 'rgba(255,255,255,0.3)' : 'white',
color: !textInput.trim() || isLoading ? 'rgba(0,0,0,0.3)' : '#667eea',
border: 'none',
borderRadius: '30px',
fontSize: '16px',
fontWeight: 'bold',
cursor: !textInput.trim() || isLoading ? 'default' : 'pointer'
}}
>
Send
</button>
</div>
</div>
<style jsx>{`
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-10px); }
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
`}</style>
</div>
)
}