From a9b3533f7add0a9d541b63f32474aa05d75a50e8 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 7 Jul 2026 16:09:02 +0530 Subject: [PATCH] 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 --- server/public/home.html | 75 ++++++++++++++++++++++++++++++++++++++++- server/repos.js | 1 + server/routes.js | 37 ++++++++++++++++++++ server/static.js | 12 +++---- 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index dd8f437..63d3676 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 @@ - @@ -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=''+_selIds.size+' selected'; + 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=''+(isG?ic('users',15):(r.avatar?'':pEsc(initials(r.name))))+''; + return '
'+av+''+pEsc(r.name)+''+ic('check',15)+'
'; +} +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=''; + 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?'':'
' + '' + + ((m.body||m.attachment)&&!m.poll?'':'') + '' + ((mine && m.body && !m.poll)?'':'') + (mine?'':'') @@ -1777,6 +1845,7 @@ function renderThread(keepScroll){ let html=''; for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='
'+pEsc(dayLabel(m.created_at))+'
'; _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 diff --git a/server/repos.js b/server/repos.js index d0de569..f0f01e1 100644 --- a/server/repos.js +++ b/server/repos.js @@ -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), 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), + 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), 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), diff --git a/server/routes.js b/server/routes.js index 2bb9d9d..f1d8ba2 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1341,6 +1341,43 @@ route('POST', '/api/messages', async (req, res) => { 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). route('POST', '/api/messages/delete', async (req, res) => { const u = currentUser(req); diff --git a/server/static.js b/server/static.js index 3749342..812fc84 100644 --- a/server/static.js +++ b/server/static.js @@ -156,15 +156,15 @@ function handleGet(req, res) { const id = path.basename(decodeURIComponent(pathOnly)); const a = R.attachments.byId(id); 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, - // or a member of the group that uses this attachment as its image. - const msg = R.messages.byAttachment(id); + // Authorize: the uploader, a member of the group using it as an avatar, or a participant of ANY + // message carrying it (the "any" covers forwarded attachments, which reuse the same id). const avatarGroup = R.conversations.byAvatar(id); + const carriers = R.messages.allByAttachment(id); const allowed = a.uploader_id === u.id || (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id)) - || (msg && ( - msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id) - : (msg.sender_id === u.id || msg.recipient_id === u.id))); + || carriers.some((msg) => msg.conversation_id + ? R.conversations.isMember(msg.conversation_id, u.id) + : (msg.sender_id === u.id || msg.recipient_id === u.id)); if (!allowed) return json(res, 403, { error: 'forbidden' }); const fp = path.join(UPLOADS_DIR, id); if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });