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; }
@@ -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 =>
+ `
`
+ + `${vvEscHtml(c.label)}${c.unread ? ' (' + c.unread + ')' : ''} `).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);