// static/js/12-calls.js "use strict"; window.Glow = window.Glow || {}; Glow.calls = Glow.calls || {}; Glow.calls.state = () => Glow.state.calls; Glow.calls.ensureState = () => { const state = Glow.calls.state(); state.activeCall ||= null; state.ws ||= null; state.session ||= null; state.localStream ||= null; state.muted ||= false; state.selfPeerId ||= null; state.peerConnections ||= new Map(); state.remoteStreams ||= new Map(); state.peers ||= []; state.reconnectTimer ||= null; state.invites ||= []; state.invitePollTimer ||= null; }; Glow.calls.scopeLabel = invite => { if (!invite) return "call"; if (invite.scope_kind === "channel") { return `channel ${invite.scope_slug}`; } return `room ${invite.scope_slug}`; }; Glow.calls.getCurrentScope = () => { if (Glow.state.activeTeam && Glow.state.activeChannel) { return { scope_kind: "channel", team_slug: Glow.state.activeTeam.slug, channel_slug: Glow.state.activeChannel.slug, label: `# ${Glow.state.activeChannel.name}` }; } if (Glow.state.activeRoom) { return { scope_kind: "room", room_slug: Glow.state.activeRoom.slug, label: Glow.state.activeRoom.name }; } return null; }; Glow.calls.isGroupCallContext = () => { return Boolean(Glow.calls.getCurrentScope()); }; Glow.calls.wsReady = () => { const ws = Glow.calls.state().ws; return Boolean(ws && ws.readyState === WebSocket.OPEN); }; Glow.calls.sendSignal = payload => { const ws = Glow.calls.state().ws; if (!ws || ws.readyState !== WebSocket.OPEN) return false; ws.send(JSON.stringify(payload)); return true; }; Glow.calls.replaceHttpWs = url => url.replace(/^https:/, "wss:").replace(/^http:/, "ws:"); Glow.calls.setRemoteAudioFromAnyPeer = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); const firstStream = [...state.remoteStreams.values()][0] || null; if (Glow.els.remoteAudio) { Glow.els.remoteAudio.srcObject = firstStream; } }; Glow.calls.upsertPeer = peer => { Glow.calls.ensureState(); const state = Glow.calls.state(); const peers = state.peers || []; const participantId = peer.participantId || peer.peer_id || peer.peerId || peer.id; if (!participantId) return; const normalized = { participantId, roomId: peer.roomId || peer.room_id || state.session?.call_id || "", username: peer.username || participantId }; const index = peers.findIndex(item => item.participantId === participantId); if (index >= 0) { peers[index] = { ...peers[index], ...normalized }; } else { peers.push(normalized); } state.peers = peers; }; Glow.calls.removePeerConnection = peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); const pc = state.peerConnections.get(peerId); if (pc) { try { pc.close(); } catch (_) {} } state.peerConnections.delete(peerId); state.remoteStreams.delete(peerId); Glow.calls.setRemoteAudioFromAnyPeer(); }; Glow.calls.removePeer = peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); state.peers = (state.peers || []).filter(peer => peer.participantId !== peerId); Glow.calls.removePeerConnection(peerId); }; Glow.calls.listOtherPeers = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); return (state.peers || []).filter(peer => peer.participantId !== state.selfPeerId); }; Glow.calls.ensureLocalAudio = async () => { Glow.calls.ensureState(); const state = Glow.calls.state(); if (state.localStream) return state.localStream; const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); state.localStream = stream; return stream; }; Glow.calls.ensurePeerConnection = async peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); if (state.peerConnections.has(peerId)) { return state.peerConnections.get(peerId); } const pc = new RTCPeerConnection({ iceServers: state.session?.iceServers || [] }); pc.onicecandidate = event => { if (!event.candidate) return; Glow.calls.sendSignal({ type: "ice", to: peerId, candidate: event.candidate.candidate, sdpMid: event.candidate.sdpMid, sdpMLineIndex: event.candidate.sdpMLineIndex }); }; pc.ontrack = event => { const stream = event.streams?.[0]; if (!stream) return; state.remoteStreams.set(peerId, stream); Glow.calls.setRemoteAudioFromAnyPeer(); }; pc.onconnectionstatechange = () => { const bad = pc.connectionState === "failed" || pc.connectionState === "disconnected"; if (bad) { Glow.showToast(`Connection issue with ${peerId}.`, "error"); } }; state.peerConnections.set(peerId, pc); const stream = await Glow.calls.ensureLocalAudio(); const existingTracks = pc.getSenders().map(sender => sender.track).filter(Boolean); for (const track of stream.getTracks()) { if (!existingTracks.includes(track)) { pc.addTrack(track, stream); } } return pc; }; Glow.calls.makeOfferTo = async peerId => { const pc = await Glow.calls.ensurePeerConnection(peerId); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); Glow.calls.sendSignal({ type: "offer", to: peerId, sdp: offer.sdp }); }; Glow.calls.callAllPeers = async () => { const peers = Glow.calls.listOtherPeers(); if (!peers.length) { Glow.showToast("No peers available yet.", "error"); return; } for (const peer of peers) { try { await Glow.calls.makeOfferTo(peer.participantId); } catch (_) {} } }; Glow.calls.fetchGroupCallSession = async scope => { const data = await Glow.apiRequest("/api/group-calls/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(scope) }); const helperResp = await fetch(`${data.calls_base}/api/call/session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: data.token }) }); let helperData = {}; try { helperData = await helperResp.json(); } catch (_) {} if (!helperResp.ok || !helperData.ok) { throw new Error(helperData.error || `Call helper failed (${helperResp.status})`); } helperData.calls_base = data.calls_base; helperData.group_session = data.session; return helperData; }; Glow.calls.connectWebSocket = async () => { Glow.calls.ensureState(); const state = Glow.calls.state(); if (!state.session?.ws_url) { throw new Error("Missing helper WebSocket URL."); } return new Promise((resolve, reject) => { const ws = new WebSocket(Glow.calls.replaceHttpWs(state.session.ws_url)); state.ws = ws; ws.onopen = () => { resolve(); }; ws.onerror = error => { reject(error); }; ws.onclose = () => { if (state.activeCall) { Glow.calls.scheduleReconnect(); } }; ws.onmessage = async event => { let signal; try { signal = JSON.parse(event.data); } catch (_) { return; } if (signal.type === "joined") { state.selfPeerId = signal.participantId; Glow.renderCallUi(); return; } if (signal.type === "participant-joined") { Glow.calls.upsertPeer({ participantId: signal.participantId, roomId: signal.roomId }); Glow.renderCallUi(); if (signal.participantId && signal.participantId !== state.selfPeerId) { try { await Glow.calls.makeOfferTo(signal.participantId); } catch (_) {} } return; } if (signal.type === "participant-left") { Glow.calls.removePeer(signal.participantId); Glow.renderCallUi(); Glow.showToast("Peer left call."); return; } if (signal.type === "peer-reconnected") { Glow.showToast("Peer reconnected."); return; } if (signal.type === "reoffer-needed") { if (signal.participantId) { try { await Glow.calls.makeOfferTo(signal.participantId); } catch (_) {} } return; } if (signal.type === "offer") { const from = signal.from; if (!from) return; const pc = await Glow.calls.ensurePeerConnection(from); await pc.setRemoteDescription({ type: "offer", sdp: signal.sdp }); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); Glow.calls.sendSignal({ type: "answer", to: from, sdp: answer.sdp }); return; } if (signal.type === "answer") { const from = signal.from; if (!from) return; const pc = await Glow.calls.ensurePeerConnection(from); await pc.setRemoteDescription({ type: "answer", sdp: signal.sdp }); return; } if (signal.type === "ice" || signal.type === "ice-candidate") { const from = signal.from; if (!from) return; const pc = await Glow.calls.ensurePeerConnection(from); try { await pc.addIceCandidate({ candidate: signal.candidate, sdpMid: signal.sdpMid, sdpMLineIndex: signal.sdpMLineIndex }); } catch (_) {} } }; }); }; Glow.calls.resetTransportOnly = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); clearTimeout(state.reconnectTimer); try { if (state.ws) { state.ws.onopen = null; state.ws.onclose = null; state.ws.onerror = null; state.ws.onmessage = null; state.ws.close(); } } catch (_) {} for (const [, pc] of state.peerConnections) { try { pc.onicecandidate = null; pc.ontrack = null; pc.onconnectionstatechange = null; pc.close(); } catch (_) {} } state.ws = null; state.peerConnections = new Map(); state.remoteStreams = new Map(); state.peers = []; state.selfPeerId = null; state.reconnectTimer = null; }; Glow.calls.resetAll = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); Glow.calls.resetTransportOnly(); try { if (state.localStream) { for (const track of state.localStream.getTracks()) { track.stop(); } } } catch (_) {} state.localStream = null; state.activeCall = null; state.session = null; state.muted = false; }; Glow.endLocalCallUi = async () => { Glow.calls.resetAll(); if (Glow.els.remoteAudio) { Glow.els.remoteAudio.srcObject = null; } Glow.renderCallUi(); }; Glow.calls.scheduleReconnect = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); clearTimeout(state.reconnectTimer); state.reconnectTimer = setTimeout(async () => { try { if (!state.activeCall) return; const scope = state.activeCall.scope; if (!scope) return; Glow.calls.resetTransportOnly(); state.session = await Glow.calls.fetchGroupCallSession(scope); await Glow.calls.connectWebSocket(); Glow.showToast("Call reconnected."); Glow.renderCallUi(); } catch (_) { Glow.calls.scheduleReconnect(); } }, 2000); }; Glow.renderCallUi = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); const active = Boolean(state.activeCall); const scope = state.activeCall?.scope || Glow.calls.getCurrentScope(); const peerCount = Glow.calls.listOtherPeers().length; if (!Glow.els.callStatus) return; Glow.els.callPeerLabel.textContent = scope?.label || Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "No active call"; Glow.els.callStatus.textContent = active ? peerCount > 0 ? `Group call active ยท ${peerCount} peer(s)` : "Call open" : "Idle"; Glow.els.startCallButton?.classList.toggle("hidden", !Glow.calls.isGroupCallContext()); Glow.els.answerCallButton?.classList.add("hidden"); Glow.els.muteCallButton?.classList.toggle("hidden", !active); Glow.els.hangupCallButton?.classList.toggle("hidden", !active); if (Glow.els.addToCallButton) { Glow.els.addToCallButton.classList.toggle("hidden", !active); } Glow.els.muteCallButton.textContent = state.muted ? "Unmute" : "Mute"; }; Glow.openCallPanel = () => { Glow.els.callOverlay?.classList.remove("hidden"); Glow.renderCallUi(); }; Glow.startCall = async () => { Glow.calls.ensureState(); const scope = Glow.calls.getCurrentScope(); if (!scope) { Glow.showToast("Open a room or channel to start a group call.", "error"); return; } if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) { Glow.showToast("Voice calls are not supported in this browser.", "error"); return; } try { const session = await Glow.calls.fetchGroupCallSession(scope); Glow.state.calls.session = session; Glow.state.calls.activeCall = { id: session.call_id, active: true, scope, groupSession: session.group_session }; Glow.state.calls.selfPeerId = session.peer_id; Glow.state.calls.peers = []; Glow.state.calls.peerConnections = new Map(); Glow.state.calls.remoteStreams = new Map(); Glow.openCallPanel(); await Glow.calls.ensureLocalAudio(); await Glow.calls.connectWebSocket(); Glow.renderCallUi(); Glow.showToast("Group call ready."); } catch (error) { Glow.showToast(error.message || "Could not start group call.", "error"); await Glow.endLocalCallUi(); } }; Glow.toggleMuteCall = () => { const stream = Glow.state.calls.localStream; if (!stream) return; Glow.state.calls.muted = !Glow.state.calls.muted; for (const track of stream.getAudioTracks()) { track.enabled = !Glow.state.calls.muted; } Glow.renderCallUi(); }; Glow.hangupCall = async () => { try { Glow.calls.sendSignal({ type: "hangup" }); } catch (_) {} Glow.showToast("Call ended."); await Glow.endLocalCallUi(); }; Glow.calls.requestInvite = async username => { Glow.calls.ensureState(); const activeCall = Glow.state.calls.activeCall; if (!activeCall?.groupSession?.id) { throw new Error("No active group call session."); } return Glow.apiRequest("/api/group-calls/invite", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ group_call_session_id: activeCall.groupSession.id, username }) }); }; Glow.openAddToCallDialog = async () => { if (!Glow.state.calls.activeCall?.groupSession?.id) { Glow.showToast("Start a call first.", "error"); return; } const values = await Glow.openDialog({ title: "Add user to call", body: "Send a stored invite to another user for this room/channel call.", confirmText: "Send invite", fields: [ { name: "username", label: "Username", placeholder: "username", maxLength: 30 } ] }); if (!values?.username) return; try { const result = await Glow.calls.requestInvite(values.username.trim().toLowerCase()); Glow.showToast(`Invite sent to ${result.invite.invitee_username}.`); } catch (error) { Glow.showToast(error.message || "Could not send invite.", "error"); } }; Glow.calls.fetchInvites = async () => { Glow.calls.ensureState(); if (!Glow.state.currentUser) return []; const data = await Glow.apiRequest("/api/group-calls/invites"); Glow.state.calls.invites = data.invites || []; return Glow.state.calls.invites; }; Glow.calls.acceptInvite = async inviteId => { const data = await Glow.apiRequest(`/api/group-calls/invites/${encodeURIComponent(inviteId)}/accept`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) }); const helperResp = await fetch(`${data.calls_base}/api/call/session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: data.token }) }); let helperData = {}; try { helperData = await helperResp.json(); } catch (_) {} if (!helperResp.ok || !helperData.ok) { throw new Error(helperData.error || `Call helper failed (${helperResp.status})`); } helperData.calls_base = data.calls_base; helperData.group_session = { helper_call_id: data.call_id }; return helperData; }; Glow.calls.dismissInvite = async inviteId => { await Glow.apiRequest(`/api/group-calls/invites/${encodeURIComponent(inviteId)}/dismiss`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) }); }; Glow.calls.openIncomingInviteDialog = async invite => { const values = await Glow.openDialog({ title: "Incoming group call invite", body: `${invite.inviter_username} invited you to join ${Glow.calls.scopeLabel(invite)}.`, confirmText: "Join call", cancelText: "Dismiss" }); if (!values) { try { await Glow.calls.dismissInvite(invite.id); } catch (_) {} return; } try { const session = await Glow.calls.acceptInvite(invite.id); Glow.state.calls.session = session; Glow.state.calls.activeCall = { id: session.call_id, active: true, scope: null, groupSession: session.group_session }; Glow.state.calls.selfPeerId = session.peer_id; Glow.state.calls.peers = []; Glow.state.calls.peerConnections = new Map(); Glow.state.calls.remoteStreams = new Map(); Glow.openCallPanel(); await Glow.calls.ensureLocalAudio(); await Glow.calls.connectWebSocket(); Glow.renderCallUi(); Glow.showToast("Joined group call."); } catch (error) { Glow.showToast(error.message || "Could not join group call.", "error"); } }; Glow.calls.pollInvites = async () => { Glow.calls.ensureState(); if (!Glow.state.currentUser || document.hidden) return; try { const invites = await Glow.calls.fetchInvites(); if (!invites.length) return; const invite = invites[0]; await Glow.calls.openIncomingInviteDialog(invite); } catch (_) {} }; Glow.calls.scheduleInvitePolling = () => { Glow.calls.ensureState(); clearTimeout(Glow.state.calls.invitePollTimer); if (!Glow.state.currentUser) return; Glow.state.calls.invitePollTimer = setTimeout(async () => { await Glow.calls.pollInvites(); Glow.calls.scheduleInvitePolling(); }, document.hidden ? 12000 : 6000); }; Glow.syncActiveCallForDm = async () => { Glow.renderCallUi(); }; Glow.pollCallSignals = async () => { }; Glow.scheduleCallPolling = () => { }; Glow.answerIncomingCall = async () => { }; Glow.els.startCallButton?.addEventListener("click", async () => { if (!Glow.state.calls.activeCall) { await Glow.startCall(); return; } await Glow.calls.callAllPeers(); }); Glow.els.answerCallButton?.addEventListener("click", () => { Glow.answerIncomingCall?.(); }); Glow.els.muteCallButton?.addEventListener("click", () => { Glow.toggleMuteCall?.(); }); Glow.els.hangupCallButton?.addEventListener("click", () => { Glow.hangupCall?.(); }); Glow.els.addToCallButton?.addEventListener("click", () => { Glow.openAddToCallDialog?.(); });