feat(chat): forward messages with multi-select (#1)

- Message action pill gains a Forward button; tapping it enters selection mode
  (tap bubbles to multi-select, footer bar shows count + Forward/Cancel, Esc exits).
- Forward picker lists EXISTING conversations only (DMs + groups from the sidebar),
  searchable, multi-target. POST /api/messages/forward copies body+attachment into
  each target (authorized as participant/member), live-pushed like a normal send.
- /files auth now accepts ANY message carrying an attachment (allByAttachment), so
  forwarded images stay viewable for the new recipients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:09:02 +05:30
parent 3a976d58ab
commit a9b3533f7a
4 changed files with 118 additions and 7 deletions
+74 -1
View File
@@ -448,6 +448,21 @@
.msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;} .msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;}
.msg-actions button:hover{background:var(--blue-soft);} .msg-actions button:hover{background:var(--blue-soft);}
.msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;} .msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;}
/* #1 Forward: message selection mode + target picker */
.bubble.selected{outline:2px solid var(--blue);outline-offset:2px;}
body.sel-mode .bubble{cursor:pointer;} body.sel-mode .msg-actions{display:none!important;}
.sel-bar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:9000;display:flex;align-items:center;gap:.7rem;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:.4rem .5rem .4rem .4rem;box-shadow:0 10px 30px rgba(20,30,60,.25);}
.sel-bar .sb-x{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
.sel-bar .sb-n{font-size:.9rem;font-weight:600;color:var(--ink);}
.sel-bar .sb-fwd{border:none;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .9rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;}
.sel-bar .sb-fwd:hover{background:var(--blue-d);}
.fwd-list{max-height:44vh;overflow-y:auto;display:flex;flex-direction:column;gap:2px;}
.fwd-item{display:flex;align-items:center;gap:.6rem;padding:.4rem .5rem;border-radius:10px;cursor:pointer;}
.fwd-item:hover,.fwd-item.sel{background:var(--blue-soft);}
.fwd-item .fi-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.92rem;}
.fwd-item .fi-check{opacity:0;color:var(--blue);} .fwd-item.sel .fi-check{opacity:1;}
.fwd-item .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.8rem;overflow:hidden;}
.fwd-item .mini-av img{width:100%;height:100%;object-fit:cover;}
.reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);} .reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);}
.reply-bar b{color:var(--ink);} .reply-bar b{color:var(--ink);}
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;} .reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
@@ -794,7 +809,7 @@
<body> <body>
<script src="/icons.js?v=4"></script> <script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-07-batch49';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-07-batch50';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load // Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5) // (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script> function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -1066,6 +1081,58 @@ async function jumpToMessage(id, at){
try{ el.scrollIntoView({block:'center'}); }catch(_){} try{ el.scrollIntoView({block:'center'}); }catch(_){}
el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400); el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400);
} }
// ---- #1 Forward: multi-select messages → forward to existing conversations only ----
let _selMode=false; const _selIds=new Set();
function enterSelect(id){
_selMode=true; _selIds.clear(); if(id) _selIds.add(id);
document.body.classList.add('sel-mode');
const box=document.getElementById('msgs'); if(box) box.querySelectorAll('.bubble').forEach(b=>b.classList.toggle('selected', _selIds.has(b.dataset.id)));
renderSelBar();
}
function toggleSel(bubble){
const id=bubble&&bubble.dataset.id; if(!id) return;
if(_selIds.has(id)){ _selIds.delete(id); bubble.classList.remove('selected'); } else { _selIds.add(id); bubble.classList.add('selected'); }
if(!_selIds.size){ exitSelect(); return; } renderSelBar();
}
function exitSelect(){
_selMode=false; _selIds.clear(); document.body.classList.remove('sel-mode');
const box=document.getElementById('msgs'); if(box) box.querySelectorAll('.bubble.selected').forEach(b=>b.classList.remove('selected'));
const bar=document.getElementById('selBar'); if(bar) bar.remove();
}
function renderSelBar(){
let bar=document.getElementById('selBar');
if(!bar){ bar=document.createElement('div'); bar.id='selBar'; bar.className='sel-bar'; document.body.appendChild(bar); }
bar.innerHTML='<button class="sb-x" title="Cancel">'+ic('x',18)+'</button><span class="sb-n">'+_selIds.size+' selected</span><button class="sb-fwd">'+ic('arrowRight',16)+' Forward</button>';
bar.querySelector('.sb-x').onclick=exitSelect;
bar.querySelector('.sb-fwd').onclick=()=>{ if(_selIds.size) openForwardPicker([..._selIds]); };
}
function fwdRowHTML(r){
const isG=r.kind==='group';
const av='<span class="mini-av" style="background:'+avColor(r.name)+'">'+(isG?ic('users',15):(r.avatar?'<img src="'+pEsc(r.avatar)+'" onerror="this.remove()">':pEsc(initials(r.name))))+'</span>';
return '<div class="fwd-item" data-k="'+r.kind+'" data-id="'+pEsc(r.id)+'" data-name="'+pEsc((r.name||'').toLowerCase())+'">'+av+'<span class="fi-name">'+pEsc(r.name)+'</span><span class="fi-check">'+ic('check',15)+'</span></div>';
}
function openForwardPicker(ids){
if(document.getElementById('fwdModal')) return;
const rows=ROWS.filter(r=>!r.self); // existing conversations only (per spec)
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='fwdModal';
ov.innerHTML='<div class="modal"><h3>Forward to…</h3>'
+'<input id="fwdSearch" placeholder="Search chats" autocomplete="off" style="width:100%;padding:.55rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;margin-bottom:.6rem;">'
+'<div class="fwd-list" id="fwdList">'+(rows.length?rows.map(fwdRowHTML).join(''):'<div class="gi-noresult">No conversations yet</div>')+'</div>'
+'<div class="bzc-actions" style="margin-top:.8rem"><button type="button" class="bzc-cancel" id="fwdCancel">Cancel</button><button type="button" class="bzc-ok" id="fwdGo" disabled>Forward (0)</button></div></div>';
document.body.appendChild(ov);
const sel=new Set(); const list=ov.querySelector('#fwdList'), go=ov.querySelector('#fwdGo');
const upd=()=>{ go.disabled=!sel.size; go.textContent='Forward ('+sel.size+')'; };
list.addEventListener('click',e=>{ const it=e.target.closest('.fwd-item'); if(!it) return; const key=it.dataset.k+':'+it.dataset.id; if(sel.has(key)){ sel.delete(key); it.classList.remove('sel'); } else { sel.add(key); it.classList.add('sel'); } upd(); });
ov.querySelector('#fwdSearch').oninput=e=>{ const q=e.target.value.trim().toLowerCase(); list.querySelectorAll('.fwd-item').forEach(it=>{ it.style.display=(!q||(it.dataset.name||'').includes(q))?'':'none'; }); };
ov.querySelector('#fwdCancel').onclick=()=>ov.remove();
ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); });
go.onclick=async()=>{
const targets=[...sel].map(k=>{ const i=k.indexOf(':'); return { kind:k.slice(0,i), id:k.slice(i+1) }; });
go.disabled=true; go.textContent='Forwarding…';
try{ await postJSON('/api/messages/forward',{ messageIds:ids, targets }); ov.remove(); exitSelect(); toast('Forwarded to '+targets.length+(targets.length===1?' chat':' chats')); }
catch(e){ toast(e.message||'Could not forward'); go.disabled=false; upd(); }
};
}
async function gotoHit(i){ async function gotoHit(i){
const hits=_searchHits; if(!hits.length) return; const hits=_searchHits; if(!hits.length) return;
_searchIdx=((i%hits.length)+hits.length)%hits.length; _searchIdx=((i%hits.length)+hits.length)%hits.length;
@@ -1386,6 +1453,7 @@ function bubbleHTML(m){
+ sender + quote + att + renderMsgBody(m) + pollHTML(m) + sender + quote + att + renderMsgBody(m) + pollHTML(m)
+ (m.deleted?'':'<div class="msg-actions">' + (m.deleted?'':'<div class="msg-actions">'
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>' + '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
+ ((m.body||m.attachment)&&!m.poll?'<button class="fwd-btn" data-fwd="'+pEsc(m.id)+'" title="Forward">'+ic('arrowRight',14)+'</button>':'')
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>' + '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
+ ((mine && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'') + ((mine && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'') + (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'')
@@ -1777,6 +1845,7 @@ function renderThread(keepScroll){
let html=''; let html='';
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); } for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
box.innerHTML=html; twemojify(box); if(!keepScroll) box.scrollTop=box.scrollHeight; // keepScroll: prepending older history — don't jump to bottom box.innerHTML=html; twemojify(box); if(!keepScroll) box.scrollTop=box.scrollHeight; // keepScroll: prepending older history — don't jump to bottom
if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render
} }
function appendBubble(m){ function appendBubble(m){
if(rendered.has(m.id)) return; rendered.add(m.id); if(rendered.has(m.id)) return; rendered.add(m.id);
@@ -1833,6 +1902,8 @@ async function openConvo(kind,id){
const csPrev=document.getElementById('convoSearchPrev'); if(csPrev) csPrev.onclick=()=>gotoHit(_searchIdx-1); const csPrev=document.getElementById('convoSearchPrev'); if(csPrev) csPrev.onclick=()=>gotoHit(_searchIdx-1);
const csNext=document.getElementById('convoSearchNext'); if(csNext) csNext.onclick=()=>gotoHit(_searchIdx+1); const csNext=document.getElementById('convoSearchNext'); if(csNext) csNext.onclick=()=>gotoHit(_searchIdx+1);
const box=document.getElementById('msgs'); if(box) box.addEventListener('click',(e)=>{ const box=document.getElementById('msgs'); if(box) box.addEventListener('click',(e)=>{
if(_selMode){ const bb=e.target.closest('.bubble'); if(bb) toggleSel(bb); return; } // #1: selection mode — tap toggles
const fw=e.target.closest('.fwd-btn'); if(fw){ enterSelect(fw.dataset.fwd); return; } // #1: enter forward-selection
const qz=e.target.closest('.quote'); if(qz && qz.dataset.jid){ jumpToMessage(qz.dataset.jid, +qz.dataset.jat||0); return; } // #8: tap a reply → go to the original const qz=e.target.closest('.quote'); if(qz && qz.dataset.jid){ jumpToMessage(qz.dataset.jid, +qz.dataset.jat||0); return; } // #8: tap a reply → go to the original
const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; } const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; }
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; } const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
@@ -2042,6 +2113,7 @@ function openPollModal(gid){
async function selectChat(kind,id){ async function selectChat(kind,id){
ensureNotifyPermission(); ensureNotifyPermission();
stopTyping(); // leaving the previous conversation → tell that peer we stopped stopTyping(); // leaving the previous conversation → tell that peer we stopped
if(_selMode) exitSelect(); // #1: leave forward-selection when changing chats
selected={kind,id}; selected={kind,id};
document.body.classList.add('chat-open'); // mobile: show the conversation pane document.body.classList.add('chat-open'); // mobile: show the conversation pane
const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread) const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread)
@@ -3194,6 +3266,7 @@ document.addEventListener('keydown',(e)=>{
if(e.key!=='Escape') return; if(e.key!=='Escape') return;
const ovs=document.querySelectorAll('.modal-ov'); const ovs=document.querySelectorAll('.modal-ov');
if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; } if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; }
if(_selMode){ exitSelect(); e.preventDefault(); e.stopPropagation(); return; } // #1: leave forward-selection
const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; } const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; }
}, true); }, true);
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && !document.getElementById('lightbox') && currentTab()==='chat' && selected!=null){ showWelcome(); } }); // #4: an open image preview closes first (its own Esc handler); the conversation only closes once no overlay remains document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && !document.getElementById('lightbox') && currentTab()==='chat' && selected!=null){ showWelcome(); } }); // #4: an open image preview closes first (its own Esc handler); the conversation only closes once no overlay remains
+1
View File
@@ -194,6 +194,7 @@ const messages = {
.run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null), .run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null),
byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id), byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id),
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId), byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId),
allByAttachment: (attachmentId) => db.prepare('SELECT sender_id, recipient_id, conversation_id FROM messages WHERE attachment_id=? AND deleted=0').all(attachmentId),
setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId), setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId),
markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id), markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id),
editBody: (id, body) => db.prepare('UPDATE messages SET body=?, edited_at=? WHERE id=?').run(body, now(), id), editBody: (id, body) => db.prepare('UPDATE messages SET body=?, edited_at=? WHERE id=?').run(body, now(), id),
+37
View File
@@ -1341,6 +1341,43 @@ route('POST', '/api/messages', async (req, res) => {
json(res, 200, dto); json(res, 200, dto);
}); });
// Forward one or more of my visible messages to existing conversations (DMs I'm in / groups I'm a
// member of). Copies body + attachment (attachment stays viewable via the any-carrier /files auth).
route('POST', '/api/messages/forward', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { messageIds, targets } = await readBody(req);
if (!Array.isArray(messageIds) || !messageIds.length || !Array.isArray(targets) || !targets.length) return json(res, 400, { error: 'messageIds and targets required' });
// Gather source messages the user is allowed to see, oldest-first (preserve order).
const srcs = [];
for (const mid of messageIds.slice(0, 30)) {
const m = R.messages.byId(mid);
if (!m || m.team_id !== u.team_id || m.deleted || m.poll_id) continue;
const ok = m.conversation_id ? R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
if (ok && (m.body || m.attachment_id)) srcs.push(m);
}
if (!srcs.length) return json(res, 400, { error: 'nothing to forward' });
srcs.sort((a, b) => a.created_at - b.created_at);
const names = namesFor(u.team_id);
let sent = 0;
for (const t of (targets || []).slice(0, 20)) {
let isGroup = t.kind === 'group', tid = t.id;
if (isGroup) { if (!R.conversations.isMember(tid, u.id)) continue; }
else { tid = R.users.resolve(tid); if (!tid || !R.users.inTenant(tid, u.team_id)) continue; }
for (const m of srcs) {
const nid = A.token(16);
if (isGroup) R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid });
else R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id });
const dto = buildMsgDTO(R.messages.byId(nid), names, u.id);
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
if (isGroup) { for (const mid of R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } }
else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} }
sent++;
}
}
json(res, 200, { ok: true, sent });
});
// Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder). // Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder).
route('POST', '/api/messages/delete', async (req, res) => { route('POST', '/api/messages/delete', async (req, res) => {
const u = currentUser(req); const u = currentUser(req);
+6 -6
View File
@@ -156,15 +156,15 @@ function handleGet(req, res) {
const id = path.basename(decodeURIComponent(pathOnly)); const id = path.basename(decodeURIComponent(pathOnly));
const a = R.attachments.byId(id); const a = R.attachments.byId(id);
if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
// Authorize: the uploader, a participant of the message carrying this attachment, // Authorize: the uploader, a member of the group using it as an avatar, or a participant of ANY
// or a member of the group that uses this attachment as its image. // message carrying it (the "any" covers forwarded attachments, which reuse the same id).
const msg = R.messages.byAttachment(id);
const avatarGroup = R.conversations.byAvatar(id); const avatarGroup = R.conversations.byAvatar(id);
const carriers = R.messages.allByAttachment(id);
const allowed = a.uploader_id === u.id const allowed = a.uploader_id === u.id
|| (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id)) || (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id))
|| (msg && ( || carriers.some((msg) => msg.conversation_id
msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id) ? R.conversations.isMember(msg.conversation_id, u.id)
: (msg.sender_id === u.id || msg.recipient_id === u.id))); : (msg.sender_id === u.id || msg.recipient_id === u.id));
if (!allowed) return json(res, 403, { error: 'forbidden' }); if (!allowed) return json(res, 403, { error: 'forbidden' });
const fp = path.join(UPLOADS_DIR, id); const fp = path.join(UPLOADS_DIR, id);
if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' }); if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });