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'] ?? ''))), 'from' => preg_replace('/[^a-z0-9]/i', '', strtolower((string)($msg['from'] ?? ''))),
'kind' => in_array($msg['kind'] ?? 'msg', ['msg', 'question', 'notice'], true) ? $msg['kind'] : 'msg', 'kind' => in_array($msg['kind'] ?? 'msg', ['msg', 'question', 'notice'], true) ? $msg['kind'] : 'msg',
'text' => mb_substr((string)$msg['text'], 0, 4000), 'text' => mb_substr((string)$msg['text'], 0, 4000),
'style' => [ 'style' => vv_nc_clean_style(is_array($msg['style'] ?? null) ? $msg['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'] : '',
],
]; ];
if ($clean['id'] === '' || $clean['from'] === '') { fwrite(STDERR, "malformed message\n"); exit(2); } 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; $c['unread'] = $unread;
$chans[] = $c; $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; exit;
} }
@@ -85,8 +88,12 @@ if ($action === 'send') {
$text = (string)($_POST['text'] ?? ''); $text = (string)($_POST['text'] ?? '');
if (trim($text) === '') { echo json_encode(['ok' => false, 'error' => 'Nothing to send']); exit; } if (trim($text) === '') { echo json_encode(['ok' => false, 'error' => 'Nothing to send']); exit; }
$msg = vv_nc_send($ch, $text, [ $msg = vv_nc_send($ch, $text, [
'color' => (string)($_POST['color'] ?? ''), 'color' => (string)($_POST['color'] ?? ''),
'font' => (string)($_POST['font'] ?? ''), '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; } 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 // 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()); } 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. // host1/host2/... → the channels this host can see. Global plus one DM per other node.
function vv_nc_channels(): array { function vv_nc_channels(): array {
$me = vv_nc_me(); $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. // migration. Everything written today is a message.
'kind' => 'msg', 'kind' => 'msg',
'text' => mb_substr(trim($text), 0, 4000), 'text' => mb_substr(trim($text), 0, 4000),
'style' => [ 'style' => vv_nc_clean_style($style),
'color' => preg_match('/^#[0-9a-f]{6}$/i', $style['color'] ?? '') ? $style['color'] : '',
'font' => in_array($style['font'] ?? '', ['mono', 'sans', 'serif'], true) ? $style['font'] : '',
],
]; ];
vv_nc_append($channelId, $msg); 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."> 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> <input type="checkbox" id="vv-nc-unread" onchange="vvNcRender()"> <span>Unread only</span>
</label> </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> <span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
</div> </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; color:#ddd;border-radius:4px;padding:6px 8px;font-family:inherit;
font-size:12px;resize:vertical;"></textarea> font-size:12px;resize:vertical;"></textarea>
<div style="display:flex;flex-direction:column;gap:5px;"> <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;"> <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;" <select id="vv-nc-emoji" class="vv-cf-sel" style="font-size:12px;padding:2px 6px;"
title="Insert an emoji" onchange="vvNcEmoji(this)"> title="Insert an emoji" onchange="vvNcEmoji(this)">
<option value="">🙂</option> <option value="">🙂</option>
@@ -192,6 +188,41 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
</div> </div>
</div> </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;"> <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 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. 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 ───────────────────────────────────────────────────────────────── // ── Mesh chat ─────────────────────────────────────────────────────────────────
// Kept apart from vvPtLoad's 10s poll: that redraws the whole page, and a redraw mid-sentence // Kept apart from vvPtLoad's 10s poll: that redraws the whole page, and a redraw mid-sentence
// would take the textarea with it. // 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() { function vvNcChans() {
fetch('/plugins/varaverk/api/node_chat.php?_=' + Date.now()) fetch('/plugins/varaverk/api/node_chat.php?_=' + Date.now())
.then(r => r.json()) .then(r => r.json())
.then(d => { .then(d => {
if (!d.ok) return; 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'); const sel = document.getElementById('vv-nc-ch');
if (!sel) return; if (!sel) return;
const keep = _vvNc.ch || (_vvNc.chans[0] || {}).id || ''; 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' }; const fonts = { mono: 'monospace', sans: 'system-ui,sans-serif', serif: 'Georgia,serif' };
box.innerHTML = msgs.map(m => { box.innerHTML = msgs.map(m => {
const mine = m.from === _vvNc.me; 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; const fresh = (m.ts || 0) > _vvNc.lastRead && !mine;
return `<div style="padding:4px 6px;margin-bottom:3px;border-left:2px solid ${fresh ? '#4caf50' : '#232323'}; 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;"> background:${fresh ? 'rgba(76,175,80,.04)' : 'transparent'};border-radius:0 3px 3px 0;">
<div style="display:flex;align-items:baseline;gap:8px;"> <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="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;" <span style="margin-left:auto;font-size:10px;color:#333;cursor:pointer;"
title="Delete on this machine only" onclick="vvNcDel('${vvEscAttr(m.id)}')">×</span> title="Delete on this machine only" onclick="vvNcDel('${vvEscAttr(m.id)}')">×</span>
</div> </div>
<div style="color:${col};font-family:${font};white-space:pre-wrap;word-break:break-word; <div style="${_vvNcStyle(m, mine)}white-space:pre-wrap;word-break:break-word;margin-top:1px;">${vvEscHtml(m.text)}</div>
font-size:12px;margin-top:1px;">${vvEscHtml(m.text)}</div>
</div>`; </div>`;
}).join(''); }).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 — // 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. // 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, action: 'send', channel: _vvNc.ch, text,
color: document.getElementById('vv-nc-color').value, color: document.getElementById('vv-nc-color').value,
font: document.getElementById('vv-nc-font').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 => { }).then(r => r.json()).then(d => {
if (!d.ok) { st.textContent = d.error || 'failed'; return; } if (!d.ok) { st.textContent = d.error || 'failed'; return; }
@@ -1456,6 +1563,7 @@ function vvNcDel(id) {
}).then(() => vvNcLoad()).catch(() => {}); }).then(() => vvNcLoad()).catch(() => {});
} }
_vvNcLoadPrefs();
vvNcChans(); vvNcChans();
setInterval(vvNcChans, 15000); setInterval(vvNcChans, 15000);