Fixed pane height inherited from the card, so switching mode no longer resizes it.
622 lines
31 KiB
PHP
622 lines
31 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// The mesh's message store. A place for the people who run these servers to talk to each
|
||
// other — "mine is down for a week", "who has seen this error", "the key you wanted is X" —
|
||
// in the one window that already knows which machines are in the partnership.
|
||
//
|
||
// OPERATIONAL MODEL
|
||
// Every node keeps its own copy. Sending is: append here, then push a copy to each recipient
|
||
// over the SSH trust the onboard already established. There is no server and no election —
|
||
// the store is a log, and a log replicated by append is the shape that survives a partner
|
||
// being switched off.
|
||
//
|
||
// data/db/node_chat/global.jsonl every HOST* in master.conf
|
||
// data/db/node_chat/dm-host1-host3.jsonl one pair, names sorted so both sides agree
|
||
// data/db/node_chat/.spool/<host>/*.json undelivered, retried by the minute job
|
||
//
|
||
// DESIGN PRINCIPLES
|
||
// Never two hosts.
|
||
// Channels are derived from vv_known_hosts(), so a third machine gets a global channel
|
||
// that includes it and a DM channel with each existing node, with no code change. The
|
||
// pair name is sorted rather than "mine-theirs" precisely so both ends compute the same
|
||
// filename without having to agree on who spoke first.
|
||
//
|
||
// Delete is local, and that is the feature.
|
||
// Removing a message everywhere needs tombstones, ordering and a story for a node that
|
||
// was offline when the delete happened. Removing it from this machine is one file write.
|
||
// The UI says "delete here" so nobody expects otherwise.
|
||
//
|
||
// Sender identity comes from the transport, not the payload's word for it.
|
||
// The record carries `from`, but anything writing to this store already had root over
|
||
// SSH — the trust boundary is the key, and a machine that holds it can say anything
|
||
// regardless of what this file validates.
|
||
//
|
||
// OPERATIONAL SAFEGUARDS
|
||
// Messages are stored PLAINTEXT on every recipient. Tailscale encrypts the wire; the file on
|
||
// disk is readable by anyone with the box. The card says so, because the first thing anyone
|
||
// wants to send a partner is a credential.
|
||
//
|
||
// Channel names are derived, never taken from the caller — a name is rebuilt from known host
|
||
// ids, so no request can address a path outside the store.
|
||
//
|
||
// Retention is per channel and enforced on write, so a busy channel cannot fill the flash.
|
||
//
|
||
// Delivery failures spool rather than throw. A partner that is asleep is the normal case for
|
||
// the message "my server is going down".
|
||
//
|
||
// EXPORTS
|
||
// vv_nc_channels() the channels this host participates in
|
||
// vv_nc_read() messages for one channel, newest last
|
||
// vv_nc_append() store one record locally
|
||
// vv_nc_send() store + deliver to the channel's other members
|
||
// vv_nc_delete_local() forget one message on this machine
|
||
// vv_nc_flush_spool() retry undelivered
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
require_once __DIR__ . '/config.php';
|
||
|
||
define('VV_NC_DIR', DATA_DIR . '/db/node_chat');
|
||
define('VV_NC_SPOOL', VV_NC_DIR . '/.spool');
|
||
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();
|
||
$hosts = array_keys(vv_known_hosts());
|
||
$out = [['id' => 'global', 'label' => 'Global', 'members' => $hosts]];
|
||
foreach ($hosts as $h) {
|
||
if ($h === $me) continue;
|
||
$pair = [$me, $h];
|
||
sort($pair);
|
||
$out[] = ['id' => 'dm-' . implode('-', $pair), 'label' => strtoupper($h), 'members' => $pair];
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
// Rebuilt from the known channel list rather than trusted — the caller names a channel, and the
|
||
// only names that resolve are ones this host is actually a member of.
|
||
function vv_nc_channel(string $id): ?array {
|
||
foreach (vv_nc_channels() as $c) if ($c['id'] === $id) return $c;
|
||
return null;
|
||
}
|
||
|
||
function vv_nc_path(string $channelId): string {
|
||
return VV_NC_DIR . '/' . $channelId . '.jsonl';
|
||
}
|
||
|
||
function vv_nc_read(string $channelId, int $limit = 200): array {
|
||
$p = vv_nc_path($channelId);
|
||
if (!is_file($p)) return [];
|
||
$lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||
if (count($lines) > $limit) $lines = array_slice($lines, -$limit);
|
||
$out = [];
|
||
foreach ($lines as $l) {
|
||
$j = json_decode($l, true);
|
||
if (is_array($j) && !empty($j['id'])) $out[] = $j;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
function vv_nc_append(string $channelId, array $msg): bool {
|
||
if (!vv_nc_channel($channelId)) return false;
|
||
@mkdir(VV_NC_DIR, 0755, true);
|
||
$p = vv_nc_path($channelId);
|
||
|
||
// Idempotent on id: a spool retry that actually landed the first time must not double-post.
|
||
foreach (vv_nc_read($channelId, VV_NC_KEEP) as $m) {
|
||
if (($m['id'] ?? '') === ($msg['id'] ?? '')) return true;
|
||
}
|
||
if (@file_put_contents($p, json_encode($msg, JSON_UNESCAPED_SLASHES) . "\n",
|
||
FILE_APPEND | LOCK_EX) === false) return false;
|
||
|
||
$lines = @file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||
if (count($lines) > VV_NC_KEEP) {
|
||
$tmp = $p . '.tmp';
|
||
if (@file_put_contents($tmp, implode("\n", array_slice($lines, -VV_NC_KEEP)) . "\n") !== false)
|
||
@rename($tmp, $p);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function vv_nc_delete_local(string $channelId, string $msgId): bool {
|
||
$p = vv_nc_path($channelId);
|
||
if (!vv_nc_channel($channelId) || !is_file($p)) return false;
|
||
$keep = [];
|
||
foreach (@file($p, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $l) {
|
||
$j = json_decode($l, true);
|
||
if (is_array($j) && ($j['id'] ?? '') === $msgId) continue;
|
||
$keep[] = $l;
|
||
}
|
||
$tmp = $p . '.tmp';
|
||
if (@file_put_contents($tmp, $keep ? implode("\n", $keep) . "\n" : '') === false) return false;
|
||
return @rename($tmp, $p);
|
||
}
|
||
|
||
// Deliver one record to one host. Returns false on any failure so the caller can spool.
|
||
function vv_nc_deliver(string $host, string $channelId, array $msg): bool {
|
||
$vars = vv_conf_vars();
|
||
$me = strtoupper(vv_nc_me());
|
||
$sshKey = $vars[$me . '_SSH_KEY'] ?? '';
|
||
if (!$sshKey || !is_file($sshKey)) return false;
|
||
|
||
$hostname = $vars[strtoupper($host)] ?? '';
|
||
if ($hostname === '') return false;
|
||
$ip = vv_resolve_tailscale_ip($hostname);
|
||
if (!$ip) return false;
|
||
|
||
// The receiving script lives wherever that host installed Varaverk, which is not necessarily
|
||
// where this one did — read from the partner's varaverk.cfg, which stays on flash whatever
|
||
// layout it uses. Assuming our own path is the bug that broke Step 1b on an appdata mirror.
|
||
$sshBase = 'ssh -i ' . escapeshellarg($sshKey)
|
||
. ' -o ConnectTimeout=8 -o BatchMode=yes -o StrictHostKeyChecking=no root@' . escapeshellarg($ip);
|
||
$cfg = trim((string)shell_exec($sshBase
|
||
. ' ' . escapeshellarg('grep -oP \'(?<=SCRIPTS_DIR=")[^"]+\' /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null')));
|
||
$sd = $cfg !== '' ? $cfg : '/boot/config/plugins/varaverk';
|
||
$remote = $sd . '/Plugin/unraid/Tools/node_chat_receive.php';
|
||
|
||
$cmd = $sshBase . ' ' . escapeshellarg('[ -f ' . $remote . ' ] || exit 127; php ' . $remote
|
||
. ' --channel=' . escapeshellarg($channelId));
|
||
$desc = [0 => ['pipe','r'], 1 => ['pipe','w'], 2 => ['pipe','w']];
|
||
$pr = @proc_open($cmd, $desc, $pipes);
|
||
if (!is_resource($pr)) return false;
|
||
fwrite($pipes[0], json_encode($msg, JSON_UNESCAPED_SLASHES));
|
||
fclose($pipes[0]);
|
||
stream_get_contents($pipes[1]); fclose($pipes[1]);
|
||
stream_get_contents($pipes[2]); fclose($pipes[2]);
|
||
return proc_close($pr) === 0;
|
||
}
|
||
|
||
function vv_nc_spool(string $host, string $channelId, array $msg): void {
|
||
$dir = VV_NC_SPOOL . '/' . $host;
|
||
@mkdir($dir, 0755, true);
|
||
@file_put_contents($dir . '/' . $msg['id'] . '.json',
|
||
json_encode(['channel' => $channelId, 'msg' => $msg], JSON_UNESCAPED_SLASHES));
|
||
}
|
||
|
||
// Store locally, then deliver to everyone else in the channel. Always returns the record: a
|
||
// message that reached no one is still a message this operator wrote and must be able to see.
|
||
function vv_nc_send(string $channelId, string $text, array $style = []): ?array {
|
||
$ch = vv_nc_channel($channelId);
|
||
if (!$ch || trim($text) === '') return null;
|
||
|
||
$msg = [
|
||
'id' => bin2hex(random_bytes(8)),
|
||
'ts' => time(),
|
||
'from' => vv_nc_me(),
|
||
// Reserved from day one so a board or an announcement is a filter later rather than a
|
||
// migration. Everything written today is a message.
|
||
'kind' => 'msg',
|
||
'text' => mb_substr(trim($text), 0, 4000),
|
||
'style' => vv_nc_clean_style($style),
|
||
];
|
||
vv_nc_append($channelId, $msg);
|
||
|
||
foreach ($ch['members'] as $h) {
|
||
if ($h === vv_nc_me()) continue;
|
||
if (!vv_nc_deliver($h, $channelId, $msg)) vv_nc_spool($h, $channelId, $msg);
|
||
}
|
||
return $msg;
|
||
}
|
||
|
||
function vv_nc_flush_spool(): int {
|
||
$sent = 0;
|
||
foreach (glob(VV_NC_SPOOL . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
|
||
$host = basename($dir);
|
||
foreach (glob($dir . '/*.json') ?: [] as $f) {
|
||
$j = json_decode((string)@file_get_contents($f), true);
|
||
if (!is_array($j) || empty($j['msg'])) { @unlink($f); continue; }
|
||
if (vv_nc_deliver($host, (string)$j['channel'], $j['msg'])) { @unlink($f); $sent++; }
|
||
}
|
||
}
|
||
return $sent;
|
||
}
|
||
|
||
// ── Card pane + client ────────────────────────────────────────────────────────
|
||
//
|
||
// Rendered inside the assistant card rather than as a card of its own, so a page carries one
|
||
// conversation surface and a switch, not two boxes competing for the same corner of the screen.
|
||
// Emitted once per page: there is one mesh store, so a second instance would be two views of the
|
||
// same thing fighting over the same element ids.
|
||
function vv_nc_pane_markup(string $prefix, bool $meshDefault = false, string $height = '300px'): void {
|
||
static $done = false;
|
||
if ($done) return;
|
||
$done = true;
|
||
?>
|
||
<div id="<?= htmlspecialchars($prefix, ENT_QUOTES) ?>-pane-mesh" hidden
|
||
style="--vv-nc-h:<?= htmlspecialchars($height, ENT_QUOTES) ?>;">
|
||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;">
|
||
<select id="vv-nc-ch" class="vv-cf-sel" style="max-width:200px;font-size:11px;padding:3px 8px;"
|
||
onchange="vvNcSelect(this.value)"></select>
|
||
<!-- Three, in the same three positions the assistant uses: what to render, what to show,
|
||
whether to follow. Switching mode should move the eye no further than the labels.
|
||
Hostnames is a view preference too, but it is not one of the three and lives in the
|
||
Aa drawer rather than making this row a different length from the assistant's. -->
|
||
<label class="vv-ai-cb" style="font-size:10px;"
|
||
title="Render colours, fonts and sizes as sent. Unticked shows every message as plain text.">
|
||
<input type="checkbox" id="vv-nc-fmt-on" checked onchange="vvNcPref('fmtOn');vvNcRender()"> <span>Formatting</span>
|
||
</label>
|
||
<label class="vv-ai-cb" style="font-size:10px;"
|
||
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>Only New</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>
|
||
|
||
<!-- Fixed height, not max-height. The assistant's transcript is a fixed box, so a mesh log
|
||
that grew with its content made the card jump size every time the mode was switched or a
|
||
message arrived — and on a dashboard a card that resizes shifts everything under it. -->
|
||
<div id="vv-nc-log"
|
||
style="height:var(--vv-nc-h,300px);overflow-y:auto;padding:6px 2px;font-size:12px;">
|
||
<div style="color:#444;font-size:11px;">Loading…</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:6px;align-items:flex-start;margin-top:8px;flex-wrap:wrap;">
|
||
<textarea id="vv-nc-text" rows="2" placeholder="Message the mesh…"
|
||
onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='Enter')vvNcSend()"
|
||
style="flex:1 1 260px;min-width:0;background:#0d0d0d;border:1px solid #2a2a2a;
|
||
color:#ddd;border-radius:4px;padding:6px 8px;font-family:inherit;
|
||
font-size:12px;resize:vertical;"></textarea>
|
||
</div>
|
||
|
||
<!-- Under the input and centred, the way the assistant's controls sit. Same three-group
|
||
layout: equal-flex sides with the actions taking only what they need, so the middle is
|
||
centred against the input above rather than against whatever the left group measures. -->
|
||
<div class="vv-ai-ctrls" style="margin-top:7px;">
|
||
<span class="vv-ai-side"></span>
|
||
<span class="vv-ai-actions">
|
||
<!-- 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 and view options" 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>
|
||
<option value="👍">👍</option><option value="👋">👋</option><option value="✅">✅</option>
|
||
<option value="⚠️">⚠️</option><option value="🔥">🔥</option><option value="🎉">🎉</option>
|
||
<option value="🛠️">🛠️</option><option value="💾">💾</option><option value="🔑">🔑</option>
|
||
<option value="😀">😀</option><option value="😅">😅</option><option value="🤔">🤔</option>
|
||
</select>
|
||
<button class="vv-pt-action-btn run" style="font-size:11px;" onclick="vvNcSend()">Send</button>
|
||
</span>
|
||
<span class="vv-ai-side vv-ai-tail"></span>
|
||
</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>
|
||
<span style="width:1px;height:16px;background:#262626;margin:0 4px;"></span>
|
||
<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>
|
||
</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.
|
||
</div>
|
||
</div>
|
||
<script>
|
||
// ── 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: [], 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 id = k === 'fmtOn' ? 'vv-nc-fmt-on' : 'vv-nc-' + k;
|
||
const el = document.getElementById(id);
|
||
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', 'fmtOn'])
|
||
p[k] = !!document.getElementById(k === 'fmtOn' ? 'vv-nc-fmt-on' : '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) {
|
||
// Reader wins over sender. Someone else's purple italic large is their idea of emphasis, and
|
||
// on a wall-mounted dashboard it is noise — this renders every message plain here without
|
||
// changing what they sent or what anyone else sees. The only thing kept is the you/them colour
|
||
// distinction, which is orientation rather than decoration.
|
||
if (!document.getElementById('vv-nc-fmt-on')?.checked)
|
||
return `color:${mine ? '#7a9a7a' : '#9aa'};font-size:12px;`;
|
||
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.hosts = d.hostnames || {};
|
||
const sel = document.getElementById('vv-nc-ch');
|
||
if (!sel) return;
|
||
const keep = _vvNc.ch || (_vvNc.chans[0] || {}).id || '';
|
||
sel.innerHTML = _vvNc.chans.map(c =>
|
||
`<option value="${vvEscAttr(c.id)}"${c.id === keep ? ' selected' : ''}>`
|
||
+ `${vvEscHtml(c.label)}${c.unread ? ' (' + c.unread + ')' : ''}</option>`).join('');
|
||
if (!_vvNc.ch) vvNcSelect(keep); else if (_vvNcMeshOn) vvNcLoad();
|
||
// Total across every channel: the badge exists so an unread message is visible while the
|
||
// card is showing the assistant, which is where it will usually be.
|
||
const tot = _vvNc.chans.reduce((n, c) => n + (c.unread || 0), 0);
|
||
const b = document.getElementById('vv-nc-badge');
|
||
if (b) { b.hidden = !tot || _vvNcMeshOn; b.textContent = '✉ ' + tot; }
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function vvNcSelect(ch) { _vvNc.ch = ch; vvNcLoad(); }
|
||
|
||
function vvNcLoad() {
|
||
if (!_vvNc.ch) return;
|
||
fetch('/plugins/varaverk/api/node_chat.php?channel=' + encodeURIComponent(_vvNc.ch) + '&_=' + Date.now())
|
||
.then(r => r.json())
|
||
.then(d => { if (d.ok) { _vvNc.msgs = d.messages || []; _vvNc.lastRead = d.last_read || 0; vvNcRender(); } })
|
||
.catch(() => {});
|
||
}
|
||
|
||
function vvNcRender() {
|
||
const box = document.getElementById('vv-nc-log');
|
||
if (!box) return;
|
||
const unreadOnly = document.getElementById('vv-nc-unread')?.checked;
|
||
let msgs = _vvNc.msgs;
|
||
if (unreadOnly) msgs = msgs.filter(m => (m.ts || 0) > _vvNc.lastRead && m.from !== _vvNc.me);
|
||
|
||
if (!msgs.length) {
|
||
box.innerHTML = `<div style="color:#3a3a3a;font-size:11px;padding:8px 0;">`
|
||
+ (unreadOnly ? 'Nothing new.' : 'No messages yet — say hello.') + `</div>`;
|
||
return;
|
||
}
|
||
const fonts = { mono: 'monospace', sans: 'system-ui,sans-serif', serif: 'Georgia,serif' };
|
||
box.innerHTML = msgs.map(m => {
|
||
const mine = m.from === _vvNc.me;
|
||
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(_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="${_vvNcStyle(m, mine)}white-space:pre-wrap;word-break:break-word;margin-top:1px;">${vvEscHtml(m.text)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
// 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.
|
||
if (!unreadOnly && msgs.length) {
|
||
fetch('/plugins/varaverk/api/node_chat.php', {
|
||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
|
||
action: 'read', channel: _vvNc.ch })
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function vvNcEmoji(sel) {
|
||
const t = document.getElementById('vv-nc-text');
|
||
if (t && sel.value) { t.value += sel.value; t.focus(); }
|
||
sel.selectedIndex = 0;
|
||
}
|
||
|
||
function vvNcSend() {
|
||
const t = document.getElementById('vv-nc-text');
|
||
const text = (t.value || '').trim();
|
||
if (!text) return;
|
||
const st = document.getElementById('vv-nc-status');
|
||
st.textContent = 'sending…';
|
||
fetch('/plugins/varaverk/api/node_chat.php', {
|
||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: new URLSearchParams({
|
||
csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
|
||
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; }
|
||
t.value = '';
|
||
// Queued is not failed. A partner that is asleep gets it on the next flush, and saying so is
|
||
// the difference between "it did not send" and "they are not awake".
|
||
st.textContent = d.queued ? `queued for ${d.queued} offline node(s)` : 'sent';
|
||
setTimeout(() => { st.textContent = ''; }, 4000);
|
||
vvNcLoad();
|
||
}).catch(e => { st.textContent = 'error'; });
|
||
}
|
||
|
||
function vvNcDel(id) {
|
||
fetch('/plugins/varaverk/api/node_chat.php', {
|
||
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||
body: new URLSearchParams({ csrf_token: typeof csrf_token !== 'undefined' ? csrf_token : '',
|
||
action: 'delete', channel: _vvNc.ch, id })
|
||
}).then(() => vvNcLoad()).catch(() => {});
|
||
}
|
||
|
||
|
||
// ── Mode switch ───────────────────────────────────────────────────────────────
|
||
// One card, two panes. The label always names where the button GOES, not where you are — a
|
||
// button reading "Mesh" while showing mesh is the commonest way a toggle gets pressed twice.
|
||
let _vvNcPrefix = '', _vvNcMeshOn = false;
|
||
function vvNcMode(force) {
|
||
const p = _vvNcPrefix;
|
||
if (!p) return;
|
||
const ai = document.getElementById(p + '-pane-ai');
|
||
const me = document.getElementById(p + '-pane-mesh');
|
||
if (!ai || !me) return;
|
||
_vvNcMeshOn = (force === undefined) ? !_vvNcMeshOn : !!force;
|
||
ai.hidden = _vvNcMeshOn;
|
||
me.hidden = !_vvNcMeshOn;
|
||
const lbl = document.getElementById(p + '-mode-l');
|
||
if (lbl) lbl.textContent = _vvNcMeshOn ? 'Assistant' : 'Mesh';
|
||
const t = document.getElementById(p + '-title');
|
||
if (t) t.textContent = _vvNcMeshOn ? 'Mesh Chat' : (t.dataset.ai || t.textContent);
|
||
try { localStorage.setItem('vvNcMode:' + p, _vvNcMeshOn ? '1' : '0'); } catch (_) {}
|
||
// Opening the mesh resets the backoff, so switching to it never waits on a 90-second timer.
|
||
if (_vvNcMeshOn) { _vvNcStep = 0; vvNcLoad(); }
|
||
if (typeof _vvNcSchedule === 'function') _vvNcSchedule();
|
||
}
|
||
|
||
function vvNcInit(prefix, meshDefault) {
|
||
_vvNcPrefix = prefix;
|
||
const t = document.getElementById(prefix + '-title');
|
||
if (t && !t.dataset.ai) t.dataset.ai = t.textContent.trim();
|
||
// meshDefault means "this page IS the mesh page" — Partnership opens on it every time rather
|
||
// than on whatever was last toggled. Everywhere else the choice is remembered, because there
|
||
// the mesh is a thing you switch to and being returned to it is the surprise.
|
||
let want = !!meshDefault;
|
||
if (!meshDefault) {
|
||
try {
|
||
const s = localStorage.getItem('vvNcMode:' + prefix);
|
||
if (s !== null) want = s === '1';
|
||
} catch (_) {}
|
||
}
|
||
_vvNcLoadPrefs();
|
||
vvNcMode(want);
|
||
vvNcChans();
|
||
_vvNcSchedule();
|
||
}
|
||
|
||
// Poll fast while the mesh is on screen, and back off while it is not.
|
||
//
|
||
// Eight pages each asking every 15 seconds is eight times the traffic for one store, and while
|
||
// the card is showing the assistant the only thing the poll feeds is the unread badge — which
|
||
// nobody is watching to the second. Backs off 30 → 60 → 90 and stays there; snaps back to 15 the
|
||
// moment the mesh pane is opened, so switching to it is never waiting on a slow timer.
|
||
let _vvNcTimer = null, _vvNcStep = 0;
|
||
const _VV_NC_BACKOFF = [30000, 60000, 90000];
|
||
|
||
function _vvNcSchedule() {
|
||
if (_vvNcTimer) clearTimeout(_vvNcTimer);
|
||
const wait = _vvNcMeshOn ? 15000 : _VV_NC_BACKOFF[Math.min(_vvNcStep, _VV_NC_BACKOFF.length - 1)];
|
||
_vvNcTimer = setTimeout(() => {
|
||
vvNcChans();
|
||
if (!_vvNcMeshOn) _vvNcStep++;
|
||
_vvNcSchedule();
|
||
}, wait);
|
||
}
|
||
|
||
vvNcInit(<?= json_encode($prefix) ?>, <?= $meshDefault ? 'true' : 'false' ?>);
|
||
</script>
|
||
<?php
|
||
}
|