diff --git a/server/calls.js b/server/calls.js index bdd6785..20b88c0 100644 --- a/server/calls.js +++ b/server/calls.js @@ -95,10 +95,18 @@ 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, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId }; + const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false }; // Log to history (both participants) so the call shows under Past meetings with its transcript. try { const hid = A.id(); 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 + // #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed β + // so the caller isn't stuck "ringing" forever. + call.ringTimer = setTimeout(() => { + if (call.answered) return; + const peers = meetingRooms.get(room); + if (peers) { for (const [, p] of peers) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended', reason: 'unanswered' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); } + endDmCallByRoom(room); + }, 40000); // A viewer-relative activity line: the caller sees "You started a call", the callee sees the name. const mid = A.id(); R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: 'π Started a call', msgType: 'call-start' }); @@ -115,10 +123,11 @@ function endDmCallByRoom(room, silent) { const call = dmCalls.get(key); roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room); if (!call) return; + if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} } if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past - // "Call ended Β· duration" activity line in the DM (shown to both) β skipped on decline. + // Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9). if (!silent) try { - const mid = A.id(); const body = 'π Call ended Β· ' + fmtDur(now() - call.startedAt); + const mid = A.id(); const body = call.answered ? ('π Call ended Β· ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : 'π Missed call'; R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' }); const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' }; call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} }); @@ -126,6 +135,13 @@ function endDmCallByRoom(room, silent) { call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} }); } +// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end +// message shows a real duration (from answer) and the unanswered timeout stands down. +function markDmAnswered(room, userId) { + const key = roomToDmCall.get(room); if (!key) return; + const call = dmCalls.get(key); if (!call) return; + if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } } +} // Called from signaling when any mesh room empties. function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); } @@ -152,4 +168,4 @@ function declineDmCall(room, byUser) { return { ok: true }; } -module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey }; +module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, finalizeTranscript, meetingContext, fmtDur, pairKey }; diff --git a/server/public/home.html b/server/public/home.html index 8485e09..51d7f3e 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -171,8 +171,7 @@ .perm-state.default,.perm-state.unsupported{background:#f1f5f9;color:#475569;} .chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;} .chat-row:hover{background:#f3f6fb;} - .chat-row.active{background:var(--blue-soft);box-shadow:inset 3px 0 0 var(--brand);} - .chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);} + .chat-row.active{background:var(--blue-soft);box-shadow:inset 0 0 0 1.5px var(--brand);} /* #6: thin yellow boundary around the selected chat */ .avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.92rem;position:relative;} .avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;} .avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;} @@ -291,7 +290,7 @@ .convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;} .convo-body .big{font-size:2.4rem;margin-bottom:.4rem;} /* message thread */ - .convo-msgs{flex:1;overflow-y:auto;overflow-anchor:none;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);} /* overflow-anchor:none so prepending older history doesn't fight our manual scroll anchor */ + .convo-msgs{flex:1;overflow-y:auto;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);} .bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);} .bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;} .bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;} @@ -827,7 +826,7 @@
- @@ -1884,7 +1883,11 @@ function renderThread(keepScroll){ if(!THREAD.length){ box.innerHTML='