Reuben_OS / app /components /Messages.tsx
Reubencf's picture
Fix quiz system: separate answers, backward compat, and bug fixes
7f6d612
'use client'
import React, { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { PaperPlaneRight, MagnifyingGlass, UserCircle, WarningCircle, List, X } from '@phosphor-icons/react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import Window from './Window'
import { useKV } from '../hooks/useKV'
interface Message {
id: string
text: string
sender: string
userId: string
timestamp: number
}
interface MessagesProps {
onClose: () => void
onMinimize?: () => void
onMaximize?: () => void
onFocus?: () => void
zIndex?: number
}
export function Messages({ onClose, onMinimize, onMaximize, onFocus, zIndex }: MessagesProps) {
const [messages, setMessages] = useState<Message[]>([])
const [inputText, setInputText] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const [sidebarOpen, setSidebarOpen] = useState(false)
// Persistent user identity
const [userId] = useKV<string>('messages-user-id', `user-${Math.random().toString(36).substring(2, 9)}`)
const [userName, setUserName] = useKV<string>('messages-user-name', 'Guest')
const [isEditingName, setIsEditingName] = useState(false)
const [tempName, setTempName] = useState(userName)
const fetchMessages = async () => {
try {
const res = await fetch('/api/messages')
if (res.ok) {
const data = await res.json()
setMessages(data)
}
} catch (err) {
console.error('Failed to fetch messages', err)
}
}
// Poll for new messages
useEffect(() => {
fetchMessages()
const interval = setInterval(fetchMessages, 3000)
return () => clearInterval(interval)
}, [])
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!inputText.trim() || isLoading) return
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: inputText,
sender: userName,
userId: userId
})
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Failed to send message')
} else {
setMessages(data)
setInputText('')
}
} catch (err) {
setError('Network error. Please try again.')
} finally {
setIsLoading(false)
}
}
const handleNameSave = () => {
if (tempName.trim()) {
setUserName(tempName.trim())
setIsEditingName(false)
}
}
return (
<Window
id="messages"
title="Messages"
isOpen={true}
onClose={onClose}
onMinimize={onMinimize}
onMaximize={onMaximize}
onFocus={onFocus}
zIndex={zIndex}
width={800}
height={600}
x={100}
y={50}
className="messages-window !bg-[#1e1e1e]/80 !backdrop-blur-2xl border border-white/10 shadow-2xl !rounded-xl overflow-hidden"
contentClassName="!bg-transparent"
headerClassName="!bg-transparent border-b border-white/5"
>
<div className="flex h-full text-white overflow-hidden relative">
{/* Mobile Sidebar Overlay */}
<AnimatePresence>
{sidebarOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/50 z-20 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
</AnimatePresence>
{/* Sidebar */}
<AnimatePresence>
{(sidebarOpen || typeof window !== 'undefined' && window.innerWidth >= 768) && (
<motion.div
initial={{ x: -256 }}
animate={{ x: 0 }}
exit={{ x: -256 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className={`${sidebarOpen ? 'absolute z-30 h-full' : 'hidden'} md:relative md:flex w-56 sm:w-64 border-r border-white/10 bg-black/90 md:bg-black/20 flex-col`}
>
<div className="p-3 sm:p-4 border-b border-white/5 flex items-center justify-between">
<div className="relative flex-1">
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
<input
type="text"
placeholder="Search"
className="w-full bg-white/10 border-none rounded-md py-1.5 pl-9 pr-3 text-xs text-white placeholder-gray-500 focus:ring-1 focus:ring-blue-500 outline-none"
/>
</div>
<button
onClick={() => setSidebarOpen(false)}
className="ml-2 p-1 hover:bg-white/10 rounded md:hidden"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
<div className="p-2 sm:p-3 rounded-lg bg-blue-600/20 border border-blue-500/30 cursor-pointer">
<div className="flex justify-between items-start">
<span className="font-semibold text-xs sm:text-sm">Global Chat</span>
<span className="text-[9px] sm:text-[10px] text-gray-400">Now</span>
</div>
<div className="text-[10px] sm:text-xs text-gray-400 mt-1 truncate">
{messages.length > 0 ? messages[messages.length - 1].text.replace(/[#*`_~\[\]]/g, '') : 'No messages yet'}
</div>
</div>
</div>
{/* User Profile */}
<div className="p-3 sm:p-4 border-t border-white/10 bg-black/10">
{isEditingName ? (
<div className="flex gap-2">
<input
type="text"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
className="flex-1 bg-white/10 rounded px-2 py-1 text-sm outline-none border border-blue-500 min-w-0"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleNameSave()}
/>
<button onClick={handleNameSave} className="text-xs text-blue-400 font-medium flex-shrink-0">Save</button>
</div>
) : (
<div className="flex items-center gap-2 sm:gap-3 cursor-pointer hover:bg-white/5 p-1.5 sm:p-2 rounded-md transition-colors" onClick={() => {
setTempName(userName)
setIsEditingName(true)
}}>
<UserCircle size={28} className="text-gray-400 sm:w-8 sm:h-8 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs sm:text-sm font-medium truncate">{userName}</div>
<div className="text-[9px] sm:text-[10px] text-gray-500">Click to change name</div>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Chat Area */}
<div className="flex-1 flex flex-col bg-transparent relative min-w-0">
{/* Header */}
<div className="h-12 sm:h-14 border-b border-white/5 flex items-center px-3 sm:px-6 bg-[#252525]/30 backdrop-blur-sm justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<button
onClick={() => setSidebarOpen(true)}
className="p-1.5 hover:bg-white/10 rounded md:hidden flex-shrink-0"
>
<List size={18} />
</button>
<div className="flex flex-col min-w-0">
<span className="text-xs sm:text-sm font-semibold truncate">To: Everyone</span>
<span className="text-[9px] sm:text-[10px] text-gray-400">Global Channel</span>
</div>
</div>
<div className="hidden xs:flex items-center gap-1 sm:gap-2 text-orange-400 bg-orange-400/10 px-2 sm:px-3 py-1 rounded-full border border-orange-400/20 flex-shrink-0">
<WarningCircle size={12} className="sm:w-3.5 sm:h-3.5" />
<span className="text-[8px] sm:text-[10px] font-medium whitespace-nowrap">Public Chat</span>
</div>
</div>
{/* Messages List */}
<div className="flex-1 overflow-y-auto p-3 sm:p-6 space-y-1 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2">
{messages.map((msg, index) => {
const isMe = msg.userId === userId
const showHeader = index === 0 || messages[index - 1].userId !== msg.userId
const showTimestamp = index === 0 || (msg.timestamp - messages[index - 1].timestamp > 300000) // 5 mins
return (
<React.Fragment key={msg.id}>
{showTimestamp && (
<div className="text-center my-3 sm:my-4">
<span className="text-[9px] sm:text-[10px] text-gray-500 font-medium">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
)}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex flex-col ${isMe ? 'items-end' : 'items-start'} ${showHeader ? 'mt-2' : 'mt-0.5'}`}
>
{showHeader && !isMe && (
<span className="text-[9px] sm:text-[10px] text-gray-500 ml-2 sm:ml-3 mb-0.5">{msg.sender}</span>
)}
<div
className={`
max-w-[85%] sm:max-w-[70%] px-2.5 sm:px-3 py-1.5 text-xs sm:text-[13px] leading-relaxed break-words relative group select-text
${isMe
? 'bg-[#0A84FF] text-white rounded-2xl rounded-br-sm message-bubble-sent'
: 'bg-[#3a3a3a] text-gray-100 rounded-2xl rounded-bl-sm message-bubble-received'}
`}
>
<div className="markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
{msg.text}
</ReactMarkdown>
</div>
{/* Tail for message bubbles */}
{isMe && showHeader && (
<svg className="absolute -bottom-[1px] -right-[6px] w-4 h-4 text-[#0A84FF]" viewBox="0 0 20 20" fill="currentColor">
<path d="M0 20C5 20 8 15 10 10V20H0Z" />
</svg>
)}
{!isMe && showHeader && (
<svg className="absolute -bottom-[1px] -left-[6px] w-4 h-4 text-[#3a3a3a] transform scale-x-[-1]" viewBox="0 0 20 20" fill="currentColor">
<path d="M0 20C5 20 8 15 10 10V20H0Z" />
</svg>
)}
</div>
{/* Delivered status for me */}
{isMe && index === messages.length - 1 && (
<span className="text-[9px] sm:text-[10px] text-gray-500 mr-1 mt-0.5 font-medium">Delivered</span>
)}
</motion.div>
</React.Fragment>
)
})}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-2 sm:p-4 bg-[#1e1e1e]/50 border-t border-white/10 backdrop-blur-md">
{error && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex items-center gap-2 text-red-400 text-[10px] sm:text-xs mb-2 px-2 justify-center"
>
<WarningCircle size={12} className="sm:w-3.5 sm:h-3.5" />
{error}
</motion.div>
)}
<form onSubmit={handleSend} className="relative flex items-center gap-2 sm:gap-3 max-w-3xl mx-auto w-full">
<div className="flex-1 relative">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend(e)
}
}}
onPaste={(e) => {
// Allow paste - default behavior
e.stopPropagation()
}}
placeholder="iMessage"
maxLength={200}
disabled={isLoading}
className="w-full bg-[#2a2a2a] border border-white/10 rounded-full py-1.5 sm:py-2 pl-3 sm:pl-4 pr-9 sm:pr-10 text-xs sm:text-sm text-white placeholder-gray-500 focus:outline-none focus:border-gray-500 transition-colors select-text"
/>
<button
type="submit"
disabled={!inputText.trim() || isLoading}
className={`
absolute right-1 top-1/2 -translate-y-1/2 p-1 sm:p-1.5 rounded-full transition-all
${inputText.trim() && !isLoading ? 'bg-[#0A84FF] text-white scale-100' : 'bg-gray-600 text-gray-400 scale-90 opacity-0 pointer-events-none'}
`}
>
<PaperPlaneRight size={12} className="sm:w-3.5 sm:h-3.5" weight="fill" />
</button>
</div>
</form>
<div className="text-[8px] sm:text-[9px] text-gray-600 text-center mt-1 sm:mt-2 font-medium">
{inputText.length}/200 • Enter to send
</div>
</div>
</div> </div>
</Window>
)
}