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}
}
{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'));