Spaces:
Running
Running
| import { useState, useEffect, useRef } from 'react'; | |
| import { apiClient } from 'app'; | |
| import { ChatMessage } from 'types'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { ScrollArea } from '@/components/ui/scroll-area'; | |
| import { MessageCircle, X, Send, Lock, MessageSquare, ChevronRight } from 'lucide-react'; | |
| import { toast } from 'sonner'; | |
| interface Props { | |
| tableId: string; | |
| currentUserId: string; | |
| players: Array<{ userId: string; displayName: string }>; | |
| } | |
| export default function ChatSidebar({ tableId, currentUserId, players }: Props) { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [messageText, setMessageText] = useState(''); | |
| const [recipient, setRecipient] = useState<string | null>(null); | |
| const [unreadCount, setUnreadCount] = useState(0); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const inputRef = useRef<HTMLInputElement>(null); | |
| // Auto-scroll to bottom when new messages arrive | |
| useEffect(() => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| } | |
| }, [messages]); | |
| // Poll for new messages every 2 seconds | |
| useEffect(() => { | |
| const fetchMessages = async () => { | |
| if (!tableId) return; | |
| try { | |
| const response = await apiClient.get_messages({ table_id: tableId }); | |
| const data = await response.json(); | |
| setMessages(data.messages || []); | |
| // Update unread count if sidebar is closed | |
| if (!isOpen) { | |
| const newMessages = (data.messages || []).filter((msg: ChatMessage) => | |
| msg.user_id !== currentUserId && | |
| new Date(msg.timestamp).getTime() > Date.now() - 4000 // Match polling interval | |
| ); | |
| setUnreadCount(prev => prev + newMessages.length); | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch chat messages:', error); | |
| } | |
| }; | |
| fetchMessages(); | |
| const interval = setInterval(fetchMessages, 4000); // Increased from 2000ms to 4000ms (4 seconds) | |
| return () => clearInterval(interval); | |
| }, [tableId, isOpen, currentUserId]); | |
| // Clear unread count when sidebar opens | |
| useEffect(() => { | |
| if (isOpen) { | |
| setUnreadCount(0); | |
| } | |
| }, [isOpen]); | |
| const sendMessage = async () => { | |
| if (!messageText.trim()) return; | |
| try { | |
| const response = await apiClient.send_message({ | |
| table_id: tableId, | |
| message: messageText, | |
| is_private: !!recipient, | |
| recipient_id: recipient || undefined, | |
| }); | |
| const newMessage = await response.json(); | |
| setMessages([...messages, newMessage]); | |
| setMessageText(''); | |
| setRecipient(null); | |
| } catch (error) { | |
| console.error('Failed to send message:', error); | |
| toast.error('Failed to send message'); | |
| } | |
| }; | |
| const handleKeyPress = (e: React.KeyboardEvent) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }; | |
| // Handle @ mentions | |
| const handleInputChange = (value: string) => { | |
| setMessageText(value); | |
| // Check for @ mention at the start | |
| const mentionMatch = value.match(/^@(\w+)/); | |
| if (mentionMatch) { | |
| const mentionedName = mentionMatch[1].toLowerCase(); | |
| const player = players.find(p => | |
| p.displayName.toLowerCase().startsWith(mentionedName) | |
| ); | |
| if (player && player.userId !== currentUserId) { | |
| setRecipient(player.userId); | |
| } | |
| } else { | |
| setRecipient(null); | |
| } | |
| }; | |
| const getRecipientName = () => { | |
| if (!recipient) return null; | |
| return players.find(p => p.userId === recipient)?.displayName; | |
| }; | |
| const formatTimestamp = (timestamp: string) => { | |
| const date = new Date(timestamp); | |
| return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); | |
| }; | |
| return ( | |
| <> | |
| {/* Toggle Button - Matches GameRules style */} | |
| {!isOpen && ( | |
| <button | |
| onClick={() => { | |
| setIsOpen(true); | |
| setUnreadCount(0); | |
| }} | |
| className="fixed top-20 right-4 z-40 bg-blue-800 hover:bg-blue-700 text-blue-100 px-3 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm transition-all" | |
| > | |
| <ChevronRight className="w-4 h-4" /> | |
| Chat | |
| {unreadCount > 0 && ( | |
| <span className="ml-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"> | |
| {unreadCount > 9 ? '9+' : unreadCount} | |
| </span> | |
| )} | |
| </button> | |
| )} | |
| {/* Sidebar Panel */} | |
| {isOpen && ( | |
| <div className="fixed right-0 top-0 h-full w-80 bg-background border-l border-border shadow-lg z-50 flex flex-col"> | |
| {/* Header */} | |
| <div className="p-4 border-b border-border flex items-center justify-between"> | |
| <h2 className="text-lg font-semibold flex items-center gap-2"> | |
| <MessageCircle className="h-5 w-5" /> | |
| Chat | |
| </h2> | |
| <Button | |
| onClick={() => setIsOpen(false)} | |
| variant="ghost" | |
| size="icon" | |
| > | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| {/* Messages */} | |
| <ScrollArea className="flex-1 p-4" ref={scrollRef}> | |
| <div className="space-y-3"> | |
| {messages.map((msg) => { | |
| const isOwnMessage = msg.user_id === currentUserId; | |
| const isPrivate = msg.is_private; | |
| const isRecipient = msg.recipient_id === currentUserId; | |
| const isSender = msg.user_id === currentUserId; | |
| return ( | |
| <div | |
| key={msg.id} | |
| className={`flex flex-col ${ | |
| isOwnMessage ? 'items-end' : 'items-start' | |
| }`} | |
| > | |
| <div className="flex items-center gap-1 text-xs text-muted-foreground mb-1"> | |
| {isPrivate && <Lock className="h-3 w-3" />} | |
| <span className="font-medium">{msg.sender_name}</span> | |
| <span>{formatTimestamp(msg.created_at)}</span> | |
| </div> | |
| <div | |
| className={`max-w-[85%] rounded-lg px-3 py-2 ${ | |
| isOwnMessage | |
| ? 'bg-primary text-primary-foreground' | |
| : 'bg-muted' | |
| } ${ | |
| isPrivate | |
| ? 'border-2 border-yellow-500 dark:border-yellow-600' | |
| : '' | |
| }`} | |
| > | |
| {isPrivate && (isSender || isRecipient) && ( | |
| <div className="text-xs italic opacity-80 mb-1"> | |
| {isSender | |
| ? `To: ${players.find(p => p.userId === msg.recipient_id)?.displayName}` | |
| : 'Private message'} | |
| </div> | |
| )} | |
| <p className="text-sm break-words">{msg.message}</p> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </ScrollArea> | |
| {/* Input Area */} | |
| <div className="p-4 border-t border-border"> | |
| {recipient && ( | |
| <div className="mb-2 flex items-center gap-2 text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 px-2 py-1 rounded"> | |
| <Lock className="h-3 w-3" /> | |
| <span>Private message to {getRecipientName()}</span> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-4 w-4 ml-auto" | |
| onClick={() => { | |
| setRecipient(null); | |
| setMessageText(''); | |
| }} | |
| > | |
| <X className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| )} | |
| <div className="flex items-center gap-2"> | |
| <Input | |
| ref={inputRef} | |
| value={messageText} | |
| onChange={(e) => handleInputChange(e.target.value)} | |
| onKeyPress={handleKeyPress} | |
| placeholder="Type a message... (@name for private)" | |
| className="flex-1" | |
| /> | |
| <Button | |
| onClick={sendMessage} | |
| disabled={!messageText.trim()} | |
| size="icon" | |
| > | |
| <Send className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-2"> | |
| Tip: Type @username to send a private message | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ); | |
| } | |