diff --git a/server/calls.js b/server/calls.js index bc19556..fe6263b 100644 --- a/server/calls.js +++ b/server/calls.js @@ -67,7 +67,7 @@ async function startGroupCall(group, teamId, user) { if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true }; let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); 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. 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 @@ -103,7 +103,7 @@ async function startDmCall(me, otherId, teamId) { let room; do { room = A.numericCode(6); } while (meetingRooms.has(room)); meetingRooms.set(room, new Map()); 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. 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 @@ -192,7 +192,9 @@ async function replayActiveCalls(userId, ws) { for (const [, call] of dmCalls) { if (call.answered) continue; 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) { @@ -200,11 +202,24 @@ async function replayActiveCalls(userId, ws) { let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {} if (!member) continue; 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 (_) {} } +// #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. 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). if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } 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); 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 @@ -271,4 +286,4 @@ async function promoteDmToGroup(room, inviter, inviteeIds) { 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 }; diff --git a/server/public/home.html b/server/public/home.html index 77105c1..f3a3745 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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)=>'').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=''+ic('edit',13)+' Editing message'+ic('x',15)+''; 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=''; + 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 ? ('
'+(PIN_IX+1)+' of '+n+'
') : ''; el.innerHTML=''+ic('pin',15)+'' - +'
Pinned'+(PINNED.length>1?' Β· '+PINNED.length:'')+(m.fromName?(' Β· '+pEsc(m.fromName)):'')+'
'+pEsc(prev)+'
' + +'
Pinned'+(m.fromName?(' Β· '+pEsc(m.fromName)):'')+'
'+pEsc(prev)+'
' + +pager +''; 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='' - +(multi?'':'') + +'' +'' - +(multi?'':'') + +'' +''+ic('download',20)+'' +'
' +'
'; // #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{ 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='
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 β€” diff --git a/server/public/icons.js b/server/public/icons.js index ad05a80..16e6556 100644 --- a/server/public/icons.js +++ b/server/public/icons.js @@ -24,6 +24,7 @@ search: '', edit: '', trash: '', + eyeOff: '', pin: '', pinOff: '', logOut: '', diff --git a/server/routes.js b/server/routes.js index af08e25..b614fe8 100644 --- a/server/routes.js +++ b/server/routes.js @@ -11,7 +11,7 @@ const PUSH = require('./push'); const MSG_MAX = 4000; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; 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; } // 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. @@ -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 // 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). + try { CALLS.clearLeft(String(room), ids); } catch (_) {} // #New1: an explicit re-invite should ring again, even if they left earlier let groupId = null; 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 (_) {} } } diff --git a/server/signaling.js b/server/signaling.js index 40a5e59..37cedcc 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -406,6 +406,10 @@ async function leaveMeeting(ws) { if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; } try { await require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript 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 // 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