Wallet-verified member messaging (matrix-lines permissions, admin visibility)
- messages.js: challenge/personal_sign/recover auth (vendored pinned js-sha3 0.9.3 + noble-secp256k1 1.7.1, server-side only; self-tested positive + tamper cases), 30d HttpOnly sessions, message store on the volume, matrix-line permissions (your downline direct or broadcast, your upline chain - nothing else, so spam is impossible by construction), daily rate limits (30 direct / 3 broadcasts), 1500-char plain text - chain.js: memberIdByAccount (wallet -> position for sign-in) - API: msg-challenge/-verify/-me/-inbox/-send/-read public + msg-unread (count only, no auth) + admin/messages (full visibility, disclosed to members in the UI) - Dashboard: Messages card with unread bell, one-tap wallet sign-in, inbox with auto-read, compose with to-ID or whole-team broadcast - Admin: Member Messages review table - Chatbot canned answer + AI system prompt updated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,7 @@
|
||||
renderGens(d);
|
||||
renderPipeline(d);
|
||||
renderCoach(d);
|
||||
renderMessages(d);
|
||||
renderAlerts(d);
|
||||
renderShare(d);
|
||||
document.getElementById('dLineage').innerHTML=d.uplineChain&&d.uplineChain.length
|
||||
@@ -158,6 +159,69 @@
|
||||
}).join('');
|
||||
el.innerHTML=`<div style="border-top:1px solid var(--line);padding-top:12px"><small style="text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-size:11px">Team depth — members per generation</small><div style="margin-top:8px">${rows}</div><p class="micro" style="margin:8px 0 0">Full generations duplicate: each one can hold twice the last. A generation pays this position at exactly one level — stay qualified and at that level to catch it.</p></div>`;
|
||||
}
|
||||
// Wallet-verified messaging: sign-in = one free personal_sign; identity is
|
||||
// the wallet that owns a position; permissions follow the matrix lines.
|
||||
function renderMessages(d){
|
||||
const el=document.getElementById('dMsg'),bell=document.getElementById('msgBell');
|
||||
if(!el)return;
|
||||
fetch('/api/public/msg-unread?id='+d.id).then(r=>r.json()).then(u=>{
|
||||
if(bell&&u&&u.count>0){bell.textContent='🔔 '+u.count+' new';bell.classList.remove('hidden');}
|
||||
}).catch(()=>{});
|
||||
loadMsgUI(d);
|
||||
}
|
||||
async function loadMsgUI(d){
|
||||
const el=document.getElementById('dMsg');
|
||||
let me=null;
|
||||
try{const r=await fetch('/api/public/msg-me');if(r.ok)me=await r.json();}catch(e){}
|
||||
if(!me){
|
||||
el.innerHTML='<p class="micro" style="margin:0 0 10px">Sign in once with the wallet that owns your position — one free signature; it can\'t move funds or approve anything.</p><button id="msgAuthBtn" class="btn btn-primary">🔐 Connect wallet & sign in</button><div id="msgAuthErr" class="micro" style="color:var(--danger);margin-top:8px"></div>';
|
||||
const b=document.getElementById('msgAuthBtn');if(b)b.addEventListener('click',function(){msgAuth(d);});
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try{data=await(await fetch('/api/public/msg-inbox')).json();}catch(e){el.innerHTML='<div class="empty">Could not load messages — refresh to retry.</div>';return;}
|
||||
const mine=Number(me.id)===Number(d.id);
|
||||
const banner=mine?'':`<div class="callout" style="margin-bottom:10px">You're signed in as <strong>#${me.id}</strong> — this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)</div>`;
|
||||
const rows=(data.inbox||[]).map(m=>`<div class="pp-row" style="padding:9px 12px${m.read?'':';border-color:rgba(240,197,109,.55)'}"><div class="pp-icon">${m.org?'📣':'✉️'}</div><div class="pp-body"><strong>From #${m.fromId}</strong> <span class="pp-meta" style="display:inline">· ${new Date(m.ts).toLocaleString()}${m.org?' · team broadcast':''}${m.read?'':' · <strong style="color:var(--gold)">NEW</strong>'}</span><div style="white-space:pre-wrap;margin-top:4px">${esc(m.body)}</div></div></div>`).join('')||'<div class="empty">No messages yet.</div>';
|
||||
const sent=(data.sent||[]).slice(0,3).map(m=>`<div class="micro" style="margin:3px 0">→ ${m.org?'whole team':'#'+m.toId} · ${new Date(m.ts).toLocaleString()}: ${esc(m.body.slice(0,90))}${m.body.length>90?'…':''}</div>`).join('');
|
||||
el.innerHTML=banner+rows+
|
||||
(sent?`<div style="margin-top:10px"><span class="micro" style="text-transform:uppercase;letter-spacing:.08em">Recently sent</span>${sent}</div>`:'')+
|
||||
`<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div style="font-weight:800;margin-bottom:6px">Send a message</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
|
||||
<input id="msgTo" inputmode="numeric" placeholder="Member #" value="${mine?'':esc(String(d.id))}" style="max-width:110px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:#08192880;color:var(--text);font-size:14px">
|
||||
<label class="micro" style="display:flex;gap:6px;align-items:center;cursor:pointer"><input type="checkbox" id="msgOrg"> send to my whole team instead</label>
|
||||
</div>
|
||||
<textarea id="msgBody" maxlength="1500" rows="3" placeholder="Plain text, up to 1500 characters. You can message your team and your upline." style="width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#08192880;color:var(--text);font-size:14px"></textarea>
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-top:8px"><button id="msgSendBtn" class="btn btn-primary">Send →</button><span id="msgStatus" class="micro"></span></div></div>`;
|
||||
const unreadIds=(data.inbox||[]).filter(m=>!m.read).map(m=>m.mid);
|
||||
if(unreadIds.length)fetch('/api/public/msg-read',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mids:unreadIds})}).catch(()=>{});
|
||||
const sb=document.getElementById('msgSendBtn');
|
||||
if(sb)sb.addEventListener('click',async function(){
|
||||
const st=document.getElementById('msgStatus');st.textContent='Sending…';sb.disabled=true;
|
||||
try{
|
||||
const payload={org:document.getElementById('msgOrg').checked,toId:(document.getElementById('msgTo').value||'').trim(),body:document.getElementById('msgBody').value};
|
||||
const r=await(await fetch('/api/public/msg-send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})).json();
|
||||
if(r.error){st.textContent=r.error;sb.disabled=false;return;}
|
||||
st.textContent='Sent ✓';setTimeout(function(){loadMsgUI(d);},700);
|
||||
}catch(e){st.textContent='Send failed — try again.';sb.disabled=false;}
|
||||
});
|
||||
}
|
||||
async function msgAuth(d){
|
||||
const err=document.getElementById('msgAuthErr');
|
||||
try{
|
||||
const eth=await window.RMCWallet.pick();
|
||||
if(!eth){err.textContent='No wallet found in this browser. On a phone, open this page inside your wallet app\'s browser (MetaMask or Trust).';return;}
|
||||
const accs=await eth.request({method:'eth_requestAccounts'});const account=accs[0];
|
||||
const ch=await(await fetch('/api/public/msg-challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account})})).json();
|
||||
if(!ch.message)throw new Error(ch.error||'Could not start sign-in.');
|
||||
let hex='0x';for(const b of new TextEncoder().encode(ch.message))hex+=b.toString(16).padStart(2,'0');
|
||||
const sig=await eth.request({method:'personal_sign',params:[hex,account]});
|
||||
const v=await(await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json();
|
||||
if(!v.ok)throw new Error(v.error||'Verification failed.');
|
||||
loadMsgUI(d);
|
||||
}catch(e){if(err)err.textContent=e.message||String(e);}
|
||||
}
|
||||
|
||||
// "Coach your team" — the same triage the team admin runs, scoped to THIS
|
||||
// position's leg: who below could use a nudge, and exactly what to tell them.
|
||||
function renderCoach(d){
|
||||
|
||||
Reference in New Issue
Block a user