import { h, render, Component } from 'preact'; import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; const API = '/api/v1'; function api(path, opts = {}) { const token = localStorage.getItem('chat_token'); const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; if (token) headers['Authorization'] = `Bearer ${token}`; return fetch(API + path, { ...opts, headers }).then(async r => { const data = await r.json().catch(() => null); if (!r.ok) throw { status: r.status, data }; return data; }); } function formatTime(ts) { const d = new Date(ts); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } // Nick color hashing function nickColor(nick) { let h = 0; for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); const hue = Math.abs(h) % 360; return `hsl(${hue}, 70%, 65%)`; } function LoginScreen({ onLogin }) { const [nick, setNick] = useState(''); const [error, setError] = useState(''); const [motd, setMotd] = useState(''); const [serverName, setServerName] = useState('Chat'); const inputRef = useRef(); useEffect(() => { api('/server').then(s => { if (s.name) setServerName(s.name); if (s.motd) setMotd(s.motd); }).catch(() => {}); // Check for saved token const saved = localStorage.getItem('chat_token'); if (saved) { api('/state').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token')); } inputRef.current?.focus(); }, []); const submit = async (e) => { e.preventDefault(); setError(''); try { const res = await api('/session', { method: 'POST', body: JSON.stringify({ nick: nick.trim() }) }); localStorage.setItem('chat_token', res.token); onLogin(res.nick, res.token); } catch (err) { setError(err.data?.error || 'Connection failed'); } }; return (

{serverName}

