(()=>{ // Minimal Preact-like runtime using raw DOM for simplicity and zero build step. // This replaces the previous Preact SPA with a vanilla JS implementation. const API = '/api/v1'; let token = localStorage.getItem('chat_token'); let myNick = ''; let myUID = 0; let lastQueueID = 0; let pollController = null; let channels = []; // [{name, topic}] let activeTab = null; // '#channel' or 'nick' or 'server' let messages = {}; // target -> [{command,from,to,body,ts,system}] let unread = {}; // target -> count let members = {}; // '#channel' -> [{nick}] function $(sel, parent) { return (parent||document).querySelector(sel); } function $$(sel, parent) { return [...(parent||document).querySelectorAll(sel)]; } function el(tag, attrs, ...children) { const e = document.createElement(tag); if (attrs) Object.entries(attrs).forEach(([k,v]) => { if (k === 'class') e.className = v; else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v); else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v); else e.setAttribute(k, v); }); children.flat(Infinity).forEach(c => { if (c == null) return; e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); return e; } async function api(path, opts = {}) { const headers = {'Content-Type': 'application/json', ...(opts.headers||{})}; if (token) headers['Authorization'] = `Bearer ${token}`; const resp = await fetch(API + path, {...opts, headers, signal: opts.signal}); const data = await resp.json().catch(() => null); if (!resp.ok) throw {status: resp.status, data}; return data; } function nickColor(nick) { let h = 0; for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); return `hsl(${Math.abs(h) % 360}, 70%, 65%)`; } function formatTime(ts) { return new Date(ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); } function addMessage(target, msg) { if (!messages[target]) messages[target] = []; messages[target].push(msg); if (messages[target].length > 500) messages[target] = messages[target].slice(-400); if (target !== activeTab) { unread[target] = (unread[target] || 0) + 1; renderTabs(); } if (target === activeTab) renderMessages(); } function addSystemMessage(target, text) { addMessage(target, {command: 'SYSTEM', from: '*', body: [text], ts: new Date().toISOString(), system: true}); } // --- Rendering --- function renderApp() { const root = $('#root'); root.innerHTML = ''; root.appendChild(el('div', {class:'app'}, el('div', {class:'tab-bar', id:'tabs'}), el('div', {class:'content'}, el('div', {class:'messages-pane'}, el('div', {class:'messages', id:'msg-list'}), el('div', {class:'input-bar', id:'input-bar'}, el('input', {id:'msg-input', placeholder:'Message...', onKeydown: e => { if(e.key==='Enter') sendInput(); }}), el('button', {onClick: sendInput}, 'Send') ) ), el('div', {class:'user-list', id:'user-list'}) ) )); renderTabs(); renderMessages(); renderMembers(); $('#msg-input')?.focus(); } function renderTabs() { const container = $('#tabs'); if (!container) return; container.innerHTML = ''; // Server tab const serverTab = el('div', {class: `tab ${activeTab === 'server' ? 'active' : ''}`, onClick: () => switchTab('server')}, 'Server'); container.appendChild(serverTab); // Channel tabs channels.forEach(ch => { const badge = unread[ch.name] ? ` (${unread[ch.name]})` : ''; const tab = el('div', {class: `tab ${activeTab === ch.name ? 'active' : ''}`}, el('span', {onClick: () => switchTab(ch.name)}, ch.name + badge), el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); partChannel(ch.name); }}, '×') ); container.appendChild(tab); }); // DM tabs Object.keys(messages).filter(k => !k.startsWith('#') && k !== 'server').forEach(nick => { const badge = unread[nick] ? ` (${unread[nick]})` : ''; const tab = el('div', {class: `tab ${activeTab === nick ? 'active' : ''}`}, el('span', {onClick: () => switchTab(nick)}, '→' + nick + badge), el('span', {class:'close-btn', onClick: (e) => { e.stopPropagation(); delete messages[nick]; delete unread[nick]; if(activeTab===nick) switchTab('server'); else renderTabs(); }}, '×') ); container.appendChild(tab); }); // Join input const joinDiv = el('div', {class:'join-dialog'}, el('input', {id:'join-input', placeholder:'#channel', onKeydown: e => { if(e.key==='Enter') joinFromInput(); }}), el('button', {onClick: joinFromInput}, 'Join') ); container.appendChild(joinDiv); } function renderMessages() { const container = $('#msg-list'); if (!container) return; const msgs = messages[activeTab] || []; container.innerHTML = ''; msgs.forEach(m => { const isSystem = m.system || ['JOIN','PART','QUIT','NICK','TOPIC'].includes(m.command); const bodyText = Array.isArray(m.body) ? m.body.join('\n') : (m.body || ''); let displayText = bodyText; if (m.command === 'JOIN') displayText = `${m.from} has joined ${m.to}`; else if (m.command === 'PART') displayText = `${m.from} has left ${m.to}` + (bodyText ? ` (${bodyText})` : ''); else if (m.command === 'QUIT') displayText = `${m.from} has quit` + (bodyText ? ` (${bodyText})` : ''); else if (m.command === 'NICK') displayText = `${m.from} is now known as ${bodyText}`; else if (m.command === 'TOPIC') displayText = `${m.from} set topic: ${bodyText}`; const msgEl = el('div', {class: `message ${isSystem ? 'system' : ''}`}, el('span', {class:'timestamp'}, m.ts ? formatTime(m.ts) : ''), isSystem ? el('span', {class:'nick'}, '*') : el('span', {class:'nick', style:{color: nickColor(m.from)}}, m.from), el('span', {class:'content'}, displayText) ); container.appendChild(msgEl); }); container.scrollTop = container.scrollHeight; } function renderMembers() { const container = $('#user-list'); if (!container) return; if (!activeTab || !activeTab.startsWith('#')) { container.innerHTML = ''; return; } const mems = members[activeTab] || []; container.innerHTML = ''; container.appendChild(el('h3', null, `Users (${mems.length})`)); mems.forEach(m => { container.appendChild(el('div', {class:'user', style:{color: nickColor(m.nick)}, onClick: () => openDM(m.nick)}, m.nick)); }); } function switchTab(target) { activeTab = target; unread[target] = 0; renderTabs(); renderMessages(); renderMembers(); if (activeTab?.startsWith('#')) fetchMembers(activeTab); $('#msg-input')?.focus(); } // --- Actions --- async function joinFromInput() { const input = $('#join-input'); if (!input) return; let name = input.value.trim(); if (!name) return; if (!name.startsWith('#')) name = '#' + name; input.value = ''; try { await api('/messages', {method:'POST', body: JSON.stringify({command:'JOIN', to: name})}); } catch(e) { addSystemMessage('server', `Failed to join ${name}: ${e.data?.error || 'error'}`); } } async function partChannel(name) { try { await api('/messages', {method:'POST', body: JSON.stringify({command:'PART', to: name})}); } catch(e) {} channels = channels.filter(c => c.name !== name); delete members[name]; if (activeTab === name) switchTab('server'); else renderTabs(); } function openDM(nick) { if (nick === myNick) return; if (!messages[nick]) messages[nick] = []; switchTab(nick); } async function sendInput() { const input = $('#msg-input'); if (!input) return; const text = input.value.trim(); if (!text) return; input.value = ''; if (text.startsWith('/')) { const parts = text.split(' '); const cmd = parts[0].toLowerCase(); if (cmd === '/join' && parts[1]) { $('#join-input').value = parts[1]; joinFromInput(); return; } if (cmd === '/part') { if(activeTab?.startsWith('#')) partChannel(activeTab); return; } if (cmd === '/nick' && parts[1]) { try { await api('/messages', {method:'POST', body: JSON.stringify({command:'NICK', body:[parts[1]]})}); } catch(e) { addSystemMessage(activeTab||'server', `Nick change failed: ${e.data?.error || 'error'}`); } 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(e) { addSystemMessage(activeTab||'server', `DM failed: ${e.data?.error || 'error'}`); } return; } if (cmd === '/quit') { try { await api('/messages', {method:'POST', body: JSON.stringify({command:'QUIT'})}); } catch(e) {} localStorage.removeItem('chat_token'); location.reload(); return; } addSystemMessage(activeTab||'server', `Unknown command: ${cmd}`); return; } if (!activeTab || activeTab === 'server') { addSystemMessage('server', 'Select a channel or user to send messages'); return; } try { await api('/messages', {method:'POST', body: JSON.stringify({command:'PRIVMSG', to: activeTab, body:[text]})}); } catch(e) { addSystemMessage(activeTab, `Send failed: ${e.data?.error || 'error'}`); } } async function fetchMembers(channel) { try { const name = channel.replace('#',''); const data = await api(`/channels/${name}/members`); members[channel] = data; renderMembers(); } catch(e) {} } // --- Polling --- async function pollLoop() { while (true) { try { if (pollController) pollController.abort(); pollController = new AbortController(); const data = await api(`/messages?after=${lastQueueID}&timeout=15`, {signal: pollController.signal}); if (data.last_id) lastQueueID = data.last_id; for (const msg of (data.messages || [])) { handleMessage(msg); } } catch(e) { if (e instanceof DOMException && e.name === 'AbortError') continue; if (e.status === 401) { localStorage.removeItem('chat_token'); location.reload(); return; } await new Promise(r => setTimeout(r, 2000)); } } } function handleMessage(msg) { const body = Array.isArray(msg.body) ? msg.body : []; const bodyText = body.join('\n'); switch (msg.command) { case 'PRIVMSG': case 'NOTICE': { let target = msg.to; // DM: if it's to me, show under sender's nick tab if (!target.startsWith('#')) { target = msg.from === myNick ? msg.to : msg.from; if (!messages[target]) messages[target] = []; } addMessage(target, msg); break; } case 'JOIN': { addMessage(msg.to, msg); if (msg.from === myNick) { // We joined a channel if (!channels.find(c => c.name === msg.to)) { channels.push({name: msg.to, topic: ''}); } switchTab(msg.to); fetchMembers(msg.to); } else if (activeTab === msg.to) { fetchMembers(msg.to); } break; } case 'PART': { addMessage(msg.to, msg); if (msg.from === myNick) { channels = channels.filter(c => c.name !== msg.to); if (activeTab === msg.to) switchTab('server'); else renderTabs(); } else if (activeTab === msg.to) { fetchMembers(msg.to); } break; } case 'QUIT': { // Show in all channels where this user might be channels.forEach(ch => { addMessage(ch.name, msg); }); break; } case 'NICK': { const newNick = body[0] || ''; if (msg.from === myNick) { myNick = newNick; addSystemMessage(activeTab || 'server', `You are now known as ${newNick}`); } else { channels.forEach(ch => { addMessage(ch.name, msg); }); } break; } case 'TOPIC': { addMessage(msg.to, msg); const ch = channels.find(c => c.name === msg.to); if (ch) ch.topic = bodyText; break; } default: addSystemMessage('server', `[${msg.command}] ${bodyText}`); } } // --- Login --- function renderLogin() { const root = $('#root'); root.innerHTML = ''; let serverName = 'Chat'; let motd = ''; api('/server').then(data => { if (data.name) { serverName = data.name; $('h1', root).textContent = serverName; } if (data.motd) { motd = data.motd; const m = $('.motd', root); if(m) m.textContent = motd; } }).catch(() => {}); const form = el('form', {class:'login-screen', onSubmit: async (e) => { e.preventDefault(); const nick = $('input', form).value.trim(); if (!nick) return; const errEl = $('.error', form); if (errEl) errEl.textContent = ''; try { const data = await api('/session', {method:'POST', body: JSON.stringify({nick})}); token = data.token; myNick = data.nick; myUID = data.id; localStorage.setItem('chat_token', token); startApp(); } catch(err) { const errEl = $('.error', form) || form.appendChild(el('div', {class:'error'})); errEl.textContent = err.data?.error || 'Connection failed'; } }}, el('h1', null, serverName), motd ? el('div', {class:'motd'}, motd) : null, el('input', {type:'text', placeholder:'Choose a nickname...', maxLength:'32', autofocus:'true'}), el('button', {type:'submit'}, 'Connect'), el('div', {class:'error'}) ); root.appendChild(form); $('input', form)?.focus(); } async function startApp() { messages = {server: []}; unread = {}; channels = []; activeTab = 'server'; lastQueueID = 0; addSystemMessage('server', `Connected as ${myNick}`); // Fetch server info try { const info = await api('/server'); if (info.motd) addSystemMessage('server', `MOTD: ${info.motd}`); } catch(e) {} // Fetch current state (channels we're already in) try { const state = await api('/state'); myNick = state.nick; myUID = state.id; if (state.channels) { state.channels.forEach(ch => { channels.push({name: ch.name, topic: ch.topic}); if (!messages[ch.name]) messages[ch.name] = []; }); if (channels.length > 0) switchTab(channels[0].name); } } catch(e) {} renderApp(); pollLoop(); } // --- Init --- if (token) { // Try to resume session api('/state').then(data => { myNick = data.nick; myUID = data.id; startApp(); }).catch(() => { localStorage.removeItem('chat_token'); token = null; renderLogin(); }); } else { renderLogin(); } })();