feat(chat): reply-jump (#8), image prev/next nav (#3), multi-file send (#5)

- #8: reply quotes are now clickable — jump to the original message (paging older
  history in if needed), then flash it. reply DTO carries the target's timestamp.
- #3: image lightbox now has ← / → arrows + keyboard nav to flip through all images
  in the conversation.
- #5: the composer queues MULTIPLE files (file input is multiple; paste still works);
  each is shown as a removable chip and sent as its own message (first carries the
  typed text as caption), in order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:02:15 +05:30
parent c672f7b65f
commit 3a976d58ab
2 changed files with 74 additions and 42 deletions
+66 -34
View File
@@ -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 @@
<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-batch48';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-07-batch49';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>
@@ -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){
+ '<button type="submit" class="sendbtn" title="Send" aria-label="Send">'+ic('send',18)+'</button>'
+ '</div>'
+ '</div>'
+ '<input type="file" id="fileInput" style="display:none">'
+ '<input type="file" id="fileInput" multiple style="display:none">'
+ '</form>'
+ '<div class="emoji-pop" id="emojiPop" style="display:none"></div>'
+ '<div class="mention-pop" id="mentionPop" style="display:none"></div>'
@@ -1348,7 +1362,7 @@ function bubbleHTML(m){
if(m.deleted) return '<div class="bubble '+(mine?'mine':'them')+' deleted" data-id="'+pEsc(m.id)+'"><span class="del-msg">'+ic('trash',12)+' This message was deleted</span><span class="t">'+pEsc(fmtClock(m.created_at))+'</span></div>';
const sender=(convoIsGroup && !mine && m.fromName)?'<div class="sender">'+senderAvatar(m.from, m.fromName)+'<span>'+pEsc(m.fromName)+'</span></div>':'';
let quote='';
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='<div class="quote" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(m.reply.body)+'</div>'; }
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='<div class="quote" data-jid="'+pEsc(m.reply.id||'')+'" data-jat="'+(m.reply.at||0)+'" title="Go to message" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(m.reply.body)+'</div>'; }
const reacts=(m.reactions&&m.reactions.length)?'<div class="reacts">'+m.reactions.map(r=>'<button class="react-chip'+(r.mine?' mine':'')+'" data-id="'+pEsc(m.id)+'" data-emoji="'+pEsc(r.emoji)+'" title="'+pEsc((r.who||[]).join(', '))+'">'+pEsc(r.emoji)+' '+r.count+'</button>').join('')+'</div>':'';
const att = m.attachment ? (m.attachment.isImage
? '<img class="att-img" src="/files/'+pEsc(m.attachment.id)+'" data-img="/files/'+pEsc(m.attachment.id)+'" alt="'+pEsc(m.attachment.name)+'" title="Click to view">'
@@ -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)
? '<img class="ap-thumb" src="/files/'+pEsc(a.id)+'" alt="">'
: '<span class="ap-ic">'+ic(uploading?'paperclip':(isImg?'camera':'file'),18)+'</span>';
bar.innerHTML='<div class="ap-item">'+lead+'<span class="ap-name">'+pEsc(name)+(uploading?' · uploading…':'')+'</span>'+(uploading?'':'<button type="button" class="ap-x" id="attachCancel" title="Remove">'+ic('x',15)+'</button>')+'</div>';
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)?'<img class="ap-thumb" src="/files/'+pEsc(a.id)+'" alt="">':'<span class="ap-ic">'+ic(a.uploading?'paperclip':(isImg?'camera':'file'),18)+'</span>';
return '<div class="ap-item">'+lead+'<span class="ap-name">'+pEsc(a.name)+(a.uploading?' · uploading…':'')+'</span>'+(a.uploading?'':'<button type="button" class="ap-x" data-ai="'+idx+'" title="Remove">'+ic('x',15)+'</button>')+'</div>';
}).join('');
bar.style.display='flex';
bar.querySelectorAll('.ap-x').forEach(b=>b.onclick=()=>{ const i=+b.dataset.ai; if(i>=0&&i<pendingAttachs.length){ pendingAttachs.splice(i,1); renderAttachBar(); } });
}
function hideAttach(){ const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const fi=document.getElementById('fileInput'); if(fi) fi.value=''; }
function hideAttach(){ pendingAttachs=[]; const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const fi=document.getElementById('fileInput'); if(fi) fi.value=''; }
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
function onPaste(e){
const items=(e.clipboardData&&e.clipboardData.items)||[];
@@ -1716,12 +1732,21 @@ function openInvitePicker(room){
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
function openLightbox(src){
if(document.getElementById('lightbox')) return;
// #3: build a gallery from the loaded thread's images so ← / → flip through them.
const gallery=THREAD.filter(m=>m.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='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button><img src="'+pEsc(src)+'" alt=""><a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>';
ov.innerHTML='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button>'
+(multi?'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('arrowLeft',26)+'</button>':'')
+'<img src="'+pEsc(src)+'" alt="">'
+(multi?'<button class="lb-nav lb-next" title="Next (→)">'+ic('arrowRight',26)+'</button>':'')
+'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>';
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 };
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);
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.
// 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; }
}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());
}catch(e){ inp.value=text; toast(e.message||'Could not send'); }
}
// 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
+1 -1
View File
@@ -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);