Spaces:
Running
Running
| '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> | |
| ) | |
| } | |