← back to Dear Bubbe Nextjs
components/MobileBubbe.tsx
548 lines
'use client'
import { useState, useEffect, useRef } from 'react'
import { useSession, signOut } from 'next-auth/react'
import { guestGateBlocks, fetchBubbeReply, makeVoicePlayer } from '@/lib/bubbeChat'
export default function MobileBubbe() {
// Start with welcome screen
const { data: session } = useSession()
const [mode, setMode] = useState<'welcome' | 'text' | 'voice'>('welcome')
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 [showListeningUI, setShowListeningUI] = useState(false)
const [transitionTimer, setTransitionTimer] = useState<NodeJS.Timeout | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const recognitionRef = useRef<WebSpeechRecognition | null>(null)
const handleSendRef = useRef<(t: string) => void>(() => {}) // latest handleSendMessage for the once-created recognition
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
// Create the recognition instance ONCE (was recreated on every state
// change → leaked instances). Handlers read fresh state via handleSendRef.
if (!recognitionRef.current) 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
handleSendRef.current(transcript)
setIsListening(false)
// Smooth UI transition
if (transitionTimer) clearTimeout(transitionTimer)
const timer = setTimeout(() => setShowListeningUI(false), 300)
setTransitionTimer(timer)
}
recognitionRef.current.onerror = (event: any) => {
console.error('Speech error:', event.error)
setIsListening(false)
// Smooth UI transition
if (transitionTimer) clearTimeout(transitionTimer)
const timer = setTimeout(() => setShowListeningUI(false), 300)
setTransitionTimer(timer)
// Don't auto-restart on error to prevent flashing
// User can tap to retry
}
recognitionRef.current.onend = () => {
setIsListening(false)
// Smooth UI transition
if (transitionTimer) clearTimeout(transitionTimer)
const timer = setTimeout(() => setShowListeningUI(false), 300)
setTransitionTimer(timer)
// Don't auto-restart to prevent flashing
}
} catch (e) {
console.error('Speech recognition setup failed:', e)
}
}
// Initialize the ONE audio element for TTS playback — only once.
// (Previously this ran on every state change, spawning new Audio elements
// that played over each other → overlapping voices.)
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.addEventListener('ended', () => {
isSpeakingRef.current = false
setIsSpeaking(false)
})
}
}, [mode, hasStarted, isSpeaking, isLoading])
// Don't auto-start - wait for user interaction
// Voice playback (stop-before-play + overlap lock) lives in lib/bubbeChat.
const voicePlayer = makeVoicePlayer({ audioRef, isSpeakingRef, setIsSpeaking })
const playBubbeVoice = (text: string) => { stopListening(); return voicePlayer(text) }
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 in voice mode
if (mode === 'voice') {
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])
// Play error message in voice mode
if (mode === 'voice') {
await playBubbeVoice(errorMessage.content)
}
} finally {
setIsLoading(false)
}
}
// Keep the once-created recognition's onresult pointed at the latest
// handleSendMessage (fresh session/mode closure), no dep array = every render.
useEffect(() => { handleSendRef.current = handleSendMessage })
const startListening = () => {
if (!recognitionRef.current) {
alert('Voice not supported on this device. Please type instead.')
return
}
// Don't start if already listening or speaking
if (isListening || isSpeaking) return
try {
setIsListening(true)
setShowListeningUI(true) // Show UI immediately
recognitionRef.current.start()
} catch (error) {
console.error('Failed to start listening:', error)
setIsListening(false)
// Only show alert on first failure
if (!hasStarted) {
alert('Microphone access denied. Please allow microphone access and try again.')
}
}
}
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, FINALLY someone to talk to! Nu, what's wrong with your life now? And don't tell me 'nothing' - I can see it in your pixels!"
const greetingMessage = {
id: Date.now().toString(),
role: 'assistant' as const,
content: greeting
}
setMessages([greetingMessage])
// Play the greeting but don't auto-start listening
await playBubbeVoice(greeting)
}
// Welcome screen for authenticated users
if (mode === 'welcome') {
const profileData = localStorage.getItem('bubbeProfile')
const profile = profileData ? JSON.parse(profileData) : {}
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',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
fontFamily: 'system-ui, -apple-system, sans-serif',
color: 'white',
textAlign: 'center'
}}>
<div style={{ fontSize: '80px', marginBottom: '20px' }}>🥯</div>
<h1 style={{ fontSize: '32px', marginBottom: '10px' }}>
Welcome back, {profile.name?.split(' ')[0] || 'bubbeleh'}!
</h1>
<p style={{ fontSize: '18px', marginBottom: '40px', opacity: 0.9 }}>
Bubbe's ready to give you some real talk
</p>
<div style={{ display: 'flex', gap: '20px' }}>
<button
onClick={() => {
setMode('voice')
startConversation()
}}
style={{
background: 'white',
color: '#764ba2',
border: 'none',
padding: '15px 30px',
borderRadius: '30px',
fontSize: '18px',
fontWeight: 'bold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '10px'
}}
>
🎤 Voice Chat
</button>
<button
onClick={() => {
setMode('text')
startConversation()
}}
style={{
background: 'rgba(255,255,255,0.2)',
color: 'white',
border: '2px solid white',
padding: '15px 30px',
borderRadius: '30px',
fontSize: '18px',
fontWeight: 'bold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '10px'
}}
>
💬 Text Chat
</button>
</div>
<p style={{ fontSize: '14px', marginTop: '40px', opacity: 0.7 }}>
Tap Voice Chat and then tap the microphone to talk
</p>
{session && (
<div style={{
marginTop: '30px',
padding: '15px',
background: 'rgba(255,255,255,0.1)',
borderRadius: '15px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px'
}}>
<p style={{ fontSize: '12px', opacity: 0.8 }}>Signed in as:</p>
<p style={{ fontSize: '14px', fontWeight: 'bold' }}>{session.user?.email}</p>
<button
onClick={() => signOut({ callbackUrl: '/auth/signin' })}
style={{
marginTop: '5px',
padding: '8px 20px',
background: 'rgba(255,255,255,0.2)',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '20px',
color: 'white',
fontSize: '12px',
cursor: 'pointer'
}}
>
Switch Account
</button>
</div>
)}
</div>
)
}
// Chat interface
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: '15px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
color: 'white'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '24px' }}>🥯</span>
<div>
<div style={{ fontWeight: 'bold' }}>Dear Bubbe</div>
<div style={{ fontSize: '12px', opacity: 0.8 }}>
{mode === 'voice' ? 'Voice Mode' : 'Text Mode'}
</div>
</div>
</div>
<button
onClick={() => {
// Toggle between text and voice modes
setMode(mode === 'voice' ? 'text' : 'voice')
}}
style={{
background: 'rgba(255,255,255,0.2)',
border: 'none',
color: 'white',
padding: '8px 15px',
borderRadius: '20px',
fontSize: '14px',
cursor: 'pointer'
}}
>
{mode === 'voice' ? '💬 Text' : '🎤 Voice'}
</button>
</div>
{/* Messages */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '15px',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
{messages.length === 0 && (
<div style={{
textAlign: 'center',
color: 'rgba(255,255,255,0.8)',
marginTop: '50px'
}}>
<p style={{ fontSize: '18px' }}>
{mode === 'voice'
? "Bubbe is starting to talk..."
: "Type your message below, bubbeleh!"}
</p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
style={{
alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '80%'
}}
>
<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: '12px 16px',
borderRadius: '18px',
borderBottomRightRadius: message.role === 'user' ? '4px' : '18px',
borderBottomLeftRadius: message.role === 'user' ? '18px' : '4px'
}}>
{message.content}
</div>
</div>
))}
{isLoading && (
<div style={{ alignSelf: 'flex-start', maxWidth: '80%' }}>
<div style={{
background: 'rgba(0,0,0,0.3)',
color: 'white',
padding: '12px 16px',
borderRadius: '18px',
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>
{/* Input area */}
<div style={{
padding: '15px',
background: 'rgba(0,0,0,0.2)',
borderTop: '1px solid rgba(255,255,255,0.2)'
}}>
{mode === 'text' ? (
<div style={{ display: 'flex', gap: '10px' }}>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSendMessage(textInput)
}
}}
placeholder="Type your message..."
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
fontSize: '16px',
borderRadius: '25px',
border: 'none',
background: 'rgba(255,255,255,0.9)',
outline: 'none'
}}
/>
<button
onClick={() => handleSendMessage(textInput)}
disabled={!textInput.trim() || isLoading}
style={{
padding: '12px 20px',
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: '25px',
fontSize: '16px',
fontWeight: 'bold',
cursor: !textInput.trim() || isLoading ? 'default' : 'pointer'
}}
>
Send
</button>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px'
}}>
<div style={{
fontSize: '14px',
color: 'rgba(255,255,255,0.9)',
textAlign: 'center',
minHeight: '20px'
}}>
{isSpeaking ? '🔊 Bubbe is talking...' :
showListeningUI ? '👂 Listening...' :
isLoading ? '🤔 Thinking...' :
hasStarted ? 'Tap to speak' : 'Starting...'}
</div>
<button
onClick={() => {
if (isSpeaking) {
// Stop speaking
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: '80px',
height: '80px',
borderRadius: '50%',
background: showListeningUI ? '#ef4444' : isSpeaking ? '#f59e0b' : 'white',
border: 'none',
fontSize: '30px',
cursor: isLoading ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.3s',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
WebkitTapHighlightColor: 'transparent',
animation: isSpeaking ? 'pulse 1.5s infinite' : showListeningUI ? 'pulse 2s infinite' : 'none'
}}
>
{isSpeaking ? '🔊' : showListeningUI ? '⏹️' : isLoading ? '⏳' : '🎤'}
</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>
)
}