mirror of
https://github.com/colanode/colanode.git
synced 2026-07-10 12:29:09 +02:00
Mobile duplicated useLiveQuery, useQuery, useMutation, and buildQueryClient from packages/ui with the only difference being the executor mechanism (appService.mediator vs window.colanode). This consolidates them by: - Adding a DOM-free core-api module to packages/ui with getColanode() and getEventBus() helpers that access globalThis, avoiding Window/DOM types - Updating shared hooks and buildQueryClient to use these helpers instead of window.colanode/window.eventBus directly - Installing a globalThis.colanode bridge in mobile's _layout.tsx that delegates to appService.mediator (matching the web/desktop pattern) - Migrating all 41 mobile files to import hooks from @colanode/ui - Deleting 4 duplicate mobile files (3 hooks + query-client) - Adding Metro resolver override to force react/react-native resolution from the mobile app's node_modules (packages/ui ships a separate web-only React 19.2.4 that conflicts with mobile's RN-compatible 19.1.0) Also standardizes useQuery to use buildQueryKey instead of inline sha256. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
|
import { modelName } from 'expo-device';
|
|
import { Slot, useRouter, useSegments } from 'expo-router';
|
|
import * as SplashScreen from 'expo-splash-screen';
|
|
import { StatusBar } from 'expo-status-bar';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import { eventBus } from '@colanode/client/lib';
|
|
import { AppMeta, AppService } from '@colanode/client/services';
|
|
import { LoadingScreen } from '@colanode/mobile/components/loading-screen';
|
|
import { ErrorBoundary } from '@colanode/mobile/components/ui/error-boundary';
|
|
import { ErrorState } from '@colanode/mobile/components/ui/error-state';
|
|
import {
|
|
AppServiceContext,
|
|
useAppService,
|
|
} from '@colanode/mobile/contexts/app-service';
|
|
import { ThemeProvider, useTheme } from '@colanode/mobile/contexts/theme';
|
|
import { ToastProvider } from '@colanode/mobile/components/ui/toast';
|
|
import { copyAssets } from '@colanode/mobile/lib/assets';
|
|
import { MOBILE_WINDOW_ID } from '@colanode/mobile/lib/constants';
|
|
import { MobileFileSystem } from '@colanode/mobile/services/file-system';
|
|
import { MobileKyselyService } from '@colanode/mobile/services/kysely-service';
|
|
import { MobilePathService } from '@colanode/mobile/services/path-service';
|
|
import { buildQueryClient } from '@colanode/ui/lib/query';
|
|
|
|
SplashScreen.preventAutoHideAsync();
|
|
|
|
function ThemeStatusBar() {
|
|
const { scheme } = useTheme();
|
|
return <StatusBar style={scheme === 'dark' ? 'light' : 'dark'} />;
|
|
}
|
|
|
|
export default function RootLayout() {
|
|
const [appService, setAppService] = useState<AppService | null>(null);
|
|
const [queryClient, setQueryClient] = useState<QueryClient | null>(null);
|
|
const [ready, setReady] = useState(false);
|
|
const [initError, setInitError] = useState<string | null>(null);
|
|
const [initAttempt, setInitAttempt] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
try {
|
|
setInitError(null);
|
|
setReady(false);
|
|
setAppService(null);
|
|
setQueryClient(null);
|
|
|
|
const paths = new MobilePathService();
|
|
await copyAssets(paths);
|
|
|
|
const appMeta: AppMeta = {
|
|
type: 'mobile',
|
|
platform: modelName ?? 'unknown',
|
|
};
|
|
|
|
const service = new AppService(
|
|
appMeta,
|
|
new MobileFileSystem(),
|
|
new MobileKyselyService(),
|
|
paths
|
|
);
|
|
|
|
await service.init();
|
|
|
|
(globalThis as any).colanode = {
|
|
executeMutation: (input: any) =>
|
|
service.mediator.executeMutation(input),
|
|
executeQuery: (input: any) => service.mediator.executeQuery(input),
|
|
executeQueryAndSubscribe: (key: string, input: any) =>
|
|
service.mediator.executeQueryAndSubscribe(
|
|
key,
|
|
MOBILE_WINDOW_ID,
|
|
input
|
|
),
|
|
unsubscribeQuery: (key: string) =>
|
|
service.mediator.unsubscribeQuery(key, MOBILE_WINDOW_ID),
|
|
};
|
|
(globalThis as any).eventBus = eventBus;
|
|
|
|
const client = buildQueryClient();
|
|
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setAppService(service);
|
|
setQueryClient(client);
|
|
setReady(true);
|
|
|
|
// Seed default public servers without blocking app startup.
|
|
void Promise.allSettled([
|
|
service.createServer(new URL('https://eu.colanode.com/config')),
|
|
service.createServer(new URL('https://us.colanode.com/config')),
|
|
]).catch((error) => {
|
|
console.error('Failed to seed default servers:', error);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to initialize AppService:', error);
|
|
if (!cancelled) {
|
|
setInitError(
|
|
error instanceof Error ? error.message : 'Failed to initialize app.'
|
|
);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
await SplashScreen.hideAsync();
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [initAttempt]);
|
|
|
|
if (initError) {
|
|
return (
|
|
<ThemeProvider>
|
|
<ThemeStatusBar />
|
|
<ErrorState
|
|
message={initError}
|
|
onRetry={() => setInitAttempt((current) => current + 1)}
|
|
/>
|
|
</ThemeProvider>
|
|
);
|
|
}
|
|
|
|
if (!ready || !appService || !queryClient) {
|
|
return (
|
|
<ThemeProvider>
|
|
<ThemeStatusBar />
|
|
<LoadingScreen />
|
|
</ThemeProvider>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ThemeProvider>
|
|
<ThemeStatusBar />
|
|
<ToastProvider>
|
|
<AppServiceContext.Provider value={{ appService }}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ErrorBoundary>
|
|
<RootNavigator />
|
|
</ErrorBoundary>
|
|
</QueryClientProvider>
|
|
</AppServiceContext.Provider>
|
|
</ToastProvider>
|
|
</ThemeProvider>
|
|
);
|
|
}
|
|
|
|
function RootNavigator() {
|
|
const { appService } = useAppService();
|
|
const segments = useSegments();
|
|
const router = useRouter();
|
|
const hasNavigated = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (hasNavigated.current) {
|
|
return;
|
|
}
|
|
|
|
const accounts = appService.getAccounts();
|
|
const workspaces = appService.getWorkspaces();
|
|
const inAuthGroup = segments[0] === '(auth)';
|
|
|
|
if (accounts.length === 0) {
|
|
// No accounts -> go to server selection
|
|
if (!inAuthGroup) {
|
|
router.replace('/(auth)/');
|
|
}
|
|
} else if (workspaces.length > 0) {
|
|
// Has account + workspace -> go to app
|
|
router.replace('/(app)/(home)');
|
|
} else {
|
|
// Has account but no workspace -> create workspace
|
|
const firstAccount = accounts[0]!;
|
|
router.replace({
|
|
pathname: '/(auth)/create-workspace',
|
|
params: { accountId: firstAccount.account.id },
|
|
});
|
|
}
|
|
|
|
hasNavigated.current = true;
|
|
}, [appService, segments, router]);
|
|
|
|
return <Slot />;
|
|
}
|