diff --git a/server/public/home.html b/server/public/home.html
index b819f42..dd8f437 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -325,6 +325,10 @@
.lightbox .lb-close{right:18px;}
.lightbox .lb-dl{right:74px;}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);}
+ .lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.14);color:#fff;width:50px;height:50px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
+ .lightbox .lb-nav:hover{background:rgba(255,255,255,.3);}
+ .lightbox .lb-prev{left:18px;} .lightbox .lb-next{right:18px;}
+ @media(max-width:560px){ .lightbox .lb-nav{width:42px;height:42px;} .lightbox .lb-prev{left:6px;} .lightbox .lb-next{right:6px;} }
.composer{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .8rem;border-top:1px solid var(--line);background:var(--card);}
.composer-box{flex:1;min-width:0;border:1.5px solid var(--line);border-radius:16px;background:#fbfcfe;display:flex;flex-direction:column;overflow:hidden;}
.composer-box:focus-within{border-color:var(--blue);}
@@ -333,7 +337,7 @@
.composer-row input:focus,.composer-row textarea:focus{outline:none;}
.ic-btn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:36px;height:36px;border-radius:10px;display:grid;place-items:center;flex:0 0 auto;}
.ic-btn:hover{color:var(--blue);background:var(--blue-soft);}
- .attach-preview{padding:.55rem .6rem .15rem;}
+ .attach-preview{padding:.55rem .6rem .15rem;gap:.4rem;flex-wrap:wrap;max-height:118px;overflow-y:auto;}
.ap-item{display:inline-flex;align-items:center;gap:.55rem;background:#fff;border:1px solid var(--line);border-radius:11px;padding:.35rem .5rem;max-width:100%;}
.ap-thumb{width:42px;height:42px;border-radius:8px;object-fit:cover;flex:0 0 auto;}
.ap-ic{display:grid;place-items:center;color:var(--blue);flex:0 0 auto;}
@@ -435,7 +439,7 @@
/* chat: reply + emoji */
.convo{position:relative;}
.bubble{position:relative;}
- .bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;}
+ .bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;cursor:pointer;}
/* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It
overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float
off into empty space where the hover (and the buttons) would vanish before you can click. */
@@ -790,7 +794,7 @@
-
@@ -1052,6 +1056,16 @@ async function doSearch(q){
gotoHit(hits.length-1); // jump to the most recent match first
}
function _bubbleEl(id){ const box=document.getElementById('msgs'); if(!box) return null; const esc=(window.CSS&&CSS.escape)?CSS.escape(id):id; return box.querySelector('.bubble[data-id="'+esc+'"]'); }
+// #8: jump to a specific message (e.g. tapping a reply quote) — paging older history in if it's
+// above the loaded window — then scroll to it and flash it.
+async function jumpToMessage(id, at){
+ if(!id) return;
+ let el=_bubbleEl(id);
+ if(!el && at){ await ensureLoadedBack(at); el=_bubbleEl(id); }
+ if(!el) return;
+ try{ el.scrollIntoView({block:'center'}); }catch(_){}
+ el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400);
+}
async function gotoHit(i){
const hits=_searchHits; if(!hits.length) return;
_searchIdx=((i%hits.length)+hits.length)%hits.length;
@@ -1333,7 +1347,7 @@ function convoShellHTML(it){
+ ''
+ ''
+ ''
- + ''
+ + ''
+ ''
+ ''
+ ''
@@ -1348,7 +1362,7 @@ function bubbleHTML(m){
if(m.deleted) return ''+ic('trash',12)+' This message was deleted'+pEsc(fmtClock(m.created_at))+'
';
const sender=(convoIsGroup && !mine && m.fromName)?''+senderAvatar(m.from, m.fromName)+''+pEsc(m.fromName)+'
':'';
let quote='';
- if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote=''+pEsc(m.reply.fromName||'')+': '+pEsc(m.reply.body)+'
'; }
+ if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote=''+pEsc(m.reply.fromName||'')+': '+pEsc(m.reply.body)+'
'; }
const reacts=(m.reactions&&m.reactions.length)?''+m.reactions.map(r=>'').join('')+'
':'';
const att = m.attachment ? (m.attachment.isImage
? '
'
@@ -1436,31 +1450,33 @@ function onChatEdited(d){
try{ loadSidebar(); }catch(_){} // refresh last-message previews
}
// attachments
-let pendingAttach=null;
+let pendingAttachs=[]; // #5: multiple files can be queued + sent together (each item: {id,name,mime,uploading})
function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
async function uploadFile(file){
if(!file) return;
- if(file.size>25*1024*1024){ toast('File too large (max 25 MB)'); return; }
- showAttachPending(file.name, true);
+ if(file.size>25*1024*1024){ toast('“'+file.name+'” is too large (max 25 MB)'); return; }
+ const ph={ id:'up-'+Math.random().toString(36).slice(2), name:file.name, mime:file.type||'', uploading:true };
+ pendingAttachs.push(ph); renderAttachBar();
try{
const r=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file });
const d=await r.json(); if(!r.ok) throw new Error(d.error||'upload failed');
- pendingAttach=d; showAttachPending(d.name,false);
+ const i=pendingAttachs.indexOf(ph); const item={ ...d, uploading:false }; if(i>=0) pendingAttachs[i]=item; else pendingAttachs.push(item);
+ renderAttachBar();
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
- }catch(e){ pendingAttach=null; hideAttach(); toast(e.message||'Upload failed'); }
+ }catch(e){ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); toast(e.message||'Upload failed'); }
}
-function showAttachPending(name, uploading){
+function renderAttachBar(){
const bar=document.getElementById('attachBar'); if(!bar) return;
- const a=pendingAttach;
- const isImg=a && /^image\//.test(a.mime||'');
- const lead=(!uploading && isImg)
- ? '
'
- : ''+ic(uploading?'paperclip':(isImg?'camera':'file'),18)+'';
- bar.innerHTML=''+lead+''+pEsc(name)+(uploading?' · uploading…':'')+''+(uploading?'':'')+'
';
- bar.style.display='block';
- const x=document.getElementById('attachCancel'); if(x) x.onclick=()=>{ pendingAttach=null; hideAttach(); };
+ if(!pendingAttachs.length){ bar.style.display='none'; bar.innerHTML=''; return; }
+ bar.innerHTML=pendingAttachs.map((a,idx)=>{
+ const isImg=/^image\//.test(a.mime||'');
+ const lead=(!a.uploading && isImg)?'
':''+ic(a.uploading?'paperclip':(isImg?'camera':'file'),18)+'';
+ return ''+lead+''+pEsc(a.name)+(a.uploading?' · uploading…':'')+''+(a.uploading?'':'')+'
';
+ }).join('');
+ bar.style.display='flex';
+ bar.querySelectorAll('.ap-x').forEach(b=>b.onclick=()=>{ const i=+b.dataset.ai; if(i>=0&&im.attachment && m.attachment.isImage && !m.deleted).map(m=>'/files/'+m.attachment.id);
+ let idx=gallery.indexOf(src); const multi=idx>=0 && gallery.length>1; if(idx<0) idx=0;
const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox';
- ov.innerHTML='
'+ic('download',20)+'';
+ ov.innerHTML=''
+ +(multi?'':'')
+ +'
'
+ +(multi?'':'')
+ +''+ic('download',20)+'';
document.body.appendChild(ov);
+ const img=ov.querySelector('img'), dl=ov.querySelector('.lb-dl');
+ const show=(i)=>{ if(!multi) return; idx=((i%gallery.length)+gallery.length)%gallery.length; const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); };
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
- const onKey=(e)=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } };
- ov.addEventListener('click',(e)=>{ if(e.target===ov || e.target.closest('.lb-close')) close(); });
+ const onKey=(e)=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); } else if(e.key==='ArrowRight'){ e.preventDefault(); show(idx+1); } };
+ ov.addEventListener('click',(e)=>{ if(e.target.closest('.lb-prev')){ show(idx-1); return; } if(e.target.closest('.lb-next')){ show(idx+1); return; } if(e.target===ov || e.target.closest('.lb-close')) close(); });
document.addEventListener('keydown', onKey);
}
function updateBubble(m){
@@ -1781,9 +1806,9 @@ async function openConvo(kind,id){
el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } };
const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); });
- clearReply(); cancelEdit(); pendingAttach=null; hideAttach();
+ clearReply(); cancelEdit(); hideAttach();
const ab2=document.getElementById('attachBtn'); if(ab2) ab2.onclick=()=>{ const fi=document.getElementById('fileInput'); if(fi) fi.click(); };
- const fi=document.getElementById('fileInput'); if(fi) fi.onchange=()=>{ if(fi.files&&fi.files[0]) uploadFile(fi.files[0]); };
+ const fi=document.getElementById('fileInput'); if(fi) fi.onchange=()=>{ Array.from(fi.files||[]).forEach(uploadFile); fi.value=''; }; // #5: queue every selected file
const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); };
const fb=document.getElementById('fmtBtn'); if(fb) fb.onclick=()=>{ const bar=document.getElementById('fmtBar'); if(bar) bar.style.display=bar.style.display==='none'?'flex':'none'; };
const fbar=document.getElementById('fmtBar'); if(fbar) fbar.querySelectorAll('button[data-fmt]').forEach(b=>b.onclick=()=>applyFmt(b.dataset.fmt));
@@ -1808,6 +1833,7 @@ 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)=>{
+ 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; }
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
@@ -2027,24 +2053,30 @@ async function sendMessage(){
const inp=document.getElementById('msgInput'); if(!inp) return;
const text=inp.value.trim();
if(editTarget){ saveEdit(text); return; } // in edit mode → save the edit instead of sending new
- if((!text&&!pendingAttach)||!selected) return;
+ const atts=pendingAttachs.slice();
+ if(atts.some(a=>a.uploading)){ toast('Please wait — files are still uploading…'); return; }
+ if((!text&&!atts.length)||!selected) return;
stopTyping(); // sending → we're no longer "typing"
inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft
- const replyTo=replyTarget?replyTarget.id:null;
- const attachmentId=pendingAttach?pendingAttach.id:null;
- const payload = selected.kind==='group' ? { group:selected.id, body:text, replyTo, attachmentId, mentions:collectMentions(text) } : { to:selected.id, body:text, replyTo, attachmentId };
- try{
- const m=await postJSON('/api/messages', payload);
- composeMentions=new Map();
- // Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo
- // can arrive BEFORE this POST resolves, so onChatMessage may have already appended it.
- if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
- clearReply(); cancelEdit(); pendingAttach=null; hideAttach();
- { const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } }
- const it=rowFor(selected.kind,selected.id);
- if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.last_msg_id=m.id; it.unread=0; }
- renderChats(searchVal());
- }catch(e){ inp.value=text; toast(e.message||'Could not send'); }
+ const replyTo=replyTarget?replyTarget.id:null; const isG=selected.kind==='group';
+ hideAttach(); clearReply(); cancelEdit();
+ // #5: one message per attachment (the first carries the typed text as its caption), else a single
+ // text message. Sent in order so they appear chronologically.
+ const jobs = atts.length ? atts.map((a,i)=>({ body:i===0?text:'', replyTo:i===0?replyTo:null, attachmentId:a.id })) : [{ body:text, replyTo, attachmentId:null }];
+ for(const j of jobs){
+ const payload = isG ? { group:selected.id, body:j.body, replyTo:j.replyTo, attachmentId:j.attachmentId, mentions:collectMentions(j.body) } : { to:selected.id, body:j.body, replyTo:j.replyTo, attachmentId:j.attachmentId };
+ try{
+ const m=await postJSON('/api/messages', payload);
+ // Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo can
+ // arrive BEFORE this POST resolves, so onChatMessage may have already appended it.
+ if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
+ { const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } }
+ const it=rowFor(selected.kind,selected.id);
+ if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.last_msg_id=m.id; it.unread=0; }
+ }catch(e){ toast(e.message||'Could not send'); if(j.body && !inp.value){ inp.value=j.body; try{ autoGrow(inp); }catch(_){} } }
+ }
+ composeMentions=new Map();
+ renderChats(searchVal());
}
// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
diff --git a/server/routes.js b/server/routes.js
index 9ca251f..2bb9d9d 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -67,7 +67,7 @@ function buildMsgDTO(m, names, userId){
const d = msgDTO(m);
if (m.reply_to) {
const r = R.messages.byId(m.reply_to);
- if (r) d.reply = { id: r.id, from: r.sender_id, fromName: (names && names[r.sender_id]) || '', body: r.body.length > 140 ? r.body.slice(0, 140) + '…' : r.body };
+ if (r) d.reply = { id: r.id, at: r.created_at, from: r.sender_id, fromName: (names && names[r.sender_id]) || '', body: r.body.length > 140 ? r.body.slice(0, 140) + '…' : r.body };
}
if (m.attachment_id) {
const a = R.attachments.byId(m.attachment_id);