Saltar al contenido principal

Llamadas de voz y video — Referencia técnica

← Volver a Llamadas de voz y video

Dónde vive esto

Backend

Frontend

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: CallIncomingDocument se importa en ChatView.tsx pero nunca se pasa a useSubscription — 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 de apps/frontend-nextjs/src. Conectado en iOS (CallStore.swift)
  • joinAsViewer / promoteToSpeaker / demoteToViewer / requestToSpeak / approveSpeakerRequest / denySpeakerRequest / cancelSpeakerRequest / pendingSpeakerRequests — cero referencias en cualquier parte de apps/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), updateCallStatus y mySpeakerRequests existen en el esquema y los resolvers pero tienen cero referencias en apps/frontend-nextjs/src ni en apps/ios — todavía no los consume ningún cliente
  • El push VoIP (voip-push.service.js, emparejado con VoIPPushManager.swift en 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

FieldDescription
conversationIdConversación asociada a la llamada
callerId / receiverIdIniciador y destinatario
typeVOICE o VIDEO
statusEstado actual de la llamada
startedAt / endedAt / durationInformación de tiempo
participantsLista de participantes de la llamada
activeParticipantCountParticipantes 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 } }