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:
+74
-1
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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' });
|
||||||
|
|||||||
Reference in New Issue
Block a user