From 2b61056a1604fc9c5d6de3341b2537fee6fa638b Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 25 Sep 2026 02:41:07 +0000 Subject: [PATCH] Update static/js/12-calls.js --- static/js/12-calls.js | 970 ++++++++++++++++++++++++++++-------------- 1 file changed, 658 insertions(+), 312 deletions(-) diff --git a/static/js/12-calls.js b/static/js/12-calls.js index fe759fa..dcdfa6b 100644 --- a/static/js/12-calls.js +++ b/static/js/12-calls.js @@ -1,54 +1,482 @@ // static/js/12-calls.js "use strict"; - window.Glow = window.Glow || {}; - Glow.calls = Glow.calls || {}; -Glow.calls.iceServers = [ - { urls: "stun:stun.l.google.com:19302" }, - { urls: "stun:stun1.l.google.com:19302" } -]; +Glow.calls.state = () => Glow.state.calls; -Glow.getActiveDmPeer = () => { - if (!Glow.state.activeDm || !Glow.state.currentUser) return null; +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; +}; - return (Glow.state.activeDm.members || []).find( - member => Number(member.id) !== Number(Glow.state.currentUser.id) - ) || 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 = () => { - const call = Glow.state.calls.activeCall; - const hasCall = Boolean(call); - const incoming = Glow.state.calls.incomingCall; - const peer = Glow.getActiveDmPeer(); + 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 = - peer?.display_name || peer?.username || "No active call"; + scope?.label || + Glow.state.activeChannel?.name || + Glow.state.activeRoom?.name || + "No active call"; - Glow.els.callStatus.textContent = incoming - ? "Incoming call" - : hasCall - ? call.active - ? "In call" - : "Ended" - : "Idle"; + Glow.els.callStatus.textContent = active + ? peerCount > 0 + ? `Group call active ยท ${peerCount} peer(s)` + : "Call open" + : "Idle"; - Glow.els.startCallButton?.classList.toggle( - "hidden", - !Glow.state.activeDm || hasCall || Boolean(incoming) - ); - - Glow.els.answerCallButton?.classList.toggle("hidden", !incoming); - - Glow.els.muteCallButton?.classList.toggle("hidden", !hasCall); - Glow.els.hangupCallButton?.classList.toggle("hidden", !hasCall && !incoming); + 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 = - Glow.state.calls.muted ? "Unmute" : "Mute"; + state.muted ? "Unmute" : "Mute"; }; Glow.openCallPanel = () => { @@ -56,101 +484,12 @@ Glow.openCallPanel = () => { Glow.renderCallUi(); }; -Glow.endLocalCallUi = async () => { - try { - if (Glow.state.calls.peerConnection) { - Glow.state.calls.peerConnection.onicecandidate = null; - Glow.state.calls.peerConnection.ontrack = null; - Glow.state.calls.peerConnection.close(); - } - } catch (_) {} - - try { - if (Glow.state.calls.localStream) { - for (const track of Glow.state.calls.localStream.getTracks()) { - track.stop(); - } - } - } catch (_) {} - - Glow.state.calls.peerConnection = null; - Glow.state.calls.localStream = null; - Glow.state.calls.activeCall = null; - Glow.state.calls.incomingCall = null; - Glow.state.calls.pendingOffer = null; - Glow.state.calls.signalLastSeenId = 0; - Glow.state.calls.muted = false; - - clearTimeout(Glow.state.calls.pollTimer); - - if (Glow.els.remoteAudio) { - Glow.els.remoteAudio.srcObject = null; - } - - Glow.renderCallUi(); -}; - -Glow.ensurePeerConnection = async () => { - if (Glow.state.calls.peerConnection) { - return Glow.state.calls.peerConnection; - } - - const pc = new RTCPeerConnection({ - iceServers: Glow.calls.iceServers - }); - - pc.onicecandidate = async event => { - if (!event.candidate || !Glow.state.calls.activeCall) return; - - try { - await Glow.apiPostCallSignal(Glow.state.calls.activeCall.id, { - signal_type: "ice", - payload: { candidate: event.candidate } - }); - } catch (_) {} - }; - - pc.ontrack = event => { - const stream = event.streams?.[0]; - if (stream && Glow.els.remoteAudio) { - Glow.els.remoteAudio.srcObject = stream; - } - }; - - Glow.state.calls.peerConnection = pc; - return pc; -}; - -Glow.ensureLocalAudio = async () => { - if (Glow.state.calls.localStream) { - return Glow.state.calls.localStream; - } - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: true, - video: false - }); - - Glow.state.calls.localStream = stream; - return stream; -}; - -Glow.attachLocalTracks = async () => { - const pc = await Glow.ensurePeerConnection(); - const stream = await Glow.ensureLocalAudio(); - - const existingSenders = pc.getSenders().map(sender => sender.track).filter(Boolean); - - for (const track of stream.getTracks()) { - if (!existingSenders.includes(track)) { - pc.addTrack(track, stream); - } - } -}; - Glow.startCall = async () => { - if (!Glow.state.activeDm) { - Glow.showToast("Open a DM to start a call.", "error"); + Glow.calls.ensureState(); + + const scope = Glow.calls.getCurrentScope(); + if (!scope) { + Glow.showToast("Open a room or channel to start a group call.", "error"); return; } @@ -160,220 +499,223 @@ Glow.startCall = async () => { } try { - const data = await Glow.apiCreateCall(Glow.state.activeDm.id); - Glow.state.calls.activeCall = data.call; - Glow.state.calls.incomingCall = null; - Glow.state.calls.pendingOffer = null; - Glow.state.calls.signalLastSeenId = 0; + 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.attachLocalTracks(); - - const pc = await Glow.ensurePeerConnection(); - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - await Glow.apiPostCallSignal(data.call.id, { - signal_type: "offer", - payload: { sdp: offer } - }); - - await Glow.apiPostCallSignal(data.call.id, { - signal_type: "ring", - payload: {} - }); - + await Glow.calls.ensureLocalAudio(); + await Glow.calls.connectWebSocket(); Glow.renderCallUi(); - Glow.scheduleCallPolling(); + Glow.showToast("Group call ready."); } catch (error) { - Glow.showToast(error.message || "Could not start call.", "error"); + Glow.showToast(error.message || "Could not start group call.", "error"); await Glow.endLocalCallUi(); } }; -Glow.answerIncomingCall = async () => { - const incoming = Glow.state.calls.incomingCall; - const pendingOffer = Glow.state.calls.pendingOffer; - - if (!incoming || !pendingOffer) return; - - try { - Glow.state.calls.activeCall = incoming; - Glow.state.calls.incomingCall = null; - Glow.openCallPanel(); - - await Glow.attachLocalTracks(); - - const pc = await Glow.ensurePeerConnection(); - await pc.setRemoteDescription(new RTCSessionDescription(pendingOffer)); - - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - await Glow.apiPostCallSignal(incoming.id, { - signal_type: "answer", - payload: { sdp: answer } - }); - - Glow.state.calls.pendingOffer = null; - Glow.renderCallUi(); - Glow.scheduleCallPolling(); - } catch (error) { - Glow.showToast(error.message || "Could not answer call.", "error"); - await Glow.endLocalCallUi(); - } -}; - -Glow.handleCallSignal = async signal => { - const activeCall = Glow.state.calls.activeCall; - const incomingCall = Glow.state.calls.incomingCall; - - if (signal.signal_type === "ring") { - if (!activeCall && !incomingCall) { - try { - const data = await Glow.apiGetCall(signal.call_session_id); - Glow.state.calls.incomingCall = data.call; - Glow.openCallPanel(); - Glow.renderCallUi(); - } catch (_) {} - } - return; - } - - if (signal.signal_type === "offer") { - try { - const data = await Glow.apiGetCall(signal.call_session_id); - Glow.state.calls.incomingCall = data.call; - Glow.state.calls.pendingOffer = signal.payload?.sdp || signal.payload; - Glow.openCallPanel(); - Glow.renderCallUi(); - } catch (_) {} - return; - } - - if (!Glow.state.calls.activeCall || Number(Glow.state.calls.activeCall.id) !== Number(signal.call_session_id)) { - return; - } - - const pc = await Glow.ensurePeerConnection(); - - if (signal.signal_type === "answer") { - const sdp = signal.payload?.sdp || signal.payload; - if (sdp) { - await pc.setRemoteDescription(new RTCSessionDescription(sdp)); - } - return; - } - - if (signal.signal_type === "ice") { - const candidate = signal.payload?.candidate || signal.payload; - if (candidate) { - try { - await pc.addIceCandidate(new RTCIceCandidate(candidate)); - } catch (_) {} - } - return; - } - - if (signal.signal_type === "hangup") { - Glow.showToast("Call ended."); - await Glow.endLocalCallUi(); - return; - } -}; - -Glow.pollCallSignals = async () => { - const activeCall = Glow.state.calls.activeCall; - const incomingCall = Glow.state.calls.incomingCall; - const call = activeCall || incomingCall; - - if (!call || document.hidden) return; - - try { - const data = await Glow.apiGetCallSignals( - call.id, - Glow.state.calls.signalLastSeenId || 0 - ); - - const signals = data.signals || []; - - for (const signal of signals) { - Glow.state.calls.signalLastSeenId = Math.max( - Glow.state.calls.signalLastSeenId || 0, - Number(signal.id) || 0 - ); - - if ( - Glow.state.currentUser && - Number(signal.user_id) === Number(Glow.state.currentUser.id) - ) { - continue; - } - - await Glow.handleCallSignal(signal); - } - } catch (_) {} -}; - -Glow.scheduleCallPolling = () => { - clearTimeout(Glow.state.calls.pollTimer); - - if (!Glow.state.calls.activeCall && !Glow.state.calls.incomingCall) { - return; - } - - Glow.state.calls.pollTimer = setTimeout(async () => { - await Glow.pollCallSignals(); - Glow.scheduleCallPolling(); - }, document.hidden ? 5000 : 2000); -}; - 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 () => { - const call = Glow.state.calls.activeCall || Glow.state.calls.incomingCall; - if (!call) return; - try { - await Glow.apiEndCall(call.id); - } catch (_) { - // ignore - } - + Glow.calls.sendSignal({ type: "hangup" }); + } catch (_) {} Glow.showToast("Call ended."); await Glow.endLocalCallUi(); }; -Glow.syncActiveCallForDm = async () => { - if (!Glow.state.activeDm) return; +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 createResult = await Glow.apiCreateCall(Glow.state.activeDm.id); - const call = createResult.call; - - if (call?.active) { - Glow.state.calls.activeCall = call; - Glow.renderCallUi(); - Glow.scheduleCallPolling(); - } - } catch (_) { - // ignore if no active call semantics vary + 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.els.startCallButton?.addEventListener("click", () => { - Glow.startCall?.(); +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", () => { @@ -387,3 +729,7 @@ Glow.els.muteCallButton?.addEventListener("click", () => { Glow.els.hangupCallButton?.addEventListener("click", () => { Glow.hangupCall?.(); }); + +Glow.els.addToCallButton?.addEventListener("click", () => { + Glow.openAddToCallDialog?.(); +}); \ No newline at end of file