Skip to content
Source
Sign inSign up

appLinking.ts

118 lines4.5 KB

1import type { NavigationState, PartialState } from "@react-navigation/native";
2​
3/**
4 * The Expo dev client launches the app via
5 * <scheme>://expo-development-client/?url=<packager> — that URL addresses
6 * the launcher, not app navigation. Without this filter it falls through
7 * to the NotFound wildcard route on every dev launch.
8 */
9export function shouldHandleAppLink(url: string, cloudConfigured: boolean): boolean {
10 // Only the route: a thread link's ?prompt= may mention either lifecycle URL.
11 const route = url.split(/[?#]/)[0] ?? "";
12 return (
13 !route.includes("expo-development-client") &&
14 !route.includes("://expo-sharing") &&
15 !/^t3code(-dev|-preview)?:\/*$/.test(url) &&
16 (cloudConfigured || !CLOUD_ONLY_LINK_PATH.test(route))
17 );
19​
20/**
21 * Screens that only exist with T3 Connect configured. A build without it has
22 * no entry points to them, so a link must not open one empty. Anchored to the
23 * app-relative path (after `exp://host/--/` in Expo Go) so a file route whose
24 * path merely ends in one of these names still opens.
25 */
26const CLOUD_ONLY_LINK_PATH =
27 /^[a-z][\w+.-]*:\/*(?:[^/]*\/--\/)?(?:connect-onboarding|settings\/(?:auth|waitlist))\/*$/i;
28​
29/** Keep the published draft URLs while using the root thread screen. */
30export function restoreDraftLinkState(
31 state: PartialState<NavigationState> | undefined,
32 path: string,
33) {
34 const pathname = path.split("?")[0]?.replace(/^\/+|\/+$/g, "");
35 if (!state || !pathname || !/^new\/draft(?:\/|$)/.test(pathname)) return state;
36 const target = state.routes.at(-1);
37 if (!target) return state;
38 const draft = target.params ?? {};
39 const routes = state.routes.slice(0, -1);
40 routes.push({ name: "Thread", params: { draft } });
41 if (pathname !== "new/draft") routes.push(target);
42 return { ...state, index: routes.length - 1, routes };
44​
45type LinkState = PartialState<NavigationState>;
46type LinkRoute = LinkState["routes"][number];
47​
48function isLinkState(value: unknown): value is LinkState {
49 return typeof value === "object" && value !== null && Array.isArray((value as LinkState).routes);
51​
52/** The first route named one of `names`, at any nesting depth of a parsed link. */
53export function findLinkedRoute(
54 state: LinkState | undefined,
55 names: ReadonlyArray<string>,
56): LinkRoute | undefined {
57 for (const route of state?.routes ?? []) {
58 if (names.includes(route.name)) return route;
59 const nested = isLinkState(route.state) ? findLinkedRoute(route.state, names) : undefined;
60 if (nested) return nested;
61 }
62 return undefined;
64​
65/**
66 * The last route named `name` (and accepted by `accept`, when given) at any depth of a live
67 * navigation state, with the key of the navigator holding it, so an action can be dispatched as
68 * that screen would. `focused` says whether it is that navigator's current route.
69 */
70export function findStackRoute(
71 state: NavigationState | undefined,
72 name: string,
73 accept?: (params: Readonly<Record<string, unknown>> | undefined) => boolean,
74):
75 | { readonly navigatorKey: string; readonly routeKey: string; readonly focused: boolean }
76 | undefined {
77 if (!state) return undefined;
78 for (let index = state.routes.length - 1; index >= 0; index -= 1) {
79 const route = state.routes[index]!;
80 if (
81 route.name === name &&
82 (accept === undefined ||
83 accept(route.params as Readonly<Record<string, unknown>> | undefined))
84 ) {
85 return { navigatorKey: state.key, routeKey: route.key, focused: index === state.index };
86 }
87 const nested = route.state;
88 // Mounted navigators always have keys; a partial state from a link has not been mounted yet.
89 if (typeof nested?.key === "string") {
90 const found = findStackRoute(nested as NavigationState, name, accept);
91 if (found) return found;
92 }
93 }
94 return undefined;
96​
97/** Rewrites the first route named one of `names`, keeping every untouched branch of the state. */
98export function updateLinkedRoute(
99 state: LinkState | undefined,
100 names: ReadonlyArray<string>,
101 update: (route: LinkRoute) => LinkRoute,
102): LinkState | undefined {
103 if (!state) return state;
104 for (const [index, route] of state.routes.entries()) {
105 let next: LinkRoute | undefined;
106 if (names.includes(route.name)) next = update(route);
107 else if (isLinkState(route.state)) {
108 const nested = updateLinkedRoute(route.state, names, update);
109 if (nested !== route.state) next = { ...route, state: nested };
110 }
111 if (next) {
112 const routes = state.routes.slice();
113 routes[index] = next;
114 return { ...state, routes };
115 }
116 }
117 return state;