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=''
+ '
'
+ + ((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' });