Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
0817ecd
Add telecom test screen
Magmusacy Jul 6, 2026
e25fa95
Add simple plugin for VoIP functionality
Magmusacy Jul 6, 2026
d235a42
Integrate with FCM
Magmusacy Jul 6, 2026
1c94332
Add FCM push to server
Magmusacy Jul 7, 2026
e55f021
Register firebase to react-native
Magmusacy Jul 7, 2026
93fb549
Decouple sdk from callkit only
Magmusacy Jul 7, 2026
f5bd827
Keep the current call in a ref to prevent race condition
Magmusacy Jul 7, 2026
1da7c6d
Remove testing files
Magmusacy Jul 7, 2026
f84a6ce
Enable toggling speaker on android + correctly set outgoing call with…
Magmusacy Jul 8, 2026
da6998b
Add vibrations + Camera permissions
Magmusacy Jul 8, 2026
a7b5685
Bump
Magmusacy Jul 9, 2026
76621be
Get rid of unecessary comments
Magmusacy Jul 9, 2026
a64a15e
Ask for camera permissions on initial app start
Magmusacy Jul 9, 2026
da7c9ee
Format + lint
Magmusacy Jul 9, 2026
35b46a6
Update readme
Magmusacy Jul 9, 2026
c659edb
Distinguish APNs and FCM
Magmusacy Jul 9, 2026
a097da1
Bump
Magmusacy Jul 9, 2026
a489710
Fix copilot issues
Magmusacy Jul 9, 2026
f933188
Remove firebase/app
Magmusacy Jul 9, 2026
5d1b14d
Update README
Magmusacy Jul 9, 2026
fe40827
Add websocket signaling
Magmusacy Jul 10, 2026
1f8284f
Add future plans (to delete later)
Magmusacy Jul 10, 2026
bc7e232
Make camera PIP on android look better
Magmusacy Jul 10, 2026
8c45dac
Add better endCall events with reasons
Magmusacy Jul 10, 2026
9bc1769
Fix multiple endCall() invocatoins and log call end reasons
Magmusacy Jul 10, 2026
3f8d9dc
Add voip answer-to-connect handshake for iOS and Android
Magmusacy Jul 13, 2026
1557633
Add voip answer-to-connect handshake for iOS and Android
Magmusacy Jul 13, 2026
8e5d40e
Add native call timeouts
Magmusacy Jul 13, 2026
a9551af
Add connecting state before acctually answering
Magmusacy Jul 14, 2026
e72a38c
Add enableCallIntents plugin option and thread handle through example
Magmusacy Jul 14, 2026
37d94e1
Declare deprecated call intent activity types for Recents redial
Magmusacy Jul 14, 2026
8514e93
Add call hold and resume support
Magmusacy Jul 14, 2026
5768bdf
Add fishjam branding icons and splash screen
Magmusacy Jul 14, 2026
40e21b1
Fix invisible status bar icons on light backgrounds
Magmusacy Jul 14, 2026
f1913fc
Align react-native-webrtc submodule pointer with checked-out commit
Magmusacy Jul 14, 2026
d9f907f
Bump react-native-webrtc submodule for call waiting support
Magmusacy Jul 14, 2026
70fe712
Handle incoming waiting calls
Magmusacy Jul 15, 2026
9e50a61
Better docs
Magmusacy Jul 15, 2026
afe0c88
Add fish avatars and notification/call icons
Magmusacy Jul 16, 2026
8bea671
Merge remote-tracking branch 'origin/FCE-3435' into FCE-3435
Magmusacy Jul 16, 2026
48bc74d
Surface mute to JS
Magmusacy Jul 16, 2026
3401f54
Add plans
Magmusacy Jul 16, 2026
118ba7e
Format + Lint
Magmusacy Jul 16, 2026
72f4a08
bump
Magmusacy Jul 16, 2026
b0ac87f
Remove plan/research docs from repo
Magmusacy Jul 16, 2026
faea39d
Merge branch 'feature/voip-calls' into FCE-3435
Magmusacy Jul 16, 2026
58aa59d
Use new fish avatar icons on server
Magmusacy Jul 16, 2026
734ec5a
Bump
Magmusacy Jul 17, 2026
2b66d5b
Address Copilot review feedback
Magmusacy Jul 17, 2026
51b5b35
Set 20s incoming/outgoing call timeouts in voip-call example
Magmusacy Jul 17, 2026
fd96a90
Callkit muted
Magmusacy Jul 17, 2026
0f2773f
Fix End & Accept race and correct Recents docs
Magmusacy Jul 17, 2026
376ce8f
Revert End & Accept onEnded swallow logic
Magmusacy Jul 17, 2026
9cfe4c1
Add BT device switch
Magmusacy Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
385 changes: 377 additions & 8 deletions examples/mobile-client/voip-call/README.md

Large diffs are not rendered by default.

