- #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:
+73
-41
@@ -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 };
|
||||
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
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user