Skip to content

Commit 9a5de2b

Browse files
ralyodioclaude
andcommitted
feat(ext): Chat tab — in-browser IRC over WebSocket (v3.3.0)
Adds a Chat app page to the AI-sidebar extension (💬 Chat button in the sidebar header opens chat.html). Phase 1 = IRC: - irc.js: minimal IRCv3-over-WebSocket client (text.ircv3.net subprotocol), SASL PLAIN, PING/PONG, channels, PRIVMSG/NOTICE, history caps. Connects directly to Ergo (default wss://irc.profullstack.com/irc) — no gateway. - chat.html/.css/.js: tab strip (Chat / Torrents / qrypt.chat); connect form (server + BBS username + IRC password + channels, remember-on-device); channel list, message pane, composer (/join, /msg). Torrents + qrypt.chat are placeholders for the next phases. Endpoint + SASL details from ~/src/agentbbs docs/irc.md (Ergo WS via Caddy, members-only, SASL PLAIN = BBS name + per-member IRC password). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4c0a00d commit 9a5de2b

31 files changed

Lines changed: 433 additions & 25 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
:root {
2+
--bg:#05070d; --panel:#0b1020; --line:#1b2540; --cyan:#34e7ff; --fg:#cfe8ff;
3+
--muted:#6f86b3; --user:#14233f; --green:#6ee7a8; --warn:#ffcf6e;
4+
}
5+
* { box-sizing: border-box; }
6+
html, body { height:100%; margin:0; }
7+
body { background:var(--bg); color:var(--fg); font:14px/1.55 ui-monospace,"SF Mono",Menlo,monospace;
8+
display:flex; flex-direction:column; }
9+
a { color:var(--cyan); }
10+
11+
.apphdr { display:flex; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--line); }
12+
.logo { width:22px; height:22px; filter:drop-shadow(0 0 6px rgba(52,231,255,.4)); }
13+
.brand { font-weight:800; letter-spacing:1px; }
14+
.accent { color:var(--cyan); }
15+
.tabs { display:flex; gap:6px; margin-left:14px; }
16+
.tab { background:transparent; border:1px solid var(--line); color:var(--muted);
17+
border-radius:8px; padding:5px 12px; cursor:pointer; font:inherit; }
18+
.tab.active { color:var(--fg); border-color:var(--cyan); background:#0b1530; }
19+
20+
.panel { display:none; flex:1; min-height:0; }
21+
.panel.active { display:flex; }
22+
23+
/* connect form */
24+
.connect { max-width:420px; margin:40px auto; padding:24px; display:flex; flex-direction:column; gap:10px; }
25+
.connect h2 { margin:0; letter-spacing:1px; }
26+
.connect .muted, .muted { color:var(--muted); }
27+
.connect label { display:flex; flex-direction:column; gap:4px; font-size:12px; color:var(--muted); }
28+
.connect label.row { flex-direction:row; align-items:center; gap:8px; }
29+
.connect input[type=text], .connect input[type=password] {
30+
background:#04060c; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px; font:inherit; }
31+
.primary { background:var(--cyan); color:#04060c; border:0; border-radius:8px; padding:10px; font-weight:700; cursor:pointer; }
32+
.status { font-size:12px; min-height:16px; }
33+
.status.err { color:var(--warn); } .status.ok { color:var(--green); }
34+
35+
/* client */
36+
.client { flex:1; display:flex; min-height:0; width:100%; }
37+
.chans { width:200px; border-right:1px solid var(--line); display:flex; flex-direction:column; min-height:0; }
38+
.chans-head { display:flex; justify-content:space-between; align-items:center; padding:10px 12px;
39+
border-bottom:1px solid var(--line); font-size:12px; color:var(--green); }
40+
.chans-head button { background:transparent; border:1px solid var(--line); color:var(--muted); border-radius:6px; cursor:pointer; }
41+
#chan-list { list-style:none; margin:0; padding:6px; overflow-y:auto; flex:1; }
42+
#chan-list li { padding:6px 10px; border-radius:6px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
43+
#chan-list li.active { background:#0b1530; color:var(--cyan); }
44+
#chan-list li .unread { color:var(--cyan); float:right; }
45+
.join { padding:8px; border-top:1px solid var(--line); }
46+
.join input { width:100%; background:#04060c; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px; font:inherit; }
47+
48+
.convo { flex:1; display:flex; flex-direction:column; min-height:0; }
49+
.convo-head { padding:10px 14px; border-bottom:1px solid var(--line); color:var(--muted); font-size:13px; }
50+
.msgs { flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:3px; }
51+
.msg { word-wrap:break-word; }
52+
.msg .t { color:var(--muted); font-size:11px; margin-right:6px; }
53+
.msg .n { color:var(--cyan); margin-right:6px; }
54+
.msg.self .n { color:var(--green); }
55+
.msg.sys { color:var(--muted); font-style:italic; }
56+
.msg.notice .n { color:var(--warn); }
57+
.composer { display:flex; gap:8px; padding:10px; border-top:1px solid var(--line); }
58+
.composer input { flex:1; background:#04060c; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
59+
.composer button { background:var(--cyan); color:#04060c; border:0; border-radius:8px; padding:0 16px; font-weight:700; cursor:pointer; }
60+
61+
.soon { margin:60px auto; max-width:480px; text-align:center; color:var(--muted); padding:24px;
62+
border:1px dashed var(--line); border-radius:12px; }
63+
.hidden { display:none !important; }
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<link rel="stylesheet" href="chat.css" />
7+
<title>TronBrowser · Chat</title>
8+
</head>
9+
<body>
10+
<header class="apphdr">
11+
<img src="icons/favicon.svg" alt="" class="logo" />
12+
<span class="brand">Tron<span class="accent">Browser</span></span>
13+
<nav class="tabs">
14+
<button class="tab active" data-tab="chat">💬 Chat</button>
15+
<button class="tab" data-tab="torrents">🧲 Torrents</button>
16+
<button class="tab" data-tab="qrypt">🔒 qrypt.chat</button>
17+
</nav>
18+
</header>
19+
20+
<!-- ===== Chat (IRC) ===== -->
21+
<section id="tab-chat" class="panel active">
22+
<!-- connect form -->
23+
<div id="irc-connect" class="connect">
24+
<h2>Connect to IRC</h2>
25+
<p class="muted">Members-only network. Sign in with your <strong>bbs.profullstack.com</strong> username and IRC password (SASL).</p>
26+
<label>Server <input id="irc-url" type="text" value="wss://irc.profullstack.com/irc" /></label>
27+
<label>Username <input id="irc-nick" type="text" placeholder="your BBS name" autocomplete="username" /></label>
28+
<label>Password <input id="irc-pass" type="password" placeholder="IRC password (SASL)" autocomplete="current-password" /></label>
29+
<label>Channels <input id="irc-chans" type="text" value="#general" /></label>
30+
<label class="row"><input id="irc-remember" type="checkbox" checked /> remember on this device</label>
31+
<button id="irc-go" class="primary">Connect</button>
32+
<div id="irc-status" class="status"></div>
33+
</div>
34+
35+
<!-- chat client -->
36+
<div id="irc-client" class="client hidden">
37+
<aside class="chans">
38+
<div class="chans-head"><span id="irc-me"></span><button id="irc-disc" title="Disconnect"></button></div>
39+
<ul id="chan-list"></ul>
40+
<form id="join-form" class="join"><input id="join-input" placeholder="#join a channel" /></form>
41+
</aside>
42+
<main class="convo">
43+
<div id="chan-title" class="convo-head"></div>
44+
<div id="msgs" class="msgs"></div>
45+
<form id="say-form" class="composer">
46+
<input id="say-input" placeholder="Message…" autocomplete="off" />
47+
<button type="submit">Send</button>
48+
</form>
49+
</main>
50+
</div>
51+
</section>
52+
53+
<!-- ===== Torrents (placeholder) ===== -->
54+
<section id="tab-torrents" class="panel">
55+
<div class="soon">🧲 <strong>Torrents</strong> — DHT search + indexed torrents via bittorrented.com. Coming next.</div>
56+
</section>
57+
58+
<!-- ===== qrypt.chat (placeholder) ===== -->
59+
<section id="tab-qrypt" class="panel">
60+
<div class="soon">🔒 <strong>qrypt.chat</strong> — quantum-resistant E2EE messaging (CoinPay login, contacts, media). Lands once the qrypt.chat backend is live.</div>
61+
</section>
62+
63+
<script type="module" src="chat.js"></script>
64+
</body>
65+
</html>
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { IrcClient } from './irc.js';
2+
3+
const $ = (id) => document.getElementById(id);
4+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
5+
6+
// --- top tabs -------------------------------------------------------------
7+
document.querySelectorAll('.tab').forEach((btn) => {
8+
btn.addEventListener('click', () => {
9+
document.querySelectorAll('.tab').forEach((b) => b.classList.toggle('active', b === btn));
10+
const name = btn.dataset.tab;
11+
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('active', p.id === 'tab-' + name));
12+
});
13+
});
14+
15+
// --- IRC ------------------------------------------------------------------
16+
const STORE_KEY = 'ircConfig';
17+
const irc = new IrcClient();
18+
const buffers = new Map(); // channel -> [{from,text,time,...}]
19+
let active = null;
20+
21+
function setStatus(text, kind) { const s = $('irc-status'); s.textContent = text; s.className = 'status ' + (kind || ''); }
22+
23+
function ensureBuffer(chan) {
24+
if (!buffers.has(chan)) { buffers.set(chan, []); renderChanList(); }
25+
}
26+
27+
function renderChanList() {
28+
const ul = $('chan-list'); ul.innerHTML = '';
29+
for (const chan of buffers.keys()) {
30+
const li = el('li', chan === active ? 'active' : '', chan);
31+
li.addEventListener('click', () => selectChannel(chan));
32+
ul.appendChild(li);
33+
}
34+
}
35+
36+
function selectChannel(chan) {
37+
active = chan;
38+
$('chan-title').textContent = chan;
39+
renderChanList();
40+
const m = $('msgs'); m.innerHTML = '';
41+
for (const line of buffers.get(chan) || []) appendMsg(line, false);
42+
m.scrollTop = m.scrollHeight;
43+
$('say-input').focus();
44+
}
45+
46+
function appendMsg(line, store = true) {
47+
if (store) { ensureBuffer(line.channel); buffers.get(line.channel).push(line); }
48+
if (line.channel !== active) return;
49+
const m = $('msgs');
50+
const div = el('div', 'msg' + (line.sys ? ' sys' : '') + (line.self ? ' self' : '') + (line.notice ? ' notice' : ''));
51+
if (line.sys) {
52+
div.append(el('span', null, '— ' + line.text));
53+
} else {
54+
const t = new Date(line.time);
55+
div.append(el('span', 't', isNaN(t) ? '' : t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })));
56+
div.append(el('span', 'n', (line.from || '') + ':'));
57+
div.append(el('span', null, line.text));
58+
}
59+
const atBottom = m.scrollHeight - m.scrollTop - m.clientHeight < 40;
60+
m.appendChild(div);
61+
if (atBottom) m.scrollTop = m.scrollHeight;
62+
}
63+
64+
irc.addEventListener('status', (e) => {
65+
const { state, error } = e.detail;
66+
if (state === 'connecting') setStatus('Connecting…');
67+
else if (state === 'connected') {
68+
setStatus('Connected ✓', 'ok');
69+
$('irc-connect').classList.add('hidden');
70+
$('irc-client').classList.remove('hidden');
71+
$('irc-me').textContent = irc.nick;
72+
} else if (state === 'disconnected') {
73+
setStatus('Disconnected', 'err');
74+
} else if (state === 'error') {
75+
setStatus(error || 'error', 'err');
76+
}
77+
});
78+
irc.addEventListener('joined', (e) => { ensureBuffer(e.detail.channel); if (!active) selectChannel(e.detail.channel); });
79+
irc.addEventListener('message', (e) => appendMsg(e.detail));
80+
irc.addEventListener('system', (e) => { if (e.detail.channel) appendMsg({ channel: e.detail.channel, sys: true, text: e.detail.text }); });
81+
irc.addEventListener('topic', (e) => { if (e.detail.channel === active) $('chan-title').textContent = `${e.detail.channel}${e.detail.topic}`; });
82+
83+
async function doConnect() {
84+
const cfg = {
85+
url: $('irc-url').value.trim(),
86+
nick: $('irc-nick').value.trim(),
87+
password: $('irc-pass').value,
88+
channels: $('irc-chans').value.split(/[,\s]+/).filter(Boolean),
89+
};
90+
if (!cfg.nick) { setStatus('Enter your username', 'err'); return; }
91+
if ($('irc-remember').checked) {
92+
chrome.storage.local.set({ [STORE_KEY]: cfg });
93+
}
94+
irc.connect(cfg);
95+
}
96+
97+
$('irc-go').addEventListener('click', doConnect);
98+
$('irc-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') doConnect(); });
99+
$('irc-disc').addEventListener('click', () => { irc.quit(); $('irc-client').classList.add('hidden'); $('irc-connect').classList.remove('hidden'); });
100+
101+
$('join-form').addEventListener('submit', (e) => { e.preventDefault(); const v = $('join-input').value.trim(); if (v) { irc.join(v); $('join-input').value = ''; } });
102+
$('say-form').addEventListener('submit', (e) => {
103+
e.preventDefault();
104+
const v = $('say-input').value;
105+
if (!v.trim() || !active) return;
106+
if (v.startsWith('/join ')) irc.join(v.slice(6).trim());
107+
else if (v.startsWith('/msg ')) { const [, who, ...rest] = v.split(' '); irc.say(who, rest.join(' ')); }
108+
else irc.say(active, v);
109+
$('say-input').value = '';
110+
});
111+
112+
// Prefill from saved config.
113+
(async () => {
114+
const { [STORE_KEY]: cfg } = await chrome.storage.local.get(STORE_KEY);
115+
if (cfg) {
116+
$('irc-url').value = cfg.url || $('irc-url').value;
117+
$('irc-nick').value = cfg.nick || '';
118+
$('irc-pass').value = cfg.password || '';
119+
$('irc-chans').value = (cfg.channels || ['#general']).join(' ');
120+
}
121+
})();

0 commit comments

Comments
 (0)