diff --git a/native/navigation/invite-link-handler.react.js b/native/invite-links/invite-links-context-provider.react.js similarity index 78% rename from native/navigation/invite-link-handler.react.js rename to native/invite-links/invite-links-context-provider.react.js index 0dac9332b..6b2cf7658 100644 --- a/native/navigation/invite-link-handler.react.js +++ b/native/invite-links/invite-links-context-provider.react.js @@ -1,111 +1,138 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as Application from 'expo-application'; import * as React from 'react'; import { Linking, Platform } from 'react-native'; import { verifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import type { SetState } from 'lib/types/hook-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; -import { InviteLinkModalRouteName } from './route-names.js'; +import { InviteLinkModalRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnFirstLaunchEffect } from '../utils/hooks.js'; -function InviteLinkHandler(): null { +type InviteLinksContextType = { + +setCurrentLinkUrl: SetState, +}; + +const defaultContext = { + setCurrentLinkUrl: () => {}, +}; + +const InviteLinksContext: React.Context = + React.createContext(defaultContext); + +type Props = { + +children: React.Node, +}; +function InviteLinksContextProvider(props: Props): React.Node { + const { children } = props; const [currentLink, setCurrentLink] = React.useState(null); React.useEffect(() => { // This listener listens for an event where a user clicked a link when the // app was running const subscription = Linking.addEventListener('url', ({ url }) => setCurrentLink(url), ); // We're also checking if the app was opened by using an invite link. // In that case the listener won't be called and we're instead checking // if the initial URL is set. (async () => { const initialURL = await Linking.getInitialURL(); if (initialURL) { setCurrentLink(initialURL); } })(); return () => { subscription.remove(); }; }, []); const checkInstallReferrer = React.useCallback(async () => { if (Platform.OS !== 'android') { return; } const installReferrer = await Application.getInstallReferrerAsync(); if (!installReferrer) { return; } const linkSecret = parseInstallReferrer(installReferrer); if (linkSecret) { setCurrentLink(linkSecret); } }, []); useOnFirstLaunchEffect('ANDROID_REFERRER', checkInstallReferrer); const loggedIn = useSelector(isLoggedIn); const dispatchActionPromise = useDispatchActionPromise(); const validateLink = useServerCall(verifyInviteLink); const navigation = useNavigation(); React.useEffect(() => { (async () => { if (!loggedIn || !currentLink) { return; } // We're setting this to null so that we ensure that each link click // results in at most one validation and navigation. setCurrentLink(null); const secret = parseSecret(currentLink); if (!secret) { return; } const validateLinkPromise = validateLink({ secret }); dispatchActionPromise(verifyInviteLinkActionTypes, validateLinkPromise); const result = await validateLinkPromise; if (result.status === 'already_joined') { return; } navigation.navigate<'InviteLinkModal'>({ name: InviteLinkModalRouteName, params: { invitationDetails: result, secret, }, }); })(); }, [currentLink, dispatchActionPromise, loggedIn, navigation, validateLink]); - return null; + const contextValue = React.useMemo( + () => ({ + setCurrentLinkUrl: setCurrentLink, + }), + [], + ); + + return ( + + {children} + + ); } const urlRegex = /invite\/(\S+)$/; function parseSecret(url: string) { const match = urlRegex.exec(url); return match?.[1]; } const referrerRegex = /utm_source=(invite\/(\S+))$/; function parseInstallReferrer(referrer: string) { const match = referrerRegex.exec(referrer); return match?.[1]; } -export default InviteLinkHandler; +export { InviteLinksContext, InviteLinksContextProvider }; diff --git a/native/markdown/markdown-link.react.js b/native/markdown/markdown-link.react.js index af97489dc..ecee51dcd 100644 --- a/native/markdown/markdown-link.react.js +++ b/native/markdown/markdown-link.react.js @@ -1,90 +1,107 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, Linking, Alert } from 'react-native'; +import { inviteLinkUrl } from 'lib/facts/links.js'; + import { MarkdownContext, type MarkdownContextType, } from './markdown-context.js'; import { MarkdownSpoilerContext } from './markdown-spoiler-context.js'; import { MessagePressResponderContext } from '../chat/message-press-responder-context.js'; import { TextMessageMarkdownContext } from '../chat/text-message-markdown-context.js'; +import { InviteLinksContext } from '../invite-links/invite-links-context-provider.react.js'; import { normalizeURL } from '../utils/url-utils.js'; -function useDisplayLinkPrompt( +function useHandleLinkClick( inputURL: string, markdownContext: MarkdownContextType, messageKey: ?string, ) { const { setLinkModalActive } = markdownContext; const onDismiss = React.useCallback(() => { messageKey && setLinkModalActive({ [messageKey]: false }); }, [setLinkModalActive, messageKey]); const url = normalizeURL(inputURL); const onConfirm = React.useCallback(() => { onDismiss(); Linking.openURL(url); }, [url, onDismiss]); let displayURL = url.substring(0, 64); if (url.length > displayURL.length) { displayURL += '…'; } + + const inviteLinksContext = React.useContext(InviteLinksContext); return React.useCallback(() => { + if (url.startsWith(inviteLinkUrl(''))) { + inviteLinksContext?.setCurrentLinkUrl(url); + return; + } messageKey && setLinkModalActive({ [messageKey]: true }); Alert.alert( 'External link', `You sure you want to open this link?\n\n${displayURL}`, [ { text: 'Cancel', style: 'cancel', onPress: onDismiss }, { text: 'Open', onPress: onConfirm }, ], { cancelable: true, onDismiss }, ); - }, [setLinkModalActive, messageKey, displayURL, onConfirm, onDismiss]); + }, [ + url, + messageKey, + setLinkModalActive, + displayURL, + onDismiss, + onConfirm, + inviteLinksContext, + ]); } type TextProps = React.ElementConfig; type Props = { +target: string, +children: React.Node, ...TextProps, }; function MarkdownLink(props: Props): React.Node { const { target, ...rest } = props; const markdownContext = React.useContext(MarkdownContext); invariant(markdownContext, 'MarkdownContext should be set'); const markdownSpoilerContext = React.useContext(MarkdownSpoilerContext); // Since MarkdownSpoilerContext may not be set, we need // to default isRevealed to true for when // we use the ternary operator in the onPress const isRevealed = markdownSpoilerContext?.isRevealed ?? true; const textMessageMarkdownContext = React.useContext( TextMessageMarkdownContext, ); const messageKey = textMessageMarkdownContext?.messageKey; const messagePressResponderContext = React.useContext( MessagePressResponderContext, ); const onPressMessage = messagePressResponderContext?.onPressMessage; - const onPressLink = useDisplayLinkPrompt(target, markdownContext, messageKey); + const onPressLink = useHandleLinkClick(target, markdownContext, messageKey); return ( ); } export default MarkdownLink; diff --git a/native/navigation/navigation-handler.react.js b/native/navigation/navigation-handler.react.js index 1a2688e43..46e016530 100644 --- a/native/navigation/navigation-handler.react.js +++ b/native/navigation/navigation-handler.react.js @@ -1,88 +1,86 @@ // @flow import * as React from 'react'; import { cookieSelector } from 'lib/selectors/keyserver-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { logInActionType, logOutActionType } from './action-types.js'; -import InviteLinkHandler from './invite-link-handler.react.js'; import ModalPruner from './modal-pruner.react.js'; import NavFromReduxHandler from './nav-from-redux-handler.react.js'; import { useIsAppLoggedIn } from './nav-selectors.js'; import { NavContext, type NavAction } from './navigation-context.js'; import PolicyAcknowledgmentHandler from './policy-acknowledgment-handler.react.js'; import ThreadScreenTracker from './thread-screen-tracker.react.js'; import DevTools from '../redux/dev-tools.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors.js'; const NavigationHandler: React.ComponentType<{}> = React.memo<{}>( function NavigationHandler() { const navContext = React.useContext(NavContext); const persistedStateLoaded = usePersistedStateLoaded(); const devTools = __DEV__ ? : null; if (!navContext || !persistedStateLoaded) { if (__DEV__) { return ( <> {devTools} ); } else { return null; } } const { dispatch } = navContext; return ( <> - {devTools} ); }, ); NavigationHandler.displayName = 'NavigationHandler'; type LogInHandlerProps = { +dispatch: (action: NavAction) => void, }; const LogInHandler = React.memo(function LogInHandler( props: LogInHandlerProps, ) { const { dispatch } = props; const hasCurrentUserInfo = useSelector(isLoggedIn); const cookie = useSelector(cookieSelector); const hasUserCookie = !!(cookie && cookie.startsWith('user=')); const loggedIn = hasCurrentUserInfo && hasUserCookie; const navLoggedIn = useIsAppLoggedIn(); const prevLoggedInRef = React.useRef(); React.useEffect(() => { if (loggedIn === prevLoggedInRef.current) { return; } prevLoggedInRef.current = loggedIn; if (loggedIn && !navLoggedIn) { dispatch({ type: (logInActionType: 'LOG_IN') }); } else if (!loggedIn && navLoggedIn) { dispatch({ type: (logOutActionType: 'LOG_OUT') }); } }, [navLoggedIn, loggedIn, dispatch]); return null; }); LogInHandler.displayName = 'LogInHandler'; export default NavigationHandler; diff --git a/native/root.react.js b/native/root.react.js index 528817c21..45507bbef 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,325 +1,328 @@ // @flow import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, UIManager, StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; import { PersistGate as ReduxPersistGate } from 'redux-persist/es/integration/react.js'; import { ENSCacheProvider } from 'lib/components/ens-cache-provider.react.js'; import { MediaCacheProvider } from 'lib/components/media-cache-provider.react.js'; import { actionLogger } from 'lib/utils/action-logger.js'; import { RegistrationContextProvider } from './account/registration/registration-context-provider.react.js'; import NativeEditThreadAvatarProvider from './avatars/native-edit-thread-avatar-provider.react.js'; import NativeEditUserAvatarProvider from './avatars/native-edit-user-avatar-provider.react.js'; import ChatContextProvider from './chat/chat-context-provider.react.js'; import MessageEditingContextProvider from './chat/message-editing-context-provider.react.js'; import { FeatureFlagsProvider } from './components/feature-flags-provider.react.js'; import PersistedStateGate from './components/persisted-state-gate.js'; import ConnectedStatusBar from './connected-status-bar.react.js'; import { SQLiteDataHandler } from './data/sqlite-data-handler.js'; import ErrorBoundary from './error-boundary.react.js'; import InputStateContainer from './input/input-state-container.react.js'; +import { InviteLinksContextProvider } from './invite-links/invite-links-context-provider.react.js'; import LifecycleHandler from './lifecycle/lifecycle-handler.react.js'; import MarkdownContextProvider from './markdown/markdown-context-provider.react.js'; import { filesystemMediaCache } from './media/media-cache.js'; import { defaultNavigationState } from './navigation/default-state.js'; import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react.js'; import { setGlobalNavContext } from './navigation/icky-global.js'; import { NavContext } from './navigation/navigation-context.js'; import NavigationHandler from './navigation/navigation-handler.react.js'; import { validNavState } from './navigation/navigation-utils.js'; import OrientationHandler from './navigation/orientation-handler.react.js'; import { navStateAsyncStorageKey } from './navigation/persistance.js'; import RootNavigator from './navigation/root-navigator.react.js'; import ConnectivityUpdater from './redux/connectivity-updater.react.js'; import { DimensionsUpdater } from './redux/dimensions-updater.react.js'; import { getPersistor } from './redux/persist.js'; import { store } from './redux/redux-setup.js'; import { useSelector } from './redux/redux-utils.js'; import { RootContext } from './root-context.js'; import { MessageSearchProvider } from './search/search-provider.react.js'; import Socket from './socket.react.js'; import { StaffContextProvider } from './staff/staff-context.provider.react.js'; import { useLoadCommFonts } from './themes/fonts.js'; import { DarkTheme, LightTheme } from './themes/navigation.js'; import ThemeHandler from './themes/theme-handler.react.js'; import { provider } from './utils/ethers-utils.js'; // Add custom items to expo-dev-menu import './dev-menu.js'; import './types/message-types-validator.js'; if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); } const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); SplashScreen.preventAutoHideAsync().catch(console.log); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef(); const navStateInitializedRef = React.useRef(false); // We call this here to start the loading process // We gate the UI on the fonts loading in AppNavigator useLoadCommFonts(); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext(prevRootContext => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector(state => state.frozen); const queuedActionsRef = React.useRef([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } for (const action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return undefined; } return navContainer.addListener('__unsafe_action__', event => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }); }, [navContainer]); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( - + + + ); } return ( {gated} {navigation} ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); function AppRoot(): React.Node { return ( ); } export default AppRoot;