From ecded4a8a7cd9e93c45f7deb2b491a58946caab2 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Mon, 17 Aug 2026 16:43:22 -0400 Subject: [PATCH] Mesh Chat card on the Partnership page Channel picker, unread filter, colour and font selectors, emoji, and the Varaverk mark faded behind it. --- Plugin/unraid/pages/partnership.php | 184 ++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/Plugin/unraid/pages/partnership.php b/Plugin/unraid/pages/partnership.php index 42f441b..ba6aa77 100644 --- a/Plugin/unraid/pages/partnership.php +++ b/Plugin/unraid/pages/partnership.php @@ -132,6 +132,73 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
Loading…
+ +
+ + +
+
+

Mesh Chat

+ + + +
+ +
+
Loading…
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ 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. +
+
+
+
@@ -1275,6 +1342,123 @@ function vvPtLoad() { _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: [] }; + +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 || []; + const sel = document.getElementById('vv-nc-ch'); + if (!sel) return; + const keep = _vvNc.ch || (_vvNc.chans[0] || {}).id || ''; + sel.innerHTML = _vvNc.chans.map(c => + ``).join(''); + if (!_vvNc.ch) vvNcSelect(keep); else vvNcLoad(); + }) + .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 = `
` + + (unreadOnly ? 'Nothing new.' : 'No messages yet β€” say hello.') + `
`; + 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 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)} + ${new Date((m.ts || 0) * 1000).toLocaleString()} + Γ— +
+
${vvEscHtml(m.text)}
+
`; + }).join(''); + 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, + }) + }).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(() => {}); +} + +vvNcChans(); +setInterval(vvNcChans, 15000); + vvPtLoad(); setInterval(vvPtLoad, 10000);