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.
This commit is contained in:
Gmer4Lfe
2026-08-17 16:51:17 -04:00
parent ecded4a8a7
commit d88fe381db
4 changed files with 161 additions and 35 deletions
+1 -4
View File
@@ -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); }
+10 -3
View File
@@ -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
+18 -4
View File
@@ -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);
+132 -24
View File
@@ -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.">
<input type="checkbox" id="vv-nc-unread" onchange="vvNcRender()"> <span>Unread only</span>
</label>
<label class="vv-ai-cb" style="font-size:10px;"
title="Show the machine's hostname instead of its slot — unRAID-Jayred36 rather than HOST2.">
<input type="checkbox" id="vv-nc-names" onchange="vvNcPref('names');vvNcRender()"> <span>Hostnames</span>
</label>
<label class="vv-ai-cb" style="font-size:10px;"
title="Follow the newest message. Unticks when you scroll up, re-ticks at the bottom.">
<input type="checkbox" id="vv-nc-follow" checked onchange="vvNcPref('follow')"> <span>Auto Scroll</span>
</label>
<span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
</div>
@@ -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;"></textarea>
<div style="display:flex;flex-direction:column;gap:5px;">
<div style="display:flex;gap:5px;">
<select id="vv-nc-color" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Message colour">
<option value="">Default</option>
<option value="#4caf50">Green</option>
<option value="#4a9eff">Blue</option>
<option value="#ff9800">Amber</option>
<option value="#ef5350">Red</option>
<option value="#ab7df6">Violet</option>
</select>
<select id="vv-nc-font" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Message font">
<option value="">Default</option>
<option value="sans">Sans</option>
<option value="mono">Mono</option>
<option value="serif">Serif</option>
</select>
</div>
<div style="display:flex;gap:5px;align-items:center;">
<!-- One button opens the formatting row, so the common case — type and send — is not
fronted by six controls nobody changes most of the time. -->
<button class="vv-pt-action-btn info" id="vv-nc-fmt-btn" style="font-size:10px;"
title="Text formatting" onclick="vvNcToggleFmt()">Aa ▸</button>
<select id="vv-nc-emoji" class="vv-cf-sel" style="font-size:12px;padding:2px 6px;"
title="Insert an emoji" onchange="vvNcEmoji(this)">
<option value="">🙂</option>
@@ -192,6 +188,41 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
</div>
</div>
</div>
<!-- Formatting row, hidden until asked for. Choices persist per browser so a house style
does not have to be re-picked every message. -->
<div id="vv-nc-fmt" style="display:none;margin-top:6px;gap:5px;flex-wrap:wrap;align-items:center;">
<select id="vv-nc-color" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Colour"
onchange="vvNcPref('color')">
<option value="">Colour</option>
<option value="#4caf50">Green</option>
<option value="#4a9eff">Blue</option>
<option value="#ff9800">Amber</option>
<option value="#ef5350">Red</option>
<option value="#ab7df6">Violet</option>
<option value="#bdbdbd">Grey</option>
</select>
<select id="vv-nc-font" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Font"
onchange="vvNcPref('font')">
<option value="">Font</option>
<option value="sans">Sans</option>
<option value="mono">Mono</option>
<option value="serif">Serif</option>
</select>
<select id="vv-nc-size" class="vv-cf-sel" style="font-size:10px;padding:3px 6px;" title="Size"
onchange="vvNcPref('size')">
<option value="">Size</option>
<option value="sm">Small</option>
<option value="lg">Large</option>
</select>
<button class="vv-btn-sm" id="vv-nc-bold" onclick="vvNcTog('bold')"
style="font-weight:700;min-width:26px;">B</button>
<button class="vv-btn-sm" id="vv-nc-italic" onclick="vvNcTog('italic')"
style="font-style:italic;min-width:26px;">I</button>
<button class="vv-btn-sm" id="vv-nc-underline" onclick="vvNcTog('underline')"
style="text-decoration:underline;min-width:26px;">U</button>
<span id="vv-nc-preview" style="margin-left:6px;font-size:11px;color:#444;">preview</span>
</div>
<div style="margin-top:5px;font-size:9px;color:#3a3a3a;line-height:1.5;">
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 `<div style="padding:4px 6px;margin-bottom:3px;border-left:2px solid ${fresh ? '#4caf50' : '#232323'};
background:${fresh ? 'rgba(76,175,80,.04)' : 'transparent'};border-radius:0 3px 3px 0;">
<div style="display:flex;align-items:baseline;gap:8px;">
<span style="font-size:10px;font-weight:600;color:${mine ? '#4caf50' : '#7a8fa6'};">${vvEscHtml(m.from)}</span>
<span style="font-size:10px;font-weight:600;color:${mine ? '#4caf50' : '#7a8fa6'};">${vvEscHtml(_vvNcWho(m.from))}</span>
<span style="font-size:9px;color:#333;">${new Date((m.ts || 0) * 1000).toLocaleString()}</span>
<span style="margin-left:auto;font-size:10px;color:#333;cursor:pointer;"
title="Delete on this machine only" onclick="vvNcDel('${vvEscAttr(m.id)}')">×</span>
</div>
<div style="color:${col};font-family:${font};white-space:pre-wrap;word-break:break-word;
font-size:12px;margin-top:1px;">${vvEscHtml(m.text)}</div>
<div style="${_vvNcStyle(m, mine)}white-space:pre-wrap;word-break:break-word;margin-top:1px;">${vvEscHtml(m.text)}</div>
</div>`;
}).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);