Chat moderation: Report message + Block user (App Store guideline 1.2)

Apple requires user-generated-content apps to offer a way to report
objectionable content and block abusive users. The chat had neither, which is
the #1 rejection cause for messaging apps. Added both, server-enforced.

Server:
- schema: message_reports + user_blocks tables.
- repos: reports {add,listForTeam,setStatus}, blocks {add,remove,has,listFor},
  users.adminsOf(); thread + threadByConversation now exclude blocked senders in
  SQL (like message_hidden) so the LIMIT counts only visible rows (no pagination
  stall).
- routes: POST /api/messages/report, /api/users/block|unblock, GET
  /api/users/blocked, GET /api/reports + POST /api/reports/resolve (admin only).
- enforcement: a blocked sender's DM/group messages are persisted but not
  delivered (no live push, no background notification) to anyone who blocked
  them; blocked users can't ring you (/api/calls/dm/start + /api/calls/invite);
  admins can delete reported content (delete route now allows role=admin).

Client (home.html, all platforms via the web UI — no rebuild):
- message menu gains Report (canned-reason picker) + Block/Unblock.
- profile menu: "Blocked users" manager (list + unblock) for everyone;
  "Reported messages" review (delete / block / resolve) for admins.
- blocked DMs hidden from the sidebar; block list loaded on boot.
- reports route to the workspace's OWN admins (org-internal moderation).

