222 lines
6.7 KiB
TypeScript
222 lines
6.7 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
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";
|
|
import * as Notifications from "expo-notifications";
|
|
import AppNavigator from "./navigation/AppNavigator";
|
|
import type { RootStackParamList } from "./navigation/types";
|
|
import { SessionProvider, useSession } from "./state/session-context";
|
|
import { getNavigationTheme, useTheme } from "./theme";
|
|
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
|
|
|
|
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);
|
|
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
|
|
const lastNotificationIdRef = useRef<string | null>(null);
|
|
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();
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
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],
|
|
);
|
|
|
|
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,
|
|
]);
|
|
|
|
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]);
|
|
|
|
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>
|
|
);
|
|
}
|