From 3a976d58ab820fd1784818938741f613eec46982 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 7 Jul 2026 16:02:15 +0530 Subject: [PATCH] feat(chat): reply-jump (#8), image prev/next nav (#3), multi-file send (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- server/public/home.html | 114 +++++++++++++++++++++++++--------------- server/routes.js | 2 +- 2 files changed, 74 insertions(+), 42 deletions(-) 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 ? ''+pEsc(m.attachment.name)+'' @@ -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);