diff --git a/native/chat/thread-screen-pruner.react.js b/native/chat/thread-screen-pruner.react.js index 10b2a75a4..b2d6ba87d 100644 --- a/native/chat/thread-screen-pruner.react.js +++ b/native/chat/thread-screen-pruner.react.js @@ -1,84 +1,103 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { Alert } from 'react-native'; import { threadIsPending } from 'lib/shared/thread-utils'; import { clearThreadsActionType } from '../navigation/action-types'; import { useActiveThread } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { getStateFromNavigatorRoute, getThreadIDFromRoute, + getChildRouteFromNavigatorRoute, } from '../navigation/navigation-utils'; +import { + AppRouteName, + ChatRouteName, + TabNavigatorRouteName, +} from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import type { AppState } from '../redux/state-types'; const ThreadScreenPruner: React.ComponentType<{}> = React.memo<{}>( function ThreadScreenPruner() { const rawThreadInfos = useSelector( (state: AppState) => state.threadStore.threadInfos, ); const navContext = React.useContext(NavContext); - const chatRoute = React.useMemo(() => { + const chatRouteState = React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; - const appState = getStateFromNavigatorRoute(state.routes[0]); - const tabState = getStateFromNavigatorRoute(appState.routes[0]); - return getStateFromNavigatorRoute(tabState.routes[1]); + const appRoute = state.routes.find(route => route.name === AppRouteName); + invariant( + appRoute, + 'Navigation context should contain route for AppNavigator ' + + 'when ThreadScreenPruner is rendered', + ); + const tabRoute = getChildRouteFromNavigatorRoute( + appRoute, + TabNavigatorRouteName, + ); + const chatRoute = getChildRouteFromNavigatorRoute( + tabRoute, + ChatRouteName, + ); + return getStateFromNavigatorRoute(chatRoute); }, [navContext]); const inStackThreadIDs = React.useMemo(() => { const threadIDs = new Set(); - if (!chatRoute) { + if (!chatRouteState) { return threadIDs; } - for (const route of chatRoute.routes) { + for (const route of chatRouteState.routes) { const threadID = getThreadIDFromRoute(route); if (threadID && !threadIsPending(threadID)) { threadIDs.add(threadID); } } return threadIDs; - }, [chatRoute]); + }, [chatRouteState]); const pruneThreadIDs = React.useMemo(() => { const threadIDs = []; for (const threadID of inStackThreadIDs) { if (!rawThreadInfos[threadID]) { threadIDs.push(threadID); } } return threadIDs; }, [inStackThreadIDs, rawThreadInfos]); const activeThreadID = useActiveThread(); React.useEffect(() => { if (pruneThreadIDs.length === 0 || !navContext) { return; } if (activeThreadID && pruneThreadIDs.includes(activeThreadID)) { Alert.alert( 'Chat invalidated', 'You no longer have permission to view this chat :(', [{ text: 'OK' }], { cancelable: true }, ); } navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: pruneThreadIDs }, }); }, [pruneThreadIDs, navContext, activeThreadID]); return null; }, ); export default ThreadScreenPruner; diff --git a/native/navigation/nav-selectors.js b/native/navigation/nav-selectors.js index f204d6af2..3521dde77 100644 --- a/native/navigation/nav-selectors.js +++ b/native/navigation/nav-selectors.js @@ -1,277 +1,278 @@ // @flow import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import _memoize from 'lodash/memoize'; import * as React from 'react'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import { useSelector } from '../redux/redux-utils'; import type { NavPlusRedux } from '../types/selector-types'; import type { GlobalTheme } from '../types/themes'; import type { NavContextType } from './navigation-context'; import { NavContext } from './navigation-context'; import { getStateFromNavigatorRoute, getThreadIDFromRoute, } from './navigation-utils'; import { AppRouteName, TabNavigatorRouteName, MessageListRouteName, ChatRouteName, CalendarRouteName, ThreadPickerModalRouteName, ActionResultModalRouteName, accountModals, scrollBlockingModals, chatRootModals, threadRoutes, } from './route-names'; const baseCreateIsForegroundSelector = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } return navigationState.routes[navigationState.index].name === routeName; }, ); const createIsForegroundSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateIsForegroundSelector, ); function useIsAppLoggedIn(): boolean { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return false; } const { state } = navContext; return !accountModals.includes(state.routes[state.index].name); }, [navContext]); } const baseCreateActiveTabSelector = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== TabNavigatorRouteName) { return false; } const tabState = getStateFromNavigatorRoute(firstAppSubroute); return tabState.routes[tabState.index].name === routeName; }, ); const createActiveTabSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateActiveTabSelector, ); const scrollBlockingModalsClosedSelector: ( context: ?NavContextType, ) => boolean = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return true; } const appState = getStateFromNavigatorRoute(currentRootSubroute); for (let i = appState.index; i >= 0; i--) { const route = appState.routes[i]; if (scrollBlockingModals.includes(route.name)) { return false; } } return true; }, ); function selectBackgroundIsDark( navigationState: ?PossiblyStaleNavigationState, theme: ?GlobalTheme, ): boolean { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { // Very bright... we'll call it non-dark. Doesn't matter right now since // we only use this selector for determining ActionResultModal appearance return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); let appIndex = appState.index; let currentAppSubroute = appState.routes[appIndex]; while (currentAppSubroute.name === ActionResultModalRouteName) { currentAppSubroute = appState.routes[--appIndex]; } if (scrollBlockingModals.includes(currentAppSubroute.name)) { // All the scroll-blocking chat modals have a dark background return true; } return theme === 'dark'; } function activeThread( navigationState: ?PossiblyStaleNavigationState, validRouteNames: $ReadOnlyArray, ): ?string { if (!navigationState) { return null; } let rootIndex = navigationState.index; let currentRootSubroute = navigationState.routes[rootIndex]; while (currentRootSubroute.name !== AppRouteName) { if (!chatRootModals.includes(currentRootSubroute.name)) { return null; } if (rootIndex === 0) { return null; } currentRootSubroute = navigationState.routes[--rootIndex]; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== TabNavigatorRouteName) { return null; } const tabState = getStateFromNavigatorRoute(firstAppSubroute); const currentTabSubroute = tabState.routes[tabState.index]; if (currentTabSubroute.name !== ChatRouteName) { return null; } const chatState = getStateFromNavigatorRoute(currentTabSubroute); const currentChatSubroute = chatState.routes[chatState.index]; return getThreadIDFromRoute(currentChatSubroute, validRouteNames); } const activeThreadSelector: ( context: ?NavContextType, ) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, threadRoutes), ); const messageListRouteNames = [MessageListRouteName]; const activeMessageListSelector: ( context: ?NavContextType, ) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, messageListRouteNames), ); function useActiveThread(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, threadRoutes); }, [navContext]); } function useActiveMessageList(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, messageListRouteNames); }, [navContext]); } const calendarTabActiveSelector = createActiveTabSelector(CalendarRouteName); const threadPickerActiveSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const calendarActiveSelector: ( context: ?NavContextType, ) => boolean = createSelector( calendarTabActiveSelector, threadPickerActiveSelector, (calendarTabActive: boolean, threadPickerActive: boolean) => calendarTabActive || threadPickerActive, ); const nativeCalendarQuery: ( input: NavPlusRedux, ) => () => CalendarQuery = createSelector( (input: NavPlusRedux) => currentCalendarQuery(input.redux), (input: NavPlusRedux) => calendarActiveSelector(input.navContext), ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: ( input: NavPlusRedux, ) => () => CalendarQuery = createSelector( nativeCalendarQuery, (input: NavPlusRedux) => nonThreadCalendarFiltersSelector(input.redux), ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); function useCalendarQuery(): () => CalendarQuery { const navContext = React.useContext(NavContext); return useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); } + export { createIsForegroundSelector, useIsAppLoggedIn, createActiveTabSelector, scrollBlockingModalsClosedSelector, selectBackgroundIsDark, activeThreadSelector, activeMessageListSelector, useActiveThread, useActiveMessageList, calendarActiveSelector, nativeCalendarQuery, nonThreadCalendarQuery, useCalendarQuery, }; diff --git a/native/navigation/navigation-utils.js b/native/navigation/navigation-utils.js index 2d40c0f8c..35ef44235 100644 --- a/native/navigation/navigation-utils.js +++ b/native/navigation/navigation-utils.js @@ -1,160 +1,176 @@ // @flow import type { PossiblyStaleNavigationState, PossiblyStaleRoute, StaleLeafRoute, ScreenParams, } from '@react-navigation/native'; import invariant from 'invariant'; import { ComposeSubchannelRouteName, AppRouteName, threadRoutes, } from './route-names'; function getStateFromNavigatorRoute( route: PossiblyStaleRoute<>, ): PossiblyStaleNavigationState { const key = route.key ? route.key : `unkeyed ${route.name}`; invariant(route.state, `expecting Route for ${key} to be NavigationState`); return route.state; } function getThreadIDFromParams(params: ?ScreenParams): string { invariant( params && params.threadInfo && typeof params.threadInfo === 'object' && params.threadInfo.id && typeof params.threadInfo.id === 'string', "there's no way in react-navigation/Flow to type this", ); return params.threadInfo.id; } function getParentThreadIDFromParams(params: ?ScreenParams): ?string { if (!params) { return undefined; } const { parentThreadInfo } = params; if (!parentThreadInfo) { return undefined; } invariant( typeof parentThreadInfo === 'object' && parentThreadInfo.id && typeof parentThreadInfo.id === 'string', "there's no way in react-navigation/Flow to type this", ); return parentThreadInfo.id; } function getThreadIDFromRoute( route: PossiblyStaleRoute<>, routes?: $ReadOnlyArray = threadRoutes, ): ?string { if (!routes.includes(route.name)) { return null; } if (route.name === ComposeSubchannelRouteName) { return getParentThreadIDFromParams(route.params); } return getThreadIDFromParams(route.params); } function currentRouteRecurse(route: PossiblyStaleRoute<>): StaleLeafRoute<> { if (!route.state) { return route; } const state = getStateFromNavigatorRoute(route); return currentRouteRecurse(state.routes[state.index]); } function currentLeafRoute( state: PossiblyStaleNavigationState, ): StaleLeafRoute<> { return currentRouteRecurse(state.routes[state.index]); } function findRouteIndexWithKey( state: PossiblyStaleNavigationState, key: string, ): ?number { for (let i = 0; i < state.routes.length; i++) { const route = state.routes[i]; if (route.key === key) { return i; } } return null; } // This function walks from the back of the stack and calls filterFunc on each // screen until the stack is exhausted or filterFunc returns "break". A screen // will be removed if and only if filterFunc returns "remove" (not "break"). function removeScreensFromStack< Route, State: { +routes: $ReadOnlyArray, +index: number, ... }, >( state: State, filterFunc: (route: Route) => 'keep' | 'remove' | 'break', ): State { const newRoutes = []; let newIndex = state.index; let screenRemoved = false; let breakActivated = false; for (let i = state.routes.length - 1; i >= 0; i--) { const route = state.routes[i]; if (breakActivated) { newRoutes.unshift(route); continue; } const result = filterFunc(route); if (result === 'break') { breakActivated = true; } if (breakActivated || result === 'keep') { newRoutes.unshift(route); continue; } screenRemoved = true; if (newIndex >= i) { invariant( newIndex !== 0, 'Attempting to remove current route and all before it', ); newIndex--; } } if (!screenRemoved) { return state; } return { ...state, index: newIndex, routes: newRoutes, }; } function validNavState(state: PossiblyStaleNavigationState): boolean { if (state.routes.length === 0) { return false; } const [firstRoute] = state.routes; if (firstRoute.name !== AppRouteName) { return false; } return true; } +function getChildRouteFromNavigatorRoute( + parentRoute: PossiblyStaleRoute<>, + childRouteName: string, +): PossiblyStaleRoute<> { + const parentState = getStateFromNavigatorRoute(parentRoute); + const childRoute = parentState.routes.find( + route => route.name === childRouteName, + ); + invariant( + childRoute, + `parentRoute should contain route for ${childRouteName}`, + ); + return childRoute; +} + export { getStateFromNavigatorRoute, getThreadIDFromParams, getThreadIDFromRoute, currentLeafRoute, findRouteIndexWithKey, removeScreensFromStack, validNavState, + getChildRouteFromNavigatorRoute, };