{motd &&
{motd}
}
setNick(e.target.value)} maxLength={32} autoFocus />
{error &&
{error}
}
); } function Message({ msg }) { return (
{formatTime(msg.createdAt)} {msg.nick} {msg.content}
); } function App() { const [loggedIn, setLoggedIn] = useState(false); const [nick, setNick] = useState(''); const [tabs, setTabs] = useState([{ type: 'server', name: 'Server' }]); const [activeTab, setActiveTab] = useState(0); const [messages, setMessages] = useState({ server: [] }); // keyed by tab name const [members, setMembers] = useState({}); // keyed by channel name const [input, setInput] = useState(''); const [joinInput, setJoinInput] = useState(''); const [lastMsgId, setLastMsgId] = useState(0); const messagesEndRef = useRef(); const inputRef = useRef(); const pollRef = useRef(); const addMessage = useCallback((tabName, msg) => { setMessages(prev => ({ ...prev, [tabName]: [...(prev[tabName] || []), msg] })); }, []); const addSystemMessage = useCallback((tabName, text) => { addMessage(tabName, { id: Date.now(), nick: '*', content: text, createdAt: new Date().toISOString(), system: true }); }, [addMessage]); const onLogin = useCallback((userNick, token) => { setNick(userNick); setLoggedIn(true); addSystemMessage('server', `Connected as ${userNick}`); // Fetch server info api('/server').then(s => { if (s.motd) addSystemMessage('server', `MOTD: ${s.motd}`); }).catch(() => {}); }, [addSystemMessage]); // Poll for new messages useEffect(() => { if (!loggedIn) return; let alive = true; const poll = async () => { try { const msgs = await api(`/messages?after=${lastMsgId}`); if (!alive) return; let maxId = lastMsgId; for (const msg of msgs) { if (msg.id > maxId) maxId = msg.id; if (msg.isDm) { const dmTab = msg.nick === nick ? msg.dmTarget : msg.nick; // Ensure DM tab exists setTabs(prev => { if (!prev.find(t => t.type === 'dm' && t.name === dmTab)) { return [...prev, { type: 'dm', name: dmTab }]; } return prev; }); addMessage(dmTab, msg); } else if (msg.channel) { addMessage(msg.channel, msg); } } if (maxId > lastMsgId) setLastMsgId(maxId); } catch (err) { // silent } }; pollRef.current = setInterval(poll, 1500); poll(); return () => { alive = false; clearInterval(pollRef.current); }; }, [loggedIn, lastMsgId, nick, addMessage]); // Fetch members for active channel tab useEffect(() => { if (!loggedIn) return; const tab = tabs[activeTab]; if (!tab || tab.type !== 'channel') return; const chName = tab.name.replace('#', ''); api(`/channels/${chName}/members`).then(m => { setMembers(prev => ({ ...prev, [tab.name]: m })); }).catch(() => {}); const iv = setInterval(() => { api(`/channels/${chName}/members`).then(m => { setMembers(prev => ({ ...prev, [tab.name]: m })); }).catch(() => {}); }, 5000); return () => clearInterval(iv); }, [loggedIn, activeTab, tabs]); // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, activeTab]); // Focus input on tab change useEffect(() => { inputRef.current?.focus(); }, [activeTab]); const joinChannel = async (name) => { if (!name) return; name = name.trim(); if (!name.startsWith('#')) name = '#' + name; try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: name }) }); setTabs(prev => { if (prev.find(t => t.type === 'channel' && t.name === name)) return prev; return [...prev, { type: 'channel', name }]; }); setActiveTab(tabs.length); // switch to new tab addSystemMessage(name, `Joined ${name}`); setJoinInput(''); } catch (err) { addSystemMessage('server', `Failed to join ${name}: ${err.data?.error || 'error'}`); } }; const partChannel = async (name) => { try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) }); } catch (err) { /* ignore */ } setTabs(prev => { const next = prev.filter(t => !(t.type === 'channel' && t.name === name)); return next; }); setActiveTab(0); }; const closeTab = (idx) => { const tab = tabs[idx]; if (tab.type === 'channel') { partChannel(tab.name); } else if (tab.type === 'dm') { setTabs(prev => prev.filter((_, i) => i !== idx)); if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1)); } }; const openDM = (targetNick) => { setTabs(prev => { if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev; return [...prev, { type: 'dm', name: targetNick }]; }); setActiveTab(tabs.findIndex(t => t.type === 'dm' && t.name === targetNick) || tabs.length); }; const sendMessage = async () => { const text = input.trim(); if (!text) return; setInput(''); const tab = tabs[activeTab]; if (!tab || tab.type === 'server') return; // Handle /commands if (text.startsWith('/')) { const parts = text.split(' '); const cmd = parts[0].toLowerCase(); if (cmd === '/join' && parts[1]) { joinChannel(parts[1]); return; } if (cmd === '/part') { if (tab.type === 'channel') partChannel(tab.name); return; } if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { const target = parts[1]; const msg = parts.slice(2).join(' '); try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [msg] }) }); openDM(target); } catch (err) { addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`); } return; } if (cmd === '/nick' && parts[1]) { try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) }); setNick(parts[1]); addSystemMessage('server', `Nick changed to ${parts[1]}`); } catch (err) { addSystemMessage('server', `Nick change failed: ${err.data?.error || 'error'}`); } return; } addSystemMessage('server', `Unknown command: ${cmd}`); return; } const to = tab.type === 'channel' ? tab.name : tab.name; try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to, body: [text] }) }); } catch (err) { addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); } }; if (!loggedIn) return ; const currentTab = tabs[activeTab] || tabs[0]; const currentMessages = messages[currentTab.name] || []; const currentMembers = members[currentTab.name] || []; return (
{tabs.map((tab, i) => (
setActiveTab(i)} > {tab.type === 'dm' ? `→${tab.name}` : tab.name} {tab.type !== 'server' && ( { e.stopPropagation(); closeTab(i); }}>× )}
))}
setJoinInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)} />
{currentTab.type === 'server' ? (
{currentMessages.map(m => )}
) : ( <>
{currentMessages.map(m => )}
setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && sendMessage()} />
)}
{currentTab.type === 'channel' && (

Users ({currentMembers.length})

{currentMembers.map(u => (
openDM(u.nick)} style={{ color: nickColor(u.nick) }}> {u.nick}
))}
)}
); } render(, document.getElementById('root'));