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 };
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 };