Verified: db-smoke 22/22 + a new moderation suite 12/12 (report+admin-list,
non-admin 403, block hides post-block history but sender still sees sent,
blocked call 403, unblock restores history + calling). New flag/ban icons added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:46:22 +05:30
parent 7e3a94b04c
commit e4d361f298
5 changed files with 236 additions and 11 deletions
+103 -1
View File
@@ -1507,6 +1507,8 @@ function profileHTML(u){
+ '<div class="ps-div"></div>'
+ '<a href="/dashboard">'+ic('layoutDashboard',16)+' Dashboard</a>'
+ '<a id="psettings">'+ic('settings',16)+' Settings</a>'
+ '<a id="pblocked">'+ic('ban',16)+' Blocked users</a>'
+ (u.role==='admin'?'<a id="preports">'+ic('flag',16)+' Reported messages</a>':'')
+ '<a class="danger" id="plogout">'+ic('logOut',16)+' Logout</a>'
+ '</div></div>';
}
@@ -1519,6 +1521,10 @@ function wireProfile(){
if(lo)lo.onclick=async()=>{ await unsubscribePush(); try{await fetch('/api/logout',{method:'POST'});}catch(_){} location.href='/';};
const ps=document.getElementById('psettings');
if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); };
const pbk=document.getElementById('pblocked');
if(pbk)pbk.onclick=()=>{ menu.classList.remove('open'); openBlockedManager(); };
const prp=document.getElementById('preports');
if(prp)prp.onclick=()=>{ menu.classList.remove('open'); openReportsAdmin(); };
const stog=menu.querySelector('#psStatusToggle'); if(stog) stog.onclick=(e)=>{ e.stopPropagation(); const o=menu.querySelector('#psOptions'); if(o) o.style.display=(o.style.display==='none'?'block':'none'); };
menu.querySelectorAll('.ps-opt').forEach(a=>a.onclick=async(e)=>{ e.stopPropagation(); const st=a.dataset.st; ME.status=st;
const cd=menu.querySelector('.ps-current .st-dot'); if(cd) cd.className='st-dot '+st;
@@ -1665,6 +1671,8 @@ function openStorage(){
// ---------- Chat (1:1 + groups) ----------
let ME={};
let CONTACTS=[]; // team users (for new DMs / picking group members)
let BLOCKED=new Set(); // ids of users I've blocked (UGC guideline 1.2) — hide their content, gate calls
async function loadBlocked(){ try{ const b=await fetch('/api/users/blocked').then(r=>r.json()); if(b&&Array.isArray(b.ids)) BLOCKED=new Set(b.ids); }catch(_){} }
let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread}
const _lastSeen={}; // #8: userId -> last-seen ts, kept STICKY so a rebuild/presence event that lacks it can't flap the label back to "Offline"
let selected=null; // {kind,id} or null = welcome
@@ -2102,7 +2110,7 @@ async function loadSidebar(){
// the last_seen DB write), which used to flip the subtitle from "last seen …" to "Offline". Remember any
// known value and backfill nulls from it so the label stays stable.
items.forEach(it=>{ if(it.kind==='dm'&&!it.self){ if(it.lastSeen) _lastSeen[it.id]=it.lastSeen; else if(_lastSeen[it.id]) it.lastSeen=_lastSeen[it.id]; } });
ROWS=items;
ROWS=(BLOCKED&&BLOCKED.size)?items.filter(it=>!(it.kind==='dm' && !it.self && BLOCKED.has(it.id))):items; // hide blocked DMs
renderChats(searchVal());
updateRailUnread();
prewarmNotifAvatars();
@@ -2313,9 +2321,102 @@ function msgMenuItems(m){
if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)});
if(m.attachment && !m.deleted) items.push({ic:'download', label:'Save', fn:()=>saveAttachment(m)}); // #9: native long-press "Save" is disabled now, so offer Save explicitly
if(!m.deleted && !m.poll) items.push({ic:(m.pinned?'pinOff':'pin'), label:(m.pinned?'Unpin':'Pin'), fn:()=>pinMessage(m.id, !m.pinned)}); // #13
// UGC moderation (App Store guideline 1.2): report someone else's message + block its sender.
const sys=(m.from==='__system__'||m.system);
if(!mine && !sys && m.from){
items.push({ic:'flag', label:'Report', fn:()=>reportMessage(m)});
const blk=BLOCKED.has(m.from);
items.push({ic:'ban', label:(blk?'Unblock user':'Block user'), danger:!blk, fn:()=>toggleBlock(m.from, m.fromName||nameForId(m.from))});
}
items.push({ic:'trash', label:'Delete', danger:true, fn:()=>openDeleteDialog(m)}); // #18: branded dialog offers "for me" / "for everyone"
return items;
}
// Best-effort display name for a user id (block confirmations / labels).
function nameForId(id){ if(!id) return 'this user'; if(id===(ME&&ME.id)) return 'you'; const c=(CONTACTS||[]).find(x=>x&&x.id===id); if(c&&c.name) return c.name; const r=(ROWS||[]).find(x=>x&&x.kind==='dm'&&x.id===id); return (r&&r.name)||'this user'; }
// Report a message to the workspace admins. Small branded picker of canned reasons.
function reportMessage(m){
if(!m||!m.id) return;
document.querySelectorAll('.bz-modal-ov').forEach(x=>x.remove());
const reasons=['Spam','Harassment or bullying','Inappropriate or offensive','Other'];
const ov=document.createElement('div'); ov.className='bz-modal-ov';
ov.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(16,26,53,.45);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;padding:20px';
ov.innerHTML='<div style="background:#fff;border-radius:16px;max-width:360px;width:100%;padding:18px;box-shadow:0 20px 60px rgba(0,0,0,.3)">'
+'<h3 style="margin:0 0 4px;font-size:1.05rem;display:flex;align-items:center;gap:.4rem;color:#1f2430">'+ic('flag',18)+'Report message</h3>'
+'<p style="margin:0 0 12px;color:#6b7280;font-size:.85rem">This message is sent to your workspace admins for review.</p>'
+'<div style="display:flex;flex-direction:column;gap:8px">'+reasons.map(r=>'<button data-r="'+pEsc(r)+'" style="text-align:left;padding:.6rem .8rem;border:1px solid #e6e9ef;background:#f8fafc;border-radius:10px;font-size:.9rem;cursor:pointer;color:#1f2430">'+pEsc(r)+'</button>').join('')+'</div>'
+'<div style="display:flex;justify-content:flex-end;margin-top:12px"><button data-x="1" style="padding:.5rem .9rem;border:none;background:transparent;color:#6b7280;font-size:.9rem;cursor:pointer">Cancel</button></div>'
+'</div>';
document.body.appendChild(ov);
ov.addEventListener('click', async (e)=>{
if(e.target===ov||e.target.closest('[data-x]')){ ov.remove(); return; }
const rb=e.target.closest('[data-r]'); if(!rb) return;
ov.remove();
try{ await postJSON('/api/messages/report',{ id:m.id, reason:rb.dataset.r }); toast('Reported. Your admins will review it.'); }
catch(err){ toast((err&&err.message)||'Could not report'); }
});
}
// Block / unblock a user. Blocking severs their messages + calls to me (server-enforced); reloads views.
async function toggleBlock(uid, name){
if(!uid || uid===(ME&&ME.id)) return;
const nm=name||nameForId(uid);
if(BLOCKED.has(uid)){
try{ await postJSON('/api/users/unblock',{ userId:uid }); BLOCKED.delete(uid); toast('Unblocked '+nm); }catch(e){ toast((e&&e.message)||'Could not unblock'); return; }
} else {
const ok=await bzConfirm("You won't receive messages or calls from "+nm+". You can unblock them anytime.", {title:'Block '+nm+'?', okText:'Block', danger:true});
if(!ok) return;
try{ await postJSON('/api/users/block',{ userId:uid }); BLOCKED.add(uid); toast('Blocked '+nm); }catch(e){ toast((e&&e.message)||'Could not block'); return; }
}
try{ await loadSidebar(); }catch(_){}
try{ if(typeof reloadThread==='function') reloadThread(); }catch(_){}
}
// "Blocked users" manager (profile menu) — list + unblock.
async function openBlockedManager(){
let list=[]; try{ const b=await fetch('/api/users/blocked').then(r=>r.json()); if(b&&Array.isArray(b.users)) list=b.users; if(b&&Array.isArray(b.ids)) BLOCKED=new Set(b.ids); }catch(_){}
document.querySelectorAll('.bz-modal-ov').forEach(x=>x.remove());
const ov=document.createElement('div'); ov.className='bz-modal-ov';
ov.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(16,26,53,.45);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;padding:20px';
const rows=list.length?list.map(u=>'<div style="display:flex;align-items:center;gap:.6rem;padding:.5rem .1rem;border-bottom:1px solid #f0f2f6"><span style="flex:1;font-size:.9rem;color:#1f2430">'+pEsc(u.name||'Unknown')+'</span><button data-u="'+pEsc(u.id)+'" style="padding:.35rem .7rem;border:1px solid #e6e9ef;background:#fff;border-radius:8px;font-size:.82rem;cursor:pointer;color:#1F3B73">Unblock</button></div>').join(''):'<p style="color:#6b7280;font-size:.88rem;text-align:center;padding:1.2rem 0">You havent blocked anyone.</p>';
ov.innerHTML='<div style="background:#fff;border-radius:16px;max-width:380px;width:100%;padding:18px;box-shadow:0 20px 60px rgba(0,0,0,.3)">'
+'<h3 style="margin:0 0 10px;font-size:1.05rem;display:flex;align-items:center;gap:.4rem;color:#1f2430">'+ic('ban',18)+'Blocked users</h3>'
+'<div style="max-height:52vh;overflow:auto">'+rows+'</div>'
+'<div style="display:flex;justify-content:flex-end;margin-top:12px"><button data-x="1" style="padding:.5rem .9rem;border:none;background:#1F3B73;color:#fff;border-radius:8px;font-size:.9rem;cursor:pointer">Done</button></div>'
+'</div>';
document.body.appendChild(ov);
ov.addEventListener('click', async (e)=>{
if(e.target===ov||e.target.closest('[data-x]')){ ov.remove(); return; }
const ub=e.target.closest('[data-u]'); if(!ub) return;
try{ await postJSON('/api/users/unblock',{ userId:ub.dataset.u }); BLOCKED.delete(ub.dataset.u); ub.parentNode.remove(); toast('Unblocked'); await loadSidebar(); }catch(err){ toast((err&&err.message)||'Could not unblock'); }
});
}
// Admin: reported-messages review (profile menu, admins only).
async function openReportsAdmin(){
let list=[]; try{ list=await fetch('/api/reports').then(r=>r.json()); }catch(_){} if(!Array.isArray(list)) list=[];
document.querySelectorAll('.bz-modal-ov').forEach(x=>x.remove());
const ov=document.createElement('div'); ov.className='bz-modal-ov';
ov.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(16,26,53,.45);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;padding:20px';
const row=(r)=>'<div data-id="'+pEsc(r.id)+'" style="padding:.6rem 0;border-bottom:1px solid #f0f2f6">'
+'<div style="font-size:.9rem;color:#1f2430"><b>'+pEsc(r.reported)+'</b> <span style="color:#6b7280">reported by</span> '+pEsc(r.reporter)+(r.status==='resolved'?' <span style="color:#16a34a;font-size:.78rem">· resolved</span>':'')+'</div>'
+(r.reason?'<div style="font-size:.82rem;color:#b45309;margin-top:.15rem">'+pEsc(r.reason)+'</div>':'')
+(r.snippet?'<div style="font-size:.82rem;color:#6b7280;font-style:italic;margin-top:.2rem">“'+pEsc(r.snippet)+'”</div>':'')
+'<div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-top:.45rem">'
+'<button data-del="'+pEsc(r.messageId)+'" style="padding:.3rem .6rem;border:1px solid #fca5a5;background:#fff;color:#b91c1c;border-radius:8px;font-size:.78rem;cursor:pointer">Delete message</button>'
+'<button data-block="'+pEsc(r.reportedId)+'" data-name="'+pEsc(r.reported)+'" style="padding:.3rem .6rem;border:1px solid #e6e9ef;background:#fff;color:#1f2430;border-radius:8px;font-size:.78rem;cursor:pointer">Block user</button>'
+(r.status!=='resolved'?'<button data-resolve="'+pEsc(r.id)+'" style="padding:.3rem .6rem;border:1px solid #e6e9ef;background:#fff;color:#1F3B73;border-radius:8px;font-size:.78rem;cursor:pointer">Mark resolved</button>':'')
+'</div></div>';
const bodyHTML=list.length?list.map(row).join(''):'<p style="color:#6b7280;font-size:.88rem;text-align:center;padding:1.2rem 0">No reports.</p>';
ov.innerHTML='<div style="background:#fff;border-radius:16px;max-width:430px;width:100%;padding:18px;box-shadow:0 20px 60px rgba(0,0,0,.3)">'
+'<h3 style="margin:0 0 10px;font-size:1.05rem;display:flex;align-items:center;gap:.4rem;color:#1f2430">'+ic('flag',18)+'Reported messages</h3>'
+'<div style="max-height:58vh;overflow:auto">'+bodyHTML+'</div>'
+'<div style="display:flex;justify-content:flex-end;margin-top:12px"><button data-x="1" style="padding:.5rem .9rem;border:none;background:#1F3B73;color:#fff;border-radius:8px;font-size:.9rem;cursor:pointer">Done</button></div>'
+'</div>';
document.body.appendChild(ov);
ov.addEventListener('click', async (e)=>{
if(e.target===ov||e.target.closest('[data-x]')){ ov.remove(); return; }
const del=e.target.closest('[data-del]'); if(del){ if(await bzConfirm('Delete this message for everyone in the chat?', {title:'Delete message?', okText:'Delete', danger:true})){ try{ await postJSON('/api/messages/delete',{ id:del.dataset.del }); try{ markMsgDeleted(del.dataset.del); }catch(_){} toast('Message deleted'); del.textContent='Deleted'; del.disabled=true; }catch(err){ toast((err&&err.message)||'Could not delete'); } } return; }
const bl=e.target.closest('[data-block]'); if(bl){ await toggleBlock(bl.dataset.block, bl.dataset.name); return; }
const rs=e.target.closest('[data-resolve]'); if(rs){ try{ await postJSON('/api/reports/resolve',{ id:rs.dataset.resolve, status:'resolved' }); const row=rs.closest('[data-id]'); if(row) row.style.opacity=.55; rs.remove(); toast('Marked resolved'); }catch(err){ toast('Could not update'); } return; }
});
}
// #9 (mobile): long-press a message → a dimmed + blurred backdrop and a bottom action sheet with quick
// reactions and every action (reply / forward / copy / pin / delete). This replaces the old hover-style
// ".show-actions" reveal on touch — which could sit hidden behind an image and stopped working after the
@@ -6453,6 +6554,7 @@ window.addEventListener('message',(e)=>{
if(_meet && /^\d{6}$/.test(_meet) && !me){ document.getElementById('loading').style.display='none'; return startGuestMeeting(_meet); }
if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; }
ME=me;
loadBlocked(); // fetch my block list (UGC moderation) — non-blocking
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
loadNotifs(); wireBell(); wireProfile();
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)