chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+69
View File
@@ -0,0 +1,69 @@
import { useTheme } from '@react-navigation/core';
import * as React from 'react';
import {
type GestureResponderEvent,
Platform,
Text,
type TextProps,
} from 'react-native';
import { type LinkProps, useLinkProps } from './useLinkProps';
type Props<ParamList extends ReactNavigation.RootParamList> =
LinkProps<ParamList> &
Omit<TextProps, 'disabled'> & {
target?: string;
onPress?: (
e:
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
| GestureResponderEvent
) => void;
disabled?: boolean | null;
children: React.ReactNode;
};
/**
* Component to render link to another screen using a path.
* Uses an anchor tag on the web.
*
* @param props.screen Name of the screen to navigate to (e.g. `'Feeds'`).
* @param props.params Params to pass to the screen to navigate to (e.g. `{ sort: 'hot' }`).
* @param props.href Optional absolute path to use for the href (e.g. `/feeds/hot`).
* @param props.action Optional action to use for in-page navigation. By default, the path is parsed to an action based on linking config.
* @param props.children Child elements to render the content.
*/
export function Link<ParamList extends ReactNavigation.RootParamList>({
screen,
params,
action,
href,
style,
...rest
}: Props<ParamList>) {
const { colors, fonts } = useTheme();
// @ts-expect-error: This is already type-checked by the prop types
const props = useLinkProps<ParamList>({ screen, params, action, href });
const onPress = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent
) => {
if ('onPress' in rest) {
rest.onPress?.(e);
}
// Let user prevent default behavior
if (!e.defaultPrevented) {
props.onPress(e);
}
};
return React.createElement(Text, {
...props,
...rest,
...Platform.select({
web: { onClick: onPress } as any,
default: { onPress },
}),
style: [{ color: colors.primary }, fonts.regular, style],
});
}
@@ -0,0 +1,16 @@
import type { ParamListBase } from '@react-navigation/core';
import * as React from 'react';
import type { LinkingOptions } from './types';
const MISSING_CONTEXT_ERROR = "Couldn't find a LinkingContext context.";
export const LinkingContext = React.createContext<{
options?: LinkingOptions<ParamListBase>;
}>({
get options(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
});
LinkingContext.displayName = 'LinkingContext';
@@ -0,0 +1,7 @@
import * as React from 'react';
import type { LocaleDirection } from './types';
export const LocaleDirContext = React.createContext<LocaleDirection>('ltr');
LocaleDirContext.displayName = 'LocaleDirContext';
@@ -0,0 +1,211 @@
import {
BaseNavigationContainer,
getActionFromState,
getPathFromState,
getStateFromPath,
type NavigationContainerProps,
type NavigationContainerRef,
type NavigationState,
type ParamListBase,
ThemeProvider,
validatePathConfig,
} from '@react-navigation/core';
import * as React from 'react';
import { I18nManager } from 'react-native';
import useLatestCallback from 'use-latest-callback';
import { LinkingContext } from './LinkingContext';
import { LocaleDirContext } from './LocaleDirContext';
import { DefaultTheme } from './theming/DefaultTheme';
import type {
DocumentTitleOptions,
LinkingOptions,
LocaleDirection,
} from './types';
import { UnhandledLinkingContext } from './UnhandledLinkingContext';
import { useBackButton } from './useBackButton';
import { useDocumentTitle } from './useDocumentTitle';
import { useLinking } from './useLinking';
import { useThenable } from './useThenable';
declare global {
var REACT_NAVIGATION_DEVTOOLS: WeakMap<
NavigationContainerRef<any>,
{ readonly linking: LinkingOptions<any> }
>;
}
globalThis.REACT_NAVIGATION_DEVTOOLS = new WeakMap();
type Props<ParamList extends {}> = NavigationContainerProps & {
/**
* Initial state object for the navigation tree.
*
* If this is provided, deep link or URLs won't be handled on the initial render.
*/
initialState?: NavigationContainerProps['initialState'];
/**
* Text direction of the components. Defaults to `'ltr'`.
*/
direction?: LocaleDirection;
/**
* Options for deep linking.
*
* Deep link handling is enabled when this prop is provided,
* unless `linking.enabled` is `false`.
*/
linking?: LinkingOptions<ParamList>;
/**
* Fallback element to render until initial state is resolved from deep linking.
*
* Defaults to `null`.
*/
fallback?: React.ReactNode;
/**
* Options to configure the document title on Web.
*
* Updating document title is handled by default,
* unless `documentTitle.enabled` is `false`.
*/
documentTitle?: DocumentTitleOptions;
};
function NavigationContainerInner(
{
direction = I18nManager.getConstants().isRTL ? 'rtl' : 'ltr',
theme = DefaultTheme,
linking,
fallback = null,
documentTitle,
onReady,
onStateChange,
...rest
}: Props<ParamListBase>,
ref?: React.Ref<NavigationContainerRef<ParamListBase> | null>
) {
const isLinkingEnabled = linking ? linking.enabled !== false : false;
if (linking?.config) {
validatePathConfig(linking.config);
}
const refContainer =
React.useRef<NavigationContainerRef<ParamListBase>>(null);
useBackButton(refContainer);
useDocumentTitle(refContainer, documentTitle);
const [lastUnhandledLink, setLastUnhandledLink] = React.useState<
string | undefined
>();
const { getInitialState } = useLinking(
refContainer,
{
enabled: isLinkingEnabled,
prefixes: [],
...linking,
},
setLastUnhandledLink
);
const linkingContext = React.useMemo(() => ({ options: linking }), [linking]);
const unhandledLinkingContext = React.useMemo(
() => ({ lastUnhandledLink, setLastUnhandledLink }),
[lastUnhandledLink, setLastUnhandledLink]
);
const onReadyForLinkingHandling = useLatestCallback(() => {
// If the screen path matches lastUnhandledLink, we do not track it
const path = refContainer.current?.getCurrentRoute()?.path;
setLastUnhandledLink((previousLastUnhandledLink) => {
if (previousLastUnhandledLink === path) {
return undefined;
}
return previousLastUnhandledLink;
});
onReady?.();
});
const onStateChangeForLinkingHandling = useLatestCallback(
(state: Readonly<NavigationState> | undefined) => {
// If the screen path matches lastUnhandledLink, we do not track it
const path = refContainer.current?.getCurrentRoute()?.path;
setLastUnhandledLink((previousLastUnhandledLink) => {
if (previousLastUnhandledLink === path) {
return undefined;
}
return previousLastUnhandledLink;
});
onStateChange?.(state);
}
);
// Add additional linking related info to the ref
// This will be used by the devtools
React.useEffect(() => {
if (refContainer.current) {
REACT_NAVIGATION_DEVTOOLS.set(refContainer.current, {
get linking() {
return {
...linking,
enabled: isLinkingEnabled,
prefixes: linking?.prefixes ?? [],
getStateFromPath: linking?.getStateFromPath ?? getStateFromPath,
getPathFromState: linking?.getPathFromState ?? getPathFromState,
getActionFromState:
linking?.getActionFromState ?? getActionFromState,
};
},
});
}
});
const [isResolved, initialState] = useThenable(getInitialState);
// FIXME
// @ts-expect-error not sure why this is not working
React.useImperativeHandle(ref, () => refContainer.current);
const isLinkingReady =
rest.initialState != null || !isLinkingEnabled || isResolved;
if (!isLinkingReady) {
return (
<LocaleDirContext.Provider value={direction}>
<ThemeProvider value={theme}>{fallback}</ThemeProvider>
</LocaleDirContext.Provider>
);
}
return (
<LocaleDirContext.Provider value={direction}>
<UnhandledLinkingContext.Provider value={unhandledLinkingContext}>
<LinkingContext.Provider value={linkingContext}>
<BaseNavigationContainer
{...rest}
theme={theme}
onReady={onReadyForLinkingHandling}
onStateChange={onStateChangeForLinkingHandling}
initialState={
rest.initialState == null ? initialState : rest.initialState
}
ref={refContainer}
/>
</LinkingContext.Provider>
</UnhandledLinkingContext.Provider>
</LocaleDirContext.Provider>
);
}
/**
* Container component that manages the navigation state.
* This should be rendered at the root wrapping the whole app.
*/
export const NavigationContainer = React.forwardRef(
NavigationContainerInner
) as <RootParamList extends {} = ReactNavigation.RootParamList>(
props: Props<RootParamList> & {
ref?: React.Ref<NavigationContainerRef<RootParamList>>;
}
) => React.ReactElement;
@@ -0,0 +1,57 @@
import { CurrentRenderContext } from '@react-navigation/core';
import * as React from 'react';
import { ServerContext, type ServerContextType } from './ServerContext';
import type { ServerContainerRef } from './types';
type Props = ServerContextType & {
children: React.ReactNode;
};
/**
* Container component for server rendering.
*
* @param props.location Location object to base the initial URL for SSR.
* @param props.children Child elements to render the content.
* @param props.ref Ref object which contains helper methods.
*/
export const ServerContainer = React.forwardRef(function ServerContainer(
{ children, location }: Props,
ref: React.Ref<ServerContainerRef>
) {
React.useEffect(() => {
console.error(
"'ServerContainer' should only be used on the server with 'react-dom/server' for SSR."
);
}, []);
// eslint-disable-next-line @eslint-react/no-unstable-context-value
const current: { options?: object } = {};
if (ref) {
const value = {
getCurrentOptions() {
return current.options;
},
};
// We write to the `ref` during render instead of `React.useImperativeHandle`
// This is because `useImperativeHandle` will update the ref after 'commit',
// and there's no 'commit' phase during SSR.
// Mutating ref during render is unsafe in concurrent mode, but we don't care about it for SSR.
if (typeof ref === 'function') {
ref(value);
} else {
ref.current = value;
}
}
return (
// eslint-disable-next-line @eslint-react/no-unstable-context-value
<ServerContext.Provider value={{ location }}>
<CurrentRenderContext.Provider value={current}>
{children}
</CurrentRenderContext.Provider>
</ServerContext.Provider>
);
});
@@ -0,0 +1,12 @@
import * as React from 'react';
export type ServerContextType = {
location?: {
pathname: string;
search: string;
};
};
export const ServerContext = React.createContext<ServerContextType | undefined>(
undefined
);
@@ -0,0 +1,18 @@
import * as React from 'react';
const MISSING_CONTEXT_ERROR =
"Couldn't find an UnhandledLinkingContext context.";
export const UnhandledLinkingContext = React.createContext<{
lastUnhandledLink: string | undefined;
setLastUnhandledLink: (lastUnhandledUrl: string | undefined) => void;
}>({
get lastUnhandledLink(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
get setLastUnhandledLink(): any {
throw new Error(MISSING_CONTEXT_ERROR);
},
});
UnhandledLinkingContext.displayName = 'UnhandledLinkingContext';
@@ -0,0 +1,46 @@
import {
createNavigatorFactory,
type DefaultNavigatorOptions,
type NavigationListBase,
type ParamListBase,
type StackNavigationState,
StackRouter,
type TypedNavigator,
useNavigationBuilder,
} from '@react-navigation/core';
const StackNavigator = (
props: DefaultNavigatorOptions<
ParamListBase,
string | undefined,
StackNavigationState<ParamListBase>,
{},
{},
unknown
>
) => {
const { state, descriptors, NavigationContent } = useNavigationBuilder(
StackRouter,
props
);
return (
<NavigationContent>
{descriptors[state.routes[state.index].key].render()}
</NavigationContent>
);
};
export function createStackNavigator<
ParamList extends ParamListBase,
>(): TypedNavigator<{
ParamList: ParamList;
NavigatorID: string | undefined;
State: StackNavigationState<ParamList>;
ScreenOptions: {};
EventMap: {};
NavigationList: NavigationListBase<ParamList>;
Navigator: typeof StackNavigator;
}> {
return createNavigatorFactory(StackNavigator)();
}
@@ -0,0 +1,77 @@
let location = new URL('', 'http://example.com');
let listeners: (() => void)[] = [];
let entries = [{ state: null, href: location.href }];
let index = 0;
let currentState: any = null;
const history = {
get state() {
return currentState;
},
pushState(state: any, _: string, path: string) {
location = new URL(path, location.origin);
currentState = state;
entries = entries.slice(0, index + 1);
entries.push({ state, href: location.href });
index = entries.length - 1;
},
replaceState(state: any, _: string, path: string) {
location = new URL(path, location.origin);
currentState = state;
entries[index] = { state, href: location.href };
},
go(n: number) {
setTimeout(() => {
if (
(n > 0 && n < entries.length - index) ||
(n < 0 && Math.abs(n) <= index)
) {
index += n;
const entry = entries[index];
location = new URL(entry.href);
currentState = entry.state;
listeners.forEach((cb) => cb());
}
}, 0);
},
back() {
this.go(-1);
},
forward() {
this.go(1);
},
};
const addEventListener = (type: 'popstate', listener: () => void) => {
if (type === 'popstate') {
listeners.push(listener);
}
};
const removeEventListener = (type: 'popstate', listener: () => void) => {
if (type === 'popstate') {
listeners = listeners.filter((cb) => cb !== listener);
}
};
export const window = {
document: { title: '' },
get location() {
return location;
},
history,
addEventListener,
removeEventListener,
get window() {
return window;
},
};
@@ -0,0 +1,229 @@
import type { NavigationState } from '@react-navigation/core';
import { nanoid } from 'nanoid/non-secure';
type HistoryRecord = {
// Unique identifier for this record to match it with window.history.state
id: string;
// Navigation state object for the history entry
state: NavigationState;
// Path of the history entry
path: string;
};
export function createMemoryHistory() {
let index = 0;
let items: HistoryRecord[] = [];
// Pending callbacks for `history.go(n)`
// We might modify the callback stored if it was interrupted, so we have a ref to identify it
const pending: { ref: unknown; cb: (interrupted?: boolean) => void }[] = [];
const interrupt = () => {
// If another history operation was performed we need to interrupt existing ones
// This makes sure that calls such as `history.replace` after `history.go` don't happen
// Since otherwise it won't be correct if something else has changed
pending.forEach((it) => {
const cb = it.cb;
it.cb = () => cb(true);
});
};
const history = {
get index(): number {
// We store an id in the state instead of an index
// Index could get out of sync with in-memory values if page reloads
const id = window.history.state?.id;
if (id) {
const index = items.findIndex((item) => item.id === id);
return index > -1 ? index : 0;
}
return 0;
},
get(index: number) {
return items[index];
},
backIndex({ path }: { path: string }) {
// We need to find the index from the element before current to get closest path to go back to
for (let i = index - 1; i >= 0; i--) {
const item = items[i];
if (item.path === path) {
return i;
}
}
return -1;
},
push({ path, state }: { path: string; state: NavigationState }) {
interrupt();
const id = nanoid();
// When a new entry is pushed, all the existing entries after index will be inaccessible
// So we remove any existing entries after the current index to clean them up
items = items.slice(0, index + 1);
items.push({ path, state, id });
index = items.length - 1;
// We pass empty string for title because it's ignored in all browsers except safari
// We don't store state object in history.state because:
// - browsers have limits on how big it can be, and we don't control the size
// - while not recommended, there could be non-serializable data in state
window.history.pushState({ id }, '', path);
},
replace({ path, state }: { path: string; state: NavigationState }) {
interrupt();
const id = window.history.state?.id ?? nanoid();
// Need to keep the hash part of the path if there was no previous history entry
// or the previous history entry had the same path
let pathWithHash = path;
const hash = pathWithHash.includes('#') ? '' : location.hash;
if (!items.length || items.findIndex((item) => item.id === id) < 0) {
// There are two scenarios for creating an array with only one history record:
// - When loaded id not found in the items array, this function by default will replace
// the first item. We need to keep only the new updated object, otherwise it will break
// the page when navigating forward in history.
// - This is the first time any state modifications are done
// So we need to push the entry as there's nothing to replace
pathWithHash = pathWithHash + hash;
items = [{ path: pathWithHash, state, id }];
index = 0;
} else {
if (items[index].path === path) {
pathWithHash = pathWithHash + hash;
}
items[index] = { path, state, id };
}
window.history.replaceState({ id }, '', pathWithHash);
},
// `history.go(n)` is asynchronous, there are couple of things to keep in mind:
// - it won't do anything if we can't go `n` steps, the `popstate` event won't fire.
// - each `history.go(n)` call will trigger a separate `popstate` event with correct location.
// - the `popstate` event fires before the next frame after calling `history.go(n)`.
// This method differs from `history.go(n)` in the sense that it'll go back as many steps it can.
go(n: number) {
interrupt();
// To guard against unexpected navigation out of the app we will assume that browser history is only as deep as the length of our memory
// history. If we don't have an item to navigate to then update our index and navigate as far as we can without taking the user out of the app.
const nextIndex = index + n;
const lastItemIndex = items.length - 1;
if (n < 0 && !items[nextIndex]) {
// Attempted to navigate beyond the first index. Negating the current index will align the browser history with the first item.
n = -index;
index = 0;
} else if (n > 0 && nextIndex > lastItemIndex) {
// Attempted to navigate past the last index. Calculate how many indices away from the last index and go there.
n = lastItemIndex - index;
index = lastItemIndex;
} else {
index = nextIndex;
}
if (n === 0) {
return;
}
// When we call `history.go`, `popstate` will fire when there's history to go back to
// So we need to somehow handle following cases:
// - There's history to go back, `history.go` is called, and `popstate` fires
// - `history.go` is called multiple times, we need to resolve on respective `popstate`
// - No history to go back, but `history.go` was called, browser has no API to detect it
return new Promise<void>((resolve, reject) => {
const done = (interrupted?: boolean) => {
clearTimeout(timer);
if (interrupted) {
reject(new Error('History was changed during navigation.'));
return;
}
// There seems to be a bug in Chrome regarding updating the title
// If we set a title just before calling `history.go`, the title gets lost
// However the value of `document.title` is still what we set it to
// It's just not displayed in the tab bar
// To update the tab bar, we need to reset the title to something else first (e.g. '')
// And set the title to what it was before so it gets applied
// It won't work without setting it to empty string coz otherwise title isn't changing
// Which means that the browser won't do anything after setting the title
const { title } = window.document;
window.document.title = '';
window.document.title = title;
resolve();
};
pending.push({ ref: done, cb: done });
// If navigation didn't happen within 100ms, assume that it won't happen
// This may not be accurate, but hopefully it won't take so much time
// In Chrome, navigation seems to happen instantly in next microtask
// But on Firefox, it seems to take much longer, around 50ms from our testing
// We're using a hacky timeout since there doesn't seem to be way to know for sure
const timer = setTimeout(() => {
const foundIndex = pending.findIndex((it) => it.ref === done);
if (foundIndex > -1) {
pending[foundIndex].cb();
pending.splice(foundIndex, 1);
}
index = this.index;
}, 100);
const onPopState = () => {
// Fix createMemoryHistory.index variable's value
// as it may go out of sync when navigating in the browser.
index = this.index;
const last = pending.pop();
window.removeEventListener('popstate', onPopState);
last?.cb();
};
window.addEventListener('popstate', onPopState);
window.history.go(n);
});
},
// The `popstate` event is triggered when history changes, except `pushState` and `replaceState`
// If we call `history.go(n)` ourselves, we don't want it to trigger the listener
// Here we normalize it so that only external changes (e.g. user pressing back/forward) trigger the listener
listen(listener: () => void) {
const onPopState = () => {
// Fix createMemoryHistory.index variable's value
// as it may go out of sync when navigating in the browser.
index = this.index;
if (pending.length) {
// This was triggered by `history.go(n)`, we shouldn't call the listener
return;
}
listener();
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
},
};
return history;
}
@@ -0,0 +1,109 @@
import {
createComponentForStaticNavigation,
createPathConfigForStaticNavigation,
type NavigationContainerRef,
type ParamListBase,
type StaticNavigation,
} from '@react-navigation/core';
import * as React from 'react';
import { NavigationContainer } from './NavigationContainer';
import type { LinkingOptions } from './types';
type Props = Omit<
React.ComponentProps<typeof NavigationContainer>,
'linking' | 'children'
> & {
/**
* Options for deep linking.
*/
linking?: Omit<LinkingOptions<ParamListBase>, 'config' | 'enabled'> & {
/**
* Whether deep link handling should be enabled.
* Defaults to `true` if any `linking` options are specified, `false` otherwise.
*
* When 'auto' is specified, all leaf screens will get a autogenerated path.
* The generated path will be a kebab-case version of the screen name.
* This can be overridden for specific screens by specifying `linking` for the screen.
*/
enabled?: 'auto' | true | false;
/**
* Additional configuration
*/
config?: Omit<
NonNullable<LinkingOptions<ParamListBase>['config']>,
'screens'
>;
};
};
/**
* Create a navigation component from a static navigation config.
* The returned component is a wrapper around `NavigationContainer`.
*
* @param tree Static navigation config.
* @returns Navigation component to use in your app.
*/
export function createStaticNavigation(tree: StaticNavigation<any, any, any>) {
const Component = createComponentForStaticNavigation(tree, 'RootNavigator');
function Navigation(
{ linking, ...rest }: Props,
ref: React.Ref<NavigationContainerRef<ParamListBase>>
) {
const linkingConfig = React.useMemo(() => {
const screens = createPathConfigForStaticNavigation(
tree,
{ initialRouteName: linking?.config?.initialRouteName },
linking?.enabled === 'auto'
);
if (!screens) return;
return {
path: linking?.config?.path,
initialRouteName: linking?.config?.initialRouteName,
screens,
};
}, [
linking?.enabled,
linking?.config?.path,
linking?.config?.initialRouteName,
]);
const memoizedLinking = React.useMemo(() => {
if (!linking) {
return undefined;
}
const enabled =
typeof linking.enabled === 'boolean'
? linking.enabled
: linkingConfig?.screens != null;
return {
...linking,
enabled,
config: linkingConfig,
};
}, [linking, linkingConfig]);
if (linking?.enabled === true && linkingConfig?.screens == null) {
throw new Error(
'Linking is enabled but no linking configuration was found for the screens.\n\n' +
'To solve this:\n' +
"- Specify a 'linking' property for the screens you want to link to.\n" +
"- Or set 'linking.enabled' to 'auto' to generate paths automatically.\n\n" +
'See usage guide: https://reactnavigation.org/docs/static-configuration#linking'
);
}
return (
<NavigationContainer {...rest} ref={ref} linking={memoizedLinking}>
<Component />
</NavigationContainer>
);
}
return React.forwardRef(Navigation);
}
@@ -0,0 +1,29 @@
import escapeStringRegexp from 'escape-string-regexp';
export function extractPathFromURL(prefixes: string[], url: string) {
for (const prefix of prefixes) {
const protocol = prefix.match(/^[^:]+:/)?.[0] ?? '';
const host = prefix
.replace(new RegExp(`^${escapeStringRegexp(protocol)}`), '')
.replace(/\/+/g, '/') // Replace multiple slash (//) with single ones
.replace(/^\//, ''); // Remove extra leading slash
const prefixRegex = new RegExp(
`^${escapeStringRegexp(protocol)}(/)*${host
.split('.')
.map((it) => (it === '*' ? '[^/]+' : escapeStringRegexp(it)))
.join('\\.')}`
);
const [originAndPath, ...searchParams] = url.split('?');
const normalizedURL = originAndPath
.replace(/\/+/g, '/')
.concat(searchParams.length ? `?${searchParams.join('?')}` : '');
if (prefixRegex.test(normalizedURL)) {
return normalizedURL.replace(prefixRegex, '');
}
}
return undefined;
}
+17
View File
@@ -0,0 +1,17 @@
export { createStaticNavigation } from './createStaticNavigation';
export { Link } from './Link';
export { LinkingContext } from './LinkingContext';
export { LocaleDirContext } from './LocaleDirContext';
export { NavigationContainer } from './NavigationContainer';
export { ServerContainer } from './ServerContainer';
export { DarkTheme } from './theming/DarkTheme';
export { DefaultTheme } from './theming/DefaultTheme';
export * from './types';
export { UnhandledLinkingContext as UNSTABLE_UnhandledLinkingContext } from './UnhandledLinkingContext';
export { useLinkBuilder } from './useLinkBuilder';
export { type LinkProps, useLinkProps } from './useLinkProps';
export { useLinkTo } from './useLinkTo';
export { useLocale } from './useLocale';
export { useRoutePath } from './useRoutePath';
export { useScrollToTop } from './useScrollToTop';
export * from '@react-navigation/core';
@@ -0,0 +1,15 @@
import type { Theme } from '../types';
import { fonts } from './fonts';
export const DarkTheme: Theme = {
dark: true,
colors: {
primary: 'rgb(10, 132, 255)',
background: 'rgb(1, 1, 1)',
card: 'rgb(18, 18, 18)',
text: 'rgb(229, 229, 231)',
border: 'rgb(39, 39, 41)',
notification: 'rgb(255, 69, 58)',
},
fonts,
};
@@ -0,0 +1,15 @@
import type { Theme } from '../types';
import { fonts } from './fonts';
export const DefaultTheme: Theme = {
dark: false,
colors: {
primary: 'rgb(0, 122, 255)',
background: 'rgb(242, 242, 242)',
card: 'rgb(255, 255, 255)',
text: 'rgb(28, 28, 30)',
border: 'rgb(216, 216, 216)',
notification: 'rgb(255, 59, 48)',
},
fonts,
};
@@ -0,0 +1,63 @@
import { Platform } from 'react-native';
import type { Theme } from '../types';
const WEB_FONT_STACK =
'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
export const fonts = Platform.select({
web: {
regular: {
fontFamily: WEB_FONT_STACK,
fontWeight: '400',
},
medium: {
fontFamily: WEB_FONT_STACK,
fontWeight: '500',
},
bold: {
fontFamily: WEB_FONT_STACK,
fontWeight: '600',
},
heavy: {
fontFamily: WEB_FONT_STACK,
fontWeight: '700',
},
},
ios: {
regular: {
fontFamily: 'System',
fontWeight: '400',
},
medium: {
fontFamily: 'System',
fontWeight: '500',
},
bold: {
fontFamily: 'System',
fontWeight: '600',
},
heavy: {
fontFamily: 'System',
fontWeight: '700',
},
},
default: {
regular: {
fontFamily: 'sans-serif',
fontWeight: 'normal',
},
medium: {
fontFamily: 'sans-serif-medium',
fontWeight: 'normal',
},
bold: {
fontFamily: 'sans-serif',
fontWeight: '600',
},
heavy: {
fontFamily: 'sans-serif',
fontWeight: '700',
},
},
} as const satisfies Record<string, Theme['fonts']>);
+191
View File
@@ -0,0 +1,191 @@
import type {
getActionFromState as getActionFromStateDefault,
getPathFromState as getPathFromStateDefault,
getStateFromPath as getStateFromPathDefault,
PathConfigMap,
Route,
} from '@react-navigation/core';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace ReactNavigation {
interface Theme extends NativeTheme {}
}
}
type FontStyle = {
fontFamily: string;
fontWeight:
| 'normal'
| 'bold'
| '100'
| '200'
| '300'
| '400'
| '500'
| '600'
| '700'
| '800'
| '900';
};
interface NativeTheme {
dark: boolean;
colors: {
primary: string;
background: string;
card: string;
text: string;
border: string;
notification: string;
};
fonts: {
regular: FontStyle;
medium: FontStyle;
bold: FontStyle;
heavy: FontStyle;
};
}
export type Theme = NativeTheme;
export type LocaleDirection = 'ltr' | 'rtl';
export type LinkingOptions<ParamList extends {}> = {
/**
* Whether deep link handling should be enabled.
* Defaults to true.
*/
enabled?: boolean;
/**
* The prefixes are stripped from the URL before parsing them.
* Usually they are the `scheme` + `host` (e.g. `myapp://chat?user=jane`)
*
* This is not supported on Web.
*
* @example
* ```js
* {
* prefixes: [
* "myapp://", // App-specific scheme
* "https://example.com", // Prefix for universal links
* "https://*.example.com" // Prefix which matches any subdomain
* ]
* }
* ```
*/
prefixes: string[];
/**
* Optional function which takes an incoming URL returns a boolean
* indicating whether React Navigation should handle it.
*
* This can be used to disable deep linking for specific URLs.
* e.g. URLs used for authentication, and not for deep linking to screens.
*
* This is not supported on Web.
*
* @example
* ```js
* {
* // Filter out URLs used by expo-auth-session
* filter: (url) => !url.includes('+expo-auth-session')
* }
* ```
*/
filter?: (url: string) => boolean;
/**
* Config to fine-tune how to parse the path.
*
* @example
* ```js
* {
* Chat: {
* path: 'chat/:author/:id',
* parse: { id: Number }
* }
* }
* ```
*/
config?: {
/**
* Path string to match against for the whole navigation tree.
* It's not possible to specify params here since this doesn't belong to a screen.
* This is useful when the whole app is under a specific path.
* e.g. all of the screens are under `/admin` in `https://example.com/admin`
*/
path?: string;
/**
* Path configuration for child screens.
*/
screens: PathConfigMap<ParamList>;
/**
* Name of the initial route to use for the root navigator.
*/
initialRouteName?: keyof ParamList;
};
/**
* Custom function to get the initial URL used for linking.
* Uses `Linking.getInitialURL()` by default.
*
* This is not supported on Web.
*
* @example
* ```js
* {
* getInitialURL () => Linking.getInitialURL(),
* }
* ```
*/
getInitialURL?: () =>
| string
| null
| undefined
| Promise<string | null | undefined>;
/**
* Custom function to get subscribe to URL updates.
* Uses `Linking.addEventListener('url', callback)` by default.
*
* This is not supported on Web.
*
* @example
* ```js
* {
* subscribe: (listener) => {
* const onReceiveURL = ({ url }) => listener(url);
*
* Linking.addEventListener('url', onReceiveURL);
*
* return () => Linking.removeEventListener('url', onReceiveURL);
* }
* }
* ```
*/
subscribe?: (
listener: (url: string) => void
) => undefined | void | (() => void);
/**
* Custom function to parse the URL to a valid navigation state (advanced).
*/
getStateFromPath?: typeof getStateFromPathDefault;
/**
* Custom function to convert the state object to a valid URL (advanced).
* Only applicable on Web.
*/
getPathFromState?: typeof getPathFromStateDefault;
/**
* Custom function to convert the state object to a valid action (advanced).
*/
getActionFromState?: typeof getActionFromStateDefault;
};
export type DocumentTitleOptions = {
enabled?: boolean;
formatter?: (
options: Record<string, any> | undefined,
route: Route<string> | undefined
) => string;
};
export type ServerContainerRef = {
getCurrentOptions(): Record<string, any> | undefined;
};
@@ -0,0 +1,33 @@
import type {
NavigationContainerRef,
ParamListBase,
} from '@react-navigation/core';
import * as React from 'react';
import { BackHandler } from 'react-native';
export function useBackButton(
ref: React.RefObject<NavigationContainerRef<ParamListBase> | null>
) {
React.useEffect(() => {
const subscription = BackHandler.addEventListener(
'hardwareBackPress',
() => {
const navigation = ref.current;
if (navigation == null) {
return false;
}
if (navigation.canGoBack()) {
navigation.goBack();
return true;
}
return false;
}
);
return () => subscription.remove();
}, [ref]);
}
@@ -0,0 +1,11 @@
import type {
NavigationContainerRef,
ParamListBase,
} from '@react-navigation/core';
export function useBackButton(
_: React.RefObject<NavigationContainerRef<ParamListBase> | null>
) {
// No-op
// BackHandler is not available on web
}
@@ -0,0 +1,3 @@
export function useDocumentTitle() {
// Noop for native platforms
}
@@ -0,0 +1,41 @@
import type {
NavigationContainerRef,
ParamListBase,
} from '@react-navigation/core';
import * as React from 'react';
import type { DocumentTitleOptions } from './types';
/**
* Set the document title for the active screen
*/
export function useDocumentTitle(
ref: React.RefObject<NavigationContainerRef<ParamListBase> | null>,
{
enabled = true,
formatter = (options, route) => options?.title ?? route?.name,
}: DocumentTitleOptions = {}
) {
React.useEffect(() => {
if (!enabled) {
return;
}
const navigation = ref.current;
if (navigation) {
const title = formatter(
navigation.getCurrentOptions(),
navigation.getCurrentRoute()
);
document.title = title;
}
return navigation?.addListener('options', (e) => {
const title = formatter(e.data.options, navigation?.getCurrentRoute());
document.title = title;
});
});
}
@@ -0,0 +1,147 @@
import {
CommonActions,
findFocusedRoute,
getActionFromState,
getPathFromState,
getStateFromPath,
NavigationHelpersContext,
NavigationRouteContext,
useStateForPath,
} from '@react-navigation/core';
import * as React from 'react';
import { LinkingContext } from './LinkingContext';
type MinimalState = {
routes: [{ name: string; params?: object; state?: MinimalState }];
};
/**
* Helper to build a href for a screen based on the linking options.
*/
export function useBuildHref() {
const navigation = React.useContext(NavigationHelpersContext);
const route = React.useContext(NavigationRouteContext);
const { options } = React.useContext(LinkingContext);
const focusedRouteState = useStateForPath();
const getPathFromStateHelper = options?.getPathFromState ?? getPathFromState;
const buildHref = React.useCallback(
(name: string, params?: object) => {
if (options?.enabled === false) {
return undefined;
}
// Check that we're inside:
// - navigator's context
// - route context of the navigator (could be a screen, tab, etc.)
// - route matches the state for path (from the screen's context)
// This ensures that we're inside a screen
const isScreen =
navigation && route?.key && focusedRouteState
? route.key === findFocusedRoute(focusedRouteState)?.key &&
navigation.getState().routes.some((r) => r.key === route.key)
: false;
const stateForRoute: MinimalState = {
routes: [{ name, params }],
};
const constructState = (
state: MinimalState | undefined
): MinimalState => {
if (state) {
const route = state.routes[0];
// If we're inside a screen and at the innermost route
// We need to replace the state with the provided one
// This assumes that we're navigating to a sibling route
if (isScreen && !route.state) {
return stateForRoute;
}
// Otherwise, dive into the nested state of the route
return {
routes: [
{
...route,
state: constructState(route.state),
},
],
};
}
// Once there is no more nested state, we're at the innermost route
// We can add a state based on provided parameters
// This assumes that we're navigating to a child of this route
// In this case, the helper is used in a navigator for its routes
return stateForRoute;
};
const state = constructState(focusedRouteState);
const path = getPathFromStateHelper(state, options?.config);
return path;
},
[
options?.enabled,
options?.config,
route?.key,
navigation,
focusedRouteState,
getPathFromStateHelper,
]
);
return buildHref;
}
/**
* Helper to build a navigation action from a href based on the linking options.
*/
export const useBuildAction = () => {
const { options } = React.useContext(LinkingContext);
const getStateFromPathHelper = options?.getStateFromPath ?? getStateFromPath;
const getActionFromStateHelper =
options?.getActionFromState ?? getActionFromState;
const buildAction = React.useCallback(
(href: string) => {
if (!href.startsWith('/')) {
throw new Error(`The href must start with '/' (${href}).`);
}
const state = getStateFromPathHelper(href, options?.config);
if (state) {
const action = getActionFromStateHelper(state, options?.config);
return action ?? CommonActions.reset(state);
} else {
throw new Error('Failed to parse the href to a navigation state.');
}
},
[options?.config, getStateFromPathHelper, getActionFromStateHelper]
);
return buildAction;
};
/**
* Helpers to build href or action based on the linking options.
*
* @returns `buildHref` to build an `href` for screen and `buildAction` to build an action from an `href`.
*/
export function useLinkBuilder() {
const buildHref = useBuildHref();
const buildAction = useBuildAction();
return {
buildHref,
buildAction,
};
}
@@ -0,0 +1,156 @@
import {
getPathFromState,
type NavigationAction,
NavigationContainerRefContext,
NavigationHelpersContext,
type NavigatorScreenParams,
type ParamListBase,
} from '@react-navigation/core';
import type { NavigationState, PartialState } from '@react-navigation/routers';
import * as React from 'react';
import { type GestureResponderEvent, Platform } from 'react-native';
import { LinkingContext } from './LinkingContext';
export type LinkProps<
ParamList extends ReactNavigation.RootParamList,
RouteName extends keyof ParamList = keyof ParamList,
> =
| ({
href?: string;
action?: NavigationAction;
} & (RouteName extends unknown
? undefined extends ParamList[RouteName]
? { screen: RouteName; params?: ParamList[RouteName] }
: { screen: RouteName; params: ParamList[RouteName] }
: never))
| {
href?: string;
action: NavigationAction;
screen?: undefined;
params?: undefined;
};
const getStateFromParams = (
params: NavigatorScreenParams<ParamListBase> | undefined
): PartialState<NavigationState> | NavigationState | undefined => {
if (params?.state) {
return params.state;
}
if (params?.screen) {
return {
routes: [
{
name: params.screen,
params: params.params,
// @ts-expect-error this is fine 🔥
state: params.screen
? getStateFromParams(
params.params as
| NavigatorScreenParams<ParamListBase>
| undefined
)
: undefined,
},
],
};
}
return undefined;
};
/**
* Hook to get props for an anchor tag so it can work with in page navigation.
*
* @param props.screen Name of the screen to navigate to (e.g. `'Feeds'`).
* @param props.params Params to pass to the screen to navigate to (e.g. `{ sort: 'hot' }`).
* @param props.href Optional absolute path to use for the href (e.g. `/feeds/hot`).
* @param props.action Optional action to use for in-page navigation. By default, the path is parsed to an action based on linking config.
*/
export function useLinkProps<ParamList extends ReactNavigation.RootParamList>({
screen,
params,
href,
action,
}: LinkProps<ParamList>) {
const root = React.useContext(NavigationContainerRefContext);
const navigation = React.useContext(NavigationHelpersContext);
const { options } = React.useContext(LinkingContext);
const onPress = (
e?: React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent
) => {
let shouldHandle = false;
if (Platform.OS !== 'web' || !e) {
e?.preventDefault?.();
shouldHandle = true;
} else {
// ignore clicks with modifier keys
const hasModifierKey =
('metaKey' in e && e.metaKey) ||
('altKey' in e && e.altKey) ||
('ctrlKey' in e && e.ctrlKey) ||
('shiftKey' in e && e.shiftKey);
// only handle left clicks
const isLeftClick =
'button' in e ? e.button == null || e.button === 0 : true;
// let browser handle "target=_blank" etc.
const isSelfTarget =
e.currentTarget && 'target' in e.currentTarget
? [undefined, null, '', 'self'].includes(e.currentTarget.target)
: true;
if (!hasModifierKey && isLeftClick && isSelfTarget) {
e.preventDefault?.();
shouldHandle = true;
}
}
if (shouldHandle) {
if (action) {
if (navigation) {
navigation.dispatch(action);
} else if (root) {
root.dispatch(action);
} else {
throw new Error(
"Couldn't find a navigation object. Is your component inside NavigationContainer?"
);
}
} else {
// @ts-expect-error This is already type-checked by the prop types
navigation?.navigate(screen, params);
}
}
};
const getPathFromStateHelper = options?.getPathFromState ?? getPathFromState;
return {
href:
href ??
(Platform.OS === 'web' && screen != null
? getPathFromStateHelper(
{
routes: [
{
// @ts-expect-error this is fine 🔥
name: screen,
// @ts-expect-error this is fine 🔥
params: params,
// @ts-expect-error this is fine 🔥
state: getStateFromParams(params),
},
],
},
options?.config
)
: undefined),
role: 'link' as const,
onPress,
};
}
+31
View File
@@ -0,0 +1,31 @@
import { NavigationContainerRefContext } from '@react-navigation/core';
import * as React from 'react';
import { useBuildAction } from './useLinkBuilder';
/**
* Helper to navigate to a screen using a href based on the linking options.
*
* @returns function that receives the href to navigate to.
*/
export function useLinkTo() {
const navigation = React.useContext(NavigationContainerRefContext);
const buildAction = useBuildAction();
const linkTo = React.useCallback(
(href: string) => {
if (navigation === undefined) {
throw new Error(
"Couldn't find a navigation object. Is your component inside NavigationContainer?"
);
}
const action = buildAction(href);
navigation.dispatch(action);
},
[buildAction, navigation]
);
return linkTo;
}
@@ -0,0 +1,217 @@
import {
getActionFromState as getActionFromStateDefault,
getStateFromPath as getStateFromPathDefault,
type NavigationContainerRef,
type ParamListBase,
useNavigationIndependentTree,
} from '@react-navigation/core';
import * as React from 'react';
import { Linking, Platform } from 'react-native';
import { extractPathFromURL } from './extractPathFromURL';
import type { LinkingOptions } from './types';
type ResultState = ReturnType<typeof getStateFromPathDefault>;
type Options = LinkingOptions<ParamListBase>;
const linkingHandlers: symbol[] = [];
export function useLinking(
ref: React.RefObject<NavigationContainerRef<ParamListBase> | null>,
{
enabled = true,
prefixes,
filter,
config,
getInitialURL = () =>
Promise.race([
Linking.getInitialURL(),
new Promise<undefined>((resolve) => {
// Timeout in 150ms if `getInitialState` doesn't resolve
// Workaround for https://github.com/facebook/react-native/issues/25675
setTimeout(resolve, 150);
}),
]),
subscribe = (listener) => {
const callback = ({ url }: { url: string }) => listener(url);
const subscription = Linking.addEventListener('url', callback) as
| { remove(): void }
| undefined;
// Storing this in a local variable stops Jest from complaining about import after teardown
// @ts-expect-error: removeEventListener is not present in newer RN versions
const removeEventListener = Linking.removeEventListener?.bind(Linking);
return () => {
// https://github.com/facebook/react-native/commit/6d1aca806cee86ad76de771ed3a1cc62982ebcd7
if (subscription?.remove) {
subscription.remove();
} else {
removeEventListener?.('url', callback);
}
};
},
getStateFromPath = getStateFromPathDefault,
getActionFromState = getActionFromStateDefault,
}: Options,
onUnhandledLinking: (lastUnhandledLining: string | undefined) => void
) {
const independent = useNavigationIndependentTree();
React.useEffect(() => {
if (process.env.NODE_ENV === 'production') {
return undefined;
}
if (independent) {
return undefined;
}
if (enabled !== false && linkingHandlers.length) {
console.error(
[
'Looks like you have configured linking in multiple places. This is likely an error since deep links should only be handled in one place to avoid conflicts. Make sure that:',
"- You don't have multiple NavigationContainers in the app each with 'linking' enabled",
'- Only a single instance of the root component is rendered',
Platform.OS === 'android'
? "- You have set 'android:launchMode=singleTask' in the '<activity />' section of the 'AndroidManifest.xml' file to avoid launching multiple instances"
: '',
]
.join('\n')
.trim()
);
}
const handler = Symbol();
if (enabled !== false) {
linkingHandlers.push(handler);
}
return () => {
const index = linkingHandlers.indexOf(handler);
if (index > -1) {
linkingHandlers.splice(index, 1);
}
};
}, [enabled, independent]);
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
const enabledRef = React.useRef(enabled);
const prefixesRef = React.useRef(prefixes);
const filterRef = React.useRef(filter);
const configRef = React.useRef(config);
const getInitialURLRef = React.useRef(getInitialURL);
const getStateFromPathRef = React.useRef(getStateFromPath);
const getActionFromStateRef = React.useRef(getActionFromState);
React.useEffect(() => {
enabledRef.current = enabled;
prefixesRef.current = prefixes;
filterRef.current = filter;
configRef.current = config;
getInitialURLRef.current = getInitialURL;
getStateFromPathRef.current = getStateFromPath;
getActionFromStateRef.current = getActionFromState;
});
const getStateFromURL = React.useCallback(
(url: string | null | undefined) => {
if (!url || (filterRef.current && !filterRef.current(url))) {
return undefined;
}
const path = extractPathFromURL(prefixesRef.current, url);
return path !== undefined
? getStateFromPathRef.current(path, configRef.current)
: undefined;
},
[]
);
const getInitialState = React.useCallback(() => {
let state: ResultState | undefined;
if (enabledRef.current) {
const url = getInitialURLRef.current();
if (url != null) {
if (typeof url !== 'string') {
return url.then((url) => {
const state = getStateFromURL(url);
if (typeof url === 'string') {
// If the link were handled, it gets cleared in NavigationContainer
onUnhandledLinking(extractPathFromURL(prefixes, url));
}
return state;
});
} else {
onUnhandledLinking(extractPathFromURL(prefixes, url));
}
}
state = getStateFromURL(url);
}
const thenable = {
then(onfulfilled?: (state: ResultState | undefined) => void) {
return Promise.resolve(onfulfilled ? onfulfilled(state) : state);
},
catch() {
return thenable;
},
};
return thenable as PromiseLike<ResultState | undefined>;
}, [getStateFromURL, onUnhandledLinking, prefixes]);
React.useEffect(() => {
const listener = (url: string) => {
if (!enabled) {
return;
}
const navigation = ref.current;
const state = navigation ? getStateFromURL(url) : undefined;
if (navigation && state) {
// If the link were handled, it gets cleared in NavigationContainer
onUnhandledLinking(extractPathFromURL(prefixes, url));
const action = getActionFromStateRef.current(state, configRef.current);
if (action !== undefined) {
try {
navigation.dispatch(action);
} catch (e) {
// Ignore any errors from deep linking.
// This could happen in case of malformed links, navigation object not being initialized etc.
console.warn(
`An error occurred when trying to handle the link '${url}': ${
typeof e === 'object' && e != null && 'message' in e
? e.message
: e
}`
);
}
} else {
navigation.resetRoot(state);
}
}
};
return subscribe(listener);
}, [enabled, getStateFromURL, onUnhandledLinking, prefixes, ref, subscribe]);
return {
getInitialState,
};
}
+442
View File
@@ -0,0 +1,442 @@
import {
findFocusedRoute,
getActionFromState as getActionFromStateDefault,
getPathFromState as getPathFromStateDefault,
getStateFromPath as getStateFromPathDefault,
type NavigationContainerRef,
type NavigationState,
type ParamListBase,
useNavigationIndependentTree,
} from '@react-navigation/core';
import isEqual from 'fast-deep-equal';
import * as React from 'react';
import { createMemoryHistory } from './createMemoryHistory';
import { ServerContext } from './ServerContext';
import type { LinkingOptions } from './types';
type ResultState = ReturnType<typeof getStateFromPathDefault>;
/**
* Find the matching navigation state that changed between 2 navigation states
* e.g.: a -> b -> c -> d and a -> b -> c -> e -> f, if history in b changed, b is the matching state
*/
const findMatchingState = <T extends NavigationState>(
a: T | undefined,
b: T | undefined
): [T | undefined, T | undefined] => {
if (a === undefined || b === undefined || a.key !== b.key) {
return [undefined, undefined];
}
// Tab and drawer will have `history` property, but stack will have history in `routes`
const aHistoryLength = a.history ? a.history.length : a.routes.length;
const bHistoryLength = b.history ? b.history.length : b.routes.length;
const aRoute = a.routes[a.index];
const bRoute = b.routes[b.index];
const aChildState = aRoute.state as T | undefined;
const bChildState = bRoute.state as T | undefined;
// Stop here if this is the state object that changed:
// - history length is different
// - focused routes are different
// - one of them doesn't have child state
// - child state keys are different
if (
aHistoryLength !== bHistoryLength ||
aRoute.key !== bRoute.key ||
aChildState === undefined ||
bChildState === undefined ||
aChildState.key !== bChildState.key
) {
return [a, b];
}
return findMatchingState(aChildState, bChildState);
};
/**
* Run async function in series as it's called.
*/
export const series = (cb: () => Promise<void>) => {
let queue = Promise.resolve();
const callback = () => {
// eslint-disable-next-line promise/no-callback-in-promise
queue = queue.then(cb);
};
return callback;
};
const linkingHandlers: symbol[] = [];
type Options = LinkingOptions<ParamListBase>;
export function useLinking(
ref: React.RefObject<NavigationContainerRef<ParamListBase> | null>,
{
enabled = true,
config,
getStateFromPath = getStateFromPathDefault,
getPathFromState = getPathFromStateDefault,
getActionFromState = getActionFromStateDefault,
}: Options,
onUnhandledLinking: (lastUnhandledLining: string | undefined) => void
) {
const independent = useNavigationIndependentTree();
React.useEffect(() => {
if (process.env.NODE_ENV === 'production') {
return undefined;
}
if (independent) {
return undefined;
}
if (enabled !== false && linkingHandlers.length) {
console.error(
[
'Looks like you have configured linking in multiple places. This is likely an error since deep links should only be handled in one place to avoid conflicts. Make sure that:',
"- You don't have multiple NavigationContainers in the app each with 'linking' enabled",
'- Only a single instance of the root component is rendered',
]
.join('\n')
.trim()
);
}
const handler = Symbol();
if (enabled !== false) {
linkingHandlers.push(handler);
}
return () => {
const index = linkingHandlers.indexOf(handler);
if (index > -1) {
linkingHandlers.splice(index, 1);
}
};
}, [enabled, independent]);
const [history] = React.useState(createMemoryHistory);
// We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners
// This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo`
// Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect
const enabledRef = React.useRef(enabled);
const configRef = React.useRef(config);
const getStateFromPathRef = React.useRef(getStateFromPath);
const getPathFromStateRef = React.useRef(getPathFromState);
const getActionFromStateRef = React.useRef(getActionFromState);
React.useEffect(() => {
enabledRef.current = enabled;
configRef.current = config;
getStateFromPathRef.current = getStateFromPath;
getPathFromStateRef.current = getPathFromState;
getActionFromStateRef.current = getActionFromState;
});
const validateRoutesNotExistInRootState = React.useCallback(
(state: ResultState) => {
const navigation = ref.current;
const rootState = navigation?.getRootState();
// Make sure that the routes in the state exist in the root navigator
// Otherwise there's an error in the linking configuration
return state?.routes.some((r) => !rootState?.routeNames.includes(r.name));
},
[ref]
);
const server = React.useContext(ServerContext);
const getInitialState = React.useCallback(() => {
let value: ResultState | undefined;
if (enabledRef.current) {
const location =
server?.location ??
(typeof window !== 'undefined' ? window.location : undefined);
const path = location ? location.pathname + location.search : undefined;
if (path) {
value = getStateFromPathRef.current(path, configRef.current);
}
// If the link were handled, it gets cleared in NavigationContainer
onUnhandledLinking(path);
}
const thenable = {
then(onfulfilled?: (state: ResultState | undefined) => void) {
return Promise.resolve(onfulfilled ? onfulfilled(value) : value);
},
catch() {
return thenable;
},
};
return thenable as PromiseLike<ResultState | undefined>;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const previousIndexRef = React.useRef<number | undefined>(undefined);
const previousStateRef = React.useRef<NavigationState | undefined>(undefined);
const pendingPopStatePathRef = React.useRef<string | undefined>(undefined);
React.useEffect(() => {
previousIndexRef.current = history.index;
return history.listen(() => {
const navigation = ref.current;
if (!navigation || !enabled) {
return;
}
const { location } = window;
const path = location.pathname + location.search;
const index = history.index;
const previousIndex = previousIndexRef.current ?? 0;
previousIndexRef.current = index;
pendingPopStatePathRef.current = path;
// When browser back/forward is clicked, we first need to check if state object for this index exists
// If it does we'll reset to that state object
// Otherwise, we'll handle it like a regular deep link
const record = history.get(index);
if (record?.path === path && record?.state) {
navigation.resetRoot(record.state);
return;
}
const state = getStateFromPathRef.current(path, configRef.current);
// We should only dispatch an action when going forward
// Otherwise the action will likely add items to history, which would mess things up
if (state) {
// If the link were handled, it gets cleared in NavigationContainer
onUnhandledLinking(path);
// Make sure that the routes in the state exist in the root navigator
// Otherwise there's an error in the linking configuration
if (validateRoutesNotExistInRootState(state)) {
return;
}
if (index > previousIndex) {
const action = getActionFromStateRef.current(
state,
configRef.current
);
if (action !== undefined) {
try {
navigation.dispatch(action);
} catch (e) {
// Ignore any errors from deep linking.
// This could happen in case of malformed links, navigation object not being initialized etc.
console.warn(
`An error occurred when trying to handle the link '${path}': ${
typeof e === 'object' && e != null && 'message' in e
? e.message
: e
}`
);
}
} else {
navigation.resetRoot(state);
}
} else {
navigation.resetRoot(state);
}
} else {
// if current path didn't return any state, we should revert to initial state
navigation.resetRoot(state);
}
});
}, [
enabled,
history,
onUnhandledLinking,
ref,
validateRoutesNotExistInRootState,
]);
React.useEffect(() => {
if (!enabled) {
return;
}
const getPathForRoute = (
route: ReturnType<typeof findFocusedRoute>,
state: NavigationState
): string => {
let path;
// If the `route` object contains a `path`, use that path as long as `route.name` and `params` still match
// This makes sure that we preserve the original URL for wildcard routes
if (route?.path) {
const stateForPath = getStateFromPathRef.current(
route.path,
configRef.current
);
if (stateForPath) {
const focusedRoute = findFocusedRoute(stateForPath);
if (
focusedRoute &&
focusedRoute.name === route.name &&
isEqual(focusedRoute.params, route.params)
) {
path = route.path;
}
}
}
if (path == null) {
path = getPathFromStateRef.current(state, configRef.current);
}
const previousRoute = previousStateRef.current
? findFocusedRoute(previousStateRef.current)
: undefined;
// Preserve the hash if the route didn't change
if (
previousRoute &&
route &&
'key' in previousRoute &&
'key' in route &&
previousRoute.key === route.key
) {
path = path + location.hash;
}
return path;
};
if (ref.current) {
// We need to record the current metadata on the first render if they aren't set
// This will allow the initial state to be in the history entry
const state = ref.current.getRootState();
if (state) {
const route = findFocusedRoute(state);
const path = getPathForRoute(route, state);
if (previousStateRef.current === undefined) {
previousStateRef.current = state;
}
history.replace({ path, state });
}
}
const onStateChange = async () => {
const navigation = ref.current;
if (!navigation || !enabled) {
return;
}
const previousState = previousStateRef.current;
const state = navigation.getRootState();
// root state may not available, for example when root navigators switch inside the container
if (!state) {
return;
}
const pendingPath = pendingPopStatePathRef.current;
const route = findFocusedRoute(state);
const path = getPathForRoute(route, state);
previousStateRef.current = state;
pendingPopStatePathRef.current = undefined;
// To detect the kind of state change, we need to:
// - Find the common focused navigation state in previous and current state
// - If only the route keys changed, compare history/routes.length to check if we go back/forward/replace
// - If no common focused navigation state found, it's a replace
const [previousFocusedState, focusedState] = findMatchingState(
previousState,
state
);
if (
previousFocusedState &&
focusedState &&
// We should only handle push/pop if path changed from what was in last `popstate`
// Otherwise it's likely a change triggered by `popstate`
path !== pendingPath
) {
const historyDelta =
(focusedState.history
? focusedState.history.length
: focusedState.routes.length) -
(previousFocusedState.history
? previousFocusedState.history.length
: previousFocusedState.routes.length);
if (historyDelta > 0) {
// If history length is increased, we should pushState
// Note that path might not actually change here, for example, drawer open should pushState
history.push({ path, state });
} else if (historyDelta < 0) {
// If history length is decreased, i.e. entries were removed, we want to go back
const nextIndex = history.backIndex({ path });
const currentIndex = history.index;
try {
if (
nextIndex !== -1 &&
nextIndex < currentIndex &&
// We should only go back if the entry exists and it's less than current index
history.get(nextIndex)
) {
// An existing entry for this path exists and it's less than current index, go back to that
await history.go(nextIndex - currentIndex);
} else {
// We couldn't find an existing entry to go back to, so we'll go back by the delta
// This won't be correct if multiple routes were pushed in one go before
// Usually this shouldn't happen and this is a fallback for that
await history.go(historyDelta);
}
// Store the updated state as well as fix the path if incorrect
history.replace({ path, state });
} catch (e) {
// The navigation was interrupted
}
} else {
// If history length is unchanged, we want to replaceState
history.replace({ path, state });
}
} else {
// If no common navigation state was found, assume it's a replace
// This would happen if the user did a reset/conditionally changed navigators
history.replace({ path, state });
}
};
// We debounce onStateChange coz we don't want multiple state changes to be handled at one time
// This could happen since `history.go(n)` is asynchronous
// If `pushState` or `replaceState` were called before `history.go(n)` completes, it'll mess stuff up
return ref.current?.addListener('state', series(onStateChange));
}, [enabled, history, ref]);
return {
getInitialState,
};
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import { LocaleDirContext } from './LocaleDirContext';
/**
* Hook to access the text direction specified in the `NavigationContainer`.
*/
export function useLocale() {
const direction = React.useContext(LocaleDirContext);
if (direction === undefined) {
throw new Error(
"Couldn't determine the text direction. Is your component inside NavigationContainer?"
);
}
return { direction };
}
@@ -0,0 +1,34 @@
import { getPathFromState, useStateForPath } from '@react-navigation/core';
import * as React from 'react';
import { LinkingContext } from './LinkingContext';
/**
* Hook to get the path for the current route based on linking options.
*
* @returns Path for the current route.
*/
export function useRoutePath() {
const { options } = React.useContext(LinkingContext);
const state = useStateForPath();
if (state === undefined) {
throw new Error(
"Couldn't find a state for the route object. Is your component inside a screen in a navigator?"
);
}
const getPathFromStateHelper = options?.getPathFromState ?? getPathFromState;
const path = React.useMemo(() => {
if (options?.enabled === false) {
return undefined;
}
const path = getPathFromStateHelper(state, options?.config);
return path;
}, [options?.enabled, options?.config, state, getPathFromStateHelper]);
return path;
}
@@ -0,0 +1,121 @@
import {
type EventArg,
NavigationContext,
type NavigationProp,
type ParamListBase,
useRoute,
} from '@react-navigation/core';
import * as React from 'react';
import type { ScrollView } from 'react-native';
type ScrollOptions = { x?: number; y?: number; animated?: boolean };
type ScrollableView =
| { scrollToTop(): void }
| { scrollTo(options: ScrollOptions): void }
| { scrollToOffset(options: { offset: number; animated?: boolean }): void }
| { scrollResponderScrollTo(options: ScrollOptions): void };
type ScrollableWrapper =
| { getScrollResponder(): React.ReactNode | ScrollView }
| { getNode(): ScrollableView }
| ScrollableView
| null;
function getScrollableNode(ref: React.RefObject<ScrollableWrapper>) {
if (ref.current == null) {
return null;
}
if (
'scrollToTop' in ref.current ||
'scrollTo' in ref.current ||
'scrollToOffset' in ref.current ||
'scrollResponderScrollTo' in ref.current
) {
// This is already a scrollable node.
return ref.current;
} else if ('getScrollResponder' in ref.current) {
// If the view is a wrapper like FlatList, SectionList etc.
// We need to use `getScrollResponder` to get access to the scroll responder
return ref.current.getScrollResponder();
} else if ('getNode' in ref.current) {
// When a `ScrollView` is wrapped in `Animated.createAnimatedComponent`
// we need to use `getNode` to get the ref to the actual scrollview.
// Note that `getNode` is deprecated in newer versions of react-native
// this is why we check if we already have a scrollable node above.
return ref.current.getNode();
} else {
return ref.current;
}
}
export function useScrollToTop(ref: React.RefObject<ScrollableWrapper>) {
const navigation = React.useContext(NavigationContext);
const route = useRoute();
if (navigation === undefined) {
throw new Error(
"Couldn't find a navigation object. Is your component inside NavigationContainer?"
);
}
React.useEffect(() => {
const tabNavigations: NavigationProp<ParamListBase>[] = [];
let currentNavigation = navigation;
// If the screen is nested inside multiple tab navigators, we should scroll to top for any of them
// So we need to find all the parent tab navigators and add the listeners there
while (currentNavigation) {
if (currentNavigation.getState().type === 'tab') {
tabNavigations.push(currentNavigation);
}
currentNavigation = currentNavigation.getParent();
}
if (tabNavigations.length === 0) {
return;
}
const unsubscribers = tabNavigations.map((tab) => {
return tab.addListener(
// We don't wanna import tab types here to avoid extra deps
// in addition, there are multiple tab implementations
// @ts-expect-error the `tabPress` event is only available when navigation type is tab
'tabPress',
(e: EventArg<'tabPress', true>) => {
// We should scroll to top only when the screen is focused
const isFocused = navigation.isFocused();
// In a nested stack navigator, tab press resets the stack to first screen
// So we should scroll to top only when we are on first screen
const isFirst =
tabNavigations.includes(navigation) ||
navigation.getState().routes[0].key === route.key;
// Run the operation in the next frame so we're sure all listeners have been run
// This is necessary to know if preventDefault() has been called
requestAnimationFrame(() => {
const scrollable = getScrollableNode(ref) as ScrollableWrapper;
if (isFocused && isFirst && scrollable && !e.defaultPrevented) {
if ('scrollToTop' in scrollable) {
scrollable.scrollToTop();
} else if ('scrollTo' in scrollable) {
scrollable.scrollTo({ y: 0, animated: true });
} else if ('scrollToOffset' in scrollable) {
scrollable.scrollToOffset({ offset: 0, animated: true });
} else if ('scrollResponderScrollTo' in scrollable) {
scrollable.scrollResponderScrollTo({ y: 0, animated: true });
}
}
});
}
);
});
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
};
}, [navigation, ref, route.key]);
}
@@ -0,0 +1,42 @@
import * as React from 'react';
export function useThenable<T>(create: () => PromiseLike<T>) {
const [promise] = React.useState(create);
let initialState: [boolean, T | undefined] = [false, undefined];
// Check if our thenable is synchronous
// eslint-disable-next-line promise/catch-or-return, promise/always-return
promise.then((result) => {
initialState = [true, result];
});
const [state, setState] = React.useState(initialState);
const [resolved] = state;
React.useEffect(() => {
let cancelled = false;
const resolve = async () => {
let result;
try {
result = await promise;
} finally {
if (!cancelled) {
setState([true, result]);
}
}
};
if (!resolved) {
resolve();
}
return () => {
cancelled = true;
};
}, [promise, resolved]);
return state;
}