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:
2026-08-13 00:22:50 +05:30
parent ad48829337
commit 971a6fdf22
5 changed files with 163 additions and 47 deletions
+21 -6
View File
@@ -67,7 +67,7 @@ async function startGroupCall(group, teamId, user) {
if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true }; if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map()); meetingRooms.set(room, new Map());
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email }; const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email, left: new Set() };
// Log the call as a meeting so it appears under Past meetings (history) with the group name. // Log the call as a meeting so it appears under Past meetings (history) with the group name.
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {} try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
@@ -103,7 +103,7 @@ async function startDmCall(me, otherId, teamId) {
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map()); meetingRooms.set(room, new Map());
const byName = me.name || me.email; const byName = me.name || me.email;
const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false }; const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false, left: new Set() };
// Log to history (both participants) so the call shows under Past meetings with its transcript. // Log to history (both participants) so the call shows under Past meetings with its transcript.
try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {} try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
@@ -192,7 +192,9 @@ async function replayActiveCalls(userId, ws) {
for (const [, call] of dmCalls) { for (const [, call] of dmCalls) {
if (call.answered) continue; if (call.answered) continue;
if (call.users.includes(userId) && call.startedBy !== userId) { if (call.users.includes(userId) && call.startedBy !== userId) {
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {} // #New1: if this user already LEFT the call, don't ring them back in (noRing) — just refresh the "Join" state.
const noRing = !!(call.left && call.left.has(userId));
try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName, noRing })); } catch (_) {}
} }
} }
for (const [group, call] of groupCalls) { for (const [group, call] of groupCalls) {
@@ -200,11 +202,24 @@ async function replayActiveCalls(userId, ws) {
let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {} let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
if (!member) continue; if (!member) continue;
let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {} let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {} const noRing = !!(call.left && call.left.has(userId)); // #New1: left already → refresh Join, don't re-ring
try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName, noRing })); } catch (_) {}
} }
} catch (_) {} } catch (_) {}
} }
// #New1: a participant who EXPLICITLY leaves an active (still-running) call must not be auto-rung back into
// it. We remember who left per call; replayActiveCalls (on their next socket reconnect) then sends the call
// state with noRing:true so their client refreshes the passive "Join" affordance without ringing / popping
// CallKit again. Before this, every reconnect (constant on mobile) re-rang the leaver until the call ended.
function callForRoom(room) {
const gid = roomToGroupCall.get(room); if (gid) { const c = groupCalls.get(gid); if (c) return c; }
const key = roomToDmCall.get(room); if (key) { const c = dmCalls.get(key); if (c) return c; }
return null;
}
function markLeft(room, userId) { if (!userId) return; const c = callForRoom(room); if (c) { if (!c.left) c.left = new Set(); c.left.add(userId); } }
function clearLeft(room, ids) { const c = callForRoom(room); if (c && c.left) { for (const id of (ids || [])) c.left.delete(id); } } // an explicit re-invite should ring again
// Called from signaling when any mesh room empties. // Called from signaling when any mesh room empties.
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); } async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }
@@ -259,7 +274,7 @@ async function promoteDmToGroup(room, inviter, inviteeIds) {
// Migrate the LIVE call: DM → group (same room/uuid/history so media, transcript and Past-meetings all continue). // Migrate the LIVE call: DM → group (same room/uuid/history so media, transcript and Past-meetings all continue).
if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; }
dmCalls.delete(key); roomToDmCall.delete(room); dmCalls.delete(key); roomToDmCall.delete(room);
groupCalls.set(gid, { room, uuid: call.uuid, startedAt: call.startedAt, startedBy: owner, startedByName: call.startedByName, teamId, historyId: call.historyId }); groupCalls.set(gid, { room, uuid: call.uuid, startedAt: call.startedAt, startedBy: owner, startedByName: call.startedByName, teamId, historyId: call.historyId, left: new Set() });
roomToGroupCall.set(room, gid); roomToGroupCall.set(room, gid);
postSystem(gid, teamId, '📞 ' + (call.startedByName || 'Someone') + ' turned this into a group call').catch(() => {}); postSystem(gid, teamId, '📞 ' + (call.startedByName || 'Someone') + ' turned this into a group call').catch(() => {});
// Tell every member's client: refresh the sidebar (the new group appears) and mark the call active (banner // Tell every member's client: refresh the sidebar (the new group appears) and mark the call active (banner
@@ -271,4 +286,4 @@ async function promoteDmToGroup(room, inviter, inviteeIds) {
return gid; return gid;
} }
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, promoteDmToGroup, finalizeTranscript, meetingContext, fmtDur, pairKey }; module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, promoteDmToGroup, markLeft, clearLeft, finalizeTranscript, meetingContext, fmtDur, pairKey };
+134 -39
View File
@@ -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-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{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-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;} .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;} .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;} .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. */ 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.them .msg-actions{left:0;right:auto;}
.bubble.mine .msg-actions{right:0;left: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{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);} .msg-actions button:hover{background:var(--blue-soft);}
/* one-tap reactions render as the emoji glyph itself */ /* one-tap reactions render as the emoji glyph itself */
@@ -815,6 +824,23 @@
.bzc-ok:disabled{opacity:.5;cursor:not-allowed;} .bzc-ok:disabled{opacity:.5;cursor:not-allowed;}
.bzc-ok.danger{background:var(--red);} .bzc-ok.danger{background:var(--red);}
.bzc-ok.danger:hover{background:#991b1b;} .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…" 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{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;} .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||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.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 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 items.push({ic:'trash', label:'Delete', danger:true, fn:()=>openDeleteDialog(m)}); // #18: one entry → branded dialog offers "for me" / "for everyone"
if(mine && !m.deleted) items.push({ic:'trash', label:'Delete for everyone', danger:true, fn:()=>deleteMessage(m.id)}); // #18: unsend for all
const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu'; 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(''); 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); 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=''; } } 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; // 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. // 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){ 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 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){ async function saveEdit(text){
const m=editTarget; if(!m) return; const m=editTarget; if(!m) return;
if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action) if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action)
if(text===(m.body||'')){ cancelEdit(); return; } // no change if(text===(m.body||'')){ cancelEdit(); return; } // no change
editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } 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); } _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{ if(selected) setDraft(selected.kind, selected.id, ''); }catch(_){} // #14: clear the draft, else the edited text reappears and re-sends as a NEW message
try{ try{
await postJSON('/api/messages/edit',{ id:m.id, body:text }); 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); } 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){ 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; 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}}); 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&&currentTab()==='chat'&&!document.hidden; const isViewing=selected&&selected.kind===kind&&selected.id===rid&&currentTab()==='chat'&&!document.hidden;
if(!isViewing){ playPing(); 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)){ if(!(document.hidden && pushActive)){
const title=(kind==='group')?(((rowFor('group',rid)||{}).name)||m&&m.groupName||'Group'):(d.by||'Reaction'); 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); 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 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. // #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){ function removeMsgLocally(id){
let changed=false; let changed=false;
const i=THREAD.findIndex(x=>x.id===id); if(i>=0){ THREAD.splice(i,1); changed=true; } 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 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. // #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 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 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){ async function loadPinned(kind, id){
const el=document.getElementById('pinnedBar'); if(!el) return; const el=document.getElementById('pinnedBar'); if(!el) return;
@@ -2622,15 +2678,21 @@ async function loadPinned(kind, id){
} }
function renderPinnedBar(){ function renderPinnedBar(){
const el=document.getElementById('pinnedBar'); if(!el) return; const el=document.getElementById('pinnedBar'); if(!el) return;
if(!PINNED.length){ el.style.display='none'; el.innerHTML=''; return; } if(!PINNED.length){ el.style.display='none'; el.innerHTML=''; PIN_IX=0; return; }
const m=PINNED[0]; // show the most recent pin; the count hints at the rest 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'))); 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>' 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>'; +'<button class="pb-unpin" title="Unpin">'+ic('x',15)+'</button>';
el.style.display='flex'; el.style.display='flex';
const body=el.querySelector('.pb-body'); if(body) body.onclick=()=>{ try{ jumpToMessage(m.id, m.created_at||0); }catch(_){} }; 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 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 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. // 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(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); 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. // 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 && !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 && 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() && 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 if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
renderChats(searchVal()); 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 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 // 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. // 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 && !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 && 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() && 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 if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
renderChats(searchVal()); renderChats(searchVal());
} }
@@ -3094,14 +3156,19 @@ async function nativeSaveFile(url, name, meta){
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc. // In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
function openLightbox(src){ function openLightbox(src){
if(document.getElementById('lightbox')) return; if(document.getElementById('lightbox')) return;
// #3: build a gallery from the loaded thread's images so ← / → flip through them. // #6: start from the loaded thread's images (shows instantly), then expand to EVERY image in the whole
const gallery=THREAD.filter(m=>m.attachment && m.attachment.isImage && !m.deleted).map(m=>'/files/'+m.attachment.id); // conversation via /api/messages/media below — older images live only on the server until you scroll them
let idx=gallery.indexOf(src); const multi=idx>=0 && gallery.length>1; if(idx<0) idx=0; // in, so a thread-only gallery stranded you at the 12 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'; 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>' 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="">' +'<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>' +'<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>' +'<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 +'<button class="lb-zin" title="Zoom in (+)">'+ic('search',15)+'<b>+</b></button></div>'; // #3
@@ -3147,14 +3214,14 @@ function openLightbox(src){
},{passive:false}); },{passive:false});
img.addEventListener('touchend',()=>{ pinchD=0; }); 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 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(!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 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); }; 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 // #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. // 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 pagerGo=(dir)=>{ // dir:+1 next (current exits left, next enters from right), -1 prev (mirror)
const target = idx + (dir>0?1:-1); 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 W=(window.innerWidth||400); let done=false;
const finish=()=>{ if(done) return; done=true; img.removeEventListener('transitionend', finish); const finish=()=>{ if(done) return; done=true; img.removeEventListener('transitionend', finish);
show(target); // load the target image; resetZoom clears its transform 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 img.addEventListener('transitionend', finish); setTimeout(finish, 180); // fallback if transitionend misses
}; };
syncArrows(); // initial: hide prev at first / next at last 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 // #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. // fades the backdrop; release past a threshold closes, otherwise it springs back.
let dsy=0, dsx=0, dDrag=false; 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('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; 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 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}); },{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; 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 && 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 img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200); // no nav → spring back
},{passive:true}); },{passive:true});
const onKey=(e)=>{ 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 C=window.Capacitor; if(!C||!C.Plugins||!C.Plugins.App) return;
const App=C.Plugins.App; 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('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 // 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 // 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}); 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(_){} 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) window.addEventListener('focus', ()=>{ checkWebBuild(); markOpenChatRead(); refreshPresenceOnResume(); }); // coming back to the app (throttled)
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden){ checkWebBuild(); markOpenChatRead(); } }); document.addEventListener('visibilitychange', ()=>{ if(!document.hidden){ checkWebBuild(); markOpenChatRead(); refreshPresenceOnResume(); } }); // #8: refresh online/last-seen on resume
setTimeout(()=>checkWebBuild(true), 3000); // shortly after boot setTimeout(()=>checkWebBuild(true), 3000); // shortly after boot
// Deliberately NO banner and NO toast: a web build is an implementation detail. Surfacing it would make // 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. // 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 const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
if(m.from!==ME.id && !isSys){ 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 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 // #2: if you're ACTIVELY looking at this chat (app visible AND this chat open), the message lands
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall // right in front of you — no ping, no popup. Alert in every other case: a different chat, OR this
// back to an in-page popup so hidden-tab users still get alerted. // same chat while the app is minimised (document.hidden) — that backgrounded case is exactly the one
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden); // that used to stay silent. When hidden with push active, let the SW/native push show it instead.
if(wantPopup && !(document.hidden && pushActive)){ 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'); 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). // 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); 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(); } 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; 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(){ function connectChatWs(){
try{ try{
// Defensive: drop any prior socket first so a flaky reconnect can't leave TWO live sockets — // Defensive: drop any prior socket first so a flaky reconnect can't leave TWO live sockets —
+1
View File
@@ -24,6 +24,7 @@
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>', search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>', edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
eyeOff: '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><path d="m2 2 20 20"/>',
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>', pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
pinOff: '<path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>', pinOff: '<path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>', logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
+2 -1
View File
@@ -11,7 +11,7 @@ const PUSH = require('./push');
const MSG_MAX = 4000; const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__'; const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, pinned: !!m.pinned_at, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type }); const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, pinned: !!m.pinned_at, edited_at: m.deleted ? null : (m.edited_at || null), system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a // Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
// plain .filter() can't await). Sequential so per-item DB order is deterministic. // plain .filter() can't await). Sequential so per-item DB order is deterministic.
@@ -971,6 +971,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
// #15: adding people to a 1:1 call promotes it to a persistent GROUP call (survives leaves + lets an added // #15: adding people to a 1:1 call promotes it to a persistent GROUP call (survives leaves + lets an added
// person rejoin after dropping). When that happens promoteDmToGroup's own group-call broadcast already rings // person rejoin after dropping). When that happens promoteDmToGroup's own group-call broadcast already rings
// the invitees in, so we only send the plain call-invite for a call that WASN'T promoted (group/ad-hoc). // the invitees in, so we only send the plain call-invite for a call that WASN'T promoted (group/ad-hoc).
try { CALLS.clearLeft(String(room), ids); } catch (_) {} // #New1: an explicit re-invite should ring again, even if they left earlier
let groupId = null; let groupId = null;
try { groupId = await CALLS.promoteDmToGroup(String(room), u, ids); } catch (_) {} try { groupId = await CALLS.promoteDmToGroup(String(room), u, ids); } catch (_) {}
if (!groupId) { for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } } if (!groupId) { for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } }
+4
View File
@@ -406,6 +406,10 @@ async function leaveMeeting(ws) {
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; } if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid); peers.delete(pid);
// #New1: remember this user LEFT, so a later socket reconnect doesn't auto-ring them back into a call that's
// still running for the others (replayActiveCalls sends them noRing state instead). Harmless on a call that
// then ends. Only meaningful for a group/promoted call that survives one person leaving.
try { if (leaverId) require('./calls').markLeft(room, leaverId); } catch (_) {}
// 1:1 call: end it for everyone ONLY when fewer than two people would remain. A DM call that had // 1:1 call: end it for everyone ONLY when fewer than two people would remain. A DM call that had
// extra people ADDED (via "Add people") is effectively a group now — one participant closing their // extra people ADDED (via "Add people") is effectively a group now — one participant closing their
// app must NOT hang up the call for the rest (#11). We only tear the whole thing down when ≤1 person // app must NOT hang up the call for the rest (#11). We only tear the whole thing down when ≤1 person