Production integration pattern (Fieldr)
Fieldr (~/Projects/fieldr-2.0-mobile) is the reference host app: chat in a bottom tab, job / customer chat rooms opened from other screens, calls and push from any screen. Its structure, file by file:
fieldr-2.0-mobile/├── index.js # VideoSDK register + push options + background handlers├── metro.config.js # local portal: install of the package├── babel.config.js # react-native-worklets/plugin├── patches/@videosdk.live+react-native-webrtc+0.0.22.patch├── android/app/src/main/AndroidManifest.xml # supportsPictureInPicture on MainActivity├── ios/Podfile # setup_permissions + FieldrBroadcast target├── ios/Fieldr/Info.plist # usage strings, RTC keys, background modes├── ios/Fieldr/AppDelegate.swift # FirebaseApp.configure()├── ios/FieldrBroadcast/ # SampleHandler.swift, Info.plist, FieldrBroadcast.entitlements└── src/ ├── navigation/rootNavigation.tsx # ActiveCallScreen route + <ChatSessionHost /> ├── screens/chat/ │ ├── ChatScreen.tsx # Chat tab: ChatProvider + ChatNavigator │ ├── ChatSessionHost.tsx # app-wide session + CallProvider │ ├── chatNavigatorPresence.ts # "is a ChatNavigator mounted?" registry │ └── scoped/ │ ├── scopedChat.ts # URLs, users query params, job chat tags │ ├── useScopedChatConfig.ts # config without ui.styles │ ├── ScopedChatList.tsx # job/customer chat list (exported ConversationListItem) │ └── ScopedChatRoomScreen.tsx # one room, opened from a job └── utils/ ├── GlobalNotificationListener.tsx # skips + forwards RealtimeX pushes ├── pushDevice.ts # logoutChatSession() on logout └── toastConfig.tsx # `notification` toast for the in-app bannerIntegration order: package.json → metro.config.js → babel.config.js → native files → index.js → rootNavigation.tsx + ChatSessionHost.tsx → ChatScreen.tsx → notification listener, logout and toast.
package.json — dependency
{ "dependencies": { "@realtimexsco/live-chat-react-native": "portal:/Users/apple/Projects/realtimex-npm-package-mobile", "@notifee/react-native": "^9.1.8", "@react-native-firebase/app": "^25.1.0", "@react-native-firebase/messaging": "^25.1.0", "react-native-callkeep": "^4.3.16", "react-native-voip-push-notification": "^3.3.3", "@videosdk.live/react-native-sdk": "^0.6.0" }, "scripts": {"postinstall": "patch-package"}}Published apps use a version range ("^1.1.3") instead of portal:.
metro.config.js — local package
Needed only because portal: symlinks the package. See Local package / monorepo for the full file and why each key exists.
src/screens/chat/ChatSessionHost.tsx — app-wide session
Connects the socket (presence, incoming calls) from app start, whichever tab is open. It renders CallProvider only while no ChatNavigator is mounted, because ChatNavigator brings its own — two would each show the incoming-call overlay.
// src/screens/chat/ChatSessionHost.tsx (permission checks trimmed)import { bootstrapChatSession, CallProvider, ChatProvider, connectChatSocket, useCallStore,} from '@realtimexsco/live-chat-react-native';import React, {useEffect} from 'react';import Config from 'react-native-config';import {useSelector} from 'react-redux';import {navigationRef} from '../../navigation/navigationUtils';import {selectUser} from '../auth/redux/selectors';import {focusMountedChatNavigator, useIsChatNavigatorMounted} from './chatNavigatorPresence';import {selectChatAccessToken} from './redux/selectors';import {useScopedChatConfig} from './scoped/useScopedChatConfig'; const CALL_SCREEN_STATUSES = new Set(['creating', 'ringing-out', 'in-call']);const CALLKIT_CALL_STATUSES = new Set(['incoming', 'joining', 'in-call']); const ChatSessionHost = () => { const userData: any = useSelector(selectUser); const chatAccessToken = useSelector(selectChatAccessToken); const chatConfig = useScopedChatConfig(); const chatNavigatorMounted = useIsChatNavigatorMounted(); const uiStatus = useCallStore(state => state.uiStatus); useEffect(() => { if (!chatAccessToken) return; connectChatSocket(chatAccessToken, Config.chatClientId); const timer = setTimeout(() => { void bootstrapChatSession({ name: userData?.name, email: userData?.email, clientId: Config.chatClientId, hostAccessToken: chatAccessToken, }); }, 0); return () => clearTimeout(timer); }, [chatAccessToken, userData?.name, userData?.email]); // A mounted ChatNavigator opens the call screen in its own stack — bring it to the top. useEffect(() => { if (chatNavigatorMounted && CALL_SCREEN_STATUSES.has(uiStatus)) { focusMountedChatNavigator(); } }, [chatNavigatorMounted, uiStatus]); if (!chatConfig) { // A call answered from CallKit on a cold start runs before login is restored. return CALLKIT_CALL_STATUSES.has(uiStatus) ? ( <CallProvider navigationRef={navigationRef as any}>{null}</CallProvider> ) : null; } return ( <ChatProvider config={chatConfig} autoConnect={false} withSafeArea={false} withGestureRoot={false} withToast={false}> {chatNavigatorMounted ? null : ( <CallProvider navigationRef={navigationRef as any}>{null}</CallProvider> )} </ChatProvider> );}; export default ChatSessionHost;chatNavigatorPresence.ts is a tiny useSyncExternalStore registry: each screen that mounts a ChatNavigator calls useRegisterChatNavigator(active, focus), and useIsChatNavigatorMounted() tells the host whether any is mounted.
src/screens/chat/ChatScreen.tsx — the Chat tab
// src/screens/chat/ChatScreen.tsx (permission checks trimmed)import { bootstrapChatSession, ChatNavigator, ChatProvider, connectChatSocket,} from '@realtimexsco/live-chat-react-native';import React, {useEffect, useMemo} from 'react';import {StyleSheet, View} from 'react-native';import Config from 'react-native-config';import {useSelector} from 'react-redux';import {useTheme} from '../../utils/ThemeContext';import {popToRootRoute} from '../../navigation/navigationUtils';import {selectUser} from '../auth/redux/selectors';import {useRegisterChatNavigator} from './chatNavigatorPresence';import {selectChatAccessToken} from './redux/selectors';import {CHAT_USERS_QUERY_PARAMS} from './scoped/scopedChat'; const ChatScreen = () => { const userData: any = useSelector(selectUser); const chatAccessToken = useSelector(selectChatAccessToken); const {theme} = useTheme(); const showChatFab = true; // Fieldr: module permission + entitlement checks const chatConfig = useMemo(() => { if (!chatAccessToken) return undefined; connectChatSocket(chatAccessToken, Config.chatClientId); return { apiUrl: 'https://realtimex.softwareco.com/api/v1', socketUrl: 'https://realtimex.softwareco.com', clientId: Config.chatClientId, accessToken: chatAccessToken, callUri: 'https://realtimex.softwareco.com/api/v2', queryParamsByApi: {users: CHAT_USERS_QUERY_PARAMS}, fonts: {Regular: 'MyApp-Regular', SemiBold: 'MyApp-SemiBold', Bold: 'MyApp-Bold'}, theme: {colorMode: 'light' as const, accent: theme.primary}, ui: { styles: { conversationList: { // hide the FAB for users without write access fabWrap: showChatFab ? {right: 24} : {display: 'none', height: 0, width: 0, opacity: 0, overflow: 'hidden'}, }, }, conversationList: { title: 'Messages', fabBottomInset: 70, // must match the tab bar height (tab bar overlays the screen) showNewChatFab: true, }, chatRoom: {header: {showVideoCall: true, showVoiceCall: true, showSearch: true}}, }, }; }, [chatAccessToken, showChatFab, theme.primary]); useEffect(() => { if (!chatAccessToken) return; const timer = setTimeout(() => { void bootstrapChatSession({ name: userData?.name, email: userData?.email, clientId: Config.chatClientId, hostAccessToken: chatAccessToken, }); }, 0); return () => clearTimeout(timer); }, [chatAccessToken, userData?.name, userData?.email]); useRegisterChatNavigator(!!chatConfig, () => popToRootRoute('BottomTab', {screen: 'ChatScreen'}), ); if (!chatConfig) return null; return ( <View style={styles.root}> <ChatProvider config={chatConfig} autoConnect={false} withSafeArea={false} withToast={false}> <ChatNavigator withContainer={false} /> </ChatProvider> </View> );}; export default ChatScreen; const styles = StyleSheet.create({root: {flex: 1}});Why each setting:
src/screens/chat/scoped/useScopedChatConfig.ts — same config without styles
The package keeps one global config and merges styles.conversationList shallowly. A style set by a job chat room would stick to the main Chat tab, so the scoped config omits ui.styles:
// src/screens/chat/scoped/useScopedChatConfig.tsimport {useMemo} from 'react';import Config from 'react-native-config';import {useSelector} from 'react-redux';import {useTheme} from '../../../utils/ThemeContext';import {selectChatAccessToken} from '../redux/selectors';import {CHAT_API_URL, CHAT_SOCKET_URL, CHAT_USERS_QUERY_PARAMS} from './scopedChat'; export const useScopedChatConfig = () => { const chatAccessToken = useSelector(selectChatAccessToken); const {theme}: any = useTheme(); return useMemo(() => { if (!chatAccessToken) return undefined; return { apiUrl: CHAT_API_URL, socketUrl: CHAT_SOCKET_URL, clientId: Config.chatClientId, accessToken: chatAccessToken, queryParamsByApi: {users: CHAT_USERS_QUERY_PARAMS}, fonts: {Regular: 'MyApp-Regular', SemiBold: 'MyApp-SemiBold', Bold: 'MyApp-Bold'}, theme: {colorMode: 'light' as const, accent: theme.primary}, ui: {chatRoom: {header: {showVideoCall: true, showVoiceCall: true, showSearch: true}}}, }; }, [chatAccessToken, theme.primary]);};scopedChat.ts holds CHAT_API_URL, CHAT_SOCKET_URL and CHAT_USERS_QUERY_PARAMS = {'metadata.workspaceIds': '…'} (extra params for GET /user/list, so new-chat and add-member pickers only show that workspace's users).
src/screens/chat/scoped/ScopedChatRoomScreen.tsx — one room from another screen
Opens the package stack directly on a room. It waits for useChatSession().isSessionReady and loggedUserId before mounting, because the room loads messages on mount:
// opening itnavigation.navigate('ScopedChatRoomScreen', { screen: 'ChatRoomScreen', params: roomParams(row, meId), // ChatRoomParams}); // inside ScopedChatRoomScreen.tsxconst {isSessionReady, loggedUserId} = useChatSession();if (!chatConfig || !isSessionReady || !loggedUserId) return <Spinner />; return ( <ChatProvider config={chatConfig} autoConnect={false} withSafeArea={false} withToast={false}> <ChatNavigator withContainer={false} /> </ChatProvider>);src/utils/GlobalNotificationListener.tsx — host push listener
Fieldr has its own FCM / Notifee listeners for non-chat pushes. They skip RealtimeX pushes (the package shows and routes them) and forward the launch notification, because FCM and Notifee hand it to the first caller only:
import { extractPushData, handleRealtimexNotifeeEvent, isRealtimexPush, openRealtimexNotification,} from '@realtimexsco/live-chat-react-native'; const isChatPush = (message: any) => isRealtimexPush(extractPushData(message)); const forwardInitialChatFcm = (message: any) => { void openRealtimexNotification(extractPushData(message));}; const forwardInitialChatNotifee = (notification: any, pressAction?: any) => { const isAction = !!pressAction?.id && pressAction.id !== 'default'; void handleRealtimexNotifeeEvent({ type: isAction ? EventType.ACTION_PRESS : EventType.PRESS, detail: {notification, pressAction}, });}; // in onMessage / onNotificationOpenedApp: if (isChatPush(message)) return;// in getInitialNotification(): if (isChatPush(message)) forwardInitialChatFcm(message);src/utils/pushDevice.ts — logout
logoutChatSession() deletes the chat push tokens server-side, so call it while the token is still valid, with a timeout so logout never hangs:
import {logoutChatSession} from '@realtimexsco/live-chat-react-native'; const withTimeout = <T,>(promise: Promise<T>, ms: number) => Promise.race([promise, new Promise<void>(resolve => setTimeout(resolve, ms))]); export const unregisterPushDeviceBestEffort = async () => { // …remove the host's own device token… try { await withTimeout(logoutChatSession(), 2000); } catch (error) { console.log('logoutChatSession error', error); }};