Spaces:
Sleeping
Sleeping
| import React, { useEffect, useMemo, useRef, useState } from 'react'; | |
| import { useToasts } from './Toasts.jsx'; | |
| export default function Chat({ socket, roomId, name, isHost, members }) { | |
| const { push } = useToasts(); | |
| const [messages, setMessages] = useState([]); | |
| const [text, setText] = useState(''); | |
| const inputRef = useRef(null); | |
| const logRef = useRef(null); | |
| const [showMentions, setShowMentions] = useState(false); | |
| const [mentionQuery, setMentionQuery] = useState(''); | |
| const [mentionAtIndex, setMentionAtIndex] = useState(-1); | |
| const mentionCandidates = useMemo(() => { | |
| const q = (mentionQuery || '').toLowerCase(); | |
| return (members || []).filter(m => m.name.toLowerCase().includes(q)).slice(0, 8); | |
| }, [mentionQuery, members]); | |
| useEffect(() => { | |
| const onMsg = (m) => setMessages((prev) => [...prev, m]); | |
| const onSys = (m) => setMessages((prev) => [...prev, { name: 'System', text: m.text || 'System message', at: Date.now(), system: true }]); | |
| socket.on('chat_message', onMsg); | |
| socket.on('system', onSys); | |
| return () => { socket.off('chat_message', onMsg); socket.off('system', onSys); }; | |
| }, [socket]); | |
| useEffect(() => { | |
| if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; | |
| }, [messages]); | |
| const parseCommand = (t) => { | |
| const raw = t.trim(); | |
| const lower = raw.toLowerCase(); | |
| if (lower.startsWith('/play ')) return { cmd: 'play', arg: raw.slice(6).trim() }; | |
| if (lower.startsWith('/kick ')) return { cmd: 'kick', arg: raw.slice(6).trim().replace(/^@/, '') }; | |
| if (lower.startsWith('/promote ')) return { cmd: 'promote', arg: raw.slice(9).trim().replace(/^@/, '') }; | |
| if (lower.startsWith('/mute ')) return { cmd: 'mute', arg: raw.slice(6).trim().replace(/^@/, '') }; | |
| return null; | |
| }; | |
| const handleSend = () => { | |
| const t = text.trim(); | |
| if (!t) return; | |
| const parsed = parseCommand(t); | |
| if (parsed) { | |
| if (parsed.cmd === 'play') { | |
| const query = parsed.arg; | |
| if (!query) return; | |
| socket.emit('song_request', { roomId, requester: name, query }); | |
| setMessages(prev => [...prev, { name: 'You', text: `Requested: ${query}`, at: Date.now(), system: true }]); | |
| setText(''); | |
| setShowMentions(false); | |
| return; | |
| } | |
| if (parsed.cmd === 'kick' || parsed.cmd === 'promote' || parsed.cmd === 'mute') { | |
| if (!isHost) push('Only host/co-host can run admin commands', 'warn'); | |
| else if (!parsed.arg) push('Provide a username like /kick @username', 'warn'); | |
| else socket.emit('admin_command', { roomId, cmd: parsed.cmd, targetName: parsed.arg }); | |
| setText(''); | |
| setShowMentions(false); | |
| return; | |
| } | |
| } | |
| const m = { name, text: t, at: Date.now() }; | |
| setMessages((prev) => [...prev, m]); | |
| socket.emit('chat_message', { roomId, name, text: t }); | |
| setText(''); | |
| setShowMentions(false); | |
| }; | |
| const onInputChange = (e) => { | |
| const val = e.target.value; | |
| const caret = e.target.selectionStart ?? val.length; | |
| setText(val); | |
| let at = -1; | |
| for (let i = caret - 1; i >= 0; i--) { | |
| if (val[i] === '@') { at = i; break; } | |
| if (/\s/.test(val[i])) break; | |
| } | |
| if (at >= 0) { | |
| const q = val.slice(at + 1, caret); | |
| setMentionAtIndex(at); | |
| setMentionQuery(q); | |
| setShowMentions(true); | |
| } else { | |
| setShowMentions(false); | |
| setMentionAtIndex(-1); | |
| setMentionQuery(''); | |
| } | |
| }; | |
| const chooseMention = (m) => { | |
| if (!inputRef.current) return; | |
| const el = inputRef.current; | |
| const val = text; | |
| const caret = el.selectionStart ?? val.length; | |
| const at = mentionAtIndex >= 0 ? mentionAtIndex : val.lastIndexOf('@', caret); | |
| if (at < 0) return; | |
| const before = val.slice(0, at); | |
| const nextSpace = val.slice(at + 1).search(/\s/); | |
| const end = nextSpace < 0 ? val.length : (at + 1 + nextSpace); | |
| const after = val.slice(end); | |
| const inserted = `${before}@${m.name} `; | |
| const nextVal = inserted + after; | |
| setText(nextVal); | |
| const newPos = inserted.length; | |
| setTimeout(() => { el.focus(); el.setSelectionRange(newPos, newPos); }, 0); | |
| setShowMentions(false); | |
| setMentionQuery(''); | |
| setMentionAtIndex(-1); | |
| }; | |
| return ( | |
| <div className="chat" style={{ position: 'relative' }}> | |
| <div ref={logRef} className="chat-log"> | |
| {messages.map((m, i) => ( | |
| <div key={i} style={{ marginBottom:6 }}> | |
| <b style={{ color: m.system ? 'var(--accent)' : 'var(--accent-2)' }}>{m.name}:</b> {m.text} | |
| </div> | |
| ))} | |
| </div> | |
| <div className="chat-input"> | |
| <input | |
| ref={inputRef} | |
| className="input" | |
| value={text} | |
| onChange={onInputChange} | |
| placeholder="Type — /play <song>, /kick @user, /promote @user, /mute @user" | |
| /> | |
| <button className="btn primary" onClick={handleSend}>Send</button> | |
| </div> | |
| {showMentions && ( | |
| <div className="mentions" style={{ left: 8, bottom: 52 }}> | |
| {mentionCandidates.length ? mentionCandidates.map(m => ( | |
| <div key={m.id} className="item" onClick={() => chooseMention(m)}> | |
| @{m.name} {m.role !== 'member' ? `(${m.role})` : ''} | |
| </div> | |
| )) : <div className="item" style={{ opacity:.7 }}>No matches</div>} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |