Round 2 fixes from on-device testing (#2,#3,#6,#8,#9,#13,#14,#18 + call re-ring)
#2 Don't ping/notify when you're ACTIVELY viewing a chat (app visible + chat open). Alert only when a different chat, OR the open chat while the app is minimised (the backgrounded case that used to stay silent). #3 A reaction to my message now raises an unread badge on that conversation (like a new message), not just a notification. #6 Image lightbox pulls EVERY image in the conversation via /api/messages/media (older images aren't in the DOM yet) — nav arrows reach them all. Nav buttons always in the DOM; syncArrows shows/hides at the ends and for a single image. #8 On app resume (visibilitychange / native appStateChange), reconnect the chat socket if it isn't OPEN and re-pull the sidebar so online/last-seen refresh — iOS freezes the WebView so the socket can be dead while its onclose lags, leaving contacts stuck on a stale "Offline". #9 Real cause was iOS "sticky :hover": a single tap latched :hover and popped the action bar. Gate the hover-reveal behind @media (hover:hover) so touch reveals actions ONLY via long-press; a plain tap performs the primary action. #13 Pinned bar gains a "‹ 1 of n ›" pager to walk through multiple pinned messages (shown only when more than one is pinned). #14 Editing a message no longer eats a half-written draft — the real draft is set aside on edit start and restored on save/cancel. edited_at is now in the message DTO so the "edited" tag survives a reload. #18 One "Delete" entry opens a branded dialog with "Delete for me" / "Delete for everyone" (icons + descriptions) and a ✕/backdrop cancel, replacing the two separate menu items. New: a participant who LEAVES a still-running call is no longer auto-rung back in on every socket reconnect. Track who left per call; replayActiveCalls sends them noRing state (refreshes the Join affordance without ringing). An explicit re-invite clears that and rings again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+135
-40
@@ -219,6 +219,10 @@
|
||||
.pinned-bar .pb-txt{font-size:.85rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.pinned-bar .pb-unpin{flex:0 0 auto;border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;}
|
||||
.pinned-bar .pb-unpin:hover{background:rgba(0,0,0,.06);color:var(--ink);}
|
||||
.pinned-bar .pb-pager{flex:0 0 auto;display:flex;align-items:center;gap:.1rem;} /* #13: ‹ 1 of n › walk through multiple pins */
|
||||
.pinned-bar .pb-pager button{border:none;background:transparent;color:var(--blue);cursor:pointer;padding:.15rem;border-radius:6px;display:grid;place-items:center;}
|
||||
.pinned-bar .pb-pager button:hover{background:rgba(0,0,0,.06);}
|
||||
.pinned-bar .pb-count{font-size:.7rem;font-weight:700;color:var(--blue);white-space:nowrap;min-width:2.9rem;text-align:center;}
|
||||
.chat-row.unread .chat-name{font-weight:700;}
|
||||
.badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
|
||||
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
|
||||
@@ -590,7 +594,12 @@
|
||||
left edge of the panel. Anchor received messages from the LEFT so it grows into the empty space. */
|
||||
.bubble.them .msg-actions{left:0;right:auto;}
|
||||
.bubble.mine .msg-actions{right:0;left:auto;}
|
||||
.bubble:hover .msg-actions,.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;}
|
||||
/* #9: reveal on .show-actions everywhere; reveal on :hover ONLY on real-pointer (desktop) devices.
|
||||
On touch, iOS/Android make :hover "sticky" — a single tap latches :hover and popped the action bar
|
||||
open (the "single tap loads the actions" bug). Gating hover behind (hover:hover) means touch reveals
|
||||
ONLY via long-press (which sets .show-actions), and a plain tap performs the primary action. */
|
||||
.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;}
|
||||
@media (hover:hover){ .bubble:hover .msg-actions{opacity:1;pointer-events:auto;} }
|
||||
.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);}
|
||||
/* one-tap reactions render as the emoji glyph itself */
|
||||
@@ -815,6 +824,23 @@
|
||||
.bzc-ok:disabled{opacity:.5;cursor:not-allowed;}
|
||||
.bzc-ok.danger{background:var(--red);}
|
||||
.bzc-ok.danger:hover{background:#991b1b;}
|
||||
/* #18: branded Delete dialog — one entry, two clear choices + a cancel ✕. */
|
||||
.del-dlg{position:relative;max-width:360px;}
|
||||
.del-dlg .del-x{position:absolute;top:.7rem;right:.7rem;border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.25rem;border-radius:8px;display:grid;place-items:center;}
|
||||
.del-dlg .del-x:hover{background:rgba(0,0,0,.06);color:var(--ink);}
|
||||
.del-dlg .del-hd{display:flex;align-items:center;gap:.6rem;margin-bottom:1rem;}
|
||||
.del-dlg .del-hd-ic{width:40px;height:40px;flex:0 0 40px;border-radius:12px;display:grid;place-items:center;background:rgba(220,38,38,.1);color:var(--red);}
|
||||
.del-dlg .del-hd h3{margin:0;color:var(--ink);font-size:1.05rem;}
|
||||
.del-dlg .del-opts{display:flex;flex-direction:column;gap:.55rem;}
|
||||
.del-dlg .del-opt{display:flex;align-items:flex-start;gap:.7rem;text-align:left;width:100%;border:2px solid var(--line);background:#fbfcfe;border-radius:12px;padding:.75rem .8rem;cursor:pointer;font-family:inherit;transition:border-color .12s,background .12s;}
|
||||
.del-dlg .del-opt:hover{border-color:var(--blue);background:var(--blue-soft);}
|
||||
.del-dlg .del-opt.danger:hover{border-color:var(--red);background:rgba(220,38,38,.06);}
|
||||
.del-dlg .del-opt-ic{flex:0 0 auto;width:34px;height:34px;border-radius:9px;display:grid;place-items:center;background:#eef1f6;color:var(--blue);}
|
||||
.del-dlg .del-opt.danger .del-opt-ic{background:rgba(220,38,38,.1);color:var(--red);}
|
||||
.del-dlg .del-opt-tx{display:flex;flex-direction:column;gap:.15rem;min-width:0;}
|
||||
.del-dlg .del-opt-tx b{font-size:.95rem;color:var(--ink);}
|
||||
.del-dlg .del-opt.danger .del-opt-tx b{color:var(--red);}
|
||||
.del-dlg .del-opt-tx small{font-size:.78rem;color:var(--muted);line-height:1.4;}
|
||||
/* "typing…" indicator inside the conversation, just above the composer. */
|
||||
.typing-row{padding:.1rem .95rem .3rem;min-height:0;color:var(--blue);font-size:.78rem;font-style:italic;font-weight:600;display:none;align-items:center;gap:.4rem;}
|
||||
.typing-row.show{display:flex;}
|
||||
@@ -2259,8 +2285,7 @@ function openMsgMore(msgId, anchor){
|
||||
if((m.body||m.attachment) && !m.poll) items.push({ic:'arrowRight', label:'Forward', fn:()=>enterSelect(m.id)});
|
||||
if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)});
|
||||
if(!m.deleted && !m.poll) items.push({ic:(m.pinned?'pinOff':'pin'), label:(m.pinned?'Unpin':'Pin'), fn:()=>pinMessage(m.id, !m.pinned)}); // #13
|
||||
items.push({ic:'trash', label:'Delete for me', danger:true, fn:()=>deleteForMe(m.id)}); // #18: hide from my view only
|
||||
if(mine && !m.deleted) items.push({ic:'trash', label:'Delete for everyone', danger:true, fn:()=>deleteMessage(m.id)}); // #18: unsend for all
|
||||
items.push({ic:'trash', label:'Delete', danger:true, fn:()=>openDeleteDialog(m)}); // #18: one entry → branded dialog offers "for me" / "for everyone"
|
||||
const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu';
|
||||
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt'+(it.danger?' danger':'')+'" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
|
||||
document.body.appendChild(menu);
|
||||
@@ -2308,20 +2333,25 @@ function setReply(m){
|
||||
function clearReply(){ replyTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } }
|
||||
// Edit an existing message (sender only): load its text into the composer, show an "Editing" bar;
|
||||
// the next send saves the edit instead of posting a new message.
|
||||
let editTarget=null;
|
||||
let editTarget=null, _editSavedDraft=''; // #14: the real (unsent) draft we set aside while editing, to restore after
|
||||
function startEdit(m){
|
||||
if(!m) return; clearReply(); editTarget=m;
|
||||
if(!m) return; clearReply();
|
||||
const inp=document.getElementById('msgInput');
|
||||
if(!editTarget) _editSavedDraft = inp ? inp.value : ''; // #14: remember the draft-in-progress BEFORE we load the edit text over it
|
||||
editTarget=m;
|
||||
const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; }
|
||||
const inp=document.getElementById('msgInput'); if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); }
|
||||
if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); }
|
||||
}
|
||||
function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } try{ if(selected) setDraft(selected.kind, selected.id, ''); }catch(_){} } // #14: clear the draft too
|
||||
// #14: leaving edit mode RESTORES the real draft (what you were typing before you hit edit) instead of
|
||||
// blanking it — editing a message no longer eats a half-written reply.
|
||||
function _restoreDraftAfterEdit(){ const inp=document.getElementById('msgInput'); if(inp){ inp.value=_editSavedDraft||''; autoGrow(inp); } try{ if(selected) setDraft(selected.kind, selected.id, _editSavedDraft||''); }catch(_){} _editSavedDraft=''; }
|
||||
function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } _restoreDraftAfterEdit(); }
|
||||
async function saveEdit(text){
|
||||
const m=editTarget; if(!m) return;
|
||||
if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action)
|
||||
if(text===(m.body||'')){ cancelEdit(); return; } // no change
|
||||
editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; }
|
||||
const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); }
|
||||
try{ if(selected) setDraft(selected.kind, selected.id, ''); }catch(_){} // #14: clear the draft, else the edited text reappears and re-sends as a NEW message
|
||||
_restoreDraftAfterEdit(); // #14: put back the real draft (NOT the edited text) so the edit can't re-send as a new message AND a pending reply survives
|
||||
try{
|
||||
await postJSON('/api/messages/edit',{ id:m.id, body:text });
|
||||
const t=THREAD.find(x=>x.id===m.id); if(t){ t.body=text; t.edited_at=Date.now(); updateBubble(t); }
|
||||
@@ -2521,11 +2551,13 @@ function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m &&
|
||||
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){
|
||||
const kind=d.convId?'group':'dm'; const rid=d.convId||d.byId;
|
||||
addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:{kind,id:rid}});
|
||||
// #3: someone reacted to MY message → actually alert (ping + OS/in-page popup), like a new message,
|
||||
// unless I'm already looking at that chat. When hidden with push active, the SW/native push shows it.
|
||||
if(notifOn(kind)){
|
||||
const isViewing=selected&&selected.kind===kind&&selected.id===rid&¤tTab()==='chat'&&!document.hidden;
|
||||
if(!isViewing){ playPing();
|
||||
const isViewing=selected&&selected.kind===kind&&selected.id===rid&¤tTab()==='chat'&&!document.hidden;
|
||||
if(!isViewing){
|
||||
// #3: someone reacted to MY message and I'm NOT looking at that chat → surface it like a new
|
||||
// message: raise the conversation's unread badge (regardless of notification-sound settings)…
|
||||
const it=rowFor(kind,rid); if(it){ it.unread=(it.unread||0)+1; try{ renderChats(searchVal()); updateRailUnread(); }catch(_){} }
|
||||
// …and, when notifications are on, also ping + show a popup (SW/native push shows it when hidden).
|
||||
if(notifOn(kind)){ playPing();
|
||||
if(!(document.hidden && pushActive)){
|
||||
const title=(kind==='group')?(((rowFor('group',rid)||{}).name)||m&&m.groupName||'Group'):(d.by||'Reaction');
|
||||
notify(title, (d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', kind, rid);
|
||||
@@ -2601,7 +2633,30 @@ function markMsgDeleted(id){
|
||||
}
|
||||
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
|
||||
// #18 Delete-for-me: hide only from MY view (echoed to all my devices) — the message stays for everyone else.
|
||||
async function deleteForMe(id){ if(!(await bzConfirm('This will remove the message from your chat on all your devices. Others will still see it.', {title:'Delete for me?', okText:'Delete', danger:true}))) return; try{ await postJSON('/api/messages/hide',{id}); removeMsgLocally(id); }catch(e){ toast(e.message||'Could not delete'); } }
|
||||
async function deleteForMe(id){ if(!(await bzConfirm('This will remove the message from your chat on all your devices. Others will still see it.', {title:'Delete for me?', okText:'Delete', danger:true}))) return; return _doDeleteForMe(id); }
|
||||
// #18: the branded delete dialog IS the confirmation, so it calls these confirm-free cores directly.
|
||||
async function _doDeleteForMe(id){ try{ await postJSON('/api/messages/hide',{id}); removeMsgLocally(id); }catch(e){ toast(e.message||'Could not delete'); } }
|
||||
async function _doDeleteForEveryone(id){ try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
|
||||
// #18: one "Delete" entry opens a branded dialog with clear choices + a cancel (✕). "Delete for everyone"
|
||||
// only appears on your OWN, not-yet-deleted messages; "Delete for me" is always available (even to hide a
|
||||
// tombstone). Clicking the backdrop or ✕ cancels.
|
||||
function openDeleteDialog(m){
|
||||
if(!m) return;
|
||||
const canAll = (m.from===ME.id) && !m.deleted;
|
||||
const ov=document.createElement('div'); ov.className='modal-ov';
|
||||
ov.innerHTML='<div class="modal del-dlg">'
|
||||
+'<button class="del-x" title="Cancel" aria-label="Cancel">'+ic('x',18)+'</button>'
|
||||
+'<div class="del-hd"><span class="del-hd-ic">'+ic('trash',22)+'</span><h3>Delete message?</h3></div>'
|
||||
+'<div class="del-opts">'
|
||||
+ '<button type="button" class="del-opt" data-act="me"><span class="del-opt-ic">'+ic('eyeOff',20)+'</span><span class="del-opt-tx"><b>Delete for me</b><small>Removes it from your chat on all your devices. Others still see it.</small></span></button>'
|
||||
+ (canAll?'<button type="button" class="del-opt danger" data-act="all"><span class="del-opt-ic">'+ic('trash',20)+'</span><span class="del-opt-tx"><b>Delete for everyone</b><small>Unsends it for everyone in this chat.</small></span></button>':'')
|
||||
+'</div></div>';
|
||||
document.body.appendChild(ov);
|
||||
const close=()=>{ if(document.body.contains(ov)) ov.remove(); };
|
||||
ov.addEventListener('mousedown',(e)=>{ if(e.target===ov) close(); }); // backdrop cancels
|
||||
const xb=ov.querySelector('.del-x'); if(xb) xb.onclick=close;
|
||||
ov.querySelectorAll('.del-opt').forEach(b=>b.onclick=()=>{ const act=b.dataset.act; close(); if(act==='me') _doDeleteForMe(m.id); else if(act==='all') _doDeleteForEveryone(m.id); });
|
||||
}
|
||||
function removeMsgLocally(id){
|
||||
let changed=false;
|
||||
const i=THREAD.findIndex(x=>x.id===id); if(i>=0){ THREAD.splice(i,1); changed=true; }
|
||||
@@ -2612,6 +2667,7 @@ function removeMsgLocally(id){
|
||||
function onChatHidden(d){ if(!d||!d.id) return; removeMsgLocally(d.id); } // another of my devices hid it
|
||||
// #13 Pin a message for the whole conversation (any participant). Live-broadcast → everyone's pinned strip.
|
||||
let PINNED=[]; // the open conversation's pinned messages (newest pin first)
|
||||
let PIN_IX=0; // #13: which pinned message the strip is currently showing (when several are pinned)
|
||||
async function pinMessage(id, on){ try{ await postJSON('/api/messages/pin',{id, on:!!on}); const t=THREAD.find(x=>x.id===id); if(t) t.pinned=!!on; updateBubble&&t&&updateBubble(t); loadPinned(); toast(on?'Pinned':'Unpinned'); }catch(e){ toast(e.message||'Could not pin'); } }
|
||||
async function loadPinned(kind, id){
|
||||
const el=document.getElementById('pinnedBar'); if(!el) return;
|
||||
@@ -2622,15 +2678,21 @@ async function loadPinned(kind, id){
|
||||
}
|
||||
function renderPinnedBar(){
|
||||
const el=document.getElementById('pinnedBar'); if(!el) return;
|
||||
if(!PINNED.length){ el.style.display='none'; el.innerHTML=''; return; }
|
||||
const m=PINNED[0]; // show the most recent pin; the count hints at the rest
|
||||
if(!PINNED.length){ el.style.display='none'; el.innerHTML=''; PIN_IX=0; return; }
|
||||
if(PIN_IX<0 || PIN_IX>=PINNED.length) PIN_IX=0; // #13: clamp if the pinned set shrank under us
|
||||
const n=PINNED.length, m=PINNED[PIN_IX]; // show the currently-paged pin
|
||||
const prev = m.deleted ? 'Message deleted' : (isGifUrl(m.body)?'🎞️ GIF':(m.body ? (m.body.length>90?m.body.slice(0,90)+'…':m.body) : (m.attachment?('📎 '+(m.attachment.name||'Attachment')):'Message')));
|
||||
// #13: when more than one message is pinned, a ‹ 1 of n › pager on the right walks through them all.
|
||||
const pager = n>1 ? ('<div class="pb-pager"><button class="pb-prev" title="Previous pin">'+ic('chevronLeft',15)+'</button><span class="pb-count">'+(PIN_IX+1)+' of '+n+'</span><button class="pb-next" title="Next pin">'+ic('chevronRight',15)+'</button></div>') : '';
|
||||
el.innerHTML='<span class="pb-ic">'+ic('pin',15)+'</span>'
|
||||
+'<div class="pb-body"><div class="pb-h">Pinned'+(PINNED.length>1?' · '+PINNED.length:'')+(m.fromName?(' · '+pEsc(m.fromName)):'')+'</div><div class="pb-txt">'+pEsc(prev)+'</div></div>'
|
||||
+'<div class="pb-body"><div class="pb-h">Pinned'+(m.fromName?(' · '+pEsc(m.fromName)):'')+'</div><div class="pb-txt">'+pEsc(prev)+'</div></div>'
|
||||
+pager
|
||||
+'<button class="pb-unpin" title="Unpin">'+ic('x',15)+'</button>';
|
||||
el.style.display='flex';
|
||||
const body=el.querySelector('.pb-body'); if(body) body.onclick=()=>{ try{ jumpToMessage(m.id, m.created_at||0); }catch(_){} };
|
||||
const up=el.querySelector('.pb-unpin'); if(up) up.onclick=(e)=>{ e.stopPropagation(); pinMessage(m.id, false); };
|
||||
const pv=el.querySelector('.pb-prev'); if(pv) pv.onclick=(e)=>{ e.stopPropagation(); PIN_IX=(PIN_IX-1+n)%n; renderPinnedBar(); };
|
||||
const nx=el.querySelector('.pb-next'); if(nx) nx.onclick=(e)=>{ e.stopPropagation(); PIN_IX=(PIN_IX+1)%n; renderPinnedBar(); };
|
||||
}
|
||||
function onChatPinned(d){ if(!d) return; if(d.id){ const t=THREAD.find(x=>x.id===d.id); if(t){ t.pinned=!!d.on; try{ updateBubble(t); }catch(_){} } } if(selected) loadPinned(); } // a pin changed → refresh the open conversation's strip
|
||||
// DM delivered: recipient's client acked → second (grey) tick.
|
||||
@@ -2667,8 +2729,8 @@ function onGroupCall(d){
|
||||
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
|
||||
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
|
||||
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
|
||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
|
||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'group', groupId:d.group, callerName:(d.groupName||(it&&it.name)||'Group call') }); // WS path → CallKit
|
||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !d.noRing && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // #New1: noRing → I already left this call; refresh Join but don't ring me back
|
||||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !d.noRing && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'group', groupId:d.group, callerName:(d.groupName||(it&&it.name)||'Group call') }); // WS path → CallKit
|
||||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
|
||||
renderChats(searchVal());
|
||||
}
|
||||
@@ -2695,8 +2757,8 @@ function onDmCall(d){
|
||||
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
|
||||
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
|
||||
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
|
||||
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
|
||||
if(d.active && d.by && d.by!==ME.id && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'dm', callerId:d.with, callerName:d.byName }); // WS path → CallKit (2nd path alongside the VoIP push)
|
||||
if(d.active && d.by && d.by!==ME.id && !d.noRing && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // #New1: noRing → I left this call; don't re-ring me
|
||||
if(d.active && d.by && d.by!==ME.id && !d.noRing && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'dm', callerId:d.with, callerName:d.byName }); // WS path → CallKit (2nd path alongside the VoIP push)
|
||||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
|
||||
renderChats(searchVal());
|
||||
}
|
||||
@@ -3094,14 +3156,19 @@ async function nativeSaveFile(url, name, meta){
|
||||
// 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;
|
||||
// #6: start from the loaded thread's images (shows instantly), then expand to EVERY image in the whole
|
||||
// conversation via /api/messages/media below — older images live only on the server until you scroll them
|
||||
// in, so a thread-only gallery stranded you at the 1–2 latest. ← / → then flip through the full set.
|
||||
let gallery=THREAD.filter(m=>m.attachment && m.attachment.isImage && !m.deleted).map(m=>'/files/'+m.attachment.id);
|
||||
let idx=gallery.indexOf(src); if(idx<0){ gallery=[src].concat(gallery); idx=0; }
|
||||
const isMulti=()=>gallery.length>1;
|
||||
const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox';
|
||||
// Nav buttons are ALWAYS in the DOM (syncArrows shows/hides them), so they light up the moment the
|
||||
// full conversation gallery finishes loading — even if the thread had only one image to start with.
|
||||
ov.innerHTML='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button>'
|
||||
+(multi?'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('chevronLeft',18)+'</button>':'')
|
||||
+'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('chevronLeft',18)+'</button>'
|
||||
+'<img src="'+pEsc(src)+'" alt="">'
|
||||
+(multi?'<button class="lb-nav lb-next" title="Next (→)">'+ic('chevronRight',18)+'</button>':'')
|
||||
+'<button class="lb-nav lb-next" title="Next (→)">'+ic('chevronRight',18)+'</button>'
|
||||
+'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>'
|
||||
+'<div class="lb-zoom"><button class="lb-zout" title="Zoom out (−)">'+ic('search',15)+'<b>−</b></button>'
|
||||
+'<button class="lb-zin" title="Zoom in (+)">'+ic('search',15)+'<b>+</b></button></div>'; // #3
|
||||
@@ -3147,14 +3214,14 @@ function openLightbox(src){
|
||||
},{passive:false});
|
||||
img.addEventListener('touchend',()=>{ pinchD=0; });
|
||||
|
||||
const syncArrows=()=>{ if(!multi) return; const pB=ov.querySelector('.lb-prev'), nB=ov.querySelector('.lb-next'); if(pB) pB.style.display=idx<=0?'none':''; if(nB) nB.style.display=idx>=gallery.length-1?'none':''; }; // #6: fully hide (not just invisible) the arrow with nothing beyond it — no left arrow at the first image, no right arrow at the last
|
||||
const show=(i)=>{ if(!multi) return; idx=Math.max(0, Math.min(gallery.length-1, i)); const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); syncArrows(); }; // clamp (no wrap → no blank slide past the last) + hide the end arrow
|
||||
const syncArrows=()=>{ const multi=isMulti(); const pB=ov.querySelector('.lb-prev'), nB=ov.querySelector('.lb-next'); if(pB) pB.style.display=(multi&&idx>0)?'':'none'; if(nB) nB.style.display=(multi&&idx<gallery.length-1)?'':'none'; }; // #6: fully hide (not just invisible) the arrow with nothing beyond it — none at all when there's a single image, no left arrow at the first, no right arrow at the last
|
||||
const show=(i)=>{ if(!isMulti()) return; idx=Math.max(0, Math.min(gallery.length-1, i)); const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); syncArrows(); }; // clamp (no wrap → no blank slide past the last) + hide the end arrow
|
||||
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
|
||||
// #8: animated pager — the current image slides fully out in the swipe direction, then the next/prev
|
||||
// image slides in from the OPPOSITE side. Stops at the first/last.
|
||||
const pagerGo=(dir)=>{ // dir:+1 next (current exits left, next enters from right), -1 prev (mirror)
|
||||
const target = idx + (dir>0?1:-1);
|
||||
if(!multi || target<0 || target>=gallery.length){ img.style.transition='transform .18s'; img.style.transform=''; return; } // at an edge → spring back
|
||||
if(!isMulti() || target<0 || target>=gallery.length){ img.style.transition='transform .18s'; img.style.transform=''; return; } // at an edge → spring back
|
||||
const W=(window.innerWidth||400); let done=false;
|
||||
const finish=()=>{ if(done) return; done=true; img.removeEventListener('transitionend', finish);
|
||||
show(target); // load the target image; resetZoom clears its transform
|
||||
@@ -3167,17 +3234,31 @@ function openLightbox(src){
|
||||
img.addEventListener('transitionend', finish); setTimeout(finish, 180); // fallback if transitionend misses
|
||||
};
|
||||
syncArrows(); // initial: hide prev at first / next at last
|
||||
// #6: pull EVERY image in this conversation (not just the ones rendered in the thread) so ← / → and
|
||||
// swipe reach the older ones too. Runs after the lightbox is already showing, so opening stays instant;
|
||||
// the arrows update the moment the full set arrives. Keeps the current image in view by matching its URL.
|
||||
(function(){ try{
|
||||
if(!selected || selected.self) return;
|
||||
const qs = selected.kind==='group' ? ('group='+encodeURIComponent(selected.id)) : ('with='+encodeURIComponent(selected.id));
|
||||
fetch('/api/messages/media?'+qs).then(r=>r.ok?r.json():null).then(data=>{
|
||||
if(!document.getElementById('lightbox') || !data || !Array.isArray(data.media)) return;
|
||||
const all=data.media.filter(a=>a.isImage).slice().sort((a,b)=>(a.at||0)-(b.at||0)).map(a=>'/files/'+a.id);
|
||||
if(all.length<=gallery.length) return; // nothing older to add
|
||||
const cur=gallery[idx]; gallery=all; const ni=gallery.indexOf(cur); if(ni>=0) idx=ni; // keep showing the same image
|
||||
syncArrows();
|
||||
}).catch(()=>{});
|
||||
}catch(_){} })();
|
||||
// #8: swipe DOWN (when not zoomed) to dismiss, like native photo viewers — drag follows the finger and
|
||||
// fades the backdrop; release past a threshold closes, otherwise it springs back.
|
||||
let dsy=0, dsx=0, dDrag=false;
|
||||
img.addEventListener('touchstart',(e)=>{ if(z<=1 && e.touches.length===1){ dDrag=true; dsy=e.touches[0].clientY; dsx=e.touches[0].clientX; } },{passive:true});
|
||||
img.addEventListener('touchmove',(e)=>{ if(!dDrag||z>1||e.touches.length!==1) return; const dy=e.touches[0].clientY-dsy, dx=e.touches[0].clientX-dsx;
|
||||
if(dy>0 && dy>Math.abs(dx)){ img.style.transition='none'; img.style.transform='translateY('+dy+'px)'; ov.style.background='rgba(0,0,0,'+Math.max(.15,0.92-dy/500)+')'; } // #8: follow finger down to dismiss
|
||||
else if(multi && Math.abs(dx)>Math.abs(dy)){ img.style.transition='none'; img.style.transform='translateX('+dx+'px)'; } // #8: follow finger sideways to flip
|
||||
else if(isMulti() && Math.abs(dx)>Math.abs(dy)){ img.style.transition='none'; img.style.transform='translateX('+dx+'px)'; } // #8: follow finger sideways to flip
|
||||
},{passive:true});
|
||||
img.addEventListener('touchend',(e)=>{ if(!dDrag) return; dDrag=false; const t=e.changedTouches[0]; const dy=t?(t.clientY-dsy):0, dx=t?(t.clientX-dsx):0;
|
||||
if(z<=1 && dy>100 && dy>Math.abs(dx)){ close(); return; } // #8: swipe down = dismiss
|
||||
if(z<=1 && multi && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)){ pagerGo(dx<0?1:-1); return; } // #8: swipe left/right = animated next/prev
|
||||
if(z<=1 && isMulti() && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)){ pagerGo(dx<0?1:-1); return; } // #8: swipe left/right = animated next/prev
|
||||
img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200); // no nav → spring back
|
||||
},{passive:true});
|
||||
const onKey=(e)=>{
|
||||
@@ -3845,7 +3926,7 @@ function paintShareError(body, e){ body.innerHTML='<div class="stor-empty">Could
|
||||
const C=window.Capacitor; if(!C||!C.Plugins||!C.Plugins.App) return;
|
||||
const App=C.Plugins.App;
|
||||
try{ App.addListener('appUrlOpen', (d)=>{ if(d&&/^bizconnect:\/\/share/i.test(d.url||'')) setTimeout(bzCheckSharedInbox, 150); }); }catch(_){}
|
||||
try{ App.addListener('appStateChange', (s)=>{ if(s&&s.isActive) setTimeout(bzCheckSharedInbox, 300); }); }catch(_){}
|
||||
try{ App.addListener('appStateChange', (s)=>{ if(s&&s.isActive){ setTimeout(bzCheckSharedInbox, 300); try{ refreshPresenceOnResume(); }catch(_){} } }); }catch(_){} // #8: native resume → reconnect + refresh presence
|
||||
})();
|
||||
// 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
|
||||
@@ -4163,8 +4244,8 @@ function markOpenChatRead(){
|
||||
const body=JSON.stringify(selected.kind==='group'?{group:selected.id}:{with:selected.id});
|
||||
try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){}
|
||||
}
|
||||
window.addEventListener('focus', ()=>{ checkWebBuild(); markOpenChatRead(); }); // coming back to the app (throttled)
|
||||
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden){ checkWebBuild(); markOpenChatRead(); } });
|
||||
window.addEventListener('focus', ()=>{ checkWebBuild(); markOpenChatRead(); refreshPresenceOnResume(); }); // coming back to the app (throttled)
|
||||
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden){ checkWebBuild(); markOpenChatRead(); refreshPresenceOnResume(); } }); // #8: refresh online/last-seen on resume
|
||||
setTimeout(()=>checkWebBuild(true), 3000); // shortly after boot
|
||||
// Deliberately NO banner and NO toast: a web build is an implementation detail. Surfacing it would make
|
||||
// users think about "web build vs app version". It just updates.
|
||||
@@ -4300,11 +4381,13 @@ function onChatMessage(m){
|
||||
const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
|
||||
if(m.from!==ME.id && !isSys){
|
||||
if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery
|
||||
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
|
||||
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
|
||||
// back to an in-page popup so hidden-tab users still get alerted.
|
||||
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden);
|
||||
if(wantPopup && !(document.hidden && pushActive)){
|
||||
// #2: if you're ACTIVELY looking at this chat (app visible AND this chat open), the message lands
|
||||
// right in front of you — no ping, no popup. Alert in every other case: a different chat, OR this
|
||||
// same chat while the app is minimised (document.hidden) — that backgrounded case is exactly the one
|
||||
// that used to stay silent. When hidden with push active, let the SW/native push show it instead.
|
||||
const activelyViewing = isOpen && !document.hidden;
|
||||
if(notifOn(kind) && !activelyViewing){ playPing();
|
||||
if(!(document.hidden && pushActive)){
|
||||
const prev=isGifUrl(m.body)?'🎞️ GIF':(m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment');
|
||||
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
|
||||
if(kind==='group') notify((it&&it.name)||m.groupName||'Group', (m.fromName?m.fromName+': ':'')+prev, kind, rid);
|
||||
@@ -4372,6 +4455,18 @@ async function refreshOpenThread(){
|
||||
if(changed){ THREAD.sort((a,b)=>(a.created_at||0)-(b.created_at||0)); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); }
|
||||
}
|
||||
let _chatReconnectT=null;
|
||||
// #8: after the app comes back from background, make sure presence is FRESH. iOS suspends the WebView and
|
||||
// freezes JS, so the chat socket can be dead while its onclose (and the 3s auto-reconnect) hasn't fired yet —
|
||||
// leaving contacts stuck on a stale "Offline"/last-seen. If the socket isn't OPEN, reconnect now (its onopen
|
||||
// re-runs resyncChat→loadSidebar); either way re-pull the sidebar so online/last-seen reflect reality. The
|
||||
// sidebar's live isOnline comes over HTTP, so it's correct even if the socket is momentarily down.
|
||||
let _lastPresenceRefresh=0;
|
||||
function refreshPresenceOnResume(){
|
||||
try{
|
||||
if(!chatWs || chatWs.readyState>1){ connectChatWs(); } // dead/closed → force reconnect (skip if OPEN/CONNECTING)
|
||||
const t=Date.now(); if(t-_lastPresenceRefresh>4000){ _lastPresenceRefresh=t; try{ loadSidebar(); }catch(_){} } // throttle so rapid tab switches don't spam
|
||||
}catch(_){}
|
||||
}
|
||||
function connectChatWs(){
|
||||
try{
|
||||
// Defensive: drop any prior socket first so a flaky reconnect can't leave TWO live sockets —
|
||||
|
||||
Reference in New Issue
Block a user