Llamadas de voz y video — Referencia técnica
← Volver a Llamadas de voz y video
Dónde vive esto
Backend
apps/backend/graphql/resolvers/call.resolver.js— resolvers para el historial de llamadas, start/answer/decline/join/leave/end, modo espectador/orador y las suscripciones de llamadasapps/backend/graphql/types/call.type.js— definiciones de tipos y enums de Call/CallParticipantapps/backend/managers/call-managers/call.manager.js— lógica de negocio central del ciclo de vida de la llamada (start, answer, decline, join, leave, end)apps/backend/managers/call-managers/speaker-request.manager.js— lógica de solicitud/aprobación/rechazo/cancelación de espectador a orador para llamadas grupalesapps/backend/data-access-services/call/call.access-service.js— persistencia del documento Callapps/backend/data-access-services/call/call-participant.access-service.js— persistencia de CallParticipant y seguimiento de rol/estadoapps/backend/data-access-services/call/speaker-request.access-service.js— persistencia de SpeakerRequest (pendiente/aprobado/rechazado/cancelado)apps/backend/services/livekit.service.js— emite tokens/salas de LiveKit para las llamadasapps/backend/services/livekit-monitor.service.js— monitorea las salas/participantes activos de LiveKitapps/backend/services/voip-push.service.js— envía notificaciones push VoIP para despertar la app de iOS ante llamadas entrantes
Frontend
apps/frontend-nextjs/src/components/chat/ChatView.tsx— inicia llamadas (StartCall) y escucha llamadas entrantes, renderiza VoiceCallModalapps/frontend-nextjs/src/components/chat/VoiceCallModal.tsx— interfaz durante la llamada — controles de silencio/altavoz, temporizador de llamada, se suscribe a eventos de fin de llamada/participantes
Checklist de implementación técnica
Las casillas a continuación (y en la página de nivel de idea) registran específicamente el estado de backend + frontend-nextjs (web), de forma consistente con cómo se puntúa cada otra función en esta documentación. El cableado en iOS se documenta por separado en cada línea como referencia, ya que gran parte de esta función es genuinamente más completa en iOS que en web — pero el estado de iOS no marca una casilla de web.
-
startCall— conectado de punta a punta en web (ChatView.tsx); también conectado en iOS (CallStore+StartCall.swift) -
answerCall/declineCall— la app web no tiene interfaz para llamadas entrantes:CallIncomingDocumentse importa enChatView.tsxpero nunca se pasa auseSubscription— es una importación muerta, y ninguna de las dos mutaciones tiene un consumidor en el frontend. Totalmente implementado en iOS mediante CallKit (CallKitManager.swift,IncomingCallView.swift) -
endCall(silenciar es un control solo de cliente) — conectado en web (VoiceCallModal.tsx:toggleMute,handleEndCall); también conectado en iOS (CallControlButtons.swift) -
myCallHistory— cero referencias en cualquier parte deapps/frontend-nextjs/src. Conectado en iOS (CallStore.swift) -
joinAsViewer/promoteToSpeaker/demoteToViewer/requestToSpeak/approveSpeakerRequest/denySpeakerRequest/cancelSpeakerRequest/pendingSpeakerRequests— cero referencias en cualquier parte deapps/frontend-nextjs/src— no hay interfaz de espectador/orador para llamadas grupales en web. Totalmente implementado en iOS (SpeakerRequestBanner.swift,SpeakerRequestsSheet.swift,CallStore+SpeakerManagement.swift) call(query de una sola llamada),updateCallStatusymySpeakerRequestsexisten en el esquema y los resolvers pero tienen cero referencias enapps/frontend-nextjs/srcni enapps/ios— todavía no los consume ningún cliente- El push VoIP (
voip-push.service.js, emparejado conVoIPPushManager.swiften iOS) es inherentemente exclusivo de iOS — no hay equivalente en navegador, por lo que no se registra como un elemento de la checklist web
Tipos
enum CallType { VOICE VIDEO }
enum CallStatus { INITIATING RINGING ACCEPTED DECLINED MISSED ENDED FAILED }
Modelo Call
| Field | Description |
|---|---|
conversationId | Conversación asociada a la llamada |
callerId / receiverId | Iniciador y destinatario |
type | VOICE o VIDEO |
status | Estado actual de la llamada |
startedAt / endedAt / duration | Información de tiempo |
participants | Lista de participantes de la llamada |
activeParticipantCount | Participantes activos actualmente |
Participantes
enum CallParticipantRole { CALLER RECEIVER PARTICIPANT }
enum CallParticipantStatus { INVITED RINGING JOINED LEFT DECLINED MISSED }
Cada participante registra joinedAt, leftAt y su duration individual.
Queries
myCallHistory devuelve una lista en orden cronológico inverso de todas las llamadas en las que participó el usuario, con type, status y duration. Filtra por tipo para mostrar "Llamadas perdidas" o "Videollamadas".
activeCall verifica si hay una llamada activa en una conversación determinada en este momento. Úsalo al entrar a una conversación para decidir si mostrar un banner de "Unirse a la llamada activa".
call obtiene una sola llamada por ID (se requiere ser llamante/destinatario o tener una fila de CallParticipant para poder verla). Todavía no se llama desde ningún cliente.
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 }
}
Iniciar y gestionar llamadas
startCall inicia una llamada y devuelve un token, wsUrl y roomName de LiveKit. El cliente del llamante se conecta a LiveKit usando estas credenciales. Al mismo tiempo, el backend envía un push VoIP al destinatario (iOS) o una notificación WebSocket (web/Android) a través de callIncoming.
answerCall es llamado por el destinatario cuando toca "Aceptar". Devuelve su propio token de LiveKit.
declineCall rechaza una llamada entrante. El llamante recibe un evento callStatusChanged con estado DECLINED.
joinCall permite que un tercer participante se una a una llamada grupal en curso. Devuelve un token de LiveKit para el nuevo participante.
leaveCall saca al llamante de la llamada activa sin finalizarla (los demás participantes permanecen). endCall termina la llamada para todos los participantes y registra la duration final.
# 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 } }
Modo espectador / orador
Las llamadas grupales admiten espectadores que solo observan sin hablar. Un espectador puede solicitar convertirse en orador; el anfitrión aprueba o rechaza la solicitud.
joinAsViewer se une a la sala de LiveKit en un rol de solo recepción. promoteToSpeaker otorga al espectador un rol de orador — el backend reemite su token de LiveKit con permisos de publicación. demoteToViewer revoca los permisos de orador y devuelve al usuario al modo de solo observación.
requestToSpeak crea una solicitud pendiente visible para el anfitrión. approveSpeakerRequest acepta la solicitud y promueve al usuario. denySpeakerRequest la rechaza. cancelSpeakerRequest permite que quien solicitó retire su propia solicitud pendiente. Se aplica un máximo de 30 oradores por llamada.
enum SpeakerRequestStatus { PENDING APPROVED DENIED CANCELLED }
pendingSpeakerRequests lista las solicitudes pendientes de una llamada (solo anfitrión/oradores). mySpeakerRequests lista las propias solicitudes del usuario que hace la llamada en todas las llamadas.
# 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 } }
Suscripciones en tiempo real
Todos los eventos de llamada se entregan por WebSocket. Suscríbete a estos en la pantalla de llamada para actualizar la interfaz sin hacer 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)
Los tokens push de VoIP permiten que las llamadas entrantes despierten la app de iOS incluso cuando está en segundo plano. Registra el token después de un inicio de sesión exitoso; anúlalo al cerrar sesión.
mutation RegisterVoIPToken($token: String!) { registerVoIPToken(token: $token) { success } }
mutation UnregisterVoIPToken($token: String!) { unregisterVoIPToken(token: $token) { success } }