// 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.participantAudioEls ||= new Map(); state.peers ||= []; state.reconnectTimer ||= null; state.invitePollTimer ||= null; state.participantPollTimer ||= null; state.heartbeatTimer ||= null; state.invites ||= []; state.reconnecting ||= false; state.joining ||= false; state.leaving ||= false; state.permissions ||= { can_manage: false, can_end: false, can_invite: false }; state.backendParticipants ||= []; state.scope ||= null; state.statusText ||= "Idle"; }; 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.setStatusText = text => { Glow.calls.ensureState(); Glow.calls.state().statusText = String(text || ""); Glow.renderCallUi(); }; Glow.calls.ensureRemoteAudioElement = peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); if (!Glow.els.remoteAudioContainer) return null; let audio = state.participantAudioEls.get(peerId); if (audio) return audio; audio = document.createElement("audio"); audio.autoplay = true; audio.playsInline = true; audio.dataset.peerId = String(peerId); Glow.els.remoteAudioContainer.append(audio); state.participantAudioEls.set(peerId, audio); return audio; }; Glow.calls.removeRemoteAudioElement = peerId => { Glow.calls.ensureState(); const state = Glow.calls.state(); const audio = state.participantAudioEls.get(peerId); if (!audio) return; audio.srcObject = null; audio.remove(); state.participantAudioEls.delete(peerId); }; Glow.calls.syncRemoteAudioElements = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); for (const [peerId, stream] of state.remoteStreams.entries()) { const audio = Glow.calls.ensureRemoteAudioElement(peerId); if (audio && audio.srcObject !== stream) { audio.srcObject = stream; } } for (const peerId of [...state.participantAudioEls.keys()]) { if (!state.remoteStreams.has(peerId)) { Glow.calls.removeRemoteAudioElement(peerId); } } }; 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.removeRemoteAudioElement(peerId); Glow.calls.syncRemoteAudioElements(); }; 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; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); state.localStream = stream; return stream; } catch (error) { if (error?.name === "NotAllowedError" || error?.name === "PermissionDeniedError") { throw new Error("Microphone permission was denied."); } if (error?.name === "NotFoundError" || error?.name === "DevicesNotFoundError") { throw new Error("No microphone was found on this device."); } if (error?.name === "NotReadableError" || error?.name === "TrackStartError") { throw new Error("The microphone is already in use by another application."); } if (error?.name === "SecurityError") { throw new Error("Microphone access is blocked by browser security settings."); } throw new Error("Could not access your microphone."); } }; 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.syncRemoteAudioElements(); }; 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; helperData.participants = data.participants || []; helperData.permissions = data.permissions || { can_manage: false, can_end: false, can_invite: false }; helperData.scope = data.scope || null; return helperData; }; Glow.calls.renderPendingInvites = () => { const wrap = Glow.els.callPendingInvites; if (!wrap) return; wrap.replaceChildren(); const activeCallId = Glow.state.calls.activeCall?.groupSession?.id; const relevant = (Glow.state.calls.invites || []).filter( invite => Number(invite.group_call_session_id) === Number(activeCallId) ); if (!relevant.length) { wrap.classList.add("hidden"); return; } const title = document.createElement("div"); title.className = "call-pending-title"; title.textContent = "Pending invites"; wrap.append(title); for (const invite of relevant) { const row = document.createElement("div"); row.className = "call-pending-row"; const left = document.createElement("div"); left.className = "call-pending-name"; left.textContent = invite.inviter_username ? `from ${invite.inviter_username}` : "pending invite"; const right = document.createElement("div"); right.className = "call-pending-status"; right.textContent = "pending"; row.append(left, right); wrap.append(row); } wrap.classList.remove("hidden"); }; Glow.calls.renderParticipants = () => { if (!Glow.els.callParticipants) return; Glow.calls.ensureState(); const state = Glow.calls.state(); const box = Glow.els.callParticipants; box.replaceChildren(); const backendParticipants = Array.isArray(state.backendParticipants) ? state.backendParticipants : []; const peerMap = new Map( (state.peers || []).map(peer => [String(peer.participantId), peer]) ); const selfRow = document.createElement("div"); selfRow.className = "call-participant self"; const selfLeft = document.createElement("div"); selfLeft.className = "call-participant-name"; selfLeft.textContent = Glow.state.currentUser?.username ? `${Glow.state.currentUser.username} (you)` : "you"; const selfRight = document.createElement("div"); selfRight.className = "call-participant-status"; selfRight.textContent = state.activeCall ? state.muted ? "connected · muted" : "connected" : "idle"; selfRow.append(selfLeft, selfRight); box.append(selfRow); const renderedUserIds = new Set(); if (Glow.state.currentUser?.id) { renderedUserIds.add(String(Glow.state.currentUser.id)); } for (const participant of backendParticipants) { const userId = String(participant.user_id); if (renderedUserIds.has(userId)) continue; renderedUserIds.add(userId); const row = document.createElement("div"); row.className = "call-participant"; const left = document.createElement("div"); left.className = "call-participant-name"; left.textContent = participant.display_name || participant.username || `user ${participant.user_id}`; if (participant) { Glow.applyUserColor(left, participant); } const right = document.createElement("div"); right.className = "call-participant-status"; const matchingPeer = [...peerMap.values()].find(peer => { return ( String(peer.username || "").toLowerCase() === String(participant.username || "").toLowerCase() ); }); right.textContent = matchingPeer ? "connected" : "joined"; row.append(left, right); box.append(row); } const extraPeers = Glow.calls.listOtherPeers().filter(peer => { return !backendParticipants.some( participant => String(participant.username || "").toLowerCase() === String(peer.username || "").toLowerCase() ); }); for (const peer of extraPeers) { const row = document.createElement("div"); row.className = "call-participant"; const left = document.createElement("div"); left.className = "call-participant-name"; left.textContent = peer.username || peer.participantId; const right = document.createElement("div"); right.className = "call-participant-status"; right.textContent = "connecting"; row.append(left, right); box.append(row); } if (box.children.length === 1 && state.activeCall) { const empty = document.createElement("div"); empty.className = "call-participant"; const label = document.createElement("div"); label.className = "call-participant-name"; label.textContent = "waiting for others to join"; empty.append(label); box.append(empty); } }; Glow.calls.renderHeaderCallChip = () => { if (!Glow.els.callStatusChip) return; const active = Boolean(Glow.state.calls.activeCall); Glow.els.callStatusChip.classList.toggle("hidden", !active); Glow.els.callStatusChip.textContent = active ? "Call active" : "Call"; }; 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 = () => { state.reconnecting = false; state.joining = false; Glow.calls.setStatusText("Connected"); 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.calls.setStatusText("Call connected"); Glow.renderCallUi(); Glow.calls.renderParticipants(); return; } if (signal.type === "participant-joined") { Glow.calls.upsertPeer({ participantId: signal.participantId, roomId: signal.roomId }); Glow.renderCallUi(); Glow.calls.renderParticipants(); 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.calls.renderParticipants(); 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; Glow.calls.upsertPeer({ participantId: from, roomId: signal.roomId, username: signal.username || from }); Glow.calls.renderParticipants(); 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 (_) {} } for (const peerId of [...state.participantAudioEls.keys()]) { Glow.calls.removeRemoteAudioElement(peerId); } state.ws = null; state.peerConnections = new Map(); state.remoteStreams = new Map(); state.participantAudioEls = new Map(); state.peers = []; state.selfPeerId = null; state.reconnectTimer = null; state.reconnecting = false; }; Glow.calls.resetAll = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); clearTimeout(state.participantPollTimer); clearTimeout(state.heartbeatTimer); 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; state.joining = false; state.leaving = false; state.backendParticipants = []; state.scope = null; state.statusText = "Idle"; state.permissions = { can_manage: false, can_end: false, can_invite: false }; }; Glow.endLocalCallUi = async () => { Glow.calls.resetAll(); if (Glow.els.callParticipants) { Glow.els.callParticipants.replaceChildren(); } if (Glow.els.callPendingInvites) { Glow.els.callPendingInvites.replaceChildren(); Glow.els.callPendingInvites.classList.add("hidden"); } if (Glow.els.remoteAudioContainer) { Glow.els.remoteAudioContainer.replaceChildren(); } Glow.renderCallUi(); Glow.calls.renderHeaderCallChip(); }; Glow.calls.scheduleReconnect = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); clearTimeout(state.reconnectTimer); state.reconnecting = true; Glow.calls.setStatusText("Reconnecting…"); Glow.renderCallUi(); state.reconnectTimer = setTimeout(async () => { try { if (!state.activeCall) return; const scope = state.activeCall.scope || state.scope || Glow.calls.getCurrentScope(); if (!scope) return; Glow.calls.resetTransportOnly(); Glow.calls.setStatusText("Reconnecting…"); state.session = await Glow.calls.fetchGroupCallSession(scope); state.permissions = state.session.permissions || state.permissions; state.scope = state.session.scope || state.scope; if (state.session.group_session?.id) { await Glow.apiJoinGroupCall(state.session.group_session.id); } await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.showToast("Call reconnected."); Glow.renderCallUi(); Glow.calls.refreshBackendParticipants(); } catch (_) { Glow.calls.scheduleReconnect(); } }, 2000); }; Glow.calls.refreshBackendParticipants = async () => { Glow.calls.ensureState(); const sessionId = Glow.state.calls.activeCall?.groupSession?.id; if (!sessionId) return; try { const data = await Glow.apiGetGroupCallParticipants(sessionId); Glow.state.calls.backendParticipants = data.participants || []; Glow.state.calls.permissions = data.permissions || Glow.state.calls.permissions; Glow.state.calls.scope = data.scope || Glow.state.calls.scope; Glow.calls.renderParticipants(); Glow.renderCallUi(); } catch (_) {} }; Glow.calls.startParticipantSync = () => { Glow.calls.ensureState(); clearTimeout(Glow.state.calls.participantPollTimer); const run = async () => { if (!Glow.state.calls.activeCall || document.hidden) { Glow.calls.startParticipantSync(); return; } await Glow.calls.refreshBackendParticipants(); Glow.calls.startParticipantSync(); }; Glow.state.calls.participantPollTimer = setTimeout(run, 5000); }; Glow.calls.startHeartbeat = () => { Glow.calls.ensureState(); clearTimeout(Glow.state.calls.heartbeatTimer); const run = async () => { const sessionId = Glow.state.calls.activeCall?.groupSession?.id; if (!sessionId) return; try { await Glow.apiHeartbeatGroupCall(sessionId); } catch (_) {} Glow.calls.startHeartbeat(); }; Glow.state.calls.heartbeatTimer = setTimeout(run, 12000); }; Glow.renderCallUi = () => { Glow.calls.ensureState(); const state = Glow.calls.state(); const active = Boolean(state.activeCall); const scope = state.activeCall?.scope || state.scope || Glow.calls.getCurrentScope(); const peerCount = Glow.calls.listOtherPeers().length; if (!Glow.els.callStatus) return; Glow.els.callPeerLabel.textContent = scope?.label || state.scope?.scope_label || Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "No active call"; let statusText = state.statusText || "Idle"; if (!active) { statusText = "Idle"; } else if (state.joining) { statusText = "Joining call…"; } else if (state.reconnecting) { statusText = "Reconnecting…"; } else if (!Glow.calls.wsReady()) { statusText = "Connecting…"; } else if (peerCount > 0) { statusText = `Group call active · ${peerCount} peer(s)`; } else { statusText = "Call open · waiting for others"; } Glow.els.callStatus.textContent = statusText; Glow.els.startCallButton?.classList.toggle( "hidden", !Glow.calls.isGroupCallContext() || active ); Glow.els.retryCallButton?.classList.toggle( "hidden", !active || !state.reconnecting ); Glow.els.muteCallButton?.classList.toggle("hidden", !active); Glow.els.hangupCallButton?.classList.toggle("hidden", !active); Glow.els.addToCallButton?.classList.toggle( "hidden", !active || !state.permissions?.can_invite ); Glow.els.endCallButton?.classList.toggle( "hidden", !active || !state.permissions?.can_end ); Glow.els.muteCallButton.textContent = state.muted ? "Unmute" : "Mute"; Glow.calls.renderParticipants(); Glow.calls.renderPendingInvites(); Glow.calls.renderHeaderCallChip(); }; Glow.openCallPanel = async () => { Glow.els.callOverlay?.classList.remove("hidden"); if (Glow.state.calls.activeCall?.groupSession?.id) { await Glow.calls.refreshBackendParticipants(); } 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 { Glow.state.calls.joining = true; Glow.calls.setStatusText("Preparing call…"); 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.scope = session.scope || scope; Glow.state.calls.permissions = session.permissions || Glow.state.calls.permissions; Glow.state.calls.selfPeerId = session.peer_id || null; Glow.state.calls.peers = []; Glow.state.calls.peerConnections = new Map(); Glow.state.calls.remoteStreams = new Map(); Glow.state.calls.participantAudioEls = new Map(); Glow.state.calls.backendParticipants = session.participants || []; Glow.openCallPanel(); Glow.calls.setStatusText("Connecting microphone…"); await Glow.calls.ensureLocalAudio(); Glow.calls.setStatusText("Joining call…"); await Glow.apiJoinGroupCall(session.group_session.id); await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.state.calls.joining = false; Glow.renderCallUi(); Glow.showToast("Group call ready."); } catch (error) { Glow.state.calls.joining = false; 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.leaveCall = async () => { Glow.calls.ensureState(); const activeCall = Glow.state.calls.activeCall; if (!activeCall) return; Glow.state.calls.leaving = true; Glow.calls.setStatusText("Leaving call…"); try { Glow.calls.sendSignal({ type: "hangup" }); } catch (_) {} try { if (activeCall.groupSession?.id) { await Glow.apiLeaveGroupCall(activeCall.groupSession.id); } } catch (_) {} Glow.showToast("Left call."); Glow.state.calls.leaving = false; await Glow.endLocalCallUi(); }; Glow.endCallForEveryone = async () => { Glow.calls.ensureState(); const activeCall = Glow.state.calls.activeCall; if (!activeCall?.groupSession?.id) return; const values = await Glow.openDialog({ title: "End call for everyone?", body: "This will end the active call session for all participants.", confirmText: "End call", cancelText: "Cancel", danger: true }); if (!values) return; try { Glow.calls.setStatusText("Ending call…"); await Glow.apiEndGroupCall(activeCall.groupSession.id); Glow.showToast("Call ended."); await Glow.endLocalCallUi(); } catch (error) { Glow.showToast(error.message || "Could not end call.", "error"); } }; Glow.hangupCall = async () => { await Glow.leaveCall(); }; 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.apiInviteToGroupCall({ 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.apiListGroupCallInvites(); Glow.state.calls.invites = data.invites || []; Glow.calls.renderPendingInvites(); return Glow.state.calls.invites; }; Glow.calls.acceptInvite = async inviteId => { const data = await Glow.apiAcceptGroupCallInvite(inviteId); 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; helperData.participants = data.participants || []; helperData.permissions = data.permissions || { can_manage: false, can_end: false, can_invite: false }; helperData.scope = data.scope || null; return helperData; }; Glow.calls.dismissInvite = async inviteId => { await Glow.apiDismissGroupCallInvite(inviteId); }; 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 { Glow.state.calls.joining = true; Glow.calls.setStatusText("Joining call…"); 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.scope = session.scope || null; Glow.state.calls.permissions = session.permissions || Glow.state.calls.permissions; Glow.state.calls.selfPeerId = session.peer_id || null; Glow.state.calls.peers = []; Glow.state.calls.peerConnections = new Map(); Glow.state.calls.remoteStreams = new Map(); Glow.state.calls.participantAudioEls = new Map(); Glow.state.calls.backendParticipants = session.participants || []; Glow.openCallPanel(); Glow.calls.setStatusText("Connecting microphone…"); await Glow.calls.ensureLocalAudio(); if (session.group_session?.id) { Glow.calls.setStatusText("Joining call…"); await Glow.apiJoinGroupCall(session.group_session.id); } await Glow.calls.connectWebSocket(); Glow.calls.startParticipantSync(); Glow.calls.startHeartbeat(); Glow.state.calls.joining = false; Glow.renderCallUi(); Glow.showToast("Joined group call."); } catch (error) { Glow.state.calls.joining = false; 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.els.startCallButton?.addEventListener("click", async () => { if (!Glow.state.calls.activeCall) { await Glow.startCall(); return; } await Glow.calls.callAllPeers(); }); Glow.els.retryCallButton?.addEventListener("click", async () => { clearTimeout(Glow.state.calls.reconnectTimer); Glow.calls.scheduleReconnect(); }); Glow.els.muteCallButton?.addEventListener("click", () => { Glow.toggleMuteCall?.(); }); Glow.els.hangupCallButton?.addEventListener("click", () => { Glow.hangupCall?.(); }); Glow.els.endCallButton?.addEventListener("click", () => { Glow.endCallForEveryone?.(); }); Glow.els.addToCallButton?.addEventListener("click", () => { Glow.openAddToCallDialog?.(); });