57 changes: 52 additions & 5 deletions examples/mobile-client/voip-call/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
useSandbox,
} from '@fishjam-cloud/react-native-client';
import { StatusBar } from 'expo-status-bar';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import {
ActivityIndicator,
PermissionsAndroid,
Expand All @@ -15,11 +15,13 @@ import {
} from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';

import type { VoipIncomingPayload } from '@fishjam-cloud/react-native-client';
import type { PropsWithChildren } from 'react';
import { DirectoryScreen } from './src/screens/DirectoryScreen';
import { InCallScreen } from './src/screens/InCallScreen';
import { LoginScreen } from './src/screens/LoginScreen';
import { OutgoingCallScreen } from './src/screens/OutgoingCallScreen';
import { UsersScreen } from './src/screens/UsersScreen';
import { useCallSignaling } from './src/signaling/useCallSignaling';
import { BrandColors } from './src/theme/colors';
import { UserProvider, useUser } from './src/user';
import { VoipProvider, useVoip } from './src/voip';
Expand All @@ -28,8 +30,35 @@ const SERVER_URL =
process.env.EXPO_PUBLIC_VOIP_SERVER_URL ?? 'http://localhost:4400';
const SANDBOX_API_URL = process.env.EXPO_PUBLIC_SANDBOX_API_URL ?? '';

// Thin wrapper that calls the signaling hook.
// Must be inside VoipProvider so useCallSignaling can access useVoip().
function CallSignaling({
username,
sendSignalRef,
}: {
username: string | null;
sendSignalRef: MutableRefObject<
((msg: Record<string, unknown>) => void) | undefined
>;
}) {
useCallSignaling({ serverUrl: SERVER_URL, username, sendSignalRef });
return null;
}

function VoipWrapper({ children }: PropsWithChildren) {
const { username } = useUser();
const sendSignalRef = useRef<
((msg: Record<string, unknown>) => void) | undefined
>(undefined);

const onWaitingCallDeclined = useCallback((payload: VoipIncomingPayload) => {
sendSignalRef.current?.({
type: 'call-rejected',
to: payload.handle,
roomName: payload.roomName,
});
}, []);
Comment thread
Copilot marked this conversation as resolved.

const { getSandboxPeerToken } = useSandbox({
sandboxApiUrl: SANDBOX_API_URL,
});
Expand Down Expand Up @@ -64,13 +93,31 @@ function VoipWrapper({ children }: PropsWithChildren) {
<VoipProvider
getPeerToken={getPeerToken}
requestCall={requestCall}
isVideo={true}>
onWaitingCallDeclined={onWaitingCallDeclined}
isVideo={true}
canStartOutgoingCall={Boolean(username)}>
<DeviceRegistration />
<CallEndedLogger />
<CallSignaling username={username} sendSignalRef={sendSignalRef} />
{children}
</VoipProvider>
);
}

function CallEndedLogger() {
const { lastEndedReason } = useVoip();
const { username } = useUser();

useEffect(() => {
if (!lastEndedReason) return;
console.log(
`On user: ${username}, [VoIP] Call ended — reason: ${lastEndedReason}`,
);
}, [lastEndedReason, username]);

return null;
}

function DeviceRegistration() {
const { username } = useUser();
const { voipToken } = useVoip();
Expand Down Expand Up @@ -136,7 +183,7 @@ function AppScreens() {
return <InCallScreen />;
}

return <DirectoryScreen />;
return <UsersScreen />;
}

const App = () => (
Expand All @@ -145,7 +192,7 @@ const App = () => (
<UserProvider>
<VoipWrapper>
<View style={styles.root}>
<StatusBar style="auto" />
<StatusBar style="dark" />
<AppScreens />
</View>
</VoipWrapper>
Expand Down
21 changes: 21 additions & 0 deletions examples/mobile-client/voip-call/app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"slug": "voip-call",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "voipcall",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
Expand All @@ -19,6 +20,11 @@
"android": {
"package": "io.fishjam.example.voipcall",
"googleServicesFile": "./google-services.json",
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"monochromeImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#F1FAFE"
},
"edgeToEdgeEnabled": true,
"permissions": [
"android.permission.CAMERA",
Expand All @@ -28,6 +34,9 @@
"android.permission.ACCESS_WIFI_STATE"
]
},
"web": {
"favicon": "./assets/images/favicon.png"
},
"plugins": [
[
"@fishjam-cloud/react-native-client",
Expand All @@ -37,8 +46,20 @@
},
"ios": {
"enableVoIPBackgroundMode": true
},
"voip": {
"incomingCallTimeout": 20,
"outgoingCallTimeout": 20
}
}
],
[
"expo-splash-screen",
{
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#F1FAFE"
}
]
]
}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions examples/mobile-client/voip-call/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@fishjam-cloud/react-native-client": "workspace:*",
"@react-native-async-storage/async-storage": "^3.1.1",
"expo": "~54.0.30",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
Expand Down
21 changes: 19 additions & 2 deletions examples/mobile-client/voip-call/app/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { StyleSheet, Text, View } from 'react-native';
import { useEffect, useState } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';

import { BrandColors, TextColors } from '../theme/colors';

type AvatarProps = {
name: string;
/** Server-assigned avatar image; falls back to initials when null/absent or on error. */
avatarUrl?: string | null;
size?: number;
speaking?: boolean;
};

export function Avatar({ name, size = 96, speaking = false }: AvatarProps) {
export function Avatar({ name, avatarUrl, size = 96, speaking = false }: AvatarProps) {
const initial = name.trim()[0]?.toUpperCase() ?? '?';
const [failed, setFailed] = useState(false);
// Reset the error state if the URL changes (e.g. list refresh).
useEffect(() => setFailed(false), [avatarUrl]);
const showImage = Boolean(avatarUrl) && !failed;
return (
<View
style={[
Expand All @@ -21,7 +28,16 @@ export function Avatar({ name, size = 96, speaking = false }: AvatarProps) {
borderWidth: speaking ? 3 : 0,
},
]}>
{/* Initials sit underneath as the fallback while the image loads or if it fails. */}
<Text style={[styles.text, { fontSize: size * 0.4 }]}>{initial}</Text>
{showImage && (
<Image
source={{ uri: avatarUrl! }}
style={[StyleSheet.absoluteFill, { borderRadius: size / 2 }]}
onError={() => setFailed(true)}
accessibilityIgnoresInvertColors
/>
)}
</View>
);
}
Expand All @@ -32,6 +48,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
borderColor: BrandColors.seaBlue80,
overflow: 'hidden',
},
text: {
color: TextColors.white,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type InCallButtonProps = {
onPress: (event: GestureResponderEvent) => void;
iconName: keyof typeof MaterialCommunityIcons.glyphMap;
accessibilityLabel?: string;
disabled?: boolean;
};

export function InCallButton({
Expand All @@ -23,6 +24,7 @@ export function InCallButton({
onPress,
iconName,
accessibilityLabel,
disabled = false,
}: InCallButtonProps) {
const isDisconnect = type === 'disconnect';
const filled = isDisconnect || active;
Expand All @@ -38,24 +40,31 @@ export function InCallButton({
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled}
activeOpacity={0.8}
accessibilityLabel={accessibilityLabel}
style={[styles.button, { backgroundColor }, !filled && styles.outline]}>
<MaterialCommunityIcons name={iconName} size={28} color={iconColor} />
style={[
styles.button,
{ backgroundColor },
!filled && styles.outline,
disabled && styles.disabled,
]}>
<MaterialCommunityIcons name={iconName} size={24} color={iconColor} />
</TouchableOpacity>
);
}

const styles = StyleSheet.create({
button: {
width: 60,
height: 60,
borderRadius: 30,
width: 52,
height: 52,
borderRadius: 26,
justifyContent: 'center',
alignItems: 'center',
},
outline: {
borderWidth: 1,
borderColor: BrandColors.darkBlue80,
},
disabled: { opacity: 0.45 },
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ function streamOf(track: Track | null | undefined) {

type VideoCallViewProps = {
remoteName: string;
remoteAvatarUrl?: string | null;
localName: string;
localAvatarUrl?: string | null;
};

export function VideoCallView({ remoteName, localName }: VideoCallViewProps) {
export function VideoCallView({
remoteName,
remoteAvatarUrl,
localName,
localAvatarUrl,
}: VideoCallViewProps) {
const insets = useSafeAreaInsets();
const { remotePeers } = usePeers();
const { cameraStream } = useCamera();
Expand All @@ -39,21 +46,22 @@ export function VideoCallView({ remoteName, localName }: VideoCallViewProps) {
/>
) : (
<View style={styles.remoteNoVideo}>
<Avatar name={remoteName} size={132} />
<Avatar name={remoteName} avatarUrl={remoteAvatarUrl} size={132} />
</View>
)}

<View style={[styles.pip, { top: insets.top + 12 }]}>
{localStream ? (
<RTCView
mediaStream={localStream}
objectFit="cover"
style={styles.pipVideo}
mirror
zOrder={1}
/>
) : (
<View style={styles.pipNoVideo}>
<Avatar name={localName} size={44} />
<Avatar name={localName} avatarUrl={localAvatarUrl} size={44} />
</View>
)}
</View>
Expand All @@ -75,8 +83,8 @@ const styles = StyleSheet.create({
right: 16,
width: 108,
height: 156,
borderRadius: 16,
overflow: 'hidden',
// Square corners on purpose: RTCView is a SurfaceView on Android and cannot
// be clipped by borderRadius/overflow, so a rounded PiP overshoots there.
backgroundColor: BrandColors.seaBlue60,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.6)',
Expand Down
Loading