diff --git a/.github/workflows/react-native-ci.yml b/.github/workflows/react-native-ci.yml index 6287b7dd6..97341e2bb 100644 --- a/.github/workflows/react-native-ci.yml +++ b/.github/workflows/react-native-ci.yml @@ -165,4 +165,48 @@ jobs: - name: Build macOS working-directory: react-native - run: yarn build:macos \ No newline at end of file + run: yarn build:macos + + build-windows: + name: Build Windows + runs-on: windows-2022 + needs: lint + timeout-minutes: 40 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + cache-dependency-path: react-native/yarn.lock + + - name: Install dependencies + working-directory: react-native + run: yarn install --frozen-lockfile + + - name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v2 + with: + msbuild-architecture: x64 + + - name: Setup Visual Studio + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: | + ~\.nuget\packages + react-native\windows\packages + key: nuget-${{ runner.os }}-${{ hashFiles('react-native/windows/**/*.csproj', 'react-native/windows/**/*.sln') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Build Windows + working-directory: react-native + run: yarn build:windows \ No newline at end of file diff --git a/.gitignore b/.gitignore index a32bd487f..fbca10f46 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ xcuserdata bazel-* build-cmake node_modules/ -.dart_tool/ \ No newline at end of file +.dart_tool/ + +# Claude settings +**/.claude/settings.local.json diff --git a/react-native/.gitignore b/react-native/.gitignore index d5ae45669..eebb84d67 100644 --- a/react-native/.gitignore +++ b/react-native/.gitignore @@ -72,3 +72,12 @@ yarn-error.log !.yarn/releases !.yarn/sdks !.yarn/versions + +# Ditto +ditto + +# MSBuild logs +*.binlog +msbuild_*.binlog +msbuild_*.err +msbuild_*.wrn \ No newline at end of file diff --git a/react-native/App.tsx b/react-native/App.tsx index 2d15f2ffc..4c91ff5ff 100644 --- a/react-native/App.tsx +++ b/react-native/App.tsx @@ -23,6 +23,7 @@ import { DITTO_PLAYGROUND_TOKEN, DITTO_AUTH_URL, } from '@env'; +import { getDittoInstance, setDittoInstance } from './dittoSingleton'; import Fab from './components/Fab'; import NewTaskModal from './components/NewTaskModal'; @@ -118,6 +119,32 @@ const App = () => { }; const initDitto = async () => { + // Check for existing global instance first + const existingInstance = getDittoInstance(); + if (existingInstance) { + ditto.current = existingInstance; + + // Re-register observers for this component + taskObserver.current = ditto.current.store.registerObserver( + 'SELECT * FROM tasks WHERE NOT deleted', + response => { + const fetchedTasks: Task[] = response.items.map(doc => ({ + id: doc.value._id, + title: doc.value.title as string, + done: doc.value.done, + deleted: doc.value.deleted, + })); + setTasks(fetchedTasks); + }, + ); + return; + } + + // Prevent multiple Ditto instances + if (ditto.current) { + return; + } + try { // https://docs.ditto.live/sdk/latest/install-guides/react-native#onlineplayground const databaseId = DITTO_APP_ID; @@ -131,6 +158,7 @@ const App = () => { const config = new DittoConfig(databaseId, connectConfig, 'custom-folder'); ditto.current = await Ditto.open(config); + setDittoInstance(ditto.current); if (connectConfig.mode === 'server') { await ditto.current.auth.setExpirationHandler(async (dittoInstance, timeUntilExpiration) => { @@ -183,18 +211,32 @@ const App = () => { }; useEffect(() => { + let mounted = true; + (async () => { const granted = Platform.OS === 'android' ? await requestPermissions() : true; - if (granted) { + if (granted && mounted) { initDitto(); - } else { + } else if (!granted) { Alert.alert( 'Permission Denied', 'You need to grant all permissions to use this app.', ); } })(); + + // Cleanup function + return () => { + mounted = false; + if (ditto.current) { + console.log('Cleaning up Ditto instance'); + ditto.current.stopSync(); + taskObserver.current?.cancel(); + taskSubscription.current?.cancel(); + // Note: We don't set ditto.current to null here to prevent re-initialization + } + }; }, []); const renderItem = ({item}: {item: Task}) => ( @@ -215,43 +257,49 @@ const App = () => { return ( - - - setModalVisible(true)} /> - setModalVisible(false)} - onSubmit={task => { - createTask(task); - setModalVisible(false); - }} - onClose={() => setModalVisible(false)} - /> - { - updateTaskTitle(taskId, newTitle); - setEditingTask(null); - }} - onClose={() => setEditingTask(null)} - /> - item.id} - /> + + + + setModalVisible(true)} /> + item.id} + /> + { + createTask(task); + setModalVisible(false); + }} + onClose={() => setModalVisible(false)} + /> + setEditingTask(null)} + onSubmit={(taskId, newTitle) => { + updateTaskTitle(taskId, newTitle); + setEditingTask(null); + }} + onClose={() => setEditingTask(null)} + /> + ); }; const styles = StyleSheet.create({ container: { - height: '100%', - padding: 20, + flex: 1, backgroundColor: '#fff', }, + appContainer: { + flex: 1, + padding: 20, + position: 'relative', + }, listContainer: { gap: 5, }, diff --git a/react-native/NuGet.config b/react-native/NuGet.config new file mode 100644 index 000000000..fe459fedd --- /dev/null +++ b/react-native/NuGet.config @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/react-native/README.md b/react-native/README.md index be70cfbd8..661a14215 100644 --- a/react-native/README.md +++ b/react-native/README.md @@ -18,6 +18,15 @@ A sample React Native application that lets you create tasks and sync them with - **Ditto Portal Account**: Ensure you have a Ditto account. Sign up [here](https://portal.ditto.live/signup). - **App Credentials**: After registration, create an application within the Ditto Portal to obtain your `AppID`, `Online Playground Token`, `Auth URL`, and `Websocket URL`. Visit the [Ditto Portal](https://portal.ditto.live/) to manage your applications. +### Windows-specific Prerequisites + +- **Windows 10 or 11** (version 10.0.19041.0 or higher) +- **Visual Studio 2022** with the following workloads: + - Universal Windows Platform development + - Desktop development with C++ + - Node.js development (under Individual Components) +- **Developer Mode**: Enabled in Windows Settings > Update & Security > For developers + ## Getting Started ### Install Dependencies @@ -49,6 +58,12 @@ For Android: yarn react-native run-android ``` +For Windows: + +```bash +yarn windows +``` + For macOS: ```bash @@ -59,11 +74,19 @@ yarn react-native run-macos - **Task Creation**: Users can add new tasks to their list. - **Real-time Sync**: Tasks are synchronized in real-time across all devices using the same Ditto application. -- **Cross-Platform**: Supports iOS, Android, and macOS platforms. +- **Cross-Platform**: Supports iOS, Android, Windows, and macOS platforms. ## Additional Information -- Limitation: React Native's Fast Refresh must be disabled and it's something we're working on fixing. +### Windows-specific Notes + +- **NuGet.config**: Required for Windows to resolve React Native Windows packages from the correct sources. +- **Ditto Singleton Pattern**: The app uses a singleton pattern (`dittoSingleton.ts`) to prevent multiple Ditto instances, particularly important for Windows which may remount components more frequently than other platforms. +- **Custom Modal Implementation**: Windows uses a custom modal overlay instead of the native Modal component to ensure proper rendering within window bounds. + +### Known Limitations + +- React Native's Fast Refresh may cause file lock issues with Ditto on Windows due to component remounting. The singleton pattern mitigates this issue. ## iOS Installation diff --git a/react-native/components/NewTaskModal.tsx b/react-native/components/NewTaskModal.tsx index 045c027ef..4c135e248 100644 --- a/react-native/components/NewTaskModal.tsx +++ b/react-native/components/NewTaskModal.tsx @@ -1,12 +1,12 @@ import React from 'react'; import {useState} from 'react'; import { - Button, StyleSheet, Text, TextInput, TouchableOpacity, View, + Platform, } from 'react-native'; type NewTaskModalProps = { @@ -15,64 +15,105 @@ type NewTaskModalProps = { onClose?: () => void; }; -type Props = NewTaskModalProps; - -const NewTaskModal: React.FC = ({visible, onSubmit, onClose}) => { +const NewTaskModal: React.FC = ({visible, onSubmit, onClose}) => { const [input, setInput] = useState(''); const submit = () => { - if (input !== '') { - onSubmit(input); + if (input.trim() !== '') { + onSubmit(input.trim()); setInput(''); + onClose?.(); } }; - if (!visible) { - return null; + if (!visible) {return null;} + + // For Windows, render as an absolute positioned overlay within the app + if (Platform.OS === 'windows') { + return ( + + + New Task + + + + Submit + + + Close + + + + + ); } + // For other platforms, use a simpler overlay return ( - - - + + New Task - -