Initial release
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
# Cathode
|
||||||
|
|
||||||
|
A terminal-style web client for [WeeChat](https://weechat.org/), using the
|
||||||
|
modern **API relay protocol** (WeeChat ≥ 4.0). No build step. No framework.
|
||||||
|
Drop three files on a web server and go.
|
||||||
|
|
||||||
|
> Inspired by [Glowing Bear](https://github.com/glowing-bear/glowing-bear).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Real-time IRC via WeeChat's JSON/WebSocket relay API
|
||||||
|
- Terminal aesthetic — white on black (default) and black on white, togglable
|
||||||
|
- Buffer list, nicklist, message history, input with command history (↑/↓)
|
||||||
|
- ANSI colour rendering, URL linkification
|
||||||
|
- Warns on browser-blocked ports before you try to connect
|
||||||
|
- Self-signed cert helper (opens relay URL in a new tab to accept the warning)
|
||||||
|
- Zero dependencies, zero build step — plain HTML/CSS/ES-module JS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **WeeChat ≥ 4.0** with the relay plugin enabled and the **api** protocol configured
|
||||||
|
- A web server to serve the three static files (nginx, Caddy, Apache, or even
|
||||||
|
`python3 -m http.server`)
|
||||||
|
- A domain + TLS cert for production use (certbot/Let's Encrypt recommended)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WeeChat relay setup
|
||||||
|
|
||||||
|
```
|
||||||
|
# Inside WeeChat:
|
||||||
|
|
||||||
|
# Load the relay plugin if not already loaded
|
||||||
|
/plugin load relay
|
||||||
|
|
||||||
|
# Set a relay password (required)
|
||||||
|
/set relay.network.password "your_strong_password_here"
|
||||||
|
|
||||||
|
# Create the API relay listener on port 9000
|
||||||
|
# Do NOT use port 6667/6697 — those are blocked by browsers
|
||||||
|
/relay add api 9000
|
||||||
|
|
||||||
|
# For TLS (recommended for non-localhost):
|
||||||
|
/relay add tls.api 9000
|
||||||
|
|
||||||
|
# Verify it's listening:
|
||||||
|
/relay listport
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Port note:** Ports 6665–6669 and 6697 (the traditional IRC port range)
|
||||||
|
> are blocked by all major browsers. Use any other port — 9000 is a safe
|
||||||
|
> default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Copy the files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone or download, then copy to your web root:
|
||||||
|
cp index.html app.js style.css /var/www/cathode/
|
||||||
|
|
||||||
|
# Or serve from the repo directly for development:
|
||||||
|
python3 -m http.server 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set up a reverse proxy (recommended)
|
||||||
|
|
||||||
|
For production you'll want a reverse proxy to handle TLS and serve both the
|
||||||
|
static files and the WeeChat relay API under the same origin — this avoids
|
||||||
|
mixed-content issues and makes self-signed certs unnecessary.
|
||||||
|
|
||||||
|
See the `proxy/` directory for ready-to-use configs:
|
||||||
|
|
||||||
|
| Server | File |
|
||||||
|
|--------|------|
|
||||||
|
| Caddy | `proxy/Caddyfile` |
|
||||||
|
| nginx | `proxy/nginx.conf` |
|
||||||
|
| Apache | `proxy/apache.conf` |
|
||||||
|
|
||||||
|
All three configs follow the same pattern:
|
||||||
|
- Serve static files at `/`
|
||||||
|
- Proxy `/api*` to `localhost:9000` (your WeeChat relay port)
|
||||||
|
- Handle WebSocket upgrade headers
|
||||||
|
|
||||||
|
### 3. Open in browser
|
||||||
|
|
||||||
|
Navigate to your domain. Enter the WeeChat relay host/port and password.
|
||||||
|
If you're using the reverse proxy setup, the host is your domain and the
|
||||||
|
port is 443 — the proxy forwards `/api` internally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-signed certificates
|
||||||
|
|
||||||
|
If you can't use Let's Encrypt (no domain, air-gapped, LAN-only), you can
|
||||||
|
still use TLS with a self-signed cert on the WeeChat relay. The browser will
|
||||||
|
refuse the WebSocket connection until you accept the cert exception:
|
||||||
|
|
||||||
|
1. Enter your host and port in Cathode's connect screen
|
||||||
|
2. Click the **⚠ CERT** button — this opens `https://host:port/api/version`
|
||||||
|
in a new tab
|
||||||
|
3. Accept the browser's security warning in that tab
|
||||||
|
4. Return to Cathode and click **CONNECT**
|
||||||
|
|
||||||
|
The exception is remembered by the browser for subsequent sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LAN / local use (no TLS)
|
||||||
|
|
||||||
|
If you're connecting from the same machine or a trusted LAN, you can skip TLS:
|
||||||
|
|
||||||
|
- In WeeChat: `/relay add api 9000` (without `tls.`)
|
||||||
|
- In Cathode: uncheck **USE TLS** before connecting
|
||||||
|
- Serve Cathode itself over plain HTTP too (or `file://` directly)
|
||||||
|
|
||||||
|
Note: `ws://` from an `https://` page is blocked by browsers (mixed content).
|
||||||
|
Either serve Cathode over plain HTTP as well, or use the reverse proxy approach.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cathode/
|
||||||
|
├── index.html — app shell
|
||||||
|
├── app.js — all client logic (ES module, no build step)
|
||||||
|
├── style.css — terminal theme, dark + light
|
||||||
|
├── proxy/
|
||||||
|
│ ├── Caddyfile — Caddy reverse proxy config
|
||||||
|
│ ├── nginx.conf — nginx reverse proxy config
|
||||||
|
│ └── apache.conf — Apache reverse proxy config
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Cathode uses WeeChat's **API relay protocol** — a clean JSON-over-WebSocket
|
||||||
|
protocol introduced in WeeChat 4.0, replacing the old binary `weechat` relay
|
||||||
|
protocol that Glowing Bear was built on.
|
||||||
|
|
||||||
|
On connect, Cathode:
|
||||||
|
1. Opens a WebSocket to `wss://host:port/api` with the password encoded in
|
||||||
|
the `Sec-WebSocket-Protocol` header (the only header the browser WebSocket
|
||||||
|
API allows you to set)
|
||||||
|
2. Sends a batched request: fetch all buffers with last 200 lines and nicks,
|
||||||
|
then subscribe to real-time events
|
||||||
|
3. Listens for push events (`buffer_line_added`, `buffer_opened`,
|
||||||
|
`nicklist_nick_added`, etc.) and updates the UI accordingly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0 — same as WeeChat and Glowing Bear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Cathode — Inspired by [Glowing Bear](https://github.com/glowing-bear/glowing-bear)*
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Cathode — operator configuration
|
||||||
|
*
|
||||||
|
* This file is loaded before app.js. Set properties on window.CATHODE_CONFIG
|
||||||
|
* to configure your deployment. All properties are optional — omit anything
|
||||||
|
* you don't need.
|
||||||
|
*
|
||||||
|
* For a self-hosted personal instance, fill this in once and forget it.
|
||||||
|
* For a shared/public instance, set the upload backend here so users don't
|
||||||
|
* need to configure it themselves (and can't change it).
|
||||||
|
*/
|
||||||
|
|
||||||
|
window.CATHODE_CONFIG = {
|
||||||
|
|
||||||
|
// ── Notifications ─────────────────────────────────────────────────────────
|
||||||
|
// The relay API doesn't expose WeeChat's per-buffer notify setting, so
|
||||||
|
// Cathode can't read it directly. Use these flags as a workaround.
|
||||||
|
//
|
||||||
|
// notifyServerBuffers: false — suppress notifications from IRC server buffers
|
||||||
|
// (type=server). Set to false if your server buffers are set to
|
||||||
|
// "notify none" in WeeChat. Default: true (don't suppress).
|
||||||
|
notifyServerBuffers: false,
|
||||||
|
|
||||||
|
// ── Upload backend ────────────────────────────────────────────────────────
|
||||||
|
// 'none' — disable file upload entirely (default)
|
||||||
|
// 'filehost' — single_php_filehost (https://github.com/Rouji/single_php_filehost)
|
||||||
|
// 'imgur' — Imgur API (requires a Client ID from https://api.imgur.com/oauth2/addclient)
|
||||||
|
uploadBackend: 'none',
|
||||||
|
filehostUrl: '', // e.g. 'https://files.example.com/' (filehost only)
|
||||||
|
imgurClientId: '', // e.g. 'abc123def456' (imgur only)
|
||||||
|
|
||||||
|
// ── Prefix align max ──────────────────────────────────────────────────────
|
||||||
|
// Mirrors weechat.look.prefix_align_max — truncates long nicks in the
|
||||||
|
// message column. Set to match your WeeChat config. Default: 16.
|
||||||
|
prefixAlignMax: 16,
|
||||||
|
|
||||||
|
};
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
+195
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Cathode — a terminal-style web client for WeeChat">
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="32x32 16x16" type="image/x-icon">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
<title>Cathode</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<!-- Emoji picker Web Component — loaded from CDN, no build step needed -->
|
||||||
|
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@1/index.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
|
||||||
|
<!-- ── Top bar ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="topbar">
|
||||||
|
<span class="logo">CATHODE<span>/irc</span></span>
|
||||||
|
<div class="status-pill">
|
||||||
|
<div id="status-dot" class="status-dot disconnected"></div>
|
||||||
|
<span id="status-text">DISCONNECTED</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-spacer"></div>
|
||||||
|
<button id="settings-btn" class="topbar-btn">⚙ SETTINGS</button>
|
||||||
|
<button id="theme-toggle" class="topbar-btn">◐ LIGHT</button>
|
||||||
|
<button id="disconnect-btn" class="topbar-btn" style="display:none">⏻ QUIT</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Connect screen ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="connect-screen">
|
||||||
|
|
||||||
|
<!-- Shown only when served over HTTPS — ws:// is blocked in that context -->
|
||||||
|
<div id="http-notice" style="display:none">
|
||||||
|
<span class="http-notice-text">
|
||||||
|
⚠ This page is served over <strong>HTTPS</strong> — only <strong>wss://</strong> relays are reachable.
|
||||||
|
Make sure your WeeChat relay is accessible over TLS.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connect-box">
|
||||||
|
<div class="connect-box-header">WEECHAT RELAY — API PROTOCOL</div>
|
||||||
|
<div class="connect-box-body">
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="host">HOST</label>
|
||||||
|
<input id="host" type="text" placeholder="irc.example.com" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="field short">
|
||||||
|
<label for="port">PORT</label>
|
||||||
|
<input id="port" type="number" value="9000" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="port-warning"></div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">RELAY PASSWORD</label>
|
||||||
|
<input id="password" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input id="tls" type="checkbox" checked>
|
||||||
|
USE TLS (wss://)
|
||||||
|
<span id="tls-locked-note" style="display:none"> — required on HTTPS pages</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="conn-error"></div>
|
||||||
|
|
||||||
|
<div class="connect-actions">
|
||||||
|
<button id="connect-btn" class="btn-primary">CONNECT</button>
|
||||||
|
<button id="cert-btn" class="btn-secondary">⚠ CERT</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="cert-hint">
|
||||||
|
Using a self-signed cert? Click ⚠ CERT to open the relay URL in a new
|
||||||
|
tab, accept the browser warning, then return here and connect.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Chat screen ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="chat-screen" style="display:none">
|
||||||
|
|
||||||
|
<!-- Buffer sidebar -->
|
||||||
|
<div id="sidebar">
|
||||||
|
<div id="sidebar-header">BUFFERS</div>
|
||||||
|
<div id="buffer-list"></div>
|
||||||
|
<div id="sidebar-footer">
|
||||||
|
<button id="sidebar-join-btn" class="sidebar-footer-btn">+ JOIN</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main area -->
|
||||||
|
<div id="main">
|
||||||
|
<div id="chat-header">
|
||||||
|
<span id="chat-title"></span>
|
||||||
|
<span id="chat-topic"></span>
|
||||||
|
<button id="smartfilter-btn" class="header-btn" style="display:none">FILTER: ON</button>
|
||||||
|
</div>
|
||||||
|
<div id="messages"></div>
|
||||||
|
<div id="inputbar">
|
||||||
|
<span id="input-prompt">›</span>
|
||||||
|
<input id="chat-input" type="text" placeholder="type a message or /command" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="emoji-btn" class="inputbar-btn" title="Emoji picker">😊</button>
|
||||||
|
<button id="upload-btn" class="inputbar-btn" title="Upload file">📎</button>
|
||||||
|
<input id="upload-file" type="file" style="display:none" multiple>
|
||||||
|
<button id="send-btn">SEND</button>
|
||||||
|
</div>
|
||||||
|
<!-- Emoji picker popup — hidden until emoji-btn clicked -->
|
||||||
|
<div id="emoji-popup" style="display:none">
|
||||||
|
<emoji-picker id="emoji-picker"></emoji-picker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nicklist panel -->
|
||||||
|
<div id="nicklist-panel">
|
||||||
|
<div id="nicklist-header">NICKS</div>
|
||||||
|
<div id="nicklist"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Footer ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="footer">
|
||||||
|
Cathode — Inspired by <a href="https://github.com/glowing-bear/glowing-bear" target="_blank" rel="noopener">Glowing Bear</a>
|
||||||
|
· <a href="https://github.com/weechat/weechat" target="_blank" rel="noopener">WeeChat</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /#app -->
|
||||||
|
|
||||||
|
<!-- ── Drag overlay ──────────────────────────────────────────────────────── -->
|
||||||
|
<div id="drag-overlay" style="display:none">
|
||||||
|
<div class="drag-overlay-inner">DROP TO UPLOAD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Settings panel ───────────────────────────────────────────────────── -->
|
||||||
|
<div id="settings-overlay" style="display:none">
|
||||||
|
<div id="settings-panel">
|
||||||
|
<div class="settings-header">
|
||||||
|
<span>SETTINGS</span>
|
||||||
|
<button id="settings-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-body">
|
||||||
|
|
||||||
|
<div class="settings-section" id="settings-upload-section">UPLOAD</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-upload-backend">BACKEND</label>
|
||||||
|
<select id="s-upload-backend">
|
||||||
|
<option value="none">Disabled</option>
|
||||||
|
<option value="filehost">Single PHP Filehost</option>
|
||||||
|
<option value="imgur">Imgur</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="s-filehost-opts">
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-filehost-url">FILEHOST URL</label>
|
||||||
|
<input id="s-filehost-url" type="text" placeholder="https://files.example.com/" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="s-imgur-opts" style="display:none">
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-imgur-key">IMGUR CLIENT ID</label>
|
||||||
|
<input id="s-imgur-key" type="text" placeholder="your_client_id" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section" style="margin-top:16px">DISPLAY</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="s-prefix-max">PREFIX ALIGN MAX (weechat.look.prefix_align_max)</label>
|
||||||
|
<input id="s-prefix-max" type="number" min="4" max="64" value="16">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button id="settings-save" class="btn-primary">SAVE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="config.js"></script>
|
||||||
|
<script src="vendor/emoji-mart.js"></script>
|
||||||
|
<script src="js/main.js" type="module"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
// ─── ANSI colour palette ──────────────────────────────────────────────────────
|
||||||
|
export const ANSI16 = [
|
||||||
|
'#1a1a1a','#cc3333','#33cc33','#cccc33',
|
||||||
|
'#3333cc','#cc33cc','#33cccc','#cccccc',
|
||||||
|
'#555555','#ff5555','#55ff55','#ffff55',
|
||||||
|
'#5555ff','#ff55ff','#55ffff','#ffffff',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ansi256(n) {
|
||||||
|
if (n < 16) return ANSI16[n];
|
||||||
|
if (n >= 232) { const v = 8 + (n - 232) * 10; return `rgb(${v},${v},${v})`; }
|
||||||
|
const i = n - 16;
|
||||||
|
return `rgb(${Math.floor(i/36)*51},${Math.floor((i%36)/6)*51},${(i%6)*51})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function luminance(css) {
|
||||||
|
let r, g, b;
|
||||||
|
const m = css.match(/^rgb\((\d+),(\d+),(\d+)\)$/);
|
||||||
|
if (m) { r = +m[1]; g = +m[2]; b = +m[3]; }
|
||||||
|
else if (css.startsWith('#')) {
|
||||||
|
const h = css.slice(1);
|
||||||
|
if (h.length === 3) {
|
||||||
|
r = parseInt(h[0]+h[0],16); g = parseInt(h[1]+h[1],16); b = parseInt(h[2]+h[2],16);
|
||||||
|
} else {
|
||||||
|
r = parseInt(h.slice(0,2),16); g = parseInt(h.slice(2,4),16); b = parseInt(h.slice(4,6),16);
|
||||||
|
}
|
||||||
|
} else return 0.5;
|
||||||
|
const lin = c => { c /= 255; return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); };
|
||||||
|
return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In light theme, force near-white foreground colours to black.
|
||||||
|
export function safeFg(css) {
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
if (theme === 'light' && luminance(css) > 0.70) return '#111111';
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial-reset SGR codes — treat like a full reset (close all open spans).
|
||||||
|
const PARTIAL_RESET = new Set([21,22,23,24,25,27,28,29,39,49,51,52,53,54,55]);
|
||||||
|
|
||||||
|
// Convert ANSI-escaped text to HTML. Linkifies URLs and adds media-toggle buttons.
|
||||||
|
export function ansiToHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
let s = text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
let out = '', spans = 0;
|
||||||
|
// NOTE: the regex literal below must contain a literal ESC character (0x1b)
|
||||||
|
// followed by \[ — do not modify with automated text tools.
|
||||||
|
const re = /\x1b\[([0-9;]*)m/g;
|
||||||
|
let last = 0, m;
|
||||||
|
while ((m = re.exec(s)) !== null) {
|
||||||
|
out += s.slice(last, m.index);
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
const codes = m[1].split(';').map(Number);
|
||||||
|
let i = 0;
|
||||||
|
while (i < codes.length) {
|
||||||
|
if (codes[i] === 0) {
|
||||||
|
if (spans > 0) { out += '</span>'.repeat(spans); spans = 0; }
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
if (PARTIAL_RESET.has(codes[i])) {
|
||||||
|
if (spans > 0) { out += '</span>'.repeat(spans); spans = 0; }
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
const st = [];
|
||||||
|
while (i < codes.length && codes[i] !== 0 && !PARTIAL_RESET.has(codes[i])) {
|
||||||
|
const c = codes[i];
|
||||||
|
if (c === 1) { st.push('font-weight:bold'); }
|
||||||
|
else if (c === 3) { st.push('font-style:italic'); }
|
||||||
|
else if (c === 4) { st.push('text-decoration:underline'); }
|
||||||
|
else if (c >= 30 && c <= 37) { st.push(`color:${safeFg(ANSI16[c-30])}`); }
|
||||||
|
else if (c === 38 && codes[i+1] === 5) { st.push(`color:${safeFg(ansi256(codes[i+2]))}`); i+=2; }
|
||||||
|
else if (c === 38 && codes[i+1] === 2) { st.push(`color:${safeFg(`rgb(${codes[i+2]},${codes[i+3]},${codes[i+4]})`)}`); i+=4; }
|
||||||
|
else if (c >= 40 && c <= 47) { st.push(`background:${ANSI16[c-40]}`); }
|
||||||
|
else if (c === 48 && codes[i+1] === 5) { st.push(`background:${ansi256(codes[i+2])}`); i+=2; }
|
||||||
|
else if (c === 48 && codes[i+1] === 2) { st.push(`background:rgb(${codes[i+2]},${codes[i+3]},${codes[i+4]})`); i+=4; }
|
||||||
|
else if (c >= 90 && c <= 97) { st.push(`color:${safeFg(ANSI16[c-90+8])}`); }
|
||||||
|
else if (c >= 100 && c <= 107) { st.push(`background:${ANSI16[c-100+8]}`); }
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (st.length) { out += `<span style="${st.join(';')}">`; spans++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += s.slice(last);
|
||||||
|
if (spans > 0) out += '</span>'.repeat(spans);
|
||||||
|
|
||||||
|
// Linkify URLs; match through & so query strings aren't cut short
|
||||||
|
return out.replace(/https?:\/\/(?:[^\s<>"']|&)+/g, (url) => {
|
||||||
|
const href = url.replace(/&/g, '&');
|
||||||
|
const isImg = /\.(png|jpe?g|gif|webp|svg|bmp)(\?.*)?$/i.test(href);
|
||||||
|
const isVid = /\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(href);
|
||||||
|
const btn = (isImg || isVid)
|
||||||
|
? ` <button class="media-toggle" data-url="${href}" data-type="${isImg?'img':'vid'}">${isImg?'Show Image':'Show Video'}</button>`
|
||||||
|
: '';
|
||||||
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>${btn}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WeeChat colour name → CSS ────────────────────────────────────────────────
|
||||||
|
const WEECHAT_COLOR_NAMES = {
|
||||||
|
'default':'inherit','bar_fg':'inherit','black':'#1a1a1a','darkgray':'#555555',
|
||||||
|
'red':'#cc3333','lightred':'#ff5555','green':'#33cc33','lightgreen':'#55ff55',
|
||||||
|
'brown':'#cccc33','yellow':'#ffff55','blue':'#3333cc','lightblue':'#5555ff',
|
||||||
|
'magenta':'#cc33cc','lightmagenta':'#ff55ff','cyan':'#33cccc','lightcyan':'#55ffff',
|
||||||
|
'gray':'#cccccc','white':'#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert a nick color value from the relay API to a CSS color string.
|
||||||
|
// The API sends either an ANSI escape sequence or a WeeChat color name.
|
||||||
|
export function nickColorToCss(colorVal) {
|
||||||
|
if (!colorVal) return '';
|
||||||
|
if (colorVal.includes('\x1b')) {
|
||||||
|
// Extract colour from ANSI escape by running it through ansiToHtml
|
||||||
|
const html = ansiToHtml(colorVal + 'X\x1b[0m');
|
||||||
|
const m = html.match(/style="([^"]+)"/);
|
||||||
|
if (m) {
|
||||||
|
const cm = m[1].match(/(?:^|;)color:([^;]+)/);
|
||||||
|
if (cm) return cm[1];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return WEECHAT_COLOR_NAMES[colorVal.toLowerCase()] || 'inherit';
|
||||||
|
}
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
import { state, saveSettings } from './state.js';
|
||||||
|
import { parseId } from './connection.js';
|
||||||
|
import { ansiToHtml, nickColorToCss, safeFg } from './ansi.js';
|
||||||
|
import { renderMessages, renderChatHeader, hideNewMsgBanner, appendLine } from './chat.js';
|
||||||
|
import { maybeNotify, updateTitle } from './notifications.js';
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
|
||||||
|
// ─── Buffer events ────────────────────────────────────────────────────────────
|
||||||
|
export function onBufOpened(buf) {
|
||||||
|
if (!buf) return;
|
||||||
|
const id = parseId(buf.id);
|
||||||
|
buf = { ...buf, id };
|
||||||
|
state.buffers.set(id, { ...buf, lines: buf.lines||[], nicks:{}, unread:0, highlight:0 });
|
||||||
|
if (!state.smartFilter.has(id)) state.smartFilter.set(id, true);
|
||||||
|
rebuildBufList();
|
||||||
|
if (state.activeBufferId == null) activateBuffer(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onBufUpdated(buf) {
|
||||||
|
if (!buf) return;
|
||||||
|
const id = parseId(buf.id);
|
||||||
|
const b = state.buffers.get(id);
|
||||||
|
if (!b) return;
|
||||||
|
Object.assign(b, { ...buf, id });
|
||||||
|
paintNode(id);
|
||||||
|
if (state.activeBufferId === id) renderChatHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onBufCleared(rawId) {
|
||||||
|
const id = parseId(rawId);
|
||||||
|
const b = state.buffers.get(id);
|
||||||
|
if (b) { b.lines = []; if (state.activeBufferId === id) el('messages').innerHTML = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onBufClosed(rawId) {
|
||||||
|
const id = parseId(rawId);
|
||||||
|
state.buffers.delete(id);
|
||||||
|
removeNode(id);
|
||||||
|
if (state.activeBufferId === id) {
|
||||||
|
const first = state.buffers.keys().next().value;
|
||||||
|
if (first != null) activateBuffer(first);
|
||||||
|
else { state.activeBufferId = null; el('messages').innerHTML = ''; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onLineAdded(rawId, line) {
|
||||||
|
if (!line) return;
|
||||||
|
const id = parseId(rawId);
|
||||||
|
const b = state.buffers.get(id);
|
||||||
|
if (!b) return;
|
||||||
|
b.lines.push(line);
|
||||||
|
if (state.activeBufferId === id) {
|
||||||
|
appendLine(line);
|
||||||
|
} else {
|
||||||
|
b.unread++;
|
||||||
|
if (line.highlight) b.highlight++;
|
||||||
|
paintNode(id);
|
||||||
|
}
|
||||||
|
maybeNotify(b, line, activateBuffer);
|
||||||
|
updateTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nick events ──────────────────────────────────────────────────────────────
|
||||||
|
export function collectNicks(group, out) {
|
||||||
|
if (!group) return;
|
||||||
|
for (const n of (group.nicks || [])) out[n.id] = n;
|
||||||
|
for (const g of (group.groups || [])) collectNicks(g, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onNickAdded(rawId, nick) {
|
||||||
|
const id = parseId(rawId);
|
||||||
|
const b = state.buffers.get(id);
|
||||||
|
if (!b || !nick) return;
|
||||||
|
b.nicks[nick.id] = nick;
|
||||||
|
if (state.activeBufferId === id) renderNicklist(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onNickRemoved(rawId, nick) {
|
||||||
|
const id = parseId(rawId);
|
||||||
|
const b = state.buffers.get(id);
|
||||||
|
if (!b || !nick) return;
|
||||||
|
delete b.nicks[nick.id];
|
||||||
|
if (state.activeBufferId === id) renderNicklist(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onGroupChanged(rawId) {
|
||||||
|
const id = parseId(rawId);
|
||||||
|
const b = state.buffers.get(id);
|
||||||
|
if (b && state.activeBufferId === id) renderNicklist(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nicklist ─────────────────────────────────────────────────────────────────
|
||||||
|
export function renderNicklist(buf) {
|
||||||
|
const box = el('nicklist');
|
||||||
|
box.innerHTML = '';
|
||||||
|
const nicks = Object.values(buf.nicks || {}).sort((a, b) => {
|
||||||
|
const w = p => p==='~'?0 : p==='&'?1 : p==='@'?2 : p==='%'?3 : p==='+'?4 : 5;
|
||||||
|
const d = w(a.prefix) - w(b.prefix);
|
||||||
|
return d !== 0 ? d : a.name.localeCompare(b.name, undefined, {sensitivity:'base'});
|
||||||
|
});
|
||||||
|
for (const nick of nicks) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'nick-item';
|
||||||
|
const pfxChar = (nick.prefix && nick.prefix.trim()) ? esc(nick.prefix) : ' ';
|
||||||
|
const pfxHtml = nick.prefix_color
|
||||||
|
? `<span class="nick-pfx" style="color:${nickColorToCss(nick.prefix_color)}">${pfxChar}</span>`
|
||||||
|
: `<span class="nick-pfx">${pfxChar}</span>`;
|
||||||
|
const nameHtml = nick.color
|
||||||
|
? `<span class="nick-name" style="color:${safeFg(nickColorToCss(nick.color))}">${esc(nick.name)}</span>`
|
||||||
|
: `<span class="nick-name">${esc(nick.name)}</span>`;
|
||||||
|
row.innerHTML = pfxHtml + nameHtml;
|
||||||
|
row.addEventListener('click', () => openNickMenu(nick, buf));
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Nick context menu ────────────────────────────────────────────────────────
|
||||||
|
function openNickMenu(nick, buf) {
|
||||||
|
closeNickMenu();
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'nick-overlay';
|
||||||
|
overlay.className = 'nick-overlay';
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) closeNickMenu(); });
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'nick-menu';
|
||||||
|
|
||||||
|
const hdr = document.createElement('div');
|
||||||
|
hdr.className = 'nick-menu-hdr';
|
||||||
|
hdr.textContent = (nick.prefix && nick.prefix.trim() ? nick.prefix : '') + nick.name;
|
||||||
|
menu.appendChild(hdr);
|
||||||
|
|
||||||
|
const myPfx = ownPrefix(buf);
|
||||||
|
const isOp = ['@','~','&'].includes(myPfx);
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ label: '💬 Query', cmd: `/query ${nick.name}` },
|
||||||
|
{ label: '🔍 Whois', cmd: `/whois ${nick.name}` },
|
||||||
|
{ label: '🔍 Whois (full)', cmd: `/whois ${nick.name} ${nick.name}` },
|
||||||
|
{ label: '📌 Ignore', cmd: `/ignore ${nick.name}` },
|
||||||
|
{ label: '🔇 Kick', cmd: `/kick ${nick.name}`, op: true },
|
||||||
|
{ label: '🚫 Ban', cmd: `/ban ${nick.name}`, op: true },
|
||||||
|
];
|
||||||
|
for (const a of actions) {
|
||||||
|
if (a.op && !isOp) continue;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'nick-menu-btn';
|
||||||
|
btn.textContent = a.label;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
wsSendRef(a.cmd, buf.name);
|
||||||
|
closeNickMenu();
|
||||||
|
});
|
||||||
|
menu.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.appendChild(menu);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay._esc = e => { if (e.key === 'Escape') closeNickMenu(); };
|
||||||
|
document.addEventListener('keydown', overlay._esc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNickMenu() {
|
||||||
|
const ov = document.getElementById('nick-overlay');
|
||||||
|
if (!ov) return;
|
||||||
|
document.removeEventListener('keydown', ov._esc);
|
||||||
|
ov.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownPrefix(buf) {
|
||||||
|
const nick = (buf.local_variables || {}).nick || '';
|
||||||
|
const entry = Object.values(buf.nicks || {}).find(n => n.name === nick);
|
||||||
|
return entry ? (entry.prefix || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsSend reference — set by main.js after connection module loads
|
||||||
|
let wsSendRef = () => {};
|
||||||
|
export function setWsSend(fn) {
|
||||||
|
wsSendRef = (cmd, bufName) => fn({ request: 'POST /api/input', body: { buffer_name: bufName, command: cmd } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Buffer list DOM ──────────────────────────────────────────────────────────
|
||||||
|
const bufNodes = new Map();
|
||||||
|
|
||||||
|
const bKey = id => 'b:' + id;
|
||||||
|
const gKey = key => 'g:' + key;
|
||||||
|
|
||||||
|
function bufMeta(buf) {
|
||||||
|
const lv = buf.local_variables || {};
|
||||||
|
const plugin = lv.plugin || '';
|
||||||
|
const server = lv.server || '';
|
||||||
|
const type = lv.type || '';
|
||||||
|
if (!plugin || plugin === 'core')
|
||||||
|
return { group:'\x00core', groupLabel:'weechat', isServer:false, indent:false };
|
||||||
|
if (plugin === 'irc') {
|
||||||
|
if (type === 'server' || !server)
|
||||||
|
return { group: server||buf.name, groupLabel: server||buf.name, isServer:true, indent:false };
|
||||||
|
return { group: server, groupLabel: server, isServer:false, indent:true };
|
||||||
|
}
|
||||||
|
const gk = server ? `${plugin}.${server}` : plugin;
|
||||||
|
return { group:gk, groupLabel: server ? `${plugin}/${server}` : plugin,
|
||||||
|
isServer:!server, indent:!!server };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWanted() {
|
||||||
|
const sorted = [...state.buffers.values()].sort((a,b) => a.number - b.number);
|
||||||
|
const groups = new Map();
|
||||||
|
for (const buf of sorted) {
|
||||||
|
const m = bufMeta(buf);
|
||||||
|
if (!groups.has(m.group)) groups.set(m.group, { label:m.groupLabel, srv:null, ch:[] });
|
||||||
|
const g = groups.get(m.group);
|
||||||
|
if (m.isServer) g.srv = buf; else g.ch.push(buf);
|
||||||
|
}
|
||||||
|
const items = [];
|
||||||
|
for (const [gk, g] of groups) {
|
||||||
|
if (g.srv) items.push({ key:bKey(g.srv.id), type:'server', buf:g.srv });
|
||||||
|
else items.push({ key:gKey(gk), type:'header', label:g.label });
|
||||||
|
for (const buf of g.ch)
|
||||||
|
items.push({ key:bKey(buf.id), type:'channel', buf });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebuildBufList() {
|
||||||
|
const container = el('buffer-list');
|
||||||
|
for (const [,node] of bufNodes) node.remove();
|
||||||
|
bufNodes.clear();
|
||||||
|
for (const item of buildWanted()) {
|
||||||
|
const node = makeNode(item);
|
||||||
|
bufNodes.set(item.key, node);
|
||||||
|
container.appendChild(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintNode(id) {
|
||||||
|
const node = bufNodes.get(bKey(id));
|
||||||
|
if (!node) return;
|
||||||
|
const buf = state.buffers.get(id);
|
||||||
|
if (!buf) return;
|
||||||
|
const isServer = node.dataset.isServer === '1';
|
||||||
|
const indent = node.dataset.indent === '1';
|
||||||
|
const classes = ['buffer-item'];
|
||||||
|
if (isServer) classes.push('buf-server');
|
||||||
|
if (indent) classes.push('buf-indented');
|
||||||
|
if (String(buf.id) === String(state.activeBufferId)) classes.push('active');
|
||||||
|
if (buf.highlight > 0) classes.push('highlight');
|
||||||
|
else if (buf.unread > 0) classes.push('unread');
|
||||||
|
node.className = classes.join(' ');
|
||||||
|
const name = buf.short_name || buf.name || '?';
|
||||||
|
const badge = buf.highlight > 0
|
||||||
|
? `<span class="badge hl-badge">${buf.highlight}</span>`
|
||||||
|
: buf.unread > 0 ? `<span class="badge">${buf.unread}</span>` : '';
|
||||||
|
node.innerHTML =
|
||||||
|
`<span class="buf-num">${buf.number}</span>` +
|
||||||
|
`<span class="buf-name">${esc(name)}</span>${badge}` +
|
||||||
|
`<button class="buf-close" data-id="${buf.id}" title="Close buffer">×</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNode(id) {
|
||||||
|
const node = bufNodes.get(bKey(id));
|
||||||
|
if (node) { node.remove(); bufNodes.delete(bKey(id)); }
|
||||||
|
rebuildBufList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(item) {
|
||||||
|
if (item.type === 'header') {
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.className = 'buf-group-header';
|
||||||
|
node.dataset.key = item.key;
|
||||||
|
node.textContent = item.label;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const isServer = item.type === 'server';
|
||||||
|
const indent = item.type === 'channel';
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.dataset.key = item.key;
|
||||||
|
node.dataset.id = String(item.buf.id);
|
||||||
|
node.dataset.isServer = isServer ? '1' : '0';
|
||||||
|
node.dataset.indent = indent ? '1' : '0';
|
||||||
|
node.addEventListener('click', () => activateBuffer(node.dataset.id));
|
||||||
|
const classes = ['buffer-item'];
|
||||||
|
if (isServer) classes.push('buf-server');
|
||||||
|
if (indent) classes.push('buf-indented');
|
||||||
|
node.className = classes.join(' ');
|
||||||
|
const buf = item.buf;
|
||||||
|
const name = buf.short_name || buf.name || '?';
|
||||||
|
node.innerHTML =
|
||||||
|
`<span class="buf-num">${buf.number}</span>` +
|
||||||
|
`<span class="buf-name">${esc(name)}</span>` +
|
||||||
|
`<button class="buf-close" data-id="${buf.id}" title="Close buffer">×</button>`;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Activate buffer ──────────────────────────────────────────────────────────
|
||||||
|
export function activateBuffer(id) {
|
||||||
|
const prev = state.activeBufferId;
|
||||||
|
const buf = state.buffers.get(id);
|
||||||
|
if (!buf) return;
|
||||||
|
state.activeBufferId = id;
|
||||||
|
state.scroll.pinned = true;
|
||||||
|
state.scroll.newCount = 0;
|
||||||
|
buf.unread = 0;
|
||||||
|
buf.highlight = 0;
|
||||||
|
if (prev != null && prev !== id) paintNode(prev);
|
||||||
|
paintNode(id);
|
||||||
|
renderChatHeader();
|
||||||
|
renderMessages(buf);
|
||||||
|
renderNicklist(buf);
|
||||||
|
hideNewMsgBanner();
|
||||||
|
updateTitle();
|
||||||
|
el('chat-input').focus();
|
||||||
|
|
||||||
|
// Sync read position back to WeeChat by switching to the buffer there too.
|
||||||
|
// This marks lines as read in WeeChat's state, updating last_read_line_id.
|
||||||
|
// We send it as a direct API input command rather than going through the input box.
|
||||||
|
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
|
||||||
|
state.ws.send(JSON.stringify({
|
||||||
|
request: 'POST /api/input',
|
||||||
|
body: { buffer_name: 'core.weechat', command: `/buffer ${buf.name}` }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { ansiToHtml } from './ansi.js';
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// ─── Prefix truncation ────────────────────────────────────────────────────────
|
||||||
|
// Mirrors weechat.look.prefix_align_max — truncates long nicks in the prefix
|
||||||
|
// column. Strips ANSI to measure visible length, re-wraps with original escape.
|
||||||
|
//
|
||||||
|
// IMPORTANT: the regex literals below must contain a literal ESC byte (0x1b).
|
||||||
|
// Do not modify with automated text replacement tools.
|
||||||
|
export function truncPrefix(raw) {
|
||||||
|
if (!raw) return raw;
|
||||||
|
const visible = raw.replace(/\x1b\[[0-9;]*m/g, '');
|
||||||
|
const max = state.prefixAlignMax;
|
||||||
|
if (visible.length <= max) return raw;
|
||||||
|
// Preserve leading colour escape, truncate visible chars, close with reset
|
||||||
|
const esc = raw.match(/^(\x1b\[[0-9;]*m)*/);
|
||||||
|
const lead = esc ? esc[0] : '';
|
||||||
|
const plain = visible.slice(0, max - 1) + '…';
|
||||||
|
return lead + plain + '\x1b[0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPrefixWidth() {
|
||||||
|
const charWidth = 8.2; // IBM Plex Mono px/char at 13px
|
||||||
|
const px = Math.round(state.prefixAlignMax * charWidth) + 16;
|
||||||
|
document.documentElement.style.setProperty('--prefix-col-width', px + 'px');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat header ──────────────────────────────────────────────────────────────
|
||||||
|
export function renderChatHeader() {
|
||||||
|
const buf = state.buffers.get(state.activeBufferId);
|
||||||
|
if (!buf) return;
|
||||||
|
el('chat-title').textContent = buf.short_name || buf.name || '';
|
||||||
|
el('chat-topic').innerHTML = buf.title ? ansiToHtml(buf.title) : '';
|
||||||
|
const lv = buf.local_variables || {};
|
||||||
|
const isIrc = lv.plugin === 'irc' && lv.type !== 'server';
|
||||||
|
const sfBtn = el('smartfilter-btn');
|
||||||
|
if (isIrc) {
|
||||||
|
const on = state.smartFilter.get(buf.id) !== false;
|
||||||
|
sfBtn.textContent = on ? 'FILTER: ON' : 'FILTER: OFF';
|
||||||
|
sfBtn.classList.toggle('sf-off', !on);
|
||||||
|
sfBtn.style.display = '';
|
||||||
|
} else {
|
||||||
|
sfBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSmartFilter() {
|
||||||
|
const id = state.activeBufferId;
|
||||||
|
if (id == null) return;
|
||||||
|
const cur = state.smartFilter.get(id) !== false;
|
||||||
|
state.smartFilter.set(id, !cur);
|
||||||
|
renderChatHeader();
|
||||||
|
const buf = state.buffers.get(id);
|
||||||
|
if (buf) renderMessages(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message rendering ────────────────────────────────────────────────────────
|
||||||
|
export function renderMessages(buf) {
|
||||||
|
const box = el('messages');
|
||||||
|
box.innerHTML = '';
|
||||||
|
for (const line of buf.lines) appendLine(line, false, buf.lastReadId);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
box.onscroll = onMessagesScroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendLine(line, scroll = true, lastReadId = null) {
|
||||||
|
if (!line.displayed) return;
|
||||||
|
// Smart filter: skip join/part/quit noise tagged by WeeChat
|
||||||
|
if (line.tags && line.tags.includes('irc_smart_filter')) {
|
||||||
|
if (state.smartFilter.get(state.activeBufferId) !== false) return;
|
||||||
|
}
|
||||||
|
const box = el('messages');
|
||||||
|
|
||||||
|
// Read marker divider — insert before the first unread line
|
||||||
|
if (lastReadId !== null && String(line.id) === String(lastReadId)) {
|
||||||
|
// This is the last-read line; the NEXT line will be unread.
|
||||||
|
// We track this by inserting the divider after this line is appended.
|
||||||
|
// We use a sentinel attribute on the box so we know to insert after this row.
|
||||||
|
box.dataset.insertDividerAfterNext = '1';
|
||||||
|
} else if (box.dataset.insertDividerAfterNext === '1') {
|
||||||
|
delete box.dataset.insertDividerAfterNext;
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'read-marker';
|
||||||
|
divider.textContent = '─── unread ───';
|
||||||
|
box.appendChild(divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subLines = (line.message || '').split('\n');
|
||||||
|
const time = line.date ? fmtTime(line.date) : '';
|
||||||
|
const prefix = line.prefix ? ansiToHtml(truncPrefix(line.prefix)) : '';
|
||||||
|
const hlClass = line.highlight ? ' msg-highlight' : '';
|
||||||
|
|
||||||
|
subLines.forEach((sub, i) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'msg-row' + hlClass;
|
||||||
|
if (i === 0) {
|
||||||
|
row.innerHTML =
|
||||||
|
`<span class="msg-time">${time}</span>` +
|
||||||
|
`<span class="msg-prefix">${prefix}</span>` +
|
||||||
|
`<span class="msg-sep"></span>` +
|
||||||
|
`<span class="msg-text">${ansiToHtml(sub)}</span>`;
|
||||||
|
} else {
|
||||||
|
row.innerHTML =
|
||||||
|
`<span class="msg-time"></span>` +
|
||||||
|
`<span class="msg-prefix"></span>` +
|
||||||
|
`<span class="msg-sep"></span>` +
|
||||||
|
`<span class="msg-text">${ansiToHtml(sub)}</span>`;
|
||||||
|
}
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scroll) {
|
||||||
|
if (state.scroll.pinned) {
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
} else {
|
||||||
|
state.scroll.newCount++;
|
||||||
|
showNewMsgBanner(state.scroll.newCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sysMsg(id, text) {
|
||||||
|
if (id != null && id !== state.activeBufferId) return;
|
||||||
|
const box = el('messages');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'msg-row msg-system';
|
||||||
|
row.innerHTML =
|
||||||
|
`<span class="msg-time">${fmtTime(new Date().toISOString())}</span>` +
|
||||||
|
`<span class="msg-prefix">--</span>` +
|
||||||
|
`<span class="msg-sep"></span>` +
|
||||||
|
`<span class="msg-text">${escHtml(text)}</span>`;
|
||||||
|
box.appendChild(row);
|
||||||
|
if (state.scroll.pinned) box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scroll lock ──────────────────────────────────────────────────────────────
|
||||||
|
export function onMessagesScroll() {
|
||||||
|
const box = el('messages');
|
||||||
|
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 2;
|
||||||
|
if (atBottom && !state.scroll.pinned) {
|
||||||
|
state.scroll.pinned = true;
|
||||||
|
state.scroll.newCount = 0;
|
||||||
|
hideNewMsgBanner();
|
||||||
|
} else if (!atBottom && state.scroll.pinned) {
|
||||||
|
state.scroll.pinned = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showNewMsgBanner(count) {
|
||||||
|
let banner = document.getElementById('new-msg-banner');
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'new-msg-banner';
|
||||||
|
banner.className = 'new-msg-banner';
|
||||||
|
banner.addEventListener('click', () => {
|
||||||
|
const box = el('messages');
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
state.scroll.pinned = true;
|
||||||
|
state.scroll.newCount = 0;
|
||||||
|
hideNewMsgBanner();
|
||||||
|
});
|
||||||
|
el('main').appendChild(banner);
|
||||||
|
}
|
||||||
|
banner.textContent = `▼ ${count} new message${count === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideNewMsgBanner() {
|
||||||
|
const b = document.getElementById('new-msg-banner');
|
||||||
|
if (b) b.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers (local) ─────────────────────────────────────────────────────────
|
||||||
|
function fmtTime(iso) {
|
||||||
|
try { return new Date(iso).toLocaleTimeString([],
|
||||||
|
{hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false}); }
|
||||||
|
catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { state, saveSettings, BLOCKED_PORTS } from './state.js';
|
||||||
|
import { initNotifications } from './notifications.js';
|
||||||
|
import { applyPrefixWidth, sysMsg } from './chat.js';
|
||||||
|
import {
|
||||||
|
onBufOpened, onBufUpdated, onBufCleared, onBufClosed,
|
||||||
|
onLineAdded, onNickAdded, onNickRemoved, onGroupChanged,
|
||||||
|
collectNicks, rebuildBufList, activateBuffer,
|
||||||
|
} from './buffers.js';
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// ─── Reconnect state ──────────────────────────────────────────────────────────
|
||||||
|
const reconnect = {
|
||||||
|
enabled: false, // set true after first successful connect; false on user disconnect
|
||||||
|
timer: null,
|
||||||
|
backoff: 1000, // ms, doubles each attempt up to MAX_BACKOFF
|
||||||
|
};
|
||||||
|
const MAX_BACKOFF = 30_000;
|
||||||
|
const INITIAL_BACKOFF = 1_000;
|
||||||
|
|
||||||
|
// ─── Float-safe ID parser ─────────────────────────────────────────────────────
|
||||||
|
// The relay sometimes sends buffer IDs as JSON floats (e.g. 1709932823649184.0).
|
||||||
|
// Parsing those as Number loses precision — parse as string when safe.
|
||||||
|
export function parseId(v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'string') return v; // already a string key
|
||||||
|
if (typeof v === 'number') return String(Math.round(v)); // float → integer string
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WebSocket send ───────────────────────────────────────────────────────────
|
||||||
|
export function wsSend(obj) {
|
||||||
|
if (state.ws && state.ws.readyState === WebSocket.OPEN)
|
||||||
|
state.ws.send(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connect ──────────────────────────────────────────────────────────────────
|
||||||
|
export function connect() {
|
||||||
|
const host = el('host').value.trim();
|
||||||
|
const port = parseInt(el('port').value, 10);
|
||||||
|
const pass = el('password').value;
|
||||||
|
const tls = el('tls').checked;
|
||||||
|
|
||||||
|
if (!host || !port) return showConnError('Host and port are required.');
|
||||||
|
if (BLOCKED_PORTS.has(port)) return showConnError(
|
||||||
|
`Port ${port} is blocked by browsers. Use a different port — e.g. 9000.`);
|
||||||
|
|
||||||
|
reconnect.enabled = false; // reset; re-enabled on first successful connect
|
||||||
|
clearReconnectTimer();
|
||||||
|
connectTo(host, port, pass, tls);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectTo(host, port, pass, tls) {
|
||||||
|
const hostFmt = (host.includes(':') && !host.startsWith('[')) ? `[${host}]` : host;
|
||||||
|
const url = `${tls ? 'wss' : 'ws'}://${hostFmt}:${port}/api`;
|
||||||
|
|
||||||
|
hideConnError();
|
||||||
|
setConnecting(true);
|
||||||
|
|
||||||
|
let ws;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url, [
|
||||||
|
'api.weechat',
|
||||||
|
`base64url.bearer.authorization.weechat.${buildAuth(pass)}`
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
setConnecting(false);
|
||||||
|
showConnError(`Could not open WebSocket: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
setConnecting(false);
|
||||||
|
showConnError('Connection timed out. Check host, port, and relay config.');
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
state.ws = ws;
|
||||||
|
state.connected = true;
|
||||||
|
reconnect.enabled = true;
|
||||||
|
reconnect.backoff = INITIAL_BACKOFF;
|
||||||
|
Object.assign(state.settings, { host, port, pass, tls });
|
||||||
|
saveSettings();
|
||||||
|
onConnected();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = e => {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(e.data); } catch { return; }
|
||||||
|
if (Array.isArray(data)) data.forEach(dispatch);
|
||||||
|
else dispatch(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
setConnecting(false);
|
||||||
|
if (location.protocol === 'https:' && !tls) {
|
||||||
|
showConnError('Secure connection error — cannot connect to an unencrypted relay (ws://) from an HTTPS page.');
|
||||||
|
} else if (!reconnect.enabled) {
|
||||||
|
showConnError('WebSocket error. Check host, port, TLS, and relay config.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (state.connected) {
|
||||||
|
onDisconnected(/* userInitiated */ false);
|
||||||
|
if (reconnect.enabled) scheduleReconnect(host, port, pass, tls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(host, port, pass, tls) {
|
||||||
|
clearReconnectTimer();
|
||||||
|
const delay = reconnect.backoff;
|
||||||
|
reconnect.backoff = Math.min(reconnect.backoff * 2, MAX_BACKOFF);
|
||||||
|
setStatus('connecting', `RECONNECTING in ${Math.round(delay/1000)}s…`);
|
||||||
|
reconnect.timer = setTimeout(() => {
|
||||||
|
if (!reconnect.enabled) return;
|
||||||
|
setStatus('connecting', 'RECONNECTING…');
|
||||||
|
connectTo(host, port, pass, tls);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReconnectTimer() {
|
||||||
|
if (reconnect.timer) { clearTimeout(reconnect.timer); reconnect.timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnect() {
|
||||||
|
reconnect.enabled = false;
|
||||||
|
clearReconnectTimer();
|
||||||
|
wsSend({ request: 'DELETE /api/sync' });
|
||||||
|
onDisconnected(/* userInitiated */ true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
function buildAuth(pw) {
|
||||||
|
return btoa('plain:' + pw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message dispatch ─────────────────────────────────────────────────────────
|
||||||
|
function dispatch(msg) {
|
||||||
|
if (!msg) return;
|
||||||
|
if (msg.code === 0 && msg.event_name) { handleEvent(msg); return; }
|
||||||
|
if (msg.request_id === 'init' && msg.body_type === 'buffers') { handleInit(msg); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEvent(msg) {
|
||||||
|
switch (msg.event_name) {
|
||||||
|
case 'buffer_opened': onBufOpened(msg.body); break;
|
||||||
|
case 'buffer_closed': onBufClosed(msg.buffer_id); break;
|
||||||
|
case 'buffer_renamed':
|
||||||
|
case 'buffer_title_changed':
|
||||||
|
case 'buffer_localvar_added':
|
||||||
|
case 'buffer_localvar_changed':
|
||||||
|
case 'buffer_localvar_removed':
|
||||||
|
case 'buffer_moved':
|
||||||
|
case 'buffer_merged':
|
||||||
|
case 'buffer_unmerged':
|
||||||
|
case 'buffer_hidden':
|
||||||
|
case 'buffer_unhidden': onBufUpdated(msg.body); break;
|
||||||
|
case 'buffer_cleared': onBufCleared(msg.buffer_id); break;
|
||||||
|
case 'buffer_line_added': onLineAdded(msg.buffer_id, msg.body); break;
|
||||||
|
case 'nicklist_nick_added':
|
||||||
|
case 'nicklist_nick_changed': onNickAdded(msg.buffer_id, msg.body); break;
|
||||||
|
case 'nicklist_nick_removing': onNickRemoved(msg.buffer_id, msg.body); break;
|
||||||
|
case 'nicklist_group_added':
|
||||||
|
case 'nicklist_group_changed': onGroupChanged(msg.buffer_id); break;
|
||||||
|
case 'upgrade': sysMsg(null, '⟳ WeeChat upgrading…'); break;
|
||||||
|
case 'upgrade_ended': sysMsg(null, '✓ WeeChat upgrade complete.'); break;
|
||||||
|
case 'quit': onDisconnected(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
|
function onConnected() {
|
||||||
|
setStatus('connected', 'CONNECTED');
|
||||||
|
initNotifications();
|
||||||
|
wsSend([
|
||||||
|
{ request: 'GET /api/buffers?lines=-200&nicks=true&colors=ansi', request_id: 'init' },
|
||||||
|
{ request: 'POST /api/sync', body: { nicks: true, colors: 'ansi' } }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInit(msg) {
|
||||||
|
state.buffers.clear();
|
||||||
|
const cfg = window.CATHODE_CONFIG || {};
|
||||||
|
if (cfg.prefixAlignMax) {
|
||||||
|
state.prefixAlignMax = cfg.prefixAlignMax;
|
||||||
|
state.settings.prefixAlignMax = cfg.prefixAlignMax;
|
||||||
|
} else if (state.settings.prefixAlignMax) {
|
||||||
|
state.prefixAlignMax = state.settings.prefixAlignMax;
|
||||||
|
}
|
||||||
|
for (const buf of (msg.body || [])) {
|
||||||
|
const nicks = {};
|
||||||
|
collectNicks(buf.nicklist_root, nicks);
|
||||||
|
// Use parseId to handle float IDs from the relay
|
||||||
|
const id = parseId(buf.id) ?? buf.id;
|
||||||
|
state.buffers.set(id, { ...buf, id, lines: buf.lines||[], nicks, unread:0, highlight:0,
|
||||||
|
lastReadId: buf.last_read_line_id ? parseId(buf.last_read_line_id) : null });
|
||||||
|
if (!state.smartFilter.has(id)) state.smartFilter.set(id, true);
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
el('disconnect-btn').style.display = '';
|
||||||
|
applyPrefixWidth();
|
||||||
|
showScreen('chat');
|
||||||
|
rebuildBufList();
|
||||||
|
state.scroll.pinned = true;
|
||||||
|
state.scroll.newCount = 0;
|
||||||
|
const first = state.buffers.keys().next().value;
|
||||||
|
if (first != null) activateBuffer(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onDisconnected(userInitiated = true) {
|
||||||
|
if (!state.connected && !state.ws) return;
|
||||||
|
state.connected = false;
|
||||||
|
if (state.ws) { try { state.ws.close(); } catch(_){} state.ws = null; }
|
||||||
|
setStatus('disconnected', 'DISCONNECTED');
|
||||||
|
el('disconnect-btn').style.display = 'none';
|
||||||
|
// Only return to connect screen on explicit user disconnect or first-time failure
|
||||||
|
// On auto-reconnect we stay on the chat screen showing the status
|
||||||
|
if (userInitiated) {
|
||||||
|
showScreen('connect');
|
||||||
|
state.buffers.clear();
|
||||||
|
state.activeBufferId = null;
|
||||||
|
state.scroll.pinned = true;
|
||||||
|
state.scroll.newCount = 0;
|
||||||
|
el('buffer-list').innerHTML = '';
|
||||||
|
el('messages').innerHTML = '';
|
||||||
|
el('nicklist').innerHTML = '';
|
||||||
|
const banner = document.getElementById('new-msg-banner');
|
||||||
|
if (banner) banner.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UI helpers (connection-screen) ──────────────────────────────────────────
|
||||||
|
function showConnError(msg) {
|
||||||
|
el('conn-error').textContent = msg;
|
||||||
|
el('conn-error').style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideConnError() { el('conn-error').style.display = 'none'; }
|
||||||
|
|
||||||
|
function setConnecting(on) {
|
||||||
|
el('connect-btn').disabled = on;
|
||||||
|
el('connect-btn').textContent = on ? 'CONNECTING…' : 'CONNECT';
|
||||||
|
if (on) setStatus('connecting', 'CONNECTING…');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(s, text) {
|
||||||
|
el('status-dot').className = 'status-dot ' + s;
|
||||||
|
el('status-text').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showScreen(name) {
|
||||||
|
el('connect-screen').style.display = name === 'connect' ? '' : 'none';
|
||||||
|
el('chat-screen').style.display = name === 'chat' ? '' : 'none';
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// Emoji tab completion via emoji-mart SearchIndex.
|
||||||
|
// Lazily initialised on first use so it doesn't block startup.
|
||||||
|
let emojiSearchReady = false;
|
||||||
|
async function initEmojiSearch() {
|
||||||
|
if (emojiSearchReady) return;
|
||||||
|
if (!window.EmojiMart?.SearchIndex) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('vendor/emoji-data.json');
|
||||||
|
const data = await res.json();
|
||||||
|
await EmojiMart.init({ data });
|
||||||
|
emojiSearchReady = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Emoji search init failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchEmoji(stem) {
|
||||||
|
await initEmojiSearch();
|
||||||
|
if (!emojiSearchReady) return [];
|
||||||
|
const results = await EmojiMart.SearchIndex.search(stem);
|
||||||
|
return (results || []).map(e => e.skins[0].native + e.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hist = { lines: [], pos: -1, draft: '' };
|
||||||
|
const tab = { matches: [], pos: -1, stem: '' };
|
||||||
|
|
||||||
|
export function sendInput(wsSend) {
|
||||||
|
const buf = state.buffers.get(state.activeBufferId);
|
||||||
|
const text = el('chat-input').value.trim();
|
||||||
|
if (!buf || !text) return;
|
||||||
|
hist.lines.unshift(text);
|
||||||
|
hist.pos = -1;
|
||||||
|
tab.matches = [];
|
||||||
|
tab.pos = -1;
|
||||||
|
wsSend({ request: 'POST /api/input', body: { buffer_name: buf.name, command: text } });
|
||||||
|
el('chat-input').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onInputKey(e, wsSend) {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
doTabComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== 'Shift') { tab.matches = []; tab.pos = -1; }
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendInput(wsSend);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (hist.pos === -1) hist.draft = el('chat-input').value;
|
||||||
|
hist.pos = Math.min(hist.pos + 1, hist.lines.length - 1);
|
||||||
|
if (hist.lines[hist.pos] !== undefined) el('chat-input').value = hist.lines[hist.pos];
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
hist.pos = Math.max(hist.pos - 1, -1);
|
||||||
|
el('chat-input').value = hist.pos === -1 ? hist.draft : hist.lines[hist.pos];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doTabComplete() {
|
||||||
|
const input = el('chat-input');
|
||||||
|
const val = input.value;
|
||||||
|
const caret = input.selectionStart;
|
||||||
|
const before = val.slice(0, caret);
|
||||||
|
const tokenMatch = before.match(/(\S+)$/);
|
||||||
|
const token = tokenMatch ? tokenMatch[1] : '';
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
|
||||||
|
if (tab.matches.length === 0 || tab.stem !== lower) {
|
||||||
|
const buf = state.buffers.get(state.activeBufferId);
|
||||||
|
if (!buf) return;
|
||||||
|
|
||||||
|
const prevWord = before.slice(0, before.length - token.length).trim().split(/\s+/).pop() || '';
|
||||||
|
const wantChannel = token.startsWith('#') || prevWord.toLowerCase() === '/join';
|
||||||
|
const wantEmoji = token.startsWith(':') && token.length > 1;
|
||||||
|
|
||||||
|
if (wantEmoji) {
|
||||||
|
const stem = token.slice(1).replace(/:$/, '').toLowerCase();
|
||||||
|
// Async: kick off search and return; next Tab press will use the results
|
||||||
|
searchEmoji(stem).then(results => {
|
||||||
|
tab.matches = results;
|
||||||
|
tab.stem = lower;
|
||||||
|
tab.pos = -1;
|
||||||
|
if (results.length > 0) doComplete(input, val, caret, before, token);
|
||||||
|
});
|
||||||
|
return; // first Tab fetches; subsequent Tab presses cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates;
|
||||||
|
if (wantChannel) {
|
||||||
|
candidates = [...state.buffers.values()]
|
||||||
|
.filter(b => {
|
||||||
|
const lv = b.local_variables || {};
|
||||||
|
return lv.type === 'channel' || (b.short_name || '').startsWith('#');
|
||||||
|
})
|
||||||
|
.map(b => b.short_name || b.name);
|
||||||
|
} else {
|
||||||
|
candidates = Object.values(buf.nicks || {}).map(n => n.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.matches = candidates.filter(c => c.toLowerCase().startsWith(lower));
|
||||||
|
tab.matches.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
tab.pos = -1;
|
||||||
|
tab.stem = lower;
|
||||||
|
if (tab.matches.length === 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
doComplete(input, val, caret, before, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doComplete(input, val, caret, before, token) {
|
||||||
|
if (tab.matches.length === 0) return;
|
||||||
|
tab.pos = (tab.pos + 1) % tab.matches.length;
|
||||||
|
const match = tab.matches[tab.pos];
|
||||||
|
const isEmoji = /^\p{Emoji}/u.test(match);
|
||||||
|
const insert = isEmoji ? [...match][0] : match;
|
||||||
|
const atStart = before.trimStart() === token;
|
||||||
|
const isNick = !insert.startsWith('#') && !isEmoji;
|
||||||
|
const suffix = (atStart && isNick) ? ': ' : ' ';
|
||||||
|
const completed = before.slice(0, before.length - token.length) + insert + suffix;
|
||||||
|
input.value = completed + val.slice(caret);
|
||||||
|
input.selectionStart = input.selectionEnd = completed.length;
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { initTheme, toggleTheme,
|
||||||
|
openSettings, closeSettings,
|
||||||
|
saveSettingsPanel, updateBackendVis,
|
||||||
|
checkPort, openCertPage } from './settings.js';
|
||||||
|
import { connect, disconnect, wsSend } from './connection.js';
|
||||||
|
import { toggleSmartFilter } from './chat.js';
|
||||||
|
import { sendInput, onInputKey } from './input.js';
|
||||||
|
import { uploadFile, initDragDrop } from './upload.js';
|
||||||
|
import { setWsSend } from './buffers.js';
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initTheme();
|
||||||
|
|
||||||
|
// ── Operator config (config.js → window.CATHODE_CONFIG) ──────────────────
|
||||||
|
const cfg = window.CATHODE_CONFIG || {};
|
||||||
|
if (cfg.uploadBackend) {
|
||||||
|
state.settings.uploadBackend = cfg.uploadBackend;
|
||||||
|
state.settings.filehostUrl = cfg.filehostUrl || '';
|
||||||
|
state.settings.imgurClientId = cfg.imgurClientId || '';
|
||||||
|
const sec = el('settings-upload-section');
|
||||||
|
if (sec) sec.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (cfg.prefixAlignMax) {
|
||||||
|
state.prefixAlignMax = cfg.prefixAlignMax;
|
||||||
|
state.settings.prefixAlignMax = cfg.prefixAlignMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Restore saved connection settings ────────────────────────────────────
|
||||||
|
const s = state.settings;
|
||||||
|
if (s.host) el('host').value = s.host;
|
||||||
|
if (s.port) el('port').value = s.port;
|
||||||
|
if (s.tls !== undefined) el('tls').checked = s.tls;
|
||||||
|
if (s.prefixAlignMax) state.prefixAlignMax = s.prefixAlignMax;
|
||||||
|
|
||||||
|
// ── Initial UI state ──────────────────────────────────────────────────────
|
||||||
|
el('connect-screen').style.display = '';
|
||||||
|
el('chat-screen').style.display = 'none';
|
||||||
|
el('status-dot').className = 'status-dot disconnected';
|
||||||
|
el('status-text').textContent = 'DISCONNECTED';
|
||||||
|
el('disconnect-btn').style.display = 'none';
|
||||||
|
|
||||||
|
if (location.protocol === 'https:') {
|
||||||
|
el('http-notice').style.display = '';
|
||||||
|
el('tls-locked-note').style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire wsSend into buffers (nick menu commands) ─────────────────────────
|
||||||
|
setWsSend(wsSend);
|
||||||
|
|
||||||
|
// ── Connection ────────────────────────────────────────────────────────────
|
||||||
|
el('connect-btn') .addEventListener('click', connect);
|
||||||
|
el('disconnect-btn').addEventListener('click', disconnect);
|
||||||
|
['host','port','password'].forEach(id =>
|
||||||
|
el(id).addEventListener('keydown', e => { if (e.key === 'Enter') connect(); })
|
||||||
|
);
|
||||||
|
el('port').addEventListener('input', checkPort);
|
||||||
|
|
||||||
|
// ── Chat input ────────────────────────────────────────────────────────────
|
||||||
|
el('send-btn') .addEventListener('click', () => sendInput(wsSend));
|
||||||
|
el('chat-input').addEventListener('keydown', e => onInputKey(e, wsSend));
|
||||||
|
|
||||||
|
// ── Smart filter ──────────────────────────────────────────────────────────
|
||||||
|
el('smartfilter-btn').addEventListener('click', toggleSmartFilter);
|
||||||
|
|
||||||
|
// ── Buffer close buttons (delegated) ─────────────────────────────────────
|
||||||
|
el('buffer-list').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.buf-close');
|
||||||
|
if (!btn) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
const buf = state.buffers.get(btn.dataset.id);
|
||||||
|
if (!buf) return;
|
||||||
|
wsSend({ request: 'POST /api/input', body: { buffer_name: buf.name, command: '/close' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sidebar join button ───────────────────────────────────────────────────
|
||||||
|
el('sidebar-join-btn').addEventListener('click', () => {
|
||||||
|
const ch = prompt('Channel to join (e.g. #weechat):');
|
||||||
|
if (!ch) return;
|
||||||
|
const buf = state.buffers.get(state.activeBufferId);
|
||||||
|
if (!buf) return;
|
||||||
|
wsSend({ request: 'POST /api/input', body: { buffer_name: buf.name, command: '/join ' + ch } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Media preview (document-level delegation) ─────────────────────────────
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.media-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
const url = btn.dataset.url.replace(/&/g, '&');
|
||||||
|
const type = btn.dataset.type;
|
||||||
|
const existing = btn.nextElementSibling;
|
||||||
|
if (existing && existing.classList.contains('media-preview')) {
|
||||||
|
existing.remove();
|
||||||
|
btn.textContent = type === 'img' ? 'Show Image' : 'Show Video';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wrap = document.createElement('span');
|
||||||
|
wrap.className = 'media-preview';
|
||||||
|
if (type === 'img') {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
img.className = 'preview-img';
|
||||||
|
img.alt = 'image';
|
||||||
|
img.title = 'Click to open full size';
|
||||||
|
img.addEventListener('click', () => window.open(url, '_blank'));
|
||||||
|
wrap.appendChild(img);
|
||||||
|
} else {
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
vid.src = url;
|
||||||
|
vid.controls = true;
|
||||||
|
vid.className = 'preview-vid';
|
||||||
|
wrap.appendChild(vid);
|
||||||
|
}
|
||||||
|
btn.after(wrap);
|
||||||
|
btn.textContent = type === 'img' ? 'Hide Image' : 'Hide Video';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Theme ─────────────────────────────────────────────────────────────────
|
||||||
|
el('theme-toggle').addEventListener('click', toggleTheme);
|
||||||
|
window.addEventListener('cathode:themechange', () => {
|
||||||
|
const buf = state.buffers.get(state.activeBufferId);
|
||||||
|
if (buf) import('./chat.js').then(m => m.renderMessages(buf));
|
||||||
|
if (_emojiPicker) { _emojiPicker.remove(); _emojiPicker = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Emoji picker ──────────────────────────────────────────────────────────
|
||||||
|
let _emojiPicker = null;
|
||||||
|
let _emojiDataCache = null;
|
||||||
|
|
||||||
|
async function getEmojiData() {
|
||||||
|
if (_emojiDataCache) return _emojiDataCache;
|
||||||
|
const res = await fetch('vendor/emoji-data.json');
|
||||||
|
_emojiDataCache = await res.json();
|
||||||
|
return _emojiDataCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
el('emoji-btn').addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (_emojiPicker) {
|
||||||
|
_emojiPicker.remove(); _emojiPicker = null; return;
|
||||||
|
}
|
||||||
|
if (!window.EmojiMart?.Picker) return;
|
||||||
|
const data = await getEmojiData();
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||||
|
_emojiPicker = new EmojiMart.Picker({
|
||||||
|
data,
|
||||||
|
theme,
|
||||||
|
onEmojiSelect: emoji => {
|
||||||
|
const input = el('chat-input');
|
||||||
|
const pos = input.selectionStart ?? input.value.length;
|
||||||
|
input.value =
|
||||||
|
input.value.slice(0, pos) + emoji.native + input.value.slice(pos);
|
||||||
|
input.selectionStart = input.selectionEnd = pos + [...emoji.native].length;
|
||||||
|
_emojiPicker.remove(); _emojiPicker = null;
|
||||||
|
input.focus();
|
||||||
|
},
|
||||||
|
onClickOutside: () => {
|
||||||
|
if (_emojiPicker) { _emojiPicker.remove(); _emojiPicker = null; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
_emojiPicker.style.cssText = 'position:absolute;bottom:48px;right:8px;z-index:200;';
|
||||||
|
el('main').appendChild(_emojiPicker);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cert helper ───────────────────────────────────────────────────────────
|
||||||
|
el('cert-btn').addEventListener('click', openCertPage);
|
||||||
|
|
||||||
|
// ── Upload ────────────────────────────────────────────────────────────────
|
||||||
|
el('upload-btn') .addEventListener('click', () => el('upload-file').click());
|
||||||
|
el('upload-file').addEventListener('change', e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) { uploadFile(file); e.target.value = ''; }
|
||||||
|
});
|
||||||
|
initDragDrop();
|
||||||
|
|
||||||
|
// ── Settings panel ────────────────────────────────────────────────────────
|
||||||
|
el('settings-btn') .addEventListener('click', openSettings);
|
||||||
|
el('settings-close') .addEventListener('click', closeSettings);
|
||||||
|
el('settings-save') .addEventListener('click', saveSettingsPanel);
|
||||||
|
el('s-upload-backend').addEventListener('change', updateBackendVis);
|
||||||
|
el('settings-overlay').addEventListener('click', e => {
|
||||||
|
if (e.target === el('settings-overlay')) closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Global keyboard shortcuts ─────────────────────────────────────────────
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeSettings();
|
||||||
|
if (_emojiPicker) { _emojiPicker.remove(); _emojiPicker = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
export async function initNotifications() {
|
||||||
|
if (!('Notification' in window)) return;
|
||||||
|
if (Notification.permission === 'default') {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeNotify(buf, line, activateBufferFn) {
|
||||||
|
if (!line) return;
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||||
|
|
||||||
|
// Buffer-level notify gate.
|
||||||
|
// WeeChat's buffer.notify setting only gates the hotlist — it does NOT affect
|
||||||
|
// line.highlight or line.notify_level, so we implement our own gate here.
|
||||||
|
// The relay API doesn't expose buffer.notify, so we use config flags instead.
|
||||||
|
const lv = buf.local_variables || {};
|
||||||
|
const type = lv.type || '';
|
||||||
|
const cfg = window.CATHODE_CONFIG || {};
|
||||||
|
if (type === 'server' && cfg.notifyServerBuffers === false) return;
|
||||||
|
|
||||||
|
// Detect highlight — check all three sources WeeChat may use
|
||||||
|
const tags = Array.isArray(line.tags) ? line.tags : [];
|
||||||
|
const isHL = line.highlight
|
||||||
|
|| (line.notify_level >= 3)
|
||||||
|
|| tags.includes('notify_highlight');
|
||||||
|
const isPrivate = (line.notify_level === 2)
|
||||||
|
|| tags.includes('notify_private');
|
||||||
|
|
||||||
|
if (!isHL && !isPrivate) return;
|
||||||
|
|
||||||
|
// Suppress only when tab is visible AND the user is on this exact buffer
|
||||||
|
const focused = document.visibilityState === 'visible'
|
||||||
|
&& state.activeBufferId === buf.id;
|
||||||
|
if (focused) return;
|
||||||
|
|
||||||
|
const bufName = buf.short_name || buf.name || '?';
|
||||||
|
const stripAnsi = s => (s || '').replace(/\x1b\[[0-9;]*m/g, '').trim();
|
||||||
|
const prefix = stripAnsi(line.prefix);
|
||||||
|
const body = stripAnsi(line.message);
|
||||||
|
const title = isPrivate ? `PM from ${prefix}` : `${prefix} in ${bufName}`;
|
||||||
|
|
||||||
|
const n = new Notification(title, {
|
||||||
|
body,
|
||||||
|
icon: 'apple-touch-icon.png',
|
||||||
|
tag: `cathode-${buf.id}`, // collapses repeated pings from same buffer
|
||||||
|
});
|
||||||
|
n.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
activateBufferFn(buf.id);
|
||||||
|
n.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTitle() {
|
||||||
|
let total = 0;
|
||||||
|
for (const buf of state.buffers.values()) total += buf.highlight;
|
||||||
|
document.title = total > 0 ? `(${total}) Cathode` : 'Cathode';
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { state, saveSettings, BLOCKED_PORTS } from './state.js';
|
||||||
|
import { applyPrefixWidth } from './chat.js';
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// ─── Theme ────────────────────────────────────────────────────────────────────
|
||||||
|
export function initTheme() {
|
||||||
|
setTheme(localStorage.getItem('cathode_theme') || 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(t) {
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
localStorage.setItem('cathode_theme', t);
|
||||||
|
el('theme-toggle').textContent = t === 'dark' ? '◐ LIGHT' : '◑ DARK';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTheme() {
|
||||||
|
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
setTheme(cur === 'dark' ? 'light' : 'dark');
|
||||||
|
// Re-render dispatched via custom event so chat.js can respond without circular import
|
||||||
|
window.dispatchEvent(new CustomEvent('cathode:themechange'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings panel ───────────────────────────────────────────────────────────
|
||||||
|
export function openSettings() {
|
||||||
|
const s = state.settings;
|
||||||
|
el('s-upload-backend').value = s.uploadBackend || 'none';
|
||||||
|
el('s-filehost-url').value = s.filehostUrl || '';
|
||||||
|
el('s-imgur-key').value = s.imgurClientId || '';
|
||||||
|
el('s-prefix-max').value = s.prefixAlignMax || state.prefixAlignMax;
|
||||||
|
updateBackendVis();
|
||||||
|
el('settings-overlay').style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSettings() {
|
||||||
|
el('settings-overlay').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettingsPanel() {
|
||||||
|
state.settings.uploadBackend = el('s-upload-backend').value;
|
||||||
|
state.settings.filehostUrl = el('s-filehost-url').value.trim();
|
||||||
|
state.settings.imgurClientId = el('s-imgur-key').value.trim();
|
||||||
|
const pm = parseInt(el('s-prefix-max').value, 10);
|
||||||
|
if (pm >= 4 && pm <= 64) {
|
||||||
|
state.settings.prefixAlignMax = pm;
|
||||||
|
state.prefixAlignMax = pm;
|
||||||
|
applyPrefixWidth();
|
||||||
|
}
|
||||||
|
saveSettings();
|
||||||
|
closeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBackendVis() {
|
||||||
|
const v = el('s-upload-backend').value;
|
||||||
|
el('s-filehost-opts').style.display = v === 'filehost' ? '' : 'none';
|
||||||
|
el('s-imgur-opts').style.display = v === 'imgur' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Port warning ─────────────────────────────────────────────────────────────
|
||||||
|
export function checkPort() {
|
||||||
|
const port = parseInt(el('port').value, 10);
|
||||||
|
const show = BLOCKED_PORTS.has(port);
|
||||||
|
el('port-warning').textContent = show
|
||||||
|
? `⚠ Port ${port} is blocked by browsers. Use a different port (e.g. 9000).` : '';
|
||||||
|
el('port-warning').style.display = show ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cert helper ──────────────────────────────────────────────────────────────
|
||||||
|
export function openCertPage() {
|
||||||
|
const host = el('host').value.trim();
|
||||||
|
const port = parseInt(el('port').value, 10);
|
||||||
|
if (!host || !port) return alert('Enter host and port first.');
|
||||||
|
window.open(`https://${host}:${port}/api/version`, '_blank');
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
// ─── Blocked ports (browser hard-block list) ─────────────────────────────────
|
||||||
|
export const BLOCKED_PORTS = new Set([
|
||||||
|
1,7,9,11,13,15,17,19,20,21,22,23,25,37,42,43,53,69,77,79,87,95,
|
||||||
|
101,102,103,104,107,109,110,111,113,115,117,119,123,135,137,139,
|
||||||
|
143,161,179,389,427,465,512,513,514,515,526,530,531,532,540,548,
|
||||||
|
554,556,563,587,601,636,989,990,993,995,1719,1720,1723,2049,3659,
|
||||||
|
4045,5060,5061,6000,6566,6665,6666,6667,6668,6669,6697,10080
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ─── Settings persistence ─────────────────────────────────────────────────────
|
||||||
|
export function loadSettings() {
|
||||||
|
try { return JSON.parse(localStorage.getItem('cathode_settings') || '{}'); }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings() {
|
||||||
|
localStorage.setItem('cathode_settings', JSON.stringify(state.settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared application state ─────────────────────────────────────────────────
|
||||||
|
export const state = {
|
||||||
|
ws: null,
|
||||||
|
connected: false,
|
||||||
|
buffers: new Map(), // id (number) → buffer object
|
||||||
|
activeBufferId: null,
|
||||||
|
settings: loadSettings(),
|
||||||
|
prefixAlignMax: 16, // mirrors weechat.look.prefix_align_max
|
||||||
|
scroll: { pinned: true, newCount: 0 },
|
||||||
|
smartFilter: new Map(), // bufferId → boolean
|
||||||
|
};
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
const el = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// ─── Upload dispatch ──────────────────────────────────────────────────────────
|
||||||
|
export async function uploadFile(file) {
|
||||||
|
const s = state.settings;
|
||||||
|
const backend = s.uploadBackend || 'none';
|
||||||
|
if (backend === 'none') {
|
||||||
|
showUploadError('No upload backend configured. Open ⚙ Settings to set one up.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadState('uploading');
|
||||||
|
try {
|
||||||
|
let url;
|
||||||
|
if (backend === 'filehost') url = await uploadToFilehost(file, s.filehostUrl);
|
||||||
|
else if (backend === 'imgur') url = await uploadToImgur(file, s.imgurClientId);
|
||||||
|
|
||||||
|
setUploadState('ok');
|
||||||
|
setTimeout(() => setUploadState('idle'), 2000);
|
||||||
|
|
||||||
|
const input = el('chat-input');
|
||||||
|
const pos = input.selectionStart || input.value.length;
|
||||||
|
const sep = input.value.length > 0 && !input.value.endsWith(' ') ? ' ' : '';
|
||||||
|
input.value = input.value.slice(0, pos) + sep + url + input.value.slice(pos);
|
||||||
|
input.focus();
|
||||||
|
input.selectionStart = input.selectionEnd = pos + sep.length + url.length;
|
||||||
|
} catch (err) {
|
||||||
|
setUploadState('err');
|
||||||
|
setTimeout(() => setUploadState('idle'), 3000);
|
||||||
|
showUploadError(`Upload failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToFilehost(file, baseUrl) {
|
||||||
|
if (!baseUrl) throw new Error('Filehost URL not configured in Settings.');
|
||||||
|
const url = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file, file.name);
|
||||||
|
const res = await fetch(url, { method: 'POST', body: form });
|
||||||
|
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||||
|
const text = (await res.text()).trim();
|
||||||
|
if (!text.startsWith('http')) throw new Error(`Unexpected response: ${text}`);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToImgur(file, clientId) {
|
||||||
|
if (!clientId) throw new Error('Imgur Client ID not configured in Settings.');
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image', file);
|
||||||
|
const res = await fetch('https://api.imgur.com/3/image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Client-ID ${clientId}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.success) throw new Error(json.data?.error || 'Imgur upload failed');
|
||||||
|
return json.data.link;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadState(s) {
|
||||||
|
const btn = el('upload-btn');
|
||||||
|
btn.classList.remove('uploading', 'upload-ok', 'upload-err');
|
||||||
|
if (s === 'uploading') { btn.classList.add('uploading'); btn.textContent = '⏳'; }
|
||||||
|
else if (s === 'ok') { btn.classList.add('upload-ok'); btn.textContent = '✓'; }
|
||||||
|
else if (s === 'err') { btn.classList.add('upload-err'); btn.textContent = '✗'; }
|
||||||
|
else { btn.textContent = '📎'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUploadError(msg) {
|
||||||
|
const box = el('messages');
|
||||||
|
if (!box) return;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'msg-row msg-system';
|
||||||
|
row.style.color = 'var(--status-disc)';
|
||||||
|
row.innerHTML =
|
||||||
|
`<span class="msg-time"></span>` +
|
||||||
|
`<span class="msg-prefix">upload</span>` +
|
||||||
|
`<span class="msg-sep"></span>` +
|
||||||
|
`<span class="msg-text">${msg}</span>`;
|
||||||
|
box.appendChild(row);
|
||||||
|
if (state.scroll.pinned) box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Drag & drop and clipboard paste ─────────────────────────────────────────
|
||||||
|
export function initDragDrop() {
|
||||||
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
window.addEventListener('dragenter', e => {
|
||||||
|
if (!e.dataTransfer.types.includes('Files')) return;
|
||||||
|
dragCounter++;
|
||||||
|
el('drag-overlay').style.display = 'flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('dragleave', () => {
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0;
|
||||||
|
el('drag-overlay').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('dragover', e => {
|
||||||
|
if (!e.dataTransfer.types.includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter = 0;
|
||||||
|
el('drag-overlay').style.display = 'none';
|
||||||
|
if (!state.connected) return;
|
||||||
|
const files = [...e.dataTransfer.files];
|
||||||
|
if (files.length) uploadFile(files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paste image from clipboard
|
||||||
|
el('chat-input').addEventListener('paste', e => {
|
||||||
|
const items = [...(e.clipboardData?.items || [])];
|
||||||
|
const imageItem = items.find(i => i.kind === 'file' && i.type.startsWith('image/'));
|
||||||
|
if (!imageItem) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const file = imageItem.getAsFile();
|
||||||
|
if (file) uploadFile(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Caddyfile — Cathode + WeeChat relay reverse proxy
|
||||||
|
#
|
||||||
|
# This config does two things:
|
||||||
|
# 1. Serves the Cathode static files at the root
|
||||||
|
# 2. Proxies /api/* to WeeChat's relay (API protocol) with WebSocket support
|
||||||
|
#
|
||||||
|
# Caddy handles TLS automatically (Let's Encrypt) when you use a real domain.
|
||||||
|
# Replace cathode.example.com with your actual domain.
|
||||||
|
|
||||||
|
cathode.example.com {
|
||||||
|
|
||||||
|
# Serve Cathode static files
|
||||||
|
root * /var/www/cathode
|
||||||
|
file_server
|
||||||
|
|
||||||
|
# Proxy WeeChat relay API (REST + WebSocket)
|
||||||
|
# WeeChat listens on localhost:9000 — adjust if needed
|
||||||
|
handle /api* {
|
||||||
|
reverse_proxy localhost:9000 {
|
||||||
|
# Pass the real client IP to WeeChat
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
|
||||||
|
# Required for WebSocket upgrade
|
||||||
|
transport http {
|
||||||
|
# If WeeChat relay uses a self-signed cert on localhost,
|
||||||
|
# disable verification for the backend connection
|
||||||
|
# tls_insecure_skip_verify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: enable compression for static assets
|
||||||
|
encode gzip
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local / LAN setup (no domain, plain HTTP) ────────────────────────────────
|
||||||
|
# If you're running on a LAN and don't have a domain, use this instead.
|
||||||
|
# Note: browsers will require ws:// (not wss://) and you must uncheck TLS
|
||||||
|
# in Cathode's connect screen.
|
||||||
|
#
|
||||||
|
# :8080 {
|
||||||
|
# root * /var/www/cathode
|
||||||
|
# file_server
|
||||||
|
#
|
||||||
|
# handle /api* {
|
||||||
|
# reverse_proxy localhost:9000
|
||||||
|
# }
|
||||||
|
# }
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Apache VirtualHost config for Cathode
|
||||||
|
# Drop in /etc/apache2/sites-available/cathode.conf
|
||||||
|
# Enable with: sudo a2ensite cathode
|
||||||
|
#
|
||||||
|
# Required modules:
|
||||||
|
# sudo a2enmod ssl proxy proxy_http proxy_wstunnel rewrite headers
|
||||||
|
#
|
||||||
|
# For TLS certs use certbot:
|
||||||
|
# sudo certbot --apache -d cathode.example.com
|
||||||
|
|
||||||
|
# HTTP → HTTPS redirect
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName cathode.example.com
|
||||||
|
Redirect permanent / https://cathode.example.com/
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName cathode.example.com
|
||||||
|
|
||||||
|
# TLS (certbot will fill these in, or provide your own)
|
||||||
|
SSLEngine on
|
||||||
|
SSLCertificateFile /etc/letsencrypt/live/cathode.example.com/fullchain.pem
|
||||||
|
SSLCertificateKeyFile /etc/letsencrypt/live/cathode.example.com/privkey.pem
|
||||||
|
|
||||||
|
# Modern TLS
|
||||||
|
SSLProtocol -all +TLSv1.2 +TLSv1.3
|
||||||
|
SSLHonorCipherOrder off
|
||||||
|
|
||||||
|
# Serve Cathode static files
|
||||||
|
DocumentRoot /var/www/cathode
|
||||||
|
<Directory /var/www/cathode>
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# Proxy WeeChat relay API — REST and WebSocket
|
||||||
|
# WeeChat listens on localhost:9000 — adjust if needed
|
||||||
|
|
||||||
|
# Enable proxy for this vhost
|
||||||
|
ProxyRequests off
|
||||||
|
|
||||||
|
# WebSocket proxy: must come before the plain HTTP proxy rule
|
||||||
|
# Apache uses mod_proxy_wstunnel for WebSocket upgrades
|
||||||
|
RewriteEngine on
|
||||||
|
RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
||||||
|
RewriteRule ^/api(.*) ws://localhost:9000/api$1 [P,L]
|
||||||
|
|
||||||
|
# Plain HTTP proxy for REST requests to /api
|
||||||
|
ProxyPass /api http://localhost:9000/api
|
||||||
|
ProxyPassReverse /api http://localhost:9000/api
|
||||||
|
|
||||||
|
# Pass real client IP
|
||||||
|
ProxyPreserveHost on
|
||||||
|
RequestHeader set X-Real-IP "%{REMOTE_ADDR}s"
|
||||||
|
|
||||||
|
# Timeouts for long-lived WebSocket connections
|
||||||
|
ProxyTimeout 3600
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-Frame-Options "DENY"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/cathode_error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/cathode_access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local / LAN setup (no domain, plain HTTP) ────────────────────────────────
|
||||||
|
# If you're on a LAN without a domain, use this simpler block.
|
||||||
|
# Cathode connect screen: uncheck TLS, use ws:// (port 8080 here).
|
||||||
|
#
|
||||||
|
# <VirtualHost *:8080>
|
||||||
|
# DocumentRoot /var/www/cathode
|
||||||
|
# <Directory /var/www/cathode>
|
||||||
|
# Options -Indexes
|
||||||
|
# Require all granted
|
||||||
|
# </Directory>
|
||||||
|
#
|
||||||
|
# ProxyRequests off
|
||||||
|
# RewriteEngine on
|
||||||
|
# RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
||||||
|
# RewriteRule ^/api(.*) ws://localhost:9000/api$1 [P,L]
|
||||||
|
# ProxyPass /api http://localhost:9000/api
|
||||||
|
# ProxyPassReverse /api http://localhost:9000/api
|
||||||
|
# ProxyTimeout 3600
|
||||||
|
# </VirtualHost>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# nginx.conf (or drop in /etc/nginx/sites-available/cathode)
|
||||||
|
#
|
||||||
|
# This config:
|
||||||
|
# 1. Serves the Cathode static files at the root
|
||||||
|
# 2. Proxies /api/* to WeeChat's relay with WebSocket support
|
||||||
|
# 3. Handles TLS termination (you must provide your own certs, or use
|
||||||
|
# certbot: sudo certbot --nginx -d cathode.example.com)
|
||||||
|
#
|
||||||
|
# Replace cathode.example.com and cert paths as needed.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name cathode.example.com;
|
||||||
|
|
||||||
|
# Redirect all HTTP → HTTPS
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name cathode.example.com;
|
||||||
|
|
||||||
|
# TLS certificates (use certbot or provide your own)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/cathode.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/cathode.example.com/privkey.pem;
|
||||||
|
|
||||||
|
# Modern TLS settings
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
|
||||||
|
# Serve Cathode static files
|
||||||
|
root /var/www/cathode;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy WeeChat relay API — REST and WebSocket
|
||||||
|
# WeeChat listens on localhost:9000 — adjust if needed
|
||||||
|
location /api {
|
||||||
|
|
||||||
|
proxy_pass http://localhost:9000;
|
||||||
|
|
||||||
|
# Required for WebSocket upgrade
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Standard proxy headers
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# WebSocket connections can be long-lived — raise the timeouts
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
|
# Disable buffering for real-time streaming
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript text/plain;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local / LAN setup (no domain, plain HTTP) ────────────────────────────────
|
||||||
|
# If you're on a LAN without a domain, use this simpler block.
|
||||||
|
# Cathode connect screen: uncheck TLS, use ws:// (port 8080 here).
|
||||||
|
#
|
||||||
|
# server {
|
||||||
|
# listen 8080;
|
||||||
|
#
|
||||||
|
# root /var/www/cathode;
|
||||||
|
# index index.html;
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# try_files $uri $uri/ =404;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /api {
|
||||||
|
# proxy_pass http://localhost:9000;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
|
# proxy_set_header Connection "upgrade";
|
||||||
|
# proxy_read_timeout 3600s;
|
||||||
|
# proxy_buffering off;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user