From 6c8473061f729e6805a570cedf72735c94e3196e Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Mon, 9 Feb 2026 15:16:31 +0100 Subject: [PATCH 1/3] add mobile example --- examples/mobile-client/text-chat/.env.example | 1 + examples/mobile-client/text-chat/.gitignore | 39 +++ examples/mobile-client/text-chat/App.tsx | 19 ++ examples/mobile-client/text-chat/app.json | 41 +++ .../mobile-client/text-chat/assets/README.md | 1 + .../mobile-client/text-chat/eslint.config.js | 1 + .../text-chat/hooks/useConnectFishjam.ts | 44 +++ examples/mobile-client/text-chat/index.ts | 5 + .../text-chat/navigation/RootNavigation.tsx | 34 +++ examples/mobile-client/text-chat/package.json | 34 +++ .../text-chat/prettier.config.js | 1 + .../text-chat/screens/chat/index.tsx | 277 ++++++++++++++++++ .../text-chat/screens/home/index.tsx | 55 ++++ .../mobile-client/text-chat/tsconfig.json | 7 + packages/mobile-client/src/index.ts | 2 + yarn.lock | 25 ++ 16 files changed, 586 insertions(+) create mode 100644 examples/mobile-client/text-chat/.env.example create mode 100644 examples/mobile-client/text-chat/.gitignore create mode 100644 examples/mobile-client/text-chat/App.tsx create mode 100644 examples/mobile-client/text-chat/app.json create mode 100644 examples/mobile-client/text-chat/assets/README.md create mode 100644 examples/mobile-client/text-chat/eslint.config.js create mode 100644 examples/mobile-client/text-chat/hooks/useConnectFishjam.ts create mode 100644 examples/mobile-client/text-chat/index.ts create mode 100644 examples/mobile-client/text-chat/navigation/RootNavigation.tsx create mode 100644 examples/mobile-client/text-chat/package.json create mode 100644 examples/mobile-client/text-chat/prettier.config.js create mode 100644 examples/mobile-client/text-chat/screens/chat/index.tsx create mode 100644 examples/mobile-client/text-chat/screens/home/index.tsx create mode 100644 examples/mobile-client/text-chat/tsconfig.json 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/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/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/assets/README.md b/examples/mobile-client/text-chat/assets/README.md new file mode 100644 index 00000000..2a28ed2b --- /dev/null +++ b/examples/mobile-client/text-chat/assets/README.md @@ -0,0 +1 @@ +This example reuses the icon and splash assets from the minimal React Native example. 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..03d5fdd2 --- /dev/null +++ b/examples/mobile-client/text-chat/eslint.config.js @@ -0,0 +1 @@ +module.exports = require("../common/eslintExpo.config.js"); 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..8c5c67b7 --- /dev/null +++ b/examples/mobile-client/text-chat/hooks/useConnectFishjam.ts @@ -0,0 +1,44 @@ +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 connect = async (roomName: string, userName: string) => { + try { + setIsLoading(true); + const peerToken = await getSandboxPeerToken(roomName, userName); + await joinRoom({ + peerToken, + peerMetadata: { + displayName: userName, + }, + }); + navigation.navigate("Chat", { roomName, userName }); + } catch (error) { + console.error("Error connecting to Fishjam", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + return () => { + leaveRoom(); + }; + }, [leaveRoom]); + + return { + connect, + isLoading, + }; +}; 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..2e3b92a2 --- /dev/null +++ b/examples/mobile-client/text-chat/navigation/RootNavigation.tsx @@ -0,0 +1,34 @@ +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/prettier.config.js b/examples/mobile-client/text-chat/prettier.config.js new file mode 100644 index 00000000..1b807236 --- /dev/null +++ b/examples/mobile-client/text-chat/prettier.config.js @@ -0,0 +1 @@ +module.exports = require("../common/prettier.config.js"); 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..893ca932 --- /dev/null +++ b/examples/mobile-client/text-chat/screens/chat/index.tsx @@ -0,0 +1,277 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + FlatList, + KeyboardAvoidingView, + Platform, + Pressable, + SafeAreaView, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { + useConnection, + useDataChannel, +} from "@fishjam-cloud/react-native-client"; +import { RootScreenProps } from "../../navigation/RootNavigation"; + +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]); + + useEffect(() => { + listRef.current?.scrollToEnd({ animated: true }); + }, [messages]); + + 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" && Connecting...} + {dataChannelLoading && Opening data channel...} + {dataChannelError && ( + {dataChannelError.message} + )} + + + `${index}`} + renderItem={renderMessage} + contentContainerStyle={styles.messagesContainer} + /> + + + + + + 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: { + minHeight: 20, + }, + errorText: { + color: "#dc3545", + }, + messagesContainer: { + paddingBottom: 8, + gap: 12, + }, + 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", + }, + inputContainer: { + flexDirection: "row", + gap: 8, + alignItems: "center", + }, + 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..1bfe4721 --- /dev/null +++ b/examples/mobile-client/text-chat/screens/home/index.tsx @@ -0,0 +1,55 @@ +import React, { useState } from "react"; +import { Button, SafeAreaView, StyleSheet, TextInput } from "react-native"; +import { useConnectFishjam } from "../../hooks/useConnectFishjam"; + +const HomeScreen = () => { + const [roomName, setRoomName] = useState(""); + const [userName, setUserName] = useState(""); + const { connect, isLoading } = useConnectFishjam(); + + return ( + + + +