UNPKG

mediasfu-reactnative-expo

Version:

mediasfu-reactnative-expo – Expo-managed React Native WebRTC SDK for video conferencing, webinars, live streaming, broadcast, screen sharing, whiteboard, chat, recording, live subtitles, translation, and AI agent rooms on iOS, Android, and web. Prebuilt r

895 lines (891 loc) 34.2 kB
import React, { useState, useEffect, useRef } from "react"; import { View, Text, TextInput, Pressable, Image, StyleSheet, ScrollView, KeyboardAvoidingView, Platform, } from "react-native"; import Orientation from "react-native-orientation-locker"; import AsyncStorage from '@react-native-async-storage/async-storage'; import RNPickerSelect from "react-native-picker-select"; import { checkLimitsAndMakeRequest } from "../../methods/utils/checkLimitsAndMakeRequest"; import { createRoomOnMediaSFU } from "../../methods/utils/createRoomOnMediaSFU"; import { joinRoomOnMediaSFU } from "../../methods/utils/joinRoomOnMediaSFU"; import { validateAlphanumeric } from "../../methods/utils/validateAlphanumeric"; /** * PreJoinPage - Room creation/join interface with media preview * * PreJoinPage is a React Native component that provides the entry point for creating * new meeting rooms or joining existing ones. It offers two modes: * 1. **UI Mode**: Full interface with room configuration options * 2. **Headless Mode**: Programmatic room creation/join without UI * * **Key Features:** * - Create new rooms with custom settings (duration, capacity, event type) * - Join existing rooms by event ID * - Media device preview (camera/microphone) * - Recording configuration options * - Waiting room and secure code settings * - Local (Community Edition) and cloud (MediaSFU) server support * - Headless/programmatic mode for automated workflows * - Input validation and error feedback * - Persistent storage of preferences * * **Room Configuration Options:** * - Event type (chat, broadcast, webinar, conference) * - Duration (up to 24 hours) * - Participant capacity * - Recording parameters (videoParticipants, videoOptions, etc.) * - Waiting room enable/disable * - Secure access codes * * **UI Customization:** * The PreJoinPage layout and styling are fixed but can be customized by creating * a custom pre-join component and using it instead of this default one. * * @component * @param {PreJoinPageOptions} props - Configuration options * * @returns {JSX.Element} Rendered pre-join page or null (headless mode) * * @example * ```tsx * // Basic usage with UI * import React, { useState } from 'react'; * import { PreJoinPage } from 'mediasfu-reactnative-expo'; * import { connectSocket } from './sockets/SocketManager'; * * function App() { * const [socket, setSocket] = useState(null); * const [validated, setValidated] = useState(false); * * const parameters = { * imgSrc: 'https://example.com/logo.png', * showAlert: ({ message, type }) => alert(message), * updateIsLoadingModalVisible: setLoading, * connectSocket: connectSocket, * updateSocket: setSocket, * updateValidated: setValidated, * updateApiUserName: setApiUserName, * updateApiToken: setApiToken, * updateLink: setLink, * updateRoomName: setRoomName, * updateMember: setMember, * }; * * const credentials = { * apiUserName: 'your-api-username', * apiKey: 'your-api-key', * }; * * if (validated) { * return <MeetingRoom socket={socket} />; * } * * return ( * <PreJoinPage * parameters={parameters} * credentials={credentials} * connectMediaSFU={true} * /> * ); * } * ``` * * @example * ```tsx * // Headless mode - programmatic room creation * const headlessOptions = { * action: 'create', * capacity: 50, * duration: 60, // minutes * eventType: 'webinar', * userName: 'Host Name', * recordingParams: { * recordingVideoParticipantsFullRoomSupport: true, * recordingAllParticipantsSupport: false, * }, * }; * * return ( * <PreJoinPage * parameters={parameters} * credentials={credentials} * returnUI={false} * noUIPreJoinOptions={headlessOptions} * /> * ); * ``` * * @example * ```tsx * // Community Edition (local server) * return ( * <PreJoinPage * parameters={parameters} * localLink="http://localhost:3000" * connectMediaSFU={false} * /> * ); * * * export default App; * ``` */ const PreJoinPage = ({ localLink = "", connectMediaSFU = true, parameters, credentials, returnUI = false, noUIPreJoinOptions, autoProceedPreJoin, createMediaSFURoom = createRoomOnMediaSFU, joinMediaSFURoom = joinRoomOnMediaSFU, }) => { // State variables const [isCreateMode, setIsCreateMode] = useState(false); const [name, setName] = useState(""); const [duration, setDuration] = useState(""); const [eventType, setEventType] = useState(""); const [capacity, setCapacity] = useState(""); const [eventID, setEventID] = useState(""); const [error, setError] = useState(""); const pending = useRef(false); const lastAutoProceedKey = useRef(null); const localConnected = useRef(false); const localData = useRef(undefined); const initSocket = useRef(undefined); const shouldAutoProceed = autoProceedPreJoin ?? !returnUI; const autoProceedKey = !shouldAutoProceed || !noUIPreJoinOptions ? null : noUIPreJoinOptions.action === "create" ? `create:${noUIPreJoinOptions.userName}:${noUIPreJoinOptions.eventType}:${noUIPreJoinOptions.duration}:${noUIPreJoinOptions.capacity}` : `join:${noUIPreJoinOptions.userName}:${noUIPreJoinOptions.meetingID}`; const { showAlert, updateIsLoadingModalVisible, connectLocalSocket, updateSocket, updateValidated, updateApiUserName, updateApiToken, updateLink, updateRoomName, updateMember, } = parameters; const validateDisplayName = async (displayName) => { const isValidDisplayName = displayName.length >= 2 && displayName.length <= 10 && (await validateAlphanumeric({ str: displayName })); if (isValidDisplayName) { return true; } const message = "Display Name must be alphanumeric and between 2 and 10 characters."; pending.current = false; if (returnUI) { setError(message); return false; } throw new Error(message); }; const handleCreateRoom = async (providedPayload) => { if (pending.current) { return; } pending.current = true; setError(""); let payload = {}; if (providedPayload) { payload = providedPayload; } else if (returnUI) { if (!name || !duration || !eventType || !capacity) { setError("Please fill all the fields."); pending.current = false; return; } payload = { action: "create", duration: parseInt(duration, 10), capacity: parseInt(capacity, 10), eventType: eventType, userName: name, recordOnly: false, }; } else { if (noUIPreJoinOptions && "action" in noUIPreJoinOptions && noUIPreJoinOptions.action === "create") { payload = noUIPreJoinOptions; } else { pending.current = false; throw new Error("Invalid options provided for creating a room without UI."); } } if (!(await validateDisplayName(payload.userName))) { return; } updateIsLoadingModalVisible(true); if (localLink.length > 0) { const secureCode = Math.random().toString(30).substring(2, 14) + Math.random().toString(30).substring(2, 14); let eventID = new Date().getTime().toString(30) + new Date().getUTCMilliseconds() + Math.floor(10 + Math.random() * 99).toString(); eventID = "m" + eventID; const eventRoomParams = localData.current?.meetingRoomParams_; eventRoomParams.type = eventType; const createData = { eventID: eventID, duration: payload.duration, capacity: payload.capacity, userName: payload.userName, scheduledDate: new Date(), secureCode: secureCode, waitRoom: false, recordingParams: localData.current?.recordingParams_, eventRoomParams: eventRoomParams, videoPreference: null, audioPreference: null, audioOutputPreference: null, mediasfuURL: "", }; // socket in main window is required and for no local room, no use of initSocket // for local room, initSocket becomes the local socket, and localSocket is the connection to MediaSFU (if connectMediaSFU is true) // else localSocket is the same as initSocket if (connectMediaSFU && initSocket.current && localData.current && localData.current.apiUserName && localData.current.apiKey) { // Store references to prevent race conditions const apiUserName = localData.current.apiUserName; const apiKey = localData.current.apiKey; // Build a unique identifier for this create request const roomIdentifier = `local_create_${payload.userName}_${payload.duration}_${payload.capacity}`; const pendingKey = `prejoin_pending_${roomIdentifier}`; const PENDING_TIMEOUT = 30 * 1000; // 30 seconds // Check pending status to prevent duplicate requests try { const pendingRequest = await AsyncStorage.getItem(pendingKey); if (pendingRequest) { const pendingData = JSON.parse(pendingRequest); const timeSincePending = Date.now() - (pendingData?.timestamp ?? 0); if (timeSincePending < PENDING_TIMEOUT) { pending.current = false; updateIsLoadingModalVisible(false); setError('Room creation already in progress'); return; } else { // Stale lock, clear it await AsyncStorage.removeItem(pendingKey).catch(() => { }); } } } catch { // Ignore AsyncStorage read/JSON errors } // Mark request as pending try { await AsyncStorage.setItem(pendingKey, JSON.stringify({ timestamp: Date.now(), payload: { action: 'create', userName: payload.userName, duration: payload.duration, capacity: payload.capacity, }, })); // Auto-clear the pending flag after timeout to avoid stale locks setTimeout(() => { AsyncStorage.removeItem(pendingKey).catch(() => { }); }, PENDING_TIMEOUT); } catch { // Ignore AsyncStorage write errors } payload.recordOnly = true; // allow production to mediasfu only; no consumption try { const response = await roomCreator({ payload, apiUserName: apiUserName, apiKey: apiKey, validate: false, }); // Clear pending status on completion try { await AsyncStorage.removeItem(pendingKey); } catch { /* ignore */ } if (response && response.success && response.data && "roomName" in response.data) { createData.eventID = response.data.roomName; createData.secureCode = response.data.secureCode || ""; createData.mediasfuURL = response.data.publicURL; await createLocalRoom({ createData: createData, link: response.data.link, }); } else { pending.current = false; updateIsLoadingModalVisible(false); setError(`Unable to create room on MediaSFU.`); } } catch (error) { // Clear pending status on error try { await AsyncStorage.removeItem(pendingKey); } catch { /* ignore */ } pending.current = false; updateIsLoadingModalVisible(false); setError(`Unable to create room on MediaSFU. ${error}`); } } else { try { updateSocket(initSocket.current); await createLocalRoom({ createData: createData }); pending.current = false; } catch (error) { pending.current = false; updateIsLoadingModalVisible(false); setError(`Unable to create room. ${error}`); } } } else { // Build a unique identifier for this create request (non-local) const roomIdentifier = `mediasfu_create_${payload.userName}_${payload.duration}_${payload.capacity}`; const pendingKey = `prejoin_pending_${roomIdentifier}`; const PENDING_TIMEOUT = 30 * 1000; // 30 seconds // Check pending status to prevent duplicate requests try { const pendingRequest = await AsyncStorage.getItem(pendingKey); if (pendingRequest) { const pendingData = JSON.parse(pendingRequest); const timeSincePending = Date.now() - (pendingData?.timestamp ?? 0); if (timeSincePending < PENDING_TIMEOUT) { pending.current = false; updateIsLoadingModalVisible(false); setError('Room creation already in progress'); return; } else { // Stale lock, clear it await AsyncStorage.removeItem(pendingKey).catch(() => { }); } } } catch { // Ignore AsyncStorage read/JSON errors } // Mark request as pending try { await AsyncStorage.setItem(pendingKey, JSON.stringify({ timestamp: Date.now(), payload: { action: 'create', userName: payload.userName, duration: payload.duration, capacity: payload.capacity, }, })); // Auto-clear the pending flag after timeout to avoid stale locks setTimeout(() => { AsyncStorage.removeItem(pendingKey).catch(() => { }); }, PENDING_TIMEOUT); } catch { // Ignore AsyncStorage write errors } try { await roomCreator({ payload, apiUserName: credentials.apiUserName, apiKey: credentials.apiKey, validate: true, }); // Clear pending status on completion try { await AsyncStorage.removeItem(pendingKey); } catch { /* ignore */ } pending.current = false; } catch (error) { // Clear pending status on error try { await AsyncStorage.removeItem(pendingKey); } catch { /* ignore */ } pending.current = false; updateIsLoadingModalVisible(false); setError(`Unable to create room. ${error}`); } } }; const handleJoinRoom = async (providedPayload) => { if (pending.current) { return; } pending.current = true; setError(""); let payload = {}; if (providedPayload) { payload = providedPayload; } else if (returnUI) { if (!name || !eventID) { setError("Please fill all the fields."); pending.current = false; return; } payload = { action: "join", meetingID: eventID, userName: name, }; } else { if (noUIPreJoinOptions && "action" in noUIPreJoinOptions && noUIPreJoinOptions.action === "join") { payload = noUIPreJoinOptions; } else { pending.current = false; throw new Error("Invalid options provided for joining a room without UI."); } } if (!(await validateDisplayName(payload.userName))) { return; } if (localLink.length > 0 && !localLink.includes("mediasfu.com")) { const joinData = { eventID: payload.meetingID, userName: payload.userName, secureCode: "", videoPreference: null, audioPreference: null, audioOutputPreference: null, }; await joinLocalRoom({ joinData: joinData }); pending.current = false; return; } updateIsLoadingModalVisible(true); const response = await joinMediaSFURoom({ payload, apiUserName: credentials.apiUserName, apiKey: credentials.apiKey, localLink: localLink, }); if (response.success && response.data && "roomName" in response.data) { await checkLimitsAndMakeRequest({ apiUserName: response.data.roomName, apiToken: response.data.secret, link: response.data.link, userName: payload.userName, parameters: parameters, }); setError(""); pending.current = false; } else { pending.current = false; updateIsLoadingModalVisible(false); const joinErrorMessage = response.data && "error" in response.data ? response.data.error : ""; setError(formatRequestError("Unable to join room", joinErrorMessage)); } }; const joinLocalRoom = async ({ joinData, link = localLink, }) => { initSocket.current?.emit("joinEventRoom", joinData, (response) => { if (response.success) { updateSocket(initSocket.current); updateApiUserName(localData.current?.apiUserName || ""); updateApiToken(response.secret); updateLink(link); updateRoomName(joinData.eventID); updateMember(joinData.userName); updateIsLoadingModalVisible(false); updateValidated(true); } else { updateIsLoadingModalVisible(false); setError(`Unable to join room. ${response.reason}`); } }); }; const createLocalRoom = async ({ createData, link = localLink, }) => { initSocket.current?.emit("createRoom", createData, (response) => { if (response.success) { updateSocket(initSocket.current); updateApiUserName(localData.current?.apiUserName || ""); updateApiToken(response.secret); updateLink(link); updateRoomName(createData.eventID); // local needs islevel updated from here // we update member as `userName` + "_2" and split it in the room updateMember(createData.userName + "_2"); updateIsLoadingModalVisible(false); updateValidated(true); } else { updateIsLoadingModalVisible(false); setError(`Unable to create room. ${response.reason}`); } }); }; const roomCreator = async ({ payload, apiUserName, apiKey, validate = true, }) => { const response = await createMediaSFURoom({ payload, apiUserName: apiUserName, apiKey: apiKey, localLink: localLink, }); if (response.success && response.data && "roomName" in response.data) { await checkLimitsAndMakeRequest({ apiUserName: response.data.roomName, apiToken: response.data.secret, link: response.data.link, userName: payload.userName, parameters: parameters, validate: validate, }); return response; } else { updateIsLoadingModalVisible(false); const createErrorMessage = response.data && "error" in response.data ? response.data.error : ""; setError(formatRequestError("Unable to create room", createErrorMessage)); } }; const checkProceed = async ({ noUIPreJoinOptions, }) => { if (shouldAutoProceed && noUIPreJoinOptions) { if ("action" in noUIPreJoinOptions && noUIPreJoinOptions.action === "create") { // update all the required parameters and call const createOptions = noUIPreJoinOptions; if (!createOptions.userName || !createOptions.duration || !createOptions.eventType || !createOptions.capacity) { throw new Error("Please provide all the required parameters: userName, duration, eventType, capacity"); } await handleCreateRoom(createOptions); } else if ("action" in noUIPreJoinOptions && noUIPreJoinOptions.action === "join") { // update all the required parameters and call const joinOptions = noUIPreJoinOptions; if (!joinOptions.userName || !joinOptions.meetingID) { throw new Error("Please provide all the required parameters: userName, meetingID"); } await handleJoinRoom(joinOptions); } else { throw new Error("Invalid options provided for creating/joining a room without UI."); } } }; useEffect(() => { if (localLink.length > 0 && !localConnected.current && !initSocket.current) { try { connectLocalSocket?.({ link: localLink }) .then((response) => { localData.current = response.data; initSocket.current = response.socket; localConnected.current = true; }) .catch((error) => { showAlert?.({ message: `Unable to connect to ${localLink}. ${error}`, type: "danger", duration: 3000, }); }); } catch { showAlert?.({ message: `Unable to connect to ${localLink}. Something went wrong.`, type: "danger", duration: 3000, }); } } }, []); useEffect(() => { if (!autoProceedKey || !noUIPreJoinOptions) { return; } if (lastAutoProceedKey.current === autoProceedKey) { return; } if (localLink.length > 0 && !localConnected.current) { return; } lastAutoProceedKey.current = autoProceedKey; void checkProceed({ noUIPreJoinOptions }).catch((caughtError) => { pending.current = false; lastAutoProceedKey.current = null; const message = caughtError instanceof Error ? caughtError.message : "Unable to proceed with the room request."; if (returnUI) { setError(message); } else { showAlert?.({ message, type: "danger", duration: 3000, }); } }); }, [autoProceedKey, localLink, noUIPreJoinOptions, returnUI, showAlert]); const handleToggleMode = () => { setIsCreateMode(!isCreateMode); setError(""); }; const formatRequestError = (prefix, errorMessage) => { if (!errorMessage) { return `${prefix}.`; } return errorMessage.startsWith(prefix) ? errorMessage : `${prefix}. ${errorMessage}`; }; /** * Locks the orientation to portrait mode when the component mounts and unlocks on unmount. */ useEffect(() => { Orientation.lockToPortrait(); return () => { Orientation.unlockAllOrientations(); }; }, []); if (!returnUI) { return <></>; } const content = (<ScrollView contentContainerStyle={styles.scrollContainer} keyboardShouldPersistTaps="handled"> <View style={[ styles.container, Platform.OS === "web" && { width: "100%", maxWidth: isCreateMode ? 500 : 440, alignSelf: "center", }, ]}> {/* Logo */} <View style={styles.logoContainer}> <Image source={{ uri: parameters.imgSrc || "https://mediasfu.com/images/logo192.png", }} style={styles.logoImage}/> </View> {/* Input Fields */} <View style={styles.inputContainer}> {isCreateMode ? (<> <TextInput style={styles.inputField} placeholder="Display Name" value={name} onChangeText={setName} autoCapitalize="none" autoCorrect={false} accessibilityLabel="Display Name" placeholderTextColor="gray"/> <TextInput style={styles.inputField} placeholder="Duration (minutes)" value={duration} onChangeText={setDuration} keyboardType="numeric" autoCapitalize="none" autoCorrect={false} accessibilityLabel="Duration (minutes)" placeholderTextColor="gray"/> <RNPickerSelect onValueChange={(value) => { setEventType(value); }} items={[ { label: "Chat", value: "chat" }, { label: "Broadcast", value: "broadcast" }, { label: "Webinar", value: "webinar" }, { label: "Conference", value: "conference" }, ]} value={eventType} style={pickerSelectStyles} placeholder={{ label: "Select Event Type", value: "", color: "gray", }} useNativeAndroidPickerStyle={false}/> <View style={styles.gap}/> <TextInput style={styles.inputField} placeholder="Room Capacity" value={capacity} onChangeText={setCapacity} keyboardType="numeric" autoCapitalize="none" autoCorrect={false} accessibilityLabel="Room Capacity" placeholderTextColor="gray"/> <Pressable style={styles.actionButton} onPress={() => { void handleCreateRoom(); }} accessibilityRole="button" accessibilityLabel="Create Room"> <Text style={styles.actionButtonText}>Create Room</Text> </Pressable> </>) : (<> <TextInput style={styles.inputField} placeholder="Display Name" value={name} onChangeText={setName} autoCapitalize="none" autoCorrect={false} accessibilityLabel="Display Name" placeholderTextColor="gray"/> <TextInput style={styles.inputField} placeholder="Event ID" value={eventID} onChangeText={setEventID} autoCapitalize="none" autoCorrect={false} accessibilityLabel="Event ID" placeholderTextColor="gray"/> <Pressable style={styles.actionButton} onPress={() => { void handleJoinRoom(); }} accessibilityRole="button" accessibilityLabel="Join Room"> <Text style={styles.actionButtonText}>Join Room</Text> </Pressable> </>)} {error !== "" && <Text style={styles.errorText}>{error}</Text>} </View> {/* OR Separator */} <View style={styles.orContainer}> <Text style={styles.orText}>OR</Text> </View> {/* Toggle Mode Button */} <View style={styles.toggleContainer}> <Pressable style={styles.toggleButton} onPress={handleToggleMode} accessibilityRole="button" accessibilityLabel={isCreateMode ? "Switch to Join Mode" : "Switch to Create Mode"}> <Text style={styles.toggleButtonText}> {isCreateMode ? "Switch to Join Mode" : "Switch to Create Mode"} </Text> </Pressable> </View> </View> </ScrollView>); return Platform.OS === "ios" ? (<KeyboardAvoidingView behavior="padding" style={styles.keyboardAvoidingContainer}> {content} </KeyboardAvoidingView>) : (<View style={styles.keyboardAvoidingContainer}>{content}</View>); }; export default PreJoinPage; /** * Stylesheet for the PreJoinPage component. */ const styles = StyleSheet.create({ keyboardAvoidingContainer: { flex: 1, }, scrollContainer: { flexGrow: 1, justifyContent: "center", backgroundColor: "#eef5ff", paddingVertical: 24, paddingHorizontal: 16, maxHeight: "100%", }, container: { width: "100%", paddingHorizontal: 20, paddingVertical: 24, justifyContent: "center", alignItems: "center", backgroundColor: "rgba(248, 251, 255, 0.96)", borderRadius: 22, borderWidth: 1, borderColor: "rgba(71, 85, 105, 0.22)", shadowColor: "#0f172a", shadowOpacity: 0.16, shadowOffset: { width: 0, height: 16 }, shadowRadius: 24, elevation: 10, }, logoContainer: { marginBottom: 24, alignItems: "center", justifyContent: "center", width: 116, height: 116, borderRadius: 58, backgroundColor: "#ffffff", borderWidth: 1, borderColor: "rgba(37, 99, 235, 0.18)", }, logoImage: { width: 84, height: 84, borderRadius: 42, }, inputContainer: { width: "100%", marginBottom: 4, gap: 2, }, inputField: { height: 46, width: "100%", borderColor: "rgba(71, 85, 105, 0.24)", borderWidth: 1, marginBottom: 12, paddingHorizontal: 15, borderRadius: 12, backgroundColor: "#ffffff", fontSize: 16, color: "#0f172a", }, actionButton: { backgroundColor: "#2563eb", paddingVertical: 12, borderRadius: 12, alignItems: "center", marginBottom: 8, }, actionButtonText: { color: "white", fontWeight: "bold", fontSize: 14, }, toggleContainer: { alignItems: "center", justifyContent: "center", }, toggleButton: { backgroundColor: "#ffffff", paddingVertical: 10, paddingHorizontal: 25, borderRadius: 999, alignItems: "center", justifyContent: "center", borderWidth: 1, borderColor: "rgba(37, 99, 235, 0.28)", }, toggleButtonText: { color: "#1d4ed8", fontWeight: "bold", fontSize: 15, }, errorText: { color: "#dc2626", textAlign: "center", marginTop: 10, fontSize: 14, }, orContainer: { flexDirection: "row", alignItems: "center", marginVertical: 20, }, orText: { color: "#475569", fontSize: 16, fontWeight: "bold", marginHorizontal: 10, }, gap: { marginBottom: 10, }, }); const pickerSelectStyles = StyleSheet.create({ inputIOS: { height: 46, borderColor: "rgba(71, 85, 105, 0.24)", borderWidth: 1, marginBottom: 12, paddingHorizontal: 12, borderRadius: 12, backgroundColor: "#ffffff", fontSize: 16, color: "#0f172a", paddingRight: 20, }, inputAndroid: { height: 46, borderColor: "rgba(71, 85, 105, 0.24)", borderWidth: 1, marginBottom: 12, paddingHorizontal: 12, borderRadius: 12, backgroundColor: "#ffffff", fontSize: 16, color: "#0f172a", paddingRight: 20, }, inputWeb: { height: 46, borderColor: "rgba(71, 85, 105, 0.24)", borderWidth: 1, marginBottom: 12, paddingHorizontal: 12, borderRadius: 12, backgroundColor: "#ffffff", fontSize: 16, color: "#0f172a", paddingRight: 20, }, placeholder: { color: "gray", }, }); //# sourceMappingURL=PreJoinPage.js.map