Major changes: - Consolidated schema into single migration with IRC envelope format - Messages table stores command/from/to/body(JSON)/meta(JSON) per spec - Per-client delivery queues (client_queues table) with fan-out - In-memory broker for long-poll notifications (no busy polling) - GET /messages supports ?after=<queue_id>&timeout=15 long-polling - All commands (JOIN/PART/NICK/TOPIC/QUIT/PING) broadcast events - Channels are ephemeral (deleted when last member leaves) - PRIVMSG to nicks (DMs) fan out to both sender and recipient - SPA rewritten in vanilla JS (no build step needed): - Long-poll via recursive fetch (not setInterval) - IRC envelope parsing with system message display - /nick, /join, /part, /msg, /quit commands - Unread indicators on inactive tabs - DM tabs from user list clicks - Removed unused models package (was for UUID-based schema) - Removed conflicting UUID-based db methods - Increased HTTP write timeout to 60s for long-poll support
465 lines
14 KiB
JavaScript
465 lines
14 KiB
JavaScript
(()=>{
|
||
// 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();
|
||
}
|
||
|
||
})();
|