Voice & Video Calls — Technical Reference
Where this lives
Backend
apps/backend/graphql/resolvers/call.resolver.js— resolvers for call history, start/answer/decline/join/leave/end, viewer/speaker mode, and call subscriptionsapps/backend/graphql/types/call.type.js— Call/CallParticipant type defs and enumsapps/backend/managers/call-managers/call.manager.js— core call lifecycle business logic (start, answer, decline, join, leave, end)apps/backend/managers/call-managers/speaker-request.manager.js— viewer-to-speaker request/approve/deny/cancel logic for group callsapps/backend/data-access-services/call/call.access-service.js— Call document persistenceapps/backend/data-access-services/call/call-participant.access-service.js— CallParticipant persistence and role/status trackingapps/backend/data-access-services/call/speaker-request.access-service.js— SpeakerRequest persistence (pending/approve/deny/cancel)apps/backend/services/livekit.service.js— issues LiveKit tokens/rooms for callsapps/backend/services/livekit-monitor.service.js— monitors active LiveKit rooms/participantsapps/backend/services/voip-push.service.js— sends VoIP push notifications to wake the iOS app for incoming calls
Frontend
apps/frontend-nextjs/src/components/chat/ChatView.tsx— initiates calls (StartCall) and listens for incoming calls, renders VoiceCallModalapps/frontend-nextjs/src/components/chat/VoiceCallModal.tsx— in-call UI — mute/speaker controls, call timer, subscribes to call-ended/participant events
Technical implementation checklist
The checkboxes below (and on the idea-tier page) track backend + frontend-nextjs (web) status specifically, consistent with how every other feature in these docs is scored. iOS wiring is documented separately in each line for reference, since a lot of this feature is genuinely more complete on iOS than on web — but iOS state doesn't check a web box.
-
startCall— wired end-to-end on web (ChatView.tsx); also wired on iOS (CallStore+StartCall.swift) -
answerCall/declineCall— web has no incoming-call UI:CallIncomingDocumentis imported inChatView.tsxbut never passed touseSubscription— dead import, and neither mutation has a frontend caller. Fully wired on iOS via CallKit (CallKitManager.swift,IncomingCallView.swift) -
endCall(mute is a client-only control) — wired on web (VoiceCallModal.tsx:toggleMute,handleEndCall); also wired on iOS (CallControlButtons.swift) -
myCallHistory— zero references anywhere inapps/frontend-nextjs/src. Wired on iOS (CallStore.swift) -
joinAsViewer/promoteToSpeaker/demoteToViewer/requestToSpeak/approveSpeakerRequest/denySpeakerRequest/cancelSpeakerRequest/pendingSpeakerRequests— zero references anywhere inapps/frontend-nextjs/src— no group-call viewer/speaker UI on web. Fully implemented on iOS (SpeakerRequestBanner.swift,SpeakerRequestsSheet.swift,CallStore+SpeakerManagement.swift) call(single-call query),updateCallStatus, andmySpeakerRequestsexist in the schema and resolvers but have zero references inapps/frontend-nextjs/srcorapps/ios— not yet consumed by any client- VoIP push (
voip-push.service.js, paired with iOSVoIPPushManager.swift) is inherently iOS-only — there's no browser equivalent, so it isn't tracked as a web checklist item
Types
enum CallType { VOICE VIDEO }
enum CallStatus { INITIATING RINGING ACCEPTED DECLINED MISSED ENDED FAILED }
Call model
| Field | Description |
|---|---|
conversationId | Conversation associated with the call |
callerId / receiverId | Initiator and recipient |
type | VOICE or VIDEO |
status | Current call status |
startedAt / endedAt / duration | Timing information |
participants | List of call participants |
activeParticipantCount | Currently active participants |
Participants
enum CallParticipantRole { CALLER RECEIVER PARTICIPANT }
enum CallParticipantStatus { INVITED RINGING JOINED LEFT DECLINED MISSED }
Each participant records joinedAt, leftAt, and their individual duration.
Queries
myCallHistory returns a reverse-chronological list of all calls the user participated in, with type, status, and duration. Filter by type to show "Missed calls" or "Video calls."
activeCall checks whether there is a live call in a given conversation right now. Use this when entering a conversation to decide whether to show a "Join active call" banner.
call fetches a single call by ID (caller/receiver or a CallParticipant row is required to view it). Not yet called from any client.
query CallHistory($limit: Int, $offset: Int) {
myCallHistory(limit: $limit, offset: $offset) {
id type status duration createdAt
caller { username profilePicture }
receiver { username profilePicture }
}
}
query ActiveCall($conversationId: String!) {
activeCall(conversationId: $conversationId) { id status activeParticipantCount }
}
query GetCall($id: ID!) {
call(id: $id) { id status duration activeParticipantCount }
}
Starting and managing calls
startCall initiates a call and returns a LiveKit token, wsUrl, and roomName. The caller's client connects to LiveKit using these credentials. The backend simultaneously dispatches a VoIP push to the receiver (iOS) or a WebSocket notification (web/Android) via callIncoming.
answerCall is called by the receiver when they tap "Accept." It returns their own LiveKit token.
declineCall rejects an incoming call. The caller receives a callStatusChanged event with status DECLINED.
joinCall allows a third participant to join an ongoing group call. It returns a LiveKit token for the new participant.
leaveCall removes the caller from the active call without ending it (other participants stay). endCall terminates the call for all participants and records the final duration.
# Initiate a call — returns LiveKit credentials
mutation StartCall($input: StartCallInput!) {
startCall(input: $input) { token wsUrl roomName callId role }
}
# Accept an incoming call
mutation AnswerCall($callId: String!) { answerCall(callId: $callId) { token wsUrl } }
# Reject an incoming call
mutation DeclineCall($callId: String!) { declineCall(callId: $callId) { status } }
# Join an ongoing group call as a new participant
mutation JoinCall($callId: String!) { joinCall(callId: $callId) { token wsUrl } }
# Leave without ending (others stay connected)
mutation LeaveCall($callId: String!) { leaveCall(callId: $callId) { status } }
# End call for all participants
mutation EndCall($input: EndCallInput!) { endCall(input: $input) { duration } }
# Update call status directly (not currently called from any client)
mutation UpdateCallStatus($input: UpdateCallStatusInput!) { updateCallStatus(input: $input) { status } }
Viewer / speaker mode
Group calls support viewers who watch without speaking. A viewer can request to become a speaker; the host approves or denies.
joinAsViewer joins the LiveKit room in a receive-only role. promoteToSpeaker grants the viewer a speaker role — the backend reissues their LiveKit token with publish permissions. demoteToViewer revokes speaker permissions and returns the user to view-only mode.
requestToSpeak creates a pending request visible to the host. approveSpeakerRequest accepts the request and promotes the user. denySpeakerRequest rejects it. cancelSpeakerRequest lets the requester withdraw their own pending request. A max of 30 speakers is enforced per call.
enum SpeakerRequestStatus { PENDING APPROVED DENIED CANCELLED }
pendingSpeakerRequests lists the outstanding requests for a call (host/speakers only). mySpeakerRequests lists the calling user's own requests across calls.
# Join without microphone/camera (receive-only)
mutation JoinAsViewer($callId: String!) { joinAsViewer(callId: $callId) { token role } }
# Host promotes a viewer to speaker (reissues LiveKit token with publish perms)
mutation PromoteToSpeaker($callId: String!, $userId: String!) { promoteToSpeaker(callId: $callId, userId: $userId) { token } }
# Host demotes a speaker back to viewer
mutation DemoteToViewer($callId: String!, $userId: String!) { demoteToViewer(callId: $callId, userId: $userId) { token } }
# Viewer requests to speak — creates a pending request the host sees
mutation RequestToSpeak($callId: String!) { requestToSpeak(callId: $callId) { id status } }
mutation ApproveSpeakerRequest($requestId: String!) { approveSpeakerRequest(requestId: $requestId) { status } }
mutation DenySpeakerRequest($requestId: String!) { denySpeakerRequest(requestId: $requestId) { status } }
mutation CancelSpeakerRequest($requestId: String!) { cancelSpeakerRequest(requestId: $requestId) { status } }
query PendingSpeakerRequests($callId: String!) { pendingSpeakerRequests(callId: $callId) { id status user { username } } }
query MySpeakerRequests { mySpeakerRequests { id status callId } }
Real-time subscriptions
All call events are delivered over WebSocket. Subscribe to these on the call screen to update the UI without polling.
# Fires on the receiver's device when a new call is incoming
subscription CallIncoming($userId: String!) { callIncoming(userId: $userId) { id type caller { username } } }
# Fires when the call status changes (RINGING → ACCEPTED, etc.)
subscription CallStatusChanged($callId: String!) { callStatusChanged(callId: $callId) { status } }
# Fires when the call ends — includes final duration
subscription CallEnded($callId: String!) { callEnded(callId: $callId) { duration } }
subscription ParticipantJoined($callId: String!) { participantJoined(callId: $callId) { user { username } } }
subscription ParticipantLeft($callId: String!) { participantLeft(callId: $callId) { user { username } } }
# Fires on the host when a viewer requests to speak
subscription SpeakerRequestReceived($callId: String!) { speakerRequestReceived(callId: $callId) { user { username } } }
# Fires on the viewer when their speaker request is approved or denied
subscription SpeakerRequestResponse($userId: String!) { speakerRequestResponse(userId: $userId) { status } }
VoIP (iOS)
VoIP push tokens allow incoming calls to wake the iOS app even when it's in the background. Register the token after a successful login; unregister it on logout.
mutation RegisterVoIPToken($token: String!) { registerVoIPToken(token: $token) { success } }
mutation UnregisterVoIPToken($token: String!) { unregisterVoIPToken(token: $token) { success } }