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:
PoolseProviderfrom@poolse/reactwith agetTokenthat 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:
- configures the core once;
- renders the call screen from the core's snapshots;
- starts calls (
place,join,startRoom) with the user's session token; - 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:
| Field | Meaning |
|---|---|
phase | ringing_in, ringing_out, connecting, active, reconnecting or ended. |
reason | Set when phase is ended: left, finished, declined_all, busy_all, no_answer, cancelled, connection_lost or failed. |
direction | incoming, outgoing or joined. |
kind | call or room. Known once the server has answered. |
callId, conversationId, conversationName, media | Which call. |
participants | The other people: userId, externalId, speaking, muted, cameraOn, connectionQuality. |
mutedSelf, cameraOn, cameraFacing | This device's state. |
audioOutputs, audioOutputId, audioRoute | Where audio plays: earpiece, speaker, Bluetooth or headset. |
waiting | A 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), andcalls.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 iscall_backif you registered thepoolse_missed_callcategory (see Push setup).
Checklist
- Credentials on the dashboard; both iOS tokens and the Android token registered.
-
configurebefore anything else; snapshot subscribed at the app root. -
call:ring_endedandcall:finishedpassed to the core. - Microphone (and camera) permission asked before the first call.
- Android 14+: full-screen notification permission checked.
-
setActiveConversationkept in sync with the open chat.