import { h, render } from 'preact'; import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; const API = '/api/v1'; const POLL_TIMEOUT = 15; const RECONNECT_DELAY = 3000; const MEMBER_REFRESH_INTERVAL = 10000; function api(path, opts = {}) { const token = localStorage.getItem('chat_token'); const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; if (token) headers['Authorization'] = `Bearer ${token}`; const { signal, ...rest } = opts; return fetch(API + path, { ...rest, headers, signal }).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' }); } 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(() => {}); const saved = localStorage.getItem('chat_token'); if (saved) { api('/state').then(u => onLogin(u.nick)).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); } catch (err) { setError(err.data?.error || 'Connection failed'); } }; return (

{serverName}

{motd &&
{motd}
}
setNick(e.target.value)} maxLength={32} autoFocus />
{error &&
{error}
}
); } function Message({ msg }) { if (msg.system) { return (
{formatTime(msg.ts)} {msg.text}
); } return (
{formatTime(msg.ts)} {msg.from} {msg.text}
); } 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: [] }); const [members, setMembers] = useState({}); const [topics, setTopics] = useState({}); const [unread, setUnread] = useState({}); const [input, setInput] = useState(''); const [joinInput, setJoinInput] = useState(''); const [connected, setConnected] = useState(true); const lastIdRef = useRef(0); const seenIdsRef = useRef(new Set()); const pollAbortRef = useRef(null); const tabsRef = useRef(tabs); const activeTabRef = useRef(activeTab); const nickRef = useRef(nick); const messagesEndRef = useRef(); const inputRef = useRef(); useEffect(() => { tabsRef.current = tabs; }, [tabs]); useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]); useEffect(() => { nickRef.current = nick; }, [nick]); // Persist joined channels useEffect(() => { const channels = tabs.filter(t => t.type === 'channel').map(t => t.name); localStorage.setItem('chat_channels', JSON.stringify(channels)); }, [tabs]); // Clear unread on tab switch useEffect(() => { const tab = tabs[activeTab]; if (tab) setUnread(prev => ({ ...prev, [tab.name]: 0 })); }, [activeTab, tabs]); const addMessage = useCallback((tabName, msg) => { if (msg.id && seenIdsRef.current.has(msg.id)) return; if (msg.id) seenIdsRef.current.add(msg.id); setMessages(prev => ({ ...prev, [tabName]: [...(prev[tabName] || []), msg] })); const currentTab = tabsRef.current[activeTabRef.current]; if (!currentTab || currentTab.name !== tabName) { setUnread(prev => ({ ...prev, [tabName]: (prev[tabName] || 0) + 1 })); } }, []); const addSystemMessage = useCallback((tabName, text) => { setMessages(prev => ({ ...prev, [tabName]: [...(prev[tabName] || []), { id: 'sys-' + Date.now() + '-' + Math.random(), ts: new Date().toISOString(), text, system: true }] })); }, []); const refreshMembers = useCallback((channel) => { const chName = channel.replace('#', ''); api(`/channels/${chName}/members`).then(m => { setMembers(prev => ({ ...prev, [channel]: m })); }).catch(() => {}); }, []); const processMessage = useCallback((msg) => { const body = Array.isArray(msg.body) ? msg.body.join('\n') : ''; const base = { id: msg.id, ts: msg.ts, from: msg.from, to: msg.to, command: msg.command }; switch (msg.command) { case 'PRIVMSG': case 'NOTICE': { const parsed = { ...base, text: body, system: false }; const target = msg.to; if (target && target.startsWith('#')) { addMessage(target, parsed); } else { const dmPeer = msg.from === nickRef.current ? msg.to : msg.from; setTabs(prev => { if (!prev.find(t => t.type === 'dm' && t.name === dmPeer)) { return [...prev, { type: 'dm', name: dmPeer }]; } return prev; }); addMessage(dmPeer, parsed); } break; } case 'JOIN': { const text = `${msg.from} has joined ${msg.to}`; if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); break; } case 'PART': { const reason = body ? ': ' + body : ''; const text = `${msg.from} has left ${msg.to}${reason}`; if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to && msg.to.startsWith('#')) refreshMembers(msg.to); break; } case 'QUIT': { const reason = body ? ': ' + body : ''; const text = `${msg.from} has quit${reason}`; tabsRef.current.forEach(tab => { if (tab.type === 'channel') { addMessage(tab.name, { ...base, text, system: true }); } }); break; } case 'NICK': { const newNick = Array.isArray(msg.body) ? msg.body[0] : body; const text = `${msg.from} is now known as ${newNick}`; tabsRef.current.forEach(tab => { if (tab.type === 'channel') { addMessage(tab.name, { ...base, text, system: true }); } }); if (msg.from === nickRef.current && newNick) setNick(newNick); // Refresh members in all channels tabsRef.current.forEach(tab => { if (tab.type === 'channel') refreshMembers(tab.name); }); break; } case 'TOPIC': { const text = `${msg.from} set the topic: ${body}`; if (msg.to) { addMessage(msg.to, { ...base, text, system: true }); setTopics(prev => ({ ...prev, [msg.to]: body })); } break; } case '375': case '372': case '376': addMessage('Server', { ...base, text: body, system: true }); break; default: addMessage('Server', { ...base, text: body || msg.command, system: true }); } }, [addMessage, refreshMembers]); // Long-poll loop useEffect(() => { if (!loggedIn) return; let alive = true; const poll = async () => { while (alive) { try { const controller = new AbortController(); pollAbortRef.current = controller; const result = await api( `/messages?after=${lastIdRef.current}&timeout=${POLL_TIMEOUT}`, { signal: controller.signal } ); if (!alive) break; setConnected(true); if (result.messages) { for (const m of result.messages) processMessage(m); } if (result.last_id > lastIdRef.current) { lastIdRef.current = result.last_id; } } catch (err) { if (!alive) break; if (err.name === 'AbortError') continue; setConnected(false); await new Promise(r => setTimeout(r, RECONNECT_DELAY)); } } }; poll(); return () => { alive = false; pollAbortRef.current?.abort(); }; }, [loggedIn, processMessage]); // Refresh members for active channel useEffect(() => { if (!loggedIn) return; const tab = tabs[activeTab]; if (!tab || tab.type !== 'channel') return; refreshMembers(tab.name); const iv = setInterval(() => refreshMembers(tab.name), MEMBER_REFRESH_INTERVAL); return () => clearInterval(iv); }, [loggedIn, activeTab, tabs, refreshMembers]); // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, activeTab]); // Focus input on tab change useEffect(() => { inputRef.current?.focus(); }, [activeTab]); // Fetch topic for active channel useEffect(() => { if (!loggedIn) return; const tab = tabs[activeTab]; if (!tab || tab.type !== 'channel') return; api('/channels').then(channels => { const ch = channels.find(c => c.name === tab.name); if (ch && ch.topic) setTopics(prev => ({ ...prev, [tab.name]: ch.topic })); }).catch(() => {}); }, [loggedIn, activeTab, tabs]); const onLogin = useCallback(async (userNick) => { setNick(userNick); setLoggedIn(true); addSystemMessage('Server', `Connected as ${userNick}`); // Auto-rejoin saved channels const saved = JSON.parse(localStorage.getItem('chat_channels') || '[]'); for (const ch of saved) { try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: ch }) }); setTabs(prev => { if (prev.find(t => t.type === 'channel' && t.name === ch)) return prev; return [...prev, { type: 'channel', name: ch }]; }); } catch (e) { // Channel may not exist anymore } } }, [addSystemMessage]); 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); // Load history try { const hist = await api(`/history?target=${encodeURIComponent(name)}&limit=50`); if (Array.isArray(hist)) { for (const m of hist) processMessage(m); } } catch (e) { // History may be empty } 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 (e) { // Ignore } setTabs(prev => prev.filter(t => !(t.type === 'channel' && t.name === name))); 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 }]; }); const idx = tabs.findIndex(t => t.type === 'dm' && t.name === targetNick); setActiveTab(idx >= 0 ? idx : tabs.length); }; const sendMessage = async () => { const text = input.trim(); if (!text) return; setInput(''); const tab = tabs[activeTab]; if (!tab || tab.type === 'server') return; 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 body = parts.slice(2).join(' '); try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [body] }) }); openDM(target); } catch (err) { addSystemMessage('Server', `DM failed: ${err.data?.error || 'error'}`); } return; } if (cmd === '/nick' && parts[1]) { try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) }); } catch (err) { addSystemMessage('Server', `Nick change failed: ${err.data?.error || 'error'}`); } return; } if (cmd === '/topic' && tab.type === 'channel') { const topicText = parts.slice(1).join(' '); try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'TOPIC', to: tab.name, body: [topicText] }) }); } catch (err) { addSystemMessage('Server', `Topic failed: ${err.data?.error || 'error'}`); } return; } addSystemMessage('Server', `Unknown command: ${cmd}`); return; } try { await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: tab.name, 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] || []; const currentTopic = topics[currentTab.name] || ''; return (
{!connected &&
⚠ Reconnecting...
} {tabs.map((tab, i) => (
setActiveTab(i)} > {tab.type === 'dm' ? `→${tab.name}` : tab.name} {unread[tab.name] > 0 && i !== activeTab && ( {unread[tab.name]} )} {tab.type !== 'server' && ( { e.stopPropagation(); closeTab(i); }}>× )}
))}
setJoinInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)} />
{currentTab.type === 'channel' && currentTopic && (
{currentTopic}
)}
{currentMessages.map(m => )}
{currentTab.type !== 'server' && (
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'));