From d88fe381db5a896977fbd9457bf0e1a7d3fc6eca Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 17 Aug 2026 16:51:17 -0400 Subject: [PATCH] Mesh chat: autoscroll toggle, hostname/slot toggle, and a formatting row behind one button Style validation moves to one shared function so the sender and receiver cannot drift on what a style is. --- Plugin/unraid/Tools/node_chat_receive.php | 5 +- Plugin/unraid/api/node_chat.php | 13 +- Plugin/unraid/include/node_chat.php | 22 ++- Plugin/unraid/pages/partnership.php | 156 ++++++++++++++++++---- 4 files changed, 161 insertions(+), 35 deletions(-) diff --git a/Plugin/unraid/Tools/node_chat_receive.php b/Plugin/unraid/Tools/node_chat_receive.php index 49b8fdf..f12974d 100644 --- a/Plugin/unraid/Tools/node_chat_receive.php +++ b/Plugin/unraid/Tools/node_chat_receive.php @@ -59,10 +59,7 @@ $clean = [ 'from' => preg_replace('/[^a-z0-9]/i', '', strtolower((string)($msg['from'] ?? ''))), 'kind' => in_array($msg['kind'] ?? 'msg', ['msg', 'question', 'notice'], true) ? $msg['kind'] : 'msg', 'text' => mb_substr((string)$msg['text'], 0, 4000), - 'style' => [ - 'color' => preg_match('/^#[0-9a-f]{6}$/i', $msg['style']['color'] ?? '') ? $msg['style']['color'] : '', - 'font' => in_array($msg['style']['font'] ?? '', ['mono','sans','serif'], true) ? $msg['style']['font'] : '', - ], + 'style' => vv_nc_clean_style(is_array($msg['style'] ?? null) ? $msg['style'] : []), ]; if ($clean['id'] === '' || $clean['from'] === '') { fwrite(STDERR, "malformed message\n"); exit(2); } diff --git a/Plugin/unraid/api/node_chat.php b/Plugin/unraid/api/node_chat.php index 17f3c70..86800dc 100644 --- a/Plugin/unraid/api/node_chat.php +++ b/Plugin/unraid/api/node_chat.php @@ -73,7 +73,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $c['unread'] = $unread; $chans[] = $c; } - echo json_encode(['ok' => true, 'me' => vv_nc_me(), 'channels' => $chans]); + // Hostnames alongside the ids, so the card can show "unRAID-Jayred36" or "HOST2" without a + // second request — the mapping is master.conf's and the page should not be guessing it. + echo json_encode(['ok' => true, 'me' => vv_nc_me(), 'channels' => $chans, + 'hostnames' => vv_known_hosts()]); exit; } @@ -85,8 +88,12 @@ if ($action === 'send') { $text = (string)($_POST['text'] ?? ''); if (trim($text) === '') { echo json_encode(['ok' => false, 'error' => 'Nothing to send']); exit; } $msg = vv_nc_send($ch, $text, [ - 'color' => (string)($_POST['color'] ?? ''), - 'font' => (string)($_POST['font'] ?? ''), + 'color' => (string)($_POST['color'] ?? ''), + 'font' => (string)($_POST['font'] ?? ''), + 'size' => (string)($_POST['size'] ?? ''), + 'bold' => !empty($_POST['bold']), + 'italic' => !empty($_POST['italic']), + 'underline' => !empty($_POST['underline']), ]); if (!$msg) { echo json_encode(['ok' => false, 'error' => 'Could not store message']); exit; } // Queued is not failed — say which, so a partner being asleep reads as pending rather than diff --git a/Plugin/unraid/include/node_chat.php b/Plugin/unraid/include/node_chat.php index 05af794..c7fd394 100644 --- a/Plugin/unraid/include/node_chat.php +++ b/Plugin/unraid/include/node_chat.php @@ -61,6 +61,23 @@ define('VV_NC_KEEP', 300); // messages retained per channel, per node function vv_nc_me(): string { return strtolower(vv_detect_host()); } +// One validator, used by both the sender and the receiver. +// +// Both ends must agree on what a style is, and they had two copies of the rules — the sender's +// and the receiver's. Two copies of a whitelist is one that drifts, and the half that drifts is +// the half a partner running newer code writes into. Every value here ends up interpolated into +// a style attribute, so the allowed set is closed rather than sanitised. +function vv_nc_clean_style(array $s): array { + return [ + 'color' => preg_match('/^#[0-9a-f]{6}$/i', $s['color'] ?? '') ? strtolower($s['color']) : '', + 'font' => in_array($s['font'] ?? '', ['mono', 'sans', 'serif'], true) ? $s['font'] : '', + 'size' => in_array($s['size'] ?? '', ['sm', 'lg'], true) ? $s['size'] : '', + 'bold' => !empty($s['bold']), + 'italic' => !empty($s['italic']), + 'underline' => !empty($s['underline']), + ]; +} + // host1/host2/... → the channels this host can see. Global plus one DM per other node. function vv_nc_channels(): array { $me = vv_nc_me(); @@ -189,10 +206,7 @@ function vv_nc_send(string $channelId, string $text, array $style = []): ?array // migration. Everything written today is a message. 'kind' => 'msg', 'text' => mb_substr(trim($text), 0, 4000), - 'style' => [ - 'color' => preg_match('/^#[0-9a-f]{6}$/i', $style['color'] ?? '') ? $style['color'] : '', - 'font' => in_array($style['font'] ?? '', ['mono', 'sans', 'serif'], true) ? $style['font'] : '', - ], + 'style' => vv_nc_clean_style($style), ]; vv_nc_append($channelId, $msg); diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index ba6aa77..3b37c8c 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -148,6 +148,14 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; } title="Show only what has arrived since you last looked. Unticked shows the whole conversation."> Unread only + + @@ -163,23 +171,11 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; } color:#ddd;border-radius:4px;padding:6px 8px;font-family:inherit; font-size:12px;resize:vertical;">
-
- - -
+ + + + + + + + + + + + + + + + preview +
Encrypted over Tailscale between mesh nodes only — but stored as plain text on every machine that receives it. Delete removes it from this machine, not from theirs. @@ -1345,14 +1376,87 @@ _vvPtReload = vvPtLoad; // expose to top-level toggle fns // ── Mesh chat ───────────────────────────────────────────────────────────────── // Kept apart from vvPtLoad's 10s poll: that redraws the whole page, and a redraw mid-sentence // would take the textarea with it. -let _vvNc = { ch: '', me: '', msgs: [], lastRead: 0, chans: [] }; +let _vvNc = { ch: '', me: '', msgs: [], lastRead: 0, chans: [], hosts: {}, + fmt: { bold: false, italic: false, underline: false } }; + +// Formatting and display choices are per browser, not per message — picking a house style once +// and having it forgotten on reload is the same complaint the web-search tick had. +const _VV_NC_PREF = 'vvNcPrefs'; +function _vvNcLoadPrefs() { + let p = {}; + try { p = JSON.parse(localStorage.getItem(_VV_NC_PREF) || '{}') || {}; } catch (_) {} + for (const k of ['color', 'font', 'size']) { + const el = document.getElementById('vv-nc-' + k); + if (el && p[k] != null) el.value = p[k]; + } + for (const k of ['names', 'follow']) { + const el = document.getElementById('vv-nc-' + k); + if (el && p[k] != null) el.checked = !!p[k]; + } + _vvNc.fmt = Object.assign(_vvNc.fmt, p.fmt || {}); + _vvNcPaintFmt(); +} +function vvNcPref() { + const p = { fmt: _vvNc.fmt }; + for (const k of ['color', 'font', 'size']) p[k] = document.getElementById('vv-nc-' + k)?.value ?? ''; + for (const k of ['names', 'follow']) p[k] = !!document.getElementById('vv-nc-' + k)?.checked; + try { localStorage.setItem(_VV_NC_PREF, JSON.stringify(p)); } catch (_) {} + _vvNcPaintFmt(); +} +function vvNcTog(k) { _vvNc.fmt[k] = !_vvNc.fmt[k]; vvNcPref(); } + +// The preview is the only way to see what B/I/U plus a colour actually look like together +// before committing them to a message everyone else keeps a copy of. +function _vvNcPaintFmt() { + for (const k of ['bold', 'italic', 'underline']) { + document.getElementById('vv-nc-' + k)?.classList.toggle('active', !!_vvNc.fmt[k]); + } + const pv = document.getElementById('vv-nc-preview'); + if (!pv) return; + const s = _vvNcStyle({ style: { + color: document.getElementById('vv-nc-color')?.value || '', + font: document.getElementById('vv-nc-font')?.value || '', + size: document.getElementById('vv-nc-size')?.value || '', + bold: _vvNc.fmt.bold, italic: _vvNc.fmt.italic, underline: _vvNc.fmt.underline, + }}, false); + pv.setAttribute('style', 'margin-left:6px;' + s); +} + +function vvNcToggleFmt() { + const row = document.getElementById('vv-nc-fmt'); + const btn = document.getElementById('vv-nc-fmt-btn'); + const open = row.style.display !== 'none'; + row.style.display = open ? 'none' : 'flex'; + btn.textContent = open ? 'Aa ▸' : 'Aa ▾'; +} + +// Style string for a message. Closed set on both ends — the server validates the same values, +// so nothing here can emit a property the store did not agree to. +function _vvNcStyle(m, mine) { + const st = m.style || {}; + const sizes = { sm: '11px', lg: '14px' }; + let s = `color:${st.color || (mine ? '#7a9a7a' : '#9aa')};`; + if (fonts[st.font]) s += `font-family:${fonts[st.font]};`; + s += `font-size:${sizes[st.size] || '12px'};`; + if (st.bold) s += 'font-weight:700;'; + if (st.italic) s += 'font-style:italic;'; + if (st.underline) s += 'text-decoration:underline;'; + return s; +} + +// HOST2 or unRAID-Jayred36, depending on the tick. The mapping comes from master.conf via the +// API rather than being reconstructed here. +function _vvNcWho(id) { + const full = document.getElementById('vv-nc-names')?.checked; + return full ? (_vvNc.hosts[id] || id) : id.toUpperCase(); +} function vvNcChans() { fetch('/plugins/varaverk/api/node_chat.php?_=' + Date.now()) .then(r => r.json()) .then(d => { if (!d.ok) return; - _vvNc.me = d.me; _vvNc.chans = d.channels || []; + _vvNc.me = d.me; _vvNc.chans = d.channels || []; _vvNc.hosts = d.hostnames || {}; const sel = document.getElementById('vv-nc-ch'); if (!sel) return; const keep = _vvNc.ch || (_vvNc.chans[0] || {}).id || ''; @@ -1389,22 +1493,21 @@ function vvNcRender() { const fonts = { mono: 'monospace', sans: 'system-ui,sans-serif', serif: 'Georgia,serif' }; box.innerHTML = msgs.map(m => { const mine = m.from === _vvNc.me; - const col = (m.style && m.style.color) || (mine ? '#7a9a7a' : '#9aa'); - const font = fonts[(m.style && m.style.font) || ''] || 'inherit'; const fresh = (m.ts || 0) > _vvNc.lastRead && !mine; return `
- ${vvEscHtml(m.from)} + ${vvEscHtml(_vvNcWho(m.from))} ${new Date((m.ts || 0) * 1000).toLocaleString()} ×
-
${vvEscHtml(m.text)}
+
${vvEscHtml(m.text)}
`; }).join(''); - box.scrollTop = box.scrollHeight; + // Only when asked. Someone reading back through a thread must not be yanked to the bottom by + // the 15s poll landing a new line. + if (document.getElementById('vv-nc-follow')?.checked) box.scrollTop = box.scrollHeight; // Marking read is what makes "unread only" mean anything next time, so it happens on view — // but not while the filter is on, or the list would empty itself as you read it. @@ -1436,6 +1539,10 @@ function vvNcSend() { action: 'send', channel: _vvNc.ch, text, color: document.getElementById('vv-nc-color').value, font: document.getElementById('vv-nc-font').value, + size: document.getElementById('vv-nc-size').value, + bold: _vvNc.fmt.bold ? '1' : '', + italic: _vvNc.fmt.italic ? '1' : '', + underline: _vvNc.fmt.underline ? '1' : '', }) }).then(r => r.json()).then(d => { if (!d.ok) { st.textContent = d.error || 'failed'; return; } @@ -1456,6 +1563,7 @@ function vvNcDel(id) { }).then(() => vvNcLoad()).catch(() => {}); } +_vvNcLoadPrefs(); vvNcChans(); setInterval(vvNcChans, 15000);