Mesh Chat card on the Partnership page
Channel picker, unread filter, colour and font selectors, emoji, and the Varaverk mark faded behind it.
This commit is contained in:
@@ -132,6 +132,73 @@ textarea.vv-pt-set-input { resize:vertical; white-space:pre; }
|
||||
<div id="vv-pt-actions-body" style="color:#444;font-size:12px;">Loading…</div>
|
||||
</div>
|
||||
|
||||
<!-- Mesh chat — the partners' own channel -->
|
||||
<div class="vv-card" id="vv-nc-card" style="margin-bottom:12px;position:relative;overflow:hidden;">
|
||||
<!-- The logo sits behind the conversation rather than beside it: this is the one card on the
|
||||
page that is about the people rather than the machines, and it can afford the room. -->
|
||||
<img src="/plugins/varaverk/icons/varaverk.png" alt="" aria-hidden="true"
|
||||
style="position:absolute;right:-30px;bottom:-40px;width:260px;opacity:.035;
|
||||
pointer-events:none;filter:grayscale(1);">
|
||||
<div style="position:relative;">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;">
|
||||
<h3 style="margin:0;">Mesh Chat</h3>
|
||||
<select id="vv-nc-ch" class="vv-cf-sel" style="max-width:200px;font-size:11px;padding:3px 8px;"
|
||||
onchange="vvNcSelect(this.value)"></select>
|
||||
<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>Unread only</span>
|
||||
</label>
|
||||
<span id="vv-nc-status" style="margin-left:auto;font-size:9px;color:#3a3a3a;"></span>
|
||||
</div>
|
||||
|
||||
<div id="vv-nc-log"
|
||||
style="max-height:260px;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 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;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Assistant -->
|
||||
<?php if (vv_ai_ui_on()): ?>
|
||||
<div class="vv-card" id="vv-pt-ai-card" style="margin-bottom:12px;">
|
||||
@@ -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 =>
|
||||
`<option value="${vvEscAttr(c.id)}"${c.id === keep ? ' selected' : ''}>`
|
||||
+ `${vvEscHtml(c.label)}${c.unread ? ' (' + c.unread + ')' : ''}</option>`).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 = `<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 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: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>`;
|
||||
}).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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user