Ringing phones end to end

This guide builds WhatsApp-style calling into a React Native app with @poolse/native. The phone rings with the app closed, you can answer from the lock screen, and calls keep going in the background. It follows Loop, poolse's reference app.

Before you start:

  • Push works: complete Push setup first. A closed app can only ring through a push.
  • Chat works: PoolseProvider from @poolse/react with a getToken that returns the user's poolse JWT. See the Integration guide.
  • Read Calls & rooms for the lifecycle.

How it fits together

@poolse/native contains a native call core, in Swift on iOS and Kotlin on Android. The core:

  • takes in rings from PushKit and FCM before React Native has started;
  • shows CallKit or Android's full-screen call screen;
  • answers and declines with the ring grant from the push;
  • connects the media and keeps the call alive in the background.

Your JavaScript:

  1. configures the core once;
  2. renders the call screen from the core's snapshots;
  3. starts calls (place, join, startRoom) with the user's session token;
  4. passes two socket events on to the core.

1. Configure and subscribe

Mount this once, above your signed-in screens, so a call answered on a cold start has a screen as soon as the app opens.

import * as calls from '@poolse/native';
import { useEffect, useState } from 'react';

export function useCallSnapshot() {
  const [snapshot, setSnapshot] = useState<calls.Snapshot | null>(null);

  useEffect(() => {
    let unsubscribe = () => {};
    (async () => {
      await calls.configure('https://api.poolse.dev', {
        ringback: 'ringback',
        apnsEnvironment: 'production',
      });
      // Subscribe first, then read, so no transition is missed.
      unsubscribe = calls.onCallState(setSnapshot);
      setSnapshot(calls.getCallState());
    })();
    return () => unsubscribe();
  }, []);

  return snapshot; // null when there is no call
}

Nothing else in @poolse/native may be called before configure resolves.

2. Render the call screen

Draw your call UI from the snapshot. Its key fields:

FieldMeaning
phaseringing_in, ringing_out, connecting, active, reconnecting or ended.
reasonSet when phase is ended: left, finished, declined_all, busy_all, no_answer, cancelled, connection_lost or failed.
directionincoming, outgoing or joined.
kindcall or room. Known once the server has answered.
callId, conversationId, conversationName, mediaWhich call.
participantsThe other people: userId, externalId, speaking, muted, cameraOn, connectionQuality.
mutedSelf, cameraOn, cameraFacingThis device's state.
audioOutputs, audioOutputId, audioRouteWhere audio plays: earpiece, speaker, Bluetooth or headset.
waitingA second call ringing during this one. See Call waiting.

Controls:

calls.answer();               // ringing_in
calls.decline();              // ringing_in
calls.hangUp();               // leave
calls.setMuted(!snapshot.mutedSelf);
calls.setCamera(!snapshot.cameraOn);
calls.switchCamera();
calls.setAudioOutput(snapshot.audioOutputs[1].id);
calls.setSpeaker(true);

Video:

<calls.CallVideoView local mirror fit="cover" style={styles.self} />
<calls.CallVideoView participantId={p.userId} fit="cover" style={styles.tile} />

Show a participant's video only when cameraOn is true. Otherwise show their avatar.

Answering on the lock screen goes through CallKit or the Android call notification, with no JS involved. Your screen appears once the app is in front, with phase: 'connecting' or 'active'.

3. Place a call

async function placeCall(conversationId: string, media: 'audio' | 'video') {
  const sessionToken = await getToken(); // the same JWT you give PoolseProvider
  await calls.place(conversationId, { media, sessionToken });
}

The core asks poolse to place the call. poolse rings every other member of the conversation. The snapshot goes to ringing_out, then connecting and active when someone answers. If something is already live on the conversation, place joins it instead.

On Android, the core starts a microphone service the moment a call begins, so get the microphone permission first. On a phone that answered from the lock screen, a video call starts audio-only until the camera is allowed. Loop asks once the call is on screen, then calls calls.setCamera(true).

4. Pass socket events to the core

A ringing phone in the foreground also needs to hear, over the socket, when its ring is over elsewhere or the call has ended. iOS gets no push for either. Listen on the user's channel and pass both events on. Both calls are safe to repeat.

import { useMe, usePoolse } from '@poolse/react';

const { me } = useMe();
const poolse = usePoolse();

useEffect(() => {
  if (!me?.id) return;
  const channel = poolse.realtime.user(me.id);
  const offRing = channel.onRingEnded((e) => calls.onRingEnded(e.call_id, e.reason));
  const offFinished = channel.onCallFinished((e) => calls.onCallFinished(e.call_id, e.reason));
  return () => {
    offRing();
    offFinished();
  };
}, [me?.id, poolse]);

onRingEnded and onCallFinished are in @poolse/sdk 3.1. See the user channel for the payloads.

5. Join a call that is already live

Group calls are joinable by any member while they run. Show a banner from useLiveCall and join with the call's id:

import { useLiveCall } from '@poolse/react';
import { LiveCallBanner } from '@poolse/react-native';

const { call } = useLiveCall(conversationId);

{call && (
  <LiveCallBanner
    call={call}
    inCall={snapshot?.callId === call.id}
    onJoin={async (c) => calls.join(c.id, conversationId, await getToken())}
  />
)}

<ConversationView> takes the same thing as props: onJoinCall, currentCallId, and onCallBack (the call-back button on a call-log row).

6. Add people

Once someone has answered, ring more of the conversation's members into the call:

const rung = await calls.ringIntoCall({ externalIds: ['acme-carol'] });
// → poolse user ids actually rung

People already in the call, still ringing, or not in the conversation are skipped. Before anyone has answered, this fails ("Nobody has answered this call yet").

Call waiting

A call that arrives during another one does not get refused. It waits: snapshot.waiting carries { callId, conversationId, conversationName, callerName, media }, and the native side shows CallKit's call-waiting screen or an Android heads-up notification.

if (snapshot.waiting) {
  calls.answerWaiting(snapshot.waiting.callId);  // end this call, take that one
  calls.declineWaiting(snapshot.waiting.callId); // the caller hears "busy"
}

Pass the callId, so a late tap does not act on a different call. A declined waiting call is recorded as busy. A third call while one is waiting is refused as busy.

Rooms on phones

A room rings nobody. Members drop in and out while the chat goes on.

const { callId, created } = await calls.startRoom(conversationId, {
  media: 'video',
  sessionToken: await getToken(),
});

It runs on the same core as a call: CallKit on iOS, the call service on Android. snapshot.kind is 'room', so you can show a bar over the chat instead of a full call screen. hangUp() leaves. The room ends on its own once it is empty.

Picture-in-picture

During a video call, the call can keep playing in a small floating window while the user is in another app:

calls.setPictureInPictureEnabled(true);
calls.onPictureInPicture((active) => setCompact(active));

Mark the video view that should go into the window with <CallVideoView participantId={…} pictureInPicture />. On Android this needs the plugin's pictureInPicture option, which is on by default. Check calls.pictureInPictureSupported() first.

Missed calls

When a call rings out, poolse sends a missed-call push, and the conversation gets a call-log message. To handle taps:

  • Android: calls.onCallAction(cb), and calls.takeCallAction() for the tap that launched the app. You get { type: 'open' | 'call_back', conversationId, callId, media }.
  • iOS: an expo-notifications response with data.type === 'call_missed'. Its action is call_back if you registered the poolse_missed_call category (see Push setup).

Checklist

  • Credentials on the dashboard; both iOS tokens and the Android token registered.
  • configure before anything else; snapshot subscribed at the app root.
  • call:ring_ended and call:finished passed to the core.
  • Microphone (and camera) permission asked before the first call.
  • Android 14+: full-screen notification permission checked.
  • setActiveConversation kept in sync with the open chat.
Ringing phones end to end · Guides · poolse docs