CoompanionApp/mobile/src/App.tsx

223 lines
6.7 KiB
TypeScript
Raw Normal View History

2026-02-03 16:35:01 +01:00
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2026-02-03 13:48:56 +01:00
import { Linking } from "react-native";
import {
NavigationContainer,
type LinkingOptions,
useNavigationContainerRef,
} from "@react-navigation/native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
2026-02-03 16:35:01 +01:00
import * as Notifications from "expo-notifications";
2026-02-03 13:48:56 +01:00
import AppNavigator from "./navigation/AppNavigator";
import type { RootStackParamList } from "./navigation/types";
import { SessionProvider, useSession } from "./state/session-context";
import { getNavigationTheme, useTheme } from "./theme";
2026-02-03 16:35:01 +01:00
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
2026-02-03 13:48:56 +01:00
function extractGameId(url: string): string | null {
try {
const parsed = new URL(url);
const path = parsed.pathname || "";
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
const match = path.match(/^\/play\/?([^/]+)?/);
const id = match?.[1]?.trim();
return id ? id : null;
}
if (parsed.host === "play") {
const id = path.replace(/^\//, "").trim();
return id ? id : null;
}
const fallbackMatch = path.match(/^\/play\/?([^/]+)?/);
const fallbackId = fallbackMatch?.[1]?.trim();
return fallbackId ? fallbackId : null;
} catch {
return null;
}
}
function logDeepLink(url: string) {
if (!__DEV__) return;
const gameId = extractGameId(url);
console.log(`[deep-link] url=${url} gameId=${gameId ?? "invalid"}`);
}
function RootNavigationGate() {
const manager = useSession();
const navigationRef = useNavigationContainerRef<RootStackParamList>();
const [navReady, setNavReady] = useState(false);
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
const lastLinkRef = useRef<string | null>(null);
2026-02-03 16:35:01 +01:00
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
const lastNotificationIdRef = useRef<string | null>(null);
2026-02-03 13:48:56 +01:00
const theme = useTheme();
const navigationTheme = getNavigationTheme(theme);
const linking = useMemo<LinkingOptions<RootStackParamList>>(
() => ({
prefixes: ["negopoly://", "https://negopoly.fr"],
config: {
screens: {
Entry: "play/:gameId",
},
},
getInitialURL: async () => {
const url = await Linking.getInitialURL();
if (url) {
lastLinkRef.current = url;
logDeepLink(url);
}
return url;
},
subscribe: (listener) => {
const onReceiveURL = ({ url }: { url: string }) => {
if (!url) return;
if (lastLinkRef.current === url) return;
lastLinkRef.current = url;
logDeepLink(url);
listener(url);
};
const subscription = Linking.addEventListener("url", onReceiveURL);
return () => subscription.remove();
},
}),
[],
);
2026-02-03 16:35:01 +01:00
const processPendingNotification = useCallback(() => {
const pending = pendingNotificationRef.current;
if (!pending) return;
if (!navReady || !navigationRef.isReady()) return;
if (manager.sessionId !== pending.sessionId) return;
if (!manager.session || manager.session.status !== "active") return;
pendingNotificationRef.current = null;
if (pending.type === "chat") {
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
navigationRef.navigate(
targetStack as never,
{
screen: targetTab,
params: {
screen: "ChatThread",
params: { chatId: pending.chatId },
},
} as never,
);
return;
}
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
navigationRef.navigate(
targetStack as never,
{ screen: targetTab } as never,
);
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
const handleNotificationResponse = useCallback(
(response: Notifications.NotificationResponse | null) => {
if (!response) return;
const identifier = response.notification.request.identifier;
if (identifier && lastNotificationIdRef.current === identifier) return;
if (identifier) {
lastNotificationIdRef.current = identifier;
}
const target = parseNotificationTarget(
response.notification.request.content.data,
);
if (!target) return;
if (__DEV__) {
console.log(
`[push] target=${target.type} session=${target.sessionId}` +
(target.type === "chat" ? ` chat=${target.chatId}` : ""),
);
}
pendingNotificationRef.current = target;
processPendingNotification();
},
[processPendingNotification],
);
2026-02-03 13:48:56 +01:00
useEffect(() => {
if (!navReady || !navigationRef.isReady()) return;
let target: keyof RootStackParamList;
if (!manager.sessionId) {
target = "Entry";
} else if (!manager.session) {
target = manager.connectionState === "error" ? "Entry" : "Lobby";
} else if (manager.session.status === "lobby") {
target = "Lobby";
} else if (manager.isBanker) {
target = "BankerTabs";
} else {
target = "PlayerTabs";
}
const currentRoute = navigationRef.getCurrentRoute();
if (currentRoute?.name === target || lastTargetRef.current === target) {
return;
}
navigationRef.reset({
index: 0,
routes: [{ name: target }],
});
lastTargetRef.current = target;
}, [
manager.sessionId,
manager.session,
manager.isBanker,
manager.connectionState,
navReady,
navigationRef,
]);
2026-02-03 16:35:01 +01:00
useEffect(() => {
processPendingNotification();
}, [processPendingNotification]);
useEffect(() => {
let mounted = true;
Notifications.getLastNotificationResponseAsync().then((response) => {
if (!mounted) return;
handleNotificationResponse(response);
});
const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
handleNotificationResponse(response);
},
);
return () => {
mounted = false;
subscription.remove();
};
}, [handleNotificationResponse]);
2026-02-03 13:48:56 +01:00
return (
<NavigationContainer
ref={navigationRef}
onReady={() => setNavReady(true)}
linking={linking}
theme={navigationTheme}
>
<AppNavigator />
</NavigationContainer>
);
}
export default function App() {
const theme = useTheme();
return (
<SafeAreaProvider>
<StatusBar
style={theme.dark ? "light" : "dark"}
backgroundColor={theme.colors.background}
/>
<SessionProvider>
<RootNavigationGate />
</SessionProvider>
</SafeAreaProvider>
);
}