diff --git a/examples/mobile-client/text-chat/.env.example b/examples/mobile-client/text-chat/.env.example new file mode 100644 index 00000000..5c3949fe --- /dev/null +++ b/examples/mobile-client/text-chat/.env.example @@ -0,0 +1 @@ +EXPO_PUBLIC_FISHJAM_ID= diff --git a/examples/mobile-client/text-chat/.gitignore b/examples/mobile-client/text-chat/.gitignore new file mode 100644 index 00000000..0206b71f --- /dev/null +++ b/examples/mobile-client/text-chat/.gitignore @@ -0,0 +1,39 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +android/* +ios/* +.env +.cursor/ diff --git a/examples/mobile-client/text-chat/.prettierrc b/examples/mobile-client/text-chat/.prettierrc new file mode 100644 index 00000000..1c5e9660 --- /dev/null +++ b/examples/mobile-client/text-chat/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 80 +} diff --git a/examples/mobile-client/text-chat/App.tsx b/examples/mobile-client/text-chat/App.tsx new file mode 100644 index 00000000..8cca7b35 --- /dev/null +++ b/examples/mobile-client/text-chat/App.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { NavigationContainer } from "@react-navigation/native"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { FishjamProvider } from "@fishjam-cloud/react-native-client"; +import RootNavigation from "./navigation/RootNavigation"; + +const App = () => { + return ( + + + + + + + + ); +}; + +export default App; diff --git a/examples/mobile-client/text-chat/README.md b/examples/mobile-client/text-chat/README.md new file mode 100644 index 00000000..daaaa66d --- /dev/null +++ b/examples/mobile-client/text-chat/README.md @@ -0,0 +1,141 @@ +# Text Chat Example + +A React Native mobile app demonstrating real-time text messaging using [Fishjam Cloud](https://fishjam.io/) data channels. This example shows how to implement peer-to-peer text chat functionality in a mobile application using the Fishjam Cloud React Native SDK. + +## Features + +- Join a room with a custom room name and user name +- Real-time text messaging between participants using WebRTC data channels +- Reliable message delivery with automatic reconnection +- Message history with sender names and timestamps + +## Getting Started + +### Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or newer recommended) +- [Yarn](https://yarnpkg.com/) or [npm](https://www.npmjs.com/) +- [Expo](https://docs.expo.dev/get-started/installation/): You do **not** need to install Expo CLI globally. Use `npx expo` to run Expo commands. + +### Installation + +1. **Clone the repository:** + + ```sh + git clone https://github.com/fishjam-cloud/web-client-sdk.git + cd web-client-sdk + ``` + +2. **Install dependencies:** + + ```sh + yarn install + ``` + +3. **Build the project:** + + ```sh + yarn build + ``` + +4. **Set up environment variables:** + - Create a `.env` file in the `examples/mobile-client/text-chat` directory: + ```env + EXPO_PUBLIC_FISHJAM_ID= + ``` + - _You can obtain your Fishjam ID at [https://fishjam.io/app/](https://fishjam.io/app/)._ + - You can also copy `.env.example` as a starting point: + ```sh + cp .env.example .env + ``` + +5. **Prebuild native files:** + ```sh + cd examples/mobile-client/text-chat + npx expo prebuild --clean + ``` + > [!NOTE] + > Be sure to run `npx expo prebuild` and not `yarn prebuild` as there's an issue with path generation for the `ios/.xcode.env.local` file + +### Running the App + +- **Start the Expo development server:** + + ```sh + cd examples/mobile-client/text-chat + yarn start + ``` + +- **Run on Android:** + + ```sh + yarn android + ``` + +- **Run on iOS:** + ```sh + yarn ios + ``` + +## Usage + +1. Enter a room name and your user name on the Home screen. +2. Tap **Connect** to join the room and initialize the data channel. +3. Once connected, you can send and receive text messages in real-time. +4. Messages from other participants will appear automatically. +5. Tap **Leave** to disconnect from the room. + +## What This Demo Shows + +This example demonstrates how to use Fishjam Cloud's data channel functionality for real-time text messaging: + +- **Data Channel Initialization**: Shows how to initialize a reliable data channel after connecting to a Fishjam room +- **Message Publishing**: Demonstrates encoding and sending JSON messages via the data channel using `publishData` +- **Message Subscription**: Shows how to subscribe to incoming messages and decode them using `subscribeData` +- **Peer-to-Peer Communication**: All messages are sent directly between peers using WebRTC data channels, without going through a server +- **Reliable Delivery**: Uses reliable data channels to ensure messages are delivered in order + +## Architecture Overview + +- **React Native + Expo**: Cross-platform mobile app framework +- **Fishjam Cloud SDK**: Handles WebRTC peer connections and data channel management +- **Data Channels**: WebRTC data channels for peer-to-peer text messaging +- **TypeScript**: Provides type safety and better developer experience + +## Troubleshooting & FAQ + +- **App fails to connect to a room:** + - Ensure your `.env` file is present and `EXPO_PUBLIC_FISHJAM_ID` is set correctly + - Check your network connection + - Review logs in the Metro/Expo console for errors + +- **Messages not appearing:** + - Make sure multiple participants have joined the same room + - Check that the data channel has initialized successfully (watch for "Opening data channel..." status) + - Verify both devices are connected to the internet + +- **Data channel errors:** + - Ensure you're using a recent version of the Fishjam SDK + - Check that both peers support data channels (most modern devices do) + - Review error messages in the app's status display + +## Development + +1. Whenever you make changes in the `packages` directory, make sure to build the app in the root directory (not in `examples/mobile-client/text-chat`). This ensures that all related workspaces are also built: + + ```sh + yarn build + ``` + +2. Linter (run in the root directory): + ```sh + yarn lint + ``` + +## License + +This example is provided under the MIT License. See [LICENSE](../../LICENSE) for details. + +--- + +_This project is maintained by the Fishjam team. For questions or support, visit [fishjam.io](https://fishjam.io/) or open an issue on GitHub._ diff --git a/examples/mobile-client/text-chat/app.json b/examples/mobile-client/text-chat/app.json new file mode 100644 index 00000000..b261f695 --- /dev/null +++ b/examples/mobile-client/text-chat/app.json @@ -0,0 +1,41 @@ +{ + "expo": { + "name": "mobile-text-chat", + "slug": "mobile-text-chat", + "version": "1.0.0", + "orientation": "portrait", + "icon": "../minimal-react-native/assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "../minimal-react-native/assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "io.fishjam.mobile.example.textchat", + "appleTeamId": "J5FM626PE2" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "../minimal-react-native/assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "package": "io.fishjam.mobile.example.textchat", + "permissions": [ + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.ACCESS_WIFI_STATE" + ] + }, + "web": { + "favicon": "../minimal-react-native/assets/favicon.png" + }, + "plugins": [ + [ + "@fishjam-cloud/react-native-client" + ] + ] + } +} diff --git a/examples/mobile-client/text-chat/eslint.config.js b/examples/mobile-client/text-chat/eslint.config.js new file mode 100644 index 00000000..67be7d8b --- /dev/null +++ b/examples/mobile-client/text-chat/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require('eslint-config-expo/flat'); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ['dist/*'], + }, +]); diff --git a/examples/mobile-client/text-chat/hooks/useConnectFishjam.ts b/examples/mobile-client/text-chat/hooks/useConnectFishjam.ts new file mode 100644 index 00000000..45684b91 --- /dev/null +++ b/examples/mobile-client/text-chat/hooks/useConnectFishjam.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { NavigationProp, useNavigation } from "@react-navigation/native"; +import { + useConnection, + useSandbox, +} from "@fishjam-cloud/react-native-client"; +import { RootStackParamList } from "../navigation/RootNavigation"; + +export const useConnectFishjam = () => { + const navigation = useNavigation>(); + const { leaveRoom, joinRoom } = useConnection(); + const { getSandboxPeerToken } = useSandbox(); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const connect = async (roomName: string, userName: string) => { + try { + setIsLoading(true); + setError(null); + const peerToken = await getSandboxPeerToken( + roomName, + userName, + "conference", + ); + await joinRoom({ + peerToken, + peerMetadata: { + displayName: userName, + }, + }); + navigation.navigate("Chat", { roomName, userName }); + } catch (err) { + const error = + err instanceof Error ? err : new Error("Failed to connect to Fishjam"); + console.error("Error connecting to Fishjam", error); + setError(error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + return () => { + leaveRoom(); + }; + }, [leaveRoom]); + + return { + connect, + isLoading, + error, + }; +}; diff --git a/examples/mobile-client/text-chat/index.ts b/examples/mobile-client/text-chat/index.ts new file mode 100644 index 00000000..e5802d26 --- /dev/null +++ b/examples/mobile-client/text-chat/index.ts @@ -0,0 +1,5 @@ +import { registerRootComponent } from "expo"; + +import App from "./App"; + +registerRootComponent(App); diff --git a/examples/mobile-client/text-chat/navigation/RootNavigation.tsx b/examples/mobile-client/text-chat/navigation/RootNavigation.tsx new file mode 100644 index 00000000..686d27f5 --- /dev/null +++ b/examples/mobile-client/text-chat/navigation/RootNavigation.tsx @@ -0,0 +1,38 @@ +import { + createNativeStackNavigator, + NativeStackScreenProps, +} from "@react-navigation/native-stack"; +import HomeScreen from "../screens/home"; +import ChatScreen from "../screens/chat"; + +export type RootStackParamList = { + Home: undefined; + Chat: { + roomName: string; + userName: string; + }; +}; + +export type RootScreenProps = + NativeStackScreenProps; + +const RootStack = createNativeStackNavigator(); + +const RootNavigation = () => { + return ( + + + + + ); +}; + +export default RootNavigation; diff --git a/examples/mobile-client/text-chat/package.json b/examples/mobile-client/text-chat/package.json new file mode 100644 index 00000000..035eb2ab --- /dev/null +++ b/examples/mobile-client/text-chat/package.json @@ -0,0 +1,34 @@ +{ + "name": "mobile-text-chat", + "version": "1.0.0", + "main": "index.ts", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web" + }, + "dependencies": { + "@fishjam-cloud/react-native-client": "workspace:*", + "@react-navigation/elements": "^2.5.2", + "@react-navigation/native": "^7.1.14", + "@react-navigation/native-stack": "^7.3.21", + "expo": "~54.0.25", + "expo-status-bar": "~3.0.8", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.14.0" + }, + "devDependencies": { + "@babel/core": "^7.28.0", + "@types/react": "~19.1.0", + "eslint": "^9.29.0", + "eslint-config-expo": "~9.2.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.5.1", + "prettier": "^3.6.2", + "typescript": "~5.9.2" + }, + "private": true +} diff --git a/examples/mobile-client/text-chat/screens/chat/index.tsx b/examples/mobile-client/text-chat/screens/chat/index.tsx new file mode 100644 index 00000000..e95d2c4c --- /dev/null +++ b/examples/mobile-client/text-chat/screens/chat/index.tsx @@ -0,0 +1,296 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + FlatList, + KeyboardAvoidingView, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { + useConnection, + useDataChannel, +} from "@fishjam-cloud/react-native-client"; +import { RootScreenProps } from "../../navigation/RootNavigation"; +import { SafeAreaView } from "react-native-safe-area-context"; + +type ChatMessage = { + timestamp: number; + sender: string; + payload: string; +}; + +const ChatScreen = ({ route, navigation }: RootScreenProps<"Chat">) => { + const { roomName, userName } = route.params; + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(""); + const listRef = useRef>(null); + + const { peerStatus, leaveRoom } = useConnection(); + const { + publishData, + subscribeData, + initializeDataChannel, + dataChannelReady, + dataChannelLoading, + dataChannelError, + } = useDataChannel(); + + useEffect(() => { + if (peerStatus === "connected") { + initializeDataChannel(); + } + }, [peerStatus, initializeDataChannel]); + + useEffect(() => { + if (dataChannelLoading || !dataChannelReady) return; + + const unsubscribe = subscribeData( + (data: Uint8Array) => { + const message = new TextDecoder().decode(data); + try { + const parsed = JSON.parse(message) as ChatMessage; + setMessages((prev) => [...prev, parsed]); + } catch { + console.error("Failed to parse message:", message); + } + }, + { reliable: true }, + ); + + return () => { + unsubscribe(); + }; + }, [dataChannelReady, dataChannelLoading, subscribeData]); + + const handleLeave = useCallback(() => { + leaveRoom(); + navigation.goBack(); + }, [leaveRoom, navigation]); + + const handleSend = useCallback(() => { + if (!inputValue.trim() || dataChannelLoading || !dataChannelReady) return; + + const message: ChatMessage = { + timestamp: Date.now(), + sender: userName, + payload: inputValue.trim(), + }; + + const encoded = new TextEncoder().encode(JSON.stringify(message)); + publishData(encoded, { reliable: true }); + setMessages((prev) => [...prev, message]); + setInputValue(""); + }, [inputValue, dataChannelLoading, dataChannelReady, userName, publishData]); + + const renderMessage = ({ item }: { item: ChatMessage }) => { + const isOwn = item.sender === userName; + return ( + + + + {item.sender}{" "} + + + {new Date(item.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + + + + {item.payload} + + + ); + }; + + return ( + + + + Fishjam Chat + + Room: {roomName} • User: {userName} + + + + Leave + + + + {(peerStatus === "connecting" || + dataChannelLoading || + dataChannelError) && ( + + {peerStatus === "connecting" && Connecting...} + {dataChannelLoading && Opening data channel...} + {dataChannelError && ( + {dataChannelError.message} + )} + + )} + + + `${item.timestamp}-${item.sender}`} + renderItem={renderMessage} + contentContainerStyle={styles.messagesContainer} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="interactive" + onContentSizeChange={() => + listRef.current?.scrollToEnd({ animated: true }) + } + ListEmptyComponent={ + No messages yet + } + /> + + + + + Send + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + gap: 12, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + title: { + fontSize: 20, + fontWeight: "600", + }, + subtitle: { + color: "#666", + marginTop: 4, + }, + leaveButton: { + paddingVertical: 8, + paddingHorizontal: 12, + backgroundColor: "#dc3545", + borderRadius: 6, + }, + leaveButtonText: { + color: "white", + fontWeight: "600", + }, + statusContainer: { + paddingVertical: 8, + }, + errorText: { + color: "#dc3545", + }, + kavContainer: { + flex: 1, + }, + messagesContainer: { + paddingHorizontal: 4, + paddingBottom: 8, + gap: 12, + }, + emptyStateText: { + textAlign: "center", + color: "#999", + marginTop: 24, + }, + message: { + padding: 10, + borderRadius: 10, + maxWidth: "80%", + }, + ownMessage: { + backgroundColor: "#007bff", + alignSelf: "flex-end", + }, + otherMessage: { + backgroundColor: "#e9e9e9", + alignSelf: "flex-start", + }, + messageMeta: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 4, + }, + sender: { + fontWeight: "600", + color: "#111", + }, + time: { + color: "#666", + }, + payload: { + color: "#111", + }, + ownMessageText: { + color: "rgba(255, 255, 255, 0.9)", + }, + inputContainer: { + flexDirection: "row", + gap: 8, + alignItems: "center", + paddingTop: 8, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: "#ccc", + }, + messageInput: { + flex: 1, + borderColor: "#ccc", + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + }, + sendButton: { + backgroundColor: "#28a745", + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + }, + sendButtonDisabled: { + opacity: 0.6, + }, + sendButtonText: { + color: "white", + fontWeight: "600", + }, +}); + +export default ChatScreen; diff --git a/examples/mobile-client/text-chat/screens/home/index.tsx b/examples/mobile-client/text-chat/screens/home/index.tsx new file mode 100644 index 00000000..403a5108 --- /dev/null +++ b/examples/mobile-client/text-chat/screens/home/index.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { + Button, + Keyboard, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { useConnectFishjam } from "../../hooks/useConnectFishjam"; +import { SafeAreaView } from "react-native-safe-area-context"; + +const HomeScreen = () => { + const [roomName, setRoomName] = useState(""); + const [userName, setUserName] = useState(""); + const { connect, isLoading, error } = useConnectFishjam(); + + return ( + + + + + + {error && ( + + Failed to connect. Please try again. + + )} +