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:hover{background:var(--blue-soft);}
|
||||
.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 b{color:var(--ink);}
|
||||
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
|
||||
@@ -794,7 +809,7 @@
|
||||
<body>
|
||||
<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>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
|
||||
// (emojis stay as plain Unicode). (#5)
|
||||
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(_){}
|
||||
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){
|
||||
const hits=_searchHits; if(!hits.length) return;
|
||||
_searchIdx=((i%hits.length)+hits.length)%hits.length;
|
||||
@@ -1386,6 +1453,7 @@ function bubbleHTML(m){
|
||||
+ sender + quote + att + renderMsgBody(m) + pollHTML(m)
|
||||
+ (m.deleted?'':'<div class="msg-actions">'
|
||||
+ '<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>'
|
||||
+ ((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>':'')
|
||||
@@ -1777,6 +1845,7 @@ function renderThread(keepScroll){
|
||||
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); }
|
||||
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){
|
||||
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 csNext=document.getElementById('convoSearchNext'); if(csNext) csNext.onclick=()=>gotoHit(_searchIdx+1);
|
||||
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 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; }
|
||||
@@ -2042,6 +2113,7 @@ function openPollModal(gid){
|
||||
async function selectChat(kind,id){
|
||||
ensureNotifyPermission();
|
||||
stopTyping(); // leaving the previous conversation → tell that peer we stopped
|
||||
if(_selMode) exitSelect(); // #1: leave forward-selection when changing chats
|
||||
selected={kind,id};
|
||||
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)
|
||||
@@ -3194,6 +3266,7 @@ document.addEventListener('keydown',(e)=>{
|
||||
if(e.key!=='Escape') return;
|
||||
const ovs=document.querySelectorAll('.modal-ov');
|
||||
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; }
|
||||
}, 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
|
||||
|
||||
Reference in New Issue
Block a user