diff --git a/__libdef-tests__/bottom-tabs_v5.x.x-test.js b/__libdef-tests__/bottom-tabs_v5.x.x-test.js new file mode 100644 index 00000000000..c34b14cad05 --- /dev/null +++ b/__libdef-tests__/bottom-tabs_v5.x.x-test.js @@ -0,0 +1,36 @@ +/* @flow strict-local */ +import { type NavigationProp } from '@react-navigation/bottom-tabs'; + +/* eslint-disable no-unused-vars */ + +function test_setParams() { + type NavProp

= NavigationProp<{| r: P |}, 'r'>; + + function test_happy(navigation: NavProp<{| a: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_accepts_missing(navigation: NavProp<{| a: number, b: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_rejects_extra(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[prop-missing] + navigation.setParams({ b: 1 }); + } + + function test_rejects_mismatch(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 'a' }); + } + + function test_rejects_object_to_void(navigation: NavProp) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 1 }); + } + + function test_rejects_void_to_object(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams(); + } +} diff --git a/__libdef-tests__/drawer_v5.x.x-test.js b/__libdef-tests__/drawer_v5.x.x-test.js new file mode 100644 index 00000000000..3c0a3a9d736 --- /dev/null +++ b/__libdef-tests__/drawer_v5.x.x-test.js @@ -0,0 +1,36 @@ +/* @flow strict-local */ +import { type NavigationProp } from '@react-navigation/drawer'; + +/* eslint-disable no-unused-vars */ + +function test_setParams() { + type NavProp

= NavigationProp<{| r: P |}, 'r'>; + + function test_happy(navigation: NavProp<{| a: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_accepts_missing(navigation: NavProp<{| a: number, b: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_rejects_extra(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[prop-missing] + navigation.setParams({ b: 1 }); + } + + function test_rejects_mismatch(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 'a' }); + } + + function test_rejects_object_to_void(navigation: NavProp) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 1 }); + } + + function test_rejects_void_to_object(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams(); + } +} diff --git a/__libdef-tests__/material-top-tabs_v5.x.x-test.js b/__libdef-tests__/material-top-tabs_v5.x.x-test.js new file mode 100644 index 00000000000..5b5a0b4a03f --- /dev/null +++ b/__libdef-tests__/material-top-tabs_v5.x.x-test.js @@ -0,0 +1,36 @@ +/* @flow strict-local */ +import { type NavigationProp } from '@react-navigation/material-top-tabs'; + +/* eslint-disable no-unused-vars */ + +function test_setParams() { + type NavProp

= NavigationProp<{| r: P |}, 'r'>; + + function test_happy(navigation: NavProp<{| a: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_accepts_missing(navigation: NavProp<{| a: number, b: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_rejects_extra(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[prop-missing] + navigation.setParams({ b: 1 }); + } + + function test_rejects_mismatch(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 'a' }); + } + + function test_rejects_object_to_void(navigation: NavProp) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 1 }); + } + + function test_rejects_void_to_object(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams(); + } +} diff --git a/__libdef-tests__/native_v5.x.x-test.js b/__libdef-tests__/native_v5.x.x-test.js new file mode 100644 index 00000000000..6ff0aec1e99 --- /dev/null +++ b/__libdef-tests__/native_v5.x.x-test.js @@ -0,0 +1,36 @@ +/* @flow strict-local */ +import { type NavigationProp } from '@react-navigation/native'; + +/* eslint-disable no-unused-vars */ + +function test_setParams() { + type NavProp

= NavigationProp<{| r: P |}, 'r'>; + + function test_happy(navigation: NavProp<{| a: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_accepts_missing(navigation: NavProp<{| a: number, b: number |}>) { + navigation.setParams({ a: 1 }); + } + + function test_rejects_extra(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[prop-missing] + navigation.setParams({ b: 1 }); + } + + function test_rejects_mismatch(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 'a' }); + } + + function test_rejects_object_to_void(navigation: NavProp) { + // $FlowExpectedError[incompatible-call] + navigation.setParams({ a: 1 }); + } + + function test_rejects_void_to_object(navigation: NavProp<{| a: number |}>) { + // $FlowExpectedError[incompatible-call] + navigation.setParams(); + } +} diff --git a/babel.config.js b/babel.config.js index 0598b4692a2..a87c0f9c861 100644 --- a/babel.config.js +++ b/babel.config.js @@ -13,5 +13,6 @@ module.exports = { // @babel/reset-env, but that doesn't get used as a base plugin; see a // comment on that issue explaining why. '@babel/plugin-proposal-numeric-separator', + 'react-native-reanimated/plugin', ], }; diff --git a/flow-typed/@react-navigation/bottom-tabs_v5.x.x.js b/flow-typed/@react-navigation/bottom-tabs_v5.x.x.js index eceffc47a96..6165a36d07e 100644 --- a/flow-typed/@react-navigation/bottom-tabs_v5.x.x.js +++ b/flow-typed/@react-navigation/bottom-tabs_v5.x.x.js @@ -840,11 +840,13 @@ declare module '@react-navigation/bottom-tabs' { >>, +setOptions: (options: $Shape) => void, +setParams: ( - params: $If< - $IsUndefined<$ElementType>, - empty, - $Shape<$NonMaybeType<$ElementType>>, - >, + // We've edited this to be less complicated, so Flow in types-first + // mode can handle it. + // + // The complicated version appears to have been a workaround for the + // brokenness of $Shape: `$Shape` is `{ ... }`. `$Partial` is + // basically the fixed `$Shape`, and makes the complexity unneeded. + params: $Partial<$NonMaybeType<$ElementType>>, ) => void, ... }; diff --git a/flow-typed/@react-navigation/drawer_v5.x.x.js b/flow-typed/@react-navigation/drawer_v5.x.x.js index be7acac6e06..3b61cfd8340 100644 --- a/flow-typed/@react-navigation/drawer_v5.x.x.js +++ b/flow-typed/@react-navigation/drawer_v5.x.x.js @@ -840,11 +840,13 @@ declare module '@react-navigation/drawer' { >>, +setOptions: (options: $Shape) => void, +setParams: ( - params: $If< - $IsUndefined<$ElementType>, - empty, - $Shape<$NonMaybeType<$ElementType>>, - >, + // We've edited this to be less complicated, so Flow in types-first + // mode can handle it. + // + // The complicated version appears to have been a workaround for the + // brokenness of $Shape: `$Shape` is `{ ... }`. `$Partial` is + // basically the fixed `$Shape`, and makes the complexity unneeded. + params: $Partial<$NonMaybeType<$ElementType>>, ) => void, ... }; diff --git a/flow-typed/@react-navigation/material-top-tabs_v5.x.x.js b/flow-typed/@react-navigation/material-top-tabs_v5.x.x.js index 1a7d2aeda0a..5dc1824ef40 100644 --- a/flow-typed/@react-navigation/material-top-tabs_v5.x.x.js +++ b/flow-typed/@react-navigation/material-top-tabs_v5.x.x.js @@ -840,11 +840,13 @@ declare module '@react-navigation/material-top-tabs' { >>, +setOptions: (options: $Shape) => void, +setParams: ( - params: $If< - $IsUndefined<$ElementType>, - empty, - $Shape<$NonMaybeType<$ElementType>>, - >, + // We've edited this to be less complicated, so Flow in types-first + // mode can handle it. + // + // The complicated version appears to have been a workaround for the + // brokenness of $Shape: `$Shape` is `{ ... }`. `$Partial` is + // basically the fixed `$Shape`, and makes the complexity unneeded. + params: $Partial<$NonMaybeType<$ElementType>>, ) => void, ... }; diff --git a/flow-typed/@react-navigation/native_v5.x.x.js b/flow-typed/@react-navigation/native_v5.x.x.js index 2c4c3813968..7605b14ecd1 100644 --- a/flow-typed/@react-navigation/native_v5.x.x.js +++ b/flow-typed/@react-navigation/native_v5.x.x.js @@ -840,11 +840,13 @@ declare module '@react-navigation/native' { >>, +setOptions: (options: $Shape) => void, +setParams: ( - params: $If< - $IsUndefined<$ElementType>, - empty, - $Shape<$NonMaybeType<$ElementType>>, - >, + // We've edited this to be less complicated, so Flow in types-first + // mode can handle it. + // + // The complicated version appears to have been a workaround for the + // brokenness of $Shape: `$Shape` is `{ ... }`. `$Partial` is + // basically the fixed `$Shape`, and makes the complexity unneeded. + params: $Partial<$NonMaybeType<$ElementType>>, ) => void, ... }; diff --git a/index.js b/index.js index cade378ab20..3df2c4db81d 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ /* @flow strict-local */ +import 'react-native-gesture-handler'; import { AppRegistry } from 'react-native'; import ZulipMobile from './src/ZulipMobile'; diff --git a/src/account-info/ProfileScreen.js b/src/account-info/ProfileScreen.js index 3d570378934..e5da89ed835 100644 --- a/src/account-info/ProfileScreen.js +++ b/src/account-info/ProfileScreen.js @@ -2,6 +2,7 @@ import React, { useContext } from 'react'; import type { Node } from 'react'; import { ScrollView, View, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { TranslationContext } from '../boot/TranslationProvider'; import type { RouteProp } from '../react-navigation'; @@ -121,19 +122,21 @@ export default function ProfileScreen(props: Props): Node { const ownUser = useSelector(getOwnUser); return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); } diff --git a/src/main/HomeScreen.js b/src/main/HomeScreen.js deleted file mode 100644 index cf545ddba5a..00000000000 --- a/src/main/HomeScreen.js +++ /dev/null @@ -1,75 +0,0 @@ -/* @flow strict-local */ - -import React from 'react'; -import type { Node } from 'react'; -import { View } from 'react-native'; - -import type { RouteProp } from '../react-navigation'; -import type { MainTabsNavigationProp } from './MainTabsScreen'; -import * as NavigationService from '../nav/NavigationService'; -import { useDispatch } from '../react-redux'; -import { HOME_NARROW, MENTIONED_NARROW, STARRED_NARROW } from '../utils/narrow'; -import { TopTabButton, TopTabButtonGeneral } from '../nav/TopTabButton'; -import UnreadCards from '../unread/UnreadCards'; -import { doNarrow, navigateToSearch } from '../actions'; -import IconUnreadMentions from '../nav/IconUnreadMentions'; -import { BRAND_COLOR, createStyleSheet } from '../styles'; -import { LoadingBanner } from '../common'; -import ServerCompatBanner from '../common/ServerCompatBanner'; -import ServerPushSetupBanner from '../common/ServerPushSetupBanner'; - -const styles = createStyleSheet({ - wrapper: { - flex: 1, - flexDirection: 'column', - }, - iconList: { - justifyContent: 'space-between', - flexDirection: 'row', - }, -}); - -type Props = $ReadOnly<{| - navigation: MainTabsNavigationProp<'home'>, - route: RouteProp<'home', void>, -|}>; - -export default function HomeScreen(props: Props): Node { - const dispatch = useDispatch(); - - return ( - - - { - dispatch(doNarrow(HOME_NARROW)); - }} - /> - { - dispatch(doNarrow(STARRED_NARROW)); - }} - /> - { - dispatch(doNarrow(MENTIONED_NARROW)); - }} - > - - - { - NavigationService.dispatch(navigateToSearch()); - }} - /> - - - - - - - ); -} diff --git a/src/main/MainTabsScreen.js b/src/main/MainTabsScreen.js index 22af3caa2f4..d13d788ca6d 100644 --- a/src/main/MainTabsScreen.js +++ b/src/main/MainTabsScreen.js @@ -1,12 +1,11 @@ /* @flow strict-local */ import React, { useContext } from 'react'; import type { Node } from 'react'; -import { Platform } from 'react-native'; +import { Platform, View } from 'react-native'; import { createBottomTabNavigator, type BottomTabNavigationProp, } from '@react-navigation/bottom-tabs'; -import { SafeAreaView } from 'react-native-safe-area-context'; import type { RouteProp, RouteParamsOf } from '../react-navigation'; import { getUnreadHuddlesTotal, getUnreadPmsTotal } from '../selectors'; @@ -14,7 +13,7 @@ import { useSelector } from '../react-redux'; import type { AppNavigationProp } from '../nav/AppNavigator'; import type { GlobalParamList } from '../nav/globalTypes'; import { bottomTabNavigatorConfig } from '../styles/tabs'; -import HomeScreen from './HomeScreen'; +import HomeDrawerNavigator from './home/HomeDrawerNavigator'; import StreamTabsScreen from './StreamTabsScreen'; import PmConversationsScreen from '../pm-conversations/PmConversationsScreen'; import { IconInbox, IconStream, IconPeople } from '../common/Icons'; @@ -23,7 +22,7 @@ import ProfileScreen from '../account-info/ProfileScreen'; import styles, { BRAND_COLOR, ThemeContext } from '../styles'; export type MainTabsNavigatorParamList = {| - home: RouteParamsOf, + home: RouteParamsOf, 'stream-tabs': RouteParamsOf, 'pm-conversations': RouteParamsOf, profile: RouteParamsOf, @@ -50,7 +49,7 @@ export default function MainTabsScreen(props: Props): Node { const unreadPmsCount = useSelector(getUnreadHuddlesTotal) + useSelector(getUnreadPmsTotal); return ( - + , @@ -105,6 +104,6 @@ export default function MainTabsScreen(props: Props): Node { }} /> - + ); } diff --git a/src/main/StreamTabsScreen.js b/src/main/StreamTabsScreen.js index bdc7f1ccc68..370dc1890bc 100644 --- a/src/main/StreamTabsScreen.js +++ b/src/main/StreamTabsScreen.js @@ -5,6 +5,7 @@ import { createMaterialTopTabNavigator, type MaterialTopTabNavigationProp, } from '@react-navigation/material-top-tabs'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { ZulipTextIntl } from '../common'; import { createStyleSheet } from '../styles'; @@ -44,31 +45,33 @@ type Props = $ReadOnly<{| export default function StreamTabsScreen(props: Props): Node { return ( - - ( - - ), - }} - /> - ( - - ), - }} - /> - + + + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); } diff --git a/src/main/home/AllNarrowScreen.js b/src/main/home/AllNarrowScreen.js new file mode 100644 index 00000000000..68214aae20b --- /dev/null +++ b/src/main/home/AllNarrowScreen.js @@ -0,0 +1,234 @@ +/* @flow strict-local */ +import React, { useCallback, useContext } from 'react'; +import type { Node } from 'react'; +import { useIsFocused } from '@react-navigation/native'; + +import { useSelector, useDispatch } from '../../react-redux'; +import type { RouteProp } from '../../react-navigation'; +import type { HomeDrawerNavigationProp } from './HomeDrawerNavigator'; +import { ThemeContext, createStyleSheet } from '../../styles'; +import type { Narrow, EditMessage } from '../../types'; +import { KeyboardAvoider, OfflineNotice } from '../../common'; +import ChatNavBar from '../../nav/ChatNavBar'; +import MessageList from '../../webview/MessageList'; +import NoMessages from '../../message/NoMessages'; +import FetchError from '../../chat/FetchError'; +import InvalidNarrow from '../../chat/InvalidNarrow'; +import { fetchMessagesInNarrow } from '../../message/fetchActions'; +import ComposeBox from '../../compose/ComposeBox'; +import UnreadNotice from '../../chat/UnreadNotice'; +import { + showComposeBoxOnNarrow, + caseNarrowDefault, + keyFromNarrow, + HOME_NARROW, +} from '../../utils/narrow'; +import { getLoading, getSession } from '../../directSelectors'; +import { getFetchingForNarrow } from '../../chat/fetchingSelectors'; +import { + getShownMessagesForNarrow, + isNarrowValid as getIsNarrowValid, +} from '../../chat/narrowsSelectors'; +import { getFirstUnreadIdInNarrow } from '../../message/messageSelectors'; +import { getDraftForNarrow } from '../../drafts/draftsSelectors'; +import { addToOutbox } from '../../actions'; +import { getAuth } from '../../selectors'; +import { showErrorAlert } from '../../utils/info'; +import { TranslationContext } from '../../boot/TranslationProvider'; +import * as api from '../../api'; + +type Props = $ReadOnly<{| + navigation: HomeDrawerNavigationProp<'all-narrow'>, + route: RouteProp<'all-narrow', {| editMessage: EditMessage | null |}>, +|}>; + +const componentStyles = createStyleSheet({ + screen: { + flex: 1, + flexDirection: 'column', + }, +}); + +/** + * Fetch messages for this narrow and report an error, if any + * + * See `MessagesState` for background about the fetching, including + * why this is nearly the only place where additional data fetching + * is required. See `fetchMessagesInNarrow` and `fetchMessages` for + * more details, including how Redux is kept up-to-date during the + * whole process. + */ +const useMessagesWithFetch = args => { + const { narrow } = args; + + const dispatch = useDispatch(); + const isFocused = useIsFocused(); + + const eventQueueId = useSelector(state => getSession(state).eventQueueId); + const loading = useSelector(getLoading); + const fetching = useSelector(state => getFetchingForNarrow(state, narrow)); + const isFetching = fetching.older || fetching.newer || loading; + const messages = useSelector(state => getShownMessagesForNarrow(state, narrow)); + const firstUnreadIdInNarrow = useSelector(state => getFirstUnreadIdInNarrow(state, narrow)); + + // This could live in state, but then we'd risk pointless rerenders; + // we only use it in our `useEffect` callbacks. Using `useRef` is + // like using instance variables in class components: + // https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables + const shouldFetchWhenNextFocused = React.useRef(false); + + const [fetchError, setFetchError] = React.useState(null); + + const fetch = React.useCallback(async () => { + shouldFetchWhenNextFocused.current = false; + try { + await dispatch(fetchMessagesInNarrow(narrow)); + } catch (e) { + setFetchError(e); + } + }, [dispatch, narrow]); + + // When the event queue changes, schedule a fetch. (Currently, we never + // set this to null from a non-null value, so this really does mean the + // event queue has changed; it can't mean that we had a queue ID and + // dropped it.) + React.useEffect(() => { + shouldFetchWhenNextFocused.current = true; + }, [eventQueueId]); + + // On first mount, fetch. Also unset `shouldFetchWhenNextFocused.current` + // that was set in the previous `useEffect`, so the fetch below doesn't + // also fire. + React.useEffect(() => { + fetch(); + }, [fetch]); + + // When a fetch is scheduled and we're focused, fetch. + React.useEffect(() => { + if (shouldFetchWhenNextFocused.current && isFocused === true) { + fetch(); + } + // `eventQueueId` needed here because it affects `shouldFetchWhenNextFocused`. + }, [isFocused, eventQueueId, fetch]); + + return { fetchError, isFetching, messages, firstUnreadIdInNarrow }; +}; + +export default function AllNarrowScreen(props: Props): Node { + const { route, navigation } = props; + const { backgroundColor } = React.useContext(ThemeContext); + + const narrow = HOME_NARROW; + const { editMessage } = route.params; + const setEditMessage = useCallback( + (value: EditMessage | null) => navigation.setParams({ editMessage: value }), + [navigation], + ); + + const isNarrowValid = useSelector(state => getIsNarrowValid(state, narrow)); + const draft = useSelector(state => getDraftForNarrow(state, narrow)); + + const { fetchError, isFetching, messages, firstUnreadIdInNarrow } = useMessagesWithFetch({ + narrow, + }); + + const showMessagePlaceholders = messages.length === 0 && isFetching; + const sayNoMessages = messages.length === 0 && !isFetching; + const showComposeBox = showComposeBoxOnNarrow(narrow) && !showMessagePlaceholders; + + const auth = useSelector(getAuth); + const dispatch = useDispatch(); + const fetching = useSelector(state => getFetchingForNarrow(state, narrow)); + const _ = useContext(TranslationContext); + + const sendCallback = useCallback( + (message: string, destinationNarrow: Narrow) => { + if (editMessage) { + const content = editMessage.content !== message ? message : undefined; + const subject = caseNarrowDefault( + destinationNarrow, + { topic: (stream, topic) => (topic !== editMessage.topic ? topic : undefined) }, + () => undefined, + ); + + if ( + (content !== undefined && content !== '') + || (subject !== undefined && subject !== '') + ) { + api.updateMessage(auth, { content, subject }, editMessage.id).catch(error => { + showErrorAlert(_('Failed to edit message'), error.message); + }); + } + + setEditMessage(null); + } else { + if (fetching.newer) { + // If we're fetching, that means that (a) we're scrolled near the + // bottom, and likely are scrolled to the very bottom so that it + // looks like we're showing the latest messages, but (b) we don't + // actually have the latest messages. So the user may be misled + // and send a reply that doesn't make sense with the later context. + // + // Ideally in this condition we'd show a warning to make sure the + // user knows what they're getting into, and then let them send + // anyway. We'd also then need to take care with how the + // resulting message appears in the message list: see #3800 and + // https://chat.zulip.org/#narrow/stream/48-mobile/topic/Failed.20to.20send.20on.20Android/near/1158162 + // + // For now, just refuse to send. After all, this condition will + // resolve itself when we complete the fetch, and if that doesn't + // happen soon then it's unlikely we could successfully send a + // message anyway. + showErrorAlert(_('Failed to send message')); + return; + } + + dispatch(addToOutbox(destinationNarrow, message)); + } + }, + [_, auth, fetching.newer, dispatch, editMessage, setEditMessage], + ); + + return ( + + + + + {(() => { + if (!isNarrowValid) { + return ; + } else if (fetchError !== null) { + return ; + } else if (sayNoMessages) { + return ; + } else { + return ( + | void)?.id + ?? null + } + showMessagePlaceholders={showMessagePlaceholders} + startEditMessage={setEditMessage} + /> + ); + } + })()} + {showComposeBox && ( + + )} + + ); +} diff --git a/src/main/home/DefaultScreen.js b/src/main/home/DefaultScreen.js new file mode 100644 index 00000000000..e52823079db --- /dev/null +++ b/src/main/home/DefaultScreen.js @@ -0,0 +1,50 @@ +/* @flow strict-local */ + +import React from 'react'; +import type { Node } from 'react'; +import { View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import type { RouteProp } from '../../react-navigation'; +import type { HomeDrawerNavigationProp } from './HomeDrawerNavigator'; +import { TopTabButton } from '../../nav/TopTabButton'; +import UnreadCards from '../../unread/UnreadCards'; +import { createStyleSheet } from '../../styles'; +import { LoadingBanner } from '../../common'; +import ServerCompatBanner from '../../common/ServerCompatBanner'; +import ServerPushSetupBanner from '../../common/ServerPushSetupBanner'; + +const styles = createStyleSheet({ + wrapper: { + flex: 1, + flexDirection: 'column', + }, + iconList: { + justifyContent: 'space-between', + flexDirection: 'row', + }, +}); + +type Props = $ReadOnly<{| + navigation: HomeDrawerNavigationProp<'default'>, + route: RouteProp<'default', void>, +|}>; + +export default function DefaultScreen(props: Props): Node { + return ( + + + { + props.navigation.openDrawer(); + }} + /> + + + + + + + ); +} diff --git a/src/main/home/HomeDrawerNavigator.js b/src/main/home/HomeDrawerNavigator.js new file mode 100644 index 00000000000..341cd485707 --- /dev/null +++ b/src/main/home/HomeDrawerNavigator.js @@ -0,0 +1,85 @@ +/* @flow strict-local */ +import React from 'react'; +import type { Node } from 'react'; +import { createDrawerNavigator, type DrawerNavigationProp } from '@react-navigation/drawer'; + +import type { RouteProp, RouteParamsOf } from '../../react-navigation'; +import type { MainTabsNavigationProp } from '../MainTabsScreen'; +import type { GlobalParamList } from '../../nav/globalTypes'; +import DefaultScreen from './DefaultScreen'; +import AllNarrowScreen from './AllNarrowScreen'; +import StarredNarrowScreen from './StarredNarrowScreen'; +import MentionedNarrowScreen from './MentionedNarrowScreen'; +import SearchInHomeDrawerScreen from './SearchInHomeDrawerScreen'; + +export type HomeDrawerNavigatorParamList = {| + default: RouteParamsOf, + 'all-narrow': RouteParamsOf, + 'starred-narrow': RouteParamsOf, + 'mentioned-narrow': RouteParamsOf, + 'search-in-home-drawer': RouteParamsOf, +|}; + +export type HomeDrawerNavigationProp< + +RouteName: $Keys = $Keys, +> = DrawerNavigationProp; + +const Tab = createDrawerNavigator< + GlobalParamList, + HomeDrawerNavigatorParamList, + HomeDrawerNavigationProp<>, +>(); + +type Props = $ReadOnly<{| + navigation: MainTabsNavigationProp<'home'>, + route: RouteProp<'home', void>, +|}>; + +export default function HomeDrawerNavigator(props: Props): Node { + return ( + CAUTION: Please note that Reanimated 2 doesn't support remote + // > debugging, only Flipper can be used for debugging. + gestureEnabled: true, + swipeEnabled: true, + }} + > + + + + + + + ); +} diff --git a/src/main/home/MentionedNarrowScreen.js b/src/main/home/MentionedNarrowScreen.js new file mode 100644 index 00000000000..ab2de232611 --- /dev/null +++ b/src/main/home/MentionedNarrowScreen.js @@ -0,0 +1,234 @@ +/* @flow strict-local */ +import React, { useCallback, useContext } from 'react'; +import type { Node } from 'react'; +import { useIsFocused } from '@react-navigation/native'; + +import { useSelector, useDispatch } from '../../react-redux'; +import type { RouteProp } from '../../react-navigation'; +import type { HomeDrawerNavigationProp } from './HomeDrawerNavigator'; +import { ThemeContext, createStyleSheet } from '../../styles'; +import type { Narrow, EditMessage } from '../../types'; +import { KeyboardAvoider, OfflineNotice } from '../../common'; +import ChatNavBar from '../../nav/ChatNavBar'; +import MessageList from '../../webview/MessageList'; +import NoMessages from '../../message/NoMessages'; +import FetchError from '../../chat/FetchError'; +import InvalidNarrow from '../../chat/InvalidNarrow'; +import { fetchMessagesInNarrow } from '../../message/fetchActions'; +import ComposeBox from '../../compose/ComposeBox'; +import UnreadNotice from '../../chat/UnreadNotice'; +import { + showComposeBoxOnNarrow, + caseNarrowDefault, + keyFromNarrow, + MENTIONED_NARROW, +} from '../../utils/narrow'; +import { getLoading, getSession } from '../../directSelectors'; +import { getFetchingForNarrow } from '../../chat/fetchingSelectors'; +import { + getShownMessagesForNarrow, + isNarrowValid as getIsNarrowValid, +} from '../../chat/narrowsSelectors'; +import { getFirstUnreadIdInNarrow } from '../../message/messageSelectors'; +import { getDraftForNarrow } from '../../drafts/draftsSelectors'; +import { addToOutbox } from '../../actions'; +import { getAuth } from '../../selectors'; +import { showErrorAlert } from '../../utils/info'; +import { TranslationContext } from '../../boot/TranslationProvider'; +import * as api from '../../api'; + +type Props = $ReadOnly<{| + navigation: HomeDrawerNavigationProp<'mentioned-narrow'>, + route: RouteProp<'mentioned-narrow', {| editMessage: EditMessage | null |}>, +|}>; + +const componentStyles = createStyleSheet({ + screen: { + flex: 1, + flexDirection: 'column', + }, +}); + +/** + * Fetch messages for this narrow and report an error, if any + * + * See `MessagesState` for background about the fetching, including + * why this is nearly the only place where additional data fetching + * is required. See `fetchMessagesInNarrow` and `fetchMessages` for + * more details, including how Redux is kept up-to-date during the + * whole process. + */ +const useMessagesWithFetch = args => { + const { narrow } = args; + + const dispatch = useDispatch(); + const isFocused = useIsFocused(); + + const eventQueueId = useSelector(state => getSession(state).eventQueueId); + const loading = useSelector(getLoading); + const fetching = useSelector(state => getFetchingForNarrow(state, narrow)); + const isFetching = fetching.older || fetching.newer || loading; + const messages = useSelector(state => getShownMessagesForNarrow(state, narrow)); + const firstUnreadIdInNarrow = useSelector(state => getFirstUnreadIdInNarrow(state, narrow)); + + // This could live in state, but then we'd risk pointless rerenders; + // we only use it in our `useEffect` callbacks. Using `useRef` is + // like using instance variables in class components: + // https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables + const shouldFetchWhenNextFocused = React.useRef(false); + + const [fetchError, setFetchError] = React.useState(null); + + const fetch = React.useCallback(async () => { + shouldFetchWhenNextFocused.current = false; + try { + await dispatch(fetchMessagesInNarrow(narrow)); + } catch (e) { + setFetchError(e); + } + }, [dispatch, narrow]); + + // When the event queue changes, schedule a fetch. (Currently, we never + // set this to null from a non-null value, so this really does mean the + // event queue has changed; it can't mean that we had a queue ID and + // dropped it.) + React.useEffect(() => { + shouldFetchWhenNextFocused.current = true; + }, [eventQueueId]); + + // On first mount, fetch. Also unset `shouldFetchWhenNextFocused.current` + // that was set in the previous `useEffect`, so the fetch below doesn't + // also fire. + React.useEffect(() => { + fetch(); + }, [fetch]); + + // When a fetch is scheduled and we're focused, fetch. + React.useEffect(() => { + if (shouldFetchWhenNextFocused.current && isFocused === true) { + fetch(); + } + // `eventQueueId` needed here because it affects `shouldFetchWhenNextFocused`. + }, [isFocused, eventQueueId, fetch]); + + return { fetchError, isFetching, messages, firstUnreadIdInNarrow }; +}; + +export default function MentionedNarrowScreen(props: Props): Node { + const { route, navigation } = props; + const { backgroundColor } = React.useContext(ThemeContext); + + const narrow = MENTIONED_NARROW; + const { editMessage } = route.params; + const setEditMessage = useCallback( + (value: EditMessage | null) => navigation.setParams({ editMessage: value }), + [navigation], + ); + + const isNarrowValid = useSelector(state => getIsNarrowValid(state, narrow)); + const draft = useSelector(state => getDraftForNarrow(state, narrow)); + + const { fetchError, isFetching, messages, firstUnreadIdInNarrow } = useMessagesWithFetch({ + narrow, + }); + + const showMessagePlaceholders = messages.length === 0 && isFetching; + const sayNoMessages = messages.length === 0 && !isFetching; + const showComposeBox = showComposeBoxOnNarrow(narrow) && !showMessagePlaceholders; + + const auth = useSelector(getAuth); + const dispatch = useDispatch(); + const fetching = useSelector(state => getFetchingForNarrow(state, narrow)); + const _ = useContext(TranslationContext); + + const sendCallback = useCallback( + (message: string, destinationNarrow: Narrow) => { + if (editMessage) { + const content = editMessage.content !== message ? message : undefined; + const subject = caseNarrowDefault( + destinationNarrow, + { topic: (stream, topic) => (topic !== editMessage.topic ? topic : undefined) }, + () => undefined, + ); + + if ( + (content !== undefined && content !== '') + || (subject !== undefined && subject !== '') + ) { + api.updateMessage(auth, { content, subject }, editMessage.id).catch(error => { + showErrorAlert(_('Failed to edit message'), error.message); + }); + } + + setEditMessage(null); + } else { + if (fetching.newer) { + // If we're fetching, that means that (a) we're scrolled near the + // bottom, and likely are scrolled to the very bottom so that it + // looks like we're showing the latest messages, but (b) we don't + // actually have the latest messages. So the user may be misled + // and send a reply that doesn't make sense with the later context. + // + // Ideally in this condition we'd show a warning to make sure the + // user knows what they're getting into, and then let them send + // anyway. We'd also then need to take care with how the + // resulting message appears in the message list: see #3800 and + // https://chat.zulip.org/#narrow/stream/48-mobile/topic/Failed.20to.20send.20on.20Android/near/1158162 + // + // For now, just refuse to send. After all, this condition will + // resolve itself when we complete the fetch, and if that doesn't + // happen soon then it's unlikely we could successfully send a + // message anyway. + showErrorAlert(_('Failed to send message')); + return; + } + + dispatch(addToOutbox(destinationNarrow, message)); + } + }, + [_, auth, fetching.newer, dispatch, editMessage, setEditMessage], + ); + + return ( + + + + + {(() => { + if (!isNarrowValid) { + return ; + } else if (fetchError !== null) { + return ; + } else if (sayNoMessages) { + return ; + } else { + return ( + | void)?.id + ?? null + } + showMessagePlaceholders={showMessagePlaceholders} + startEditMessage={setEditMessage} + /> + ); + } + })()} + {showComposeBox && ( + + )} + + ); +} diff --git a/src/main/home/SearchInHomeDrawerScreen.js b/src/main/home/SearchInHomeDrawerScreen.js new file mode 100644 index 00000000000..ff821c5dfb1 --- /dev/null +++ b/src/main/home/SearchInHomeDrawerScreen.js @@ -0,0 +1,158 @@ +/* @flow strict-local */ +import React, { PureComponent } from 'react'; +import type { ComponentType } from 'react'; +import type { EditingEvent } from 'react-native/Libraries/Components/TextInput/TextInput'; + +import type { RouteProp } from '../../react-navigation'; +import type { HomeDrawerNavigationProp } from './HomeDrawerNavigator'; +import type { Auth, Dispatch, Message } from '../../types'; +import { Screen } from '../../common'; +import SearchMessagesCard from '../../search/SearchMessagesCard'; +import styles from '../../styles'; +import { SEARCH_NARROW } from '../../utils/narrow'; +import { LAST_MESSAGE_ANCHOR } from '../../anchor'; +import { connect } from '../../react-redux'; +import { getAuth } from '../../account/accountsSelectors'; +import { fetchMessages } from '../../message/fetchActions'; + +type OuterProps = $ReadOnly<{| + // These should be passed from React Navigation + navigation: HomeDrawerNavigationProp<'search-in-home-drawer'>, + route: RouteProp<'search-in-home-drawer', void>, +|}>; + +type SelectorProps = $ReadOnly<{| + auth: Auth, +|}>; + +type Props = $ReadOnly<{| + ...OuterProps, + + dispatch: Dispatch, + ...SelectorProps, + // Warning: do not add new props without considering their effect on the + // behavior of this component's non-React internal state. See comment below. +|}>; + +type State = {| + /** The latest search query we have results for. */ + query: string, + + /** + * The list of messages found as results for `query`. + * + * This is `null` if `query` is empty, representing an empty search box + * and so effectively not a query to have results from at all. + */ + messages: $ReadOnlyArray | null, + + /** Whether there is currently an active valid network request. */ + isFetching: boolean, +|}; + +class SearchInHomeDrawerScreenInner extends PureComponent { + state = { + query: '', + messages: null, + isFetching: false, + }; + + /** + * PRIVATE. Send search query to server, fetching message results. + * + * Stores the fetched messages in the Redux store. Does not read any + * of the component's data except `props.dispatch`. + */ + fetchSearchMessages = async (query: string): Promise<$ReadOnlyArray> => { + const fetchArgs = { + narrow: SEARCH_NARROW(query), + anchor: LAST_MESSAGE_ANCHOR, + numBefore: 20, + numAfter: 0, + }; + + return this.props.dispatch(fetchMessages(fetchArgs)); + }; + + // Non-React state. See comment following. + // Invariant: lastIdSuccess <= lastIdReceived <= lastIdSent. + lastIdSuccess: number = 1000; + lastIdReceived: number = 1000; + lastIdSent: number = 1000; + + // This component is less pure than it should be. The correct behavior here is + // probably that, when props change, all outstanding asynchronous requests + // should be **synchronously** invalidated before the next render. + // + // As the only React prop this component has is `auth`, we ignore this for + // now: any updates to `auth` would involve this screen being torn down and + // reconstructed anyway. However, addition of any new props which need to + // invalidate outstanding requests on change will require more work. + + handleQuerySubmit = async (e: EditingEvent) => { + const query = e.nativeEvent.text; + const id = ++this.lastIdSent; + + if (query === '') { + // The empty query can be resolved without a network call. + this.lastIdReceived = id; + this.lastIdSuccess = id; + this.setState({ query, messages: null, isFetching: false }); + return; + } + + this.setState({ isFetching: true }); + try { + const messages = await this.fetchSearchMessages(query); + + // Update `state.messages` if this is our new latest result. + if (id > this.lastIdSuccess) { + this.lastIdSuccess = id; + this.setState({ query, messages }); + } + } finally { + // Updating `isFetching` is the same for success or failure. + if (id > this.lastIdReceived) { + this.lastIdReceived = id; + if (this.lastIdReceived === this.lastIdSent) { + this.setState({ isFetching: false }); + } + + // TODO: if the request failed, should we arrange to display + // something to the user? + } + } + }; + + // The real work to be done on a query is async. This wrapper exists + // just to fire off `handleQuerySubmit` without waiting for it. + // TODO do we even need this wrapper? + handleQuerySubmitWrapper = (e: EditingEvent) => { + this.handleQuerySubmit(e); + }; + + render() { + const { messages, isFetching } = this.state; + + return ( + + + + ); + } +} + +const SearchInHomeDrawerScreen: ComponentType = connect(state => ({ + auth: getAuth(state), +}))(SearchInHomeDrawerScreenInner); + +export default SearchInHomeDrawerScreen; diff --git a/src/main/home/StarredNarrowScreen.js b/src/main/home/StarredNarrowScreen.js new file mode 100644 index 00000000000..65f09fb2cd4 --- /dev/null +++ b/src/main/home/StarredNarrowScreen.js @@ -0,0 +1,234 @@ +/* @flow strict-local */ +import React, { useCallback, useContext } from 'react'; +import type { Node } from 'react'; +import { useIsFocused } from '@react-navigation/native'; + +import { useSelector, useDispatch } from '../../react-redux'; +import type { RouteProp } from '../../react-navigation'; +import type { HomeDrawerNavigationProp } from './HomeDrawerNavigator'; +import { ThemeContext, createStyleSheet } from '../../styles'; +import type { Narrow, EditMessage } from '../../types'; +import { KeyboardAvoider, OfflineNotice } from '../../common'; +import ChatNavBar from '../../nav/ChatNavBar'; +import MessageList from '../../webview/MessageList'; +import NoMessages from '../../message/NoMessages'; +import FetchError from '../../chat/FetchError'; +import InvalidNarrow from '../../chat/InvalidNarrow'; +import { fetchMessagesInNarrow } from '../../message/fetchActions'; +import ComposeBox from '../../compose/ComposeBox'; +import UnreadNotice from '../../chat/UnreadNotice'; +import { + showComposeBoxOnNarrow, + caseNarrowDefault, + keyFromNarrow, + STARRED_NARROW, +} from '../../utils/narrow'; +import { getLoading, getSession } from '../../directSelectors'; +import { getFetchingForNarrow } from '../../chat/fetchingSelectors'; +import { + getShownMessagesForNarrow, + isNarrowValid as getIsNarrowValid, +} from '../../chat/narrowsSelectors'; +import { getFirstUnreadIdInNarrow } from '../../message/messageSelectors'; +import { getDraftForNarrow } from '../../drafts/draftsSelectors'; +import { addToOutbox } from '../../actions'; +import { getAuth } from '../../selectors'; +import { showErrorAlert } from '../../utils/info'; +import { TranslationContext } from '../../boot/TranslationProvider'; +import * as api from '../../api'; + +type Props = $ReadOnly<{| + navigation: HomeDrawerNavigationProp<'starred-narrow'>, + route: RouteProp<'starred-narrow', {| editMessage: EditMessage | null |}>, +|}>; + +const componentStyles = createStyleSheet({ + screen: { + flex: 1, + flexDirection: 'column', + }, +}); + +/** + * Fetch messages for this narrow and report an error, if any + * + * See `MessagesState` for background about the fetching, including + * why this is nearly the only place where additional data fetching + * is required. See `fetchMessagesInNarrow` and `fetchMessages` for + * more details, including how Redux is kept up-to-date during the + * whole process. + */ +const useMessagesWithFetch = args => { + const { narrow } = args; + + const dispatch = useDispatch(); + const isFocused = useIsFocused(); + + const eventQueueId = useSelector(state => getSession(state).eventQueueId); + const loading = useSelector(getLoading); + const fetching = useSelector(state => getFetchingForNarrow(state, narrow)); + const isFetching = fetching.older || fetching.newer || loading; + const messages = useSelector(state => getShownMessagesForNarrow(state, narrow)); + const firstUnreadIdInNarrow = useSelector(state => getFirstUnreadIdInNarrow(state, narrow)); + + // This could live in state, but then we'd risk pointless rerenders; + // we only use it in our `useEffect` callbacks. Using `useRef` is + // like using instance variables in class components: + // https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables + const shouldFetchWhenNextFocused = React.useRef(false); + + const [fetchError, setFetchError] = React.useState(null); + + const fetch = React.useCallback(async () => { + shouldFetchWhenNextFocused.current = false; + try { + await dispatch(fetchMessagesInNarrow(narrow)); + } catch (e) { + setFetchError(e); + } + }, [dispatch, narrow]); + + // When the event queue changes, schedule a fetch. (Currently, we never + // set this to null from a non-null value, so this really does mean the + // event queue has changed; it can't mean that we had a queue ID and + // dropped it.) + React.useEffect(() => { + shouldFetchWhenNextFocused.current = true; + }, [eventQueueId]); + + // On first mount, fetch. Also unset `shouldFetchWhenNextFocused.current` + // that was set in the previous `useEffect`, so the fetch below doesn't + // also fire. + React.useEffect(() => { + fetch(); + }, [fetch]); + + // When a fetch is scheduled and we're focused, fetch. + React.useEffect(() => { + if (shouldFetchWhenNextFocused.current && isFocused === true) { + fetch(); + } + // `eventQueueId` needed here because it affects `shouldFetchWhenNextFocused`. + }, [isFocused, eventQueueId, fetch]); + + return { fetchError, isFetching, messages, firstUnreadIdInNarrow }; +}; + +export default function StarredNarrowScreen(props: Props): Node { + const { route, navigation } = props; + const { backgroundColor } = React.useContext(ThemeContext); + + const narrow = STARRED_NARROW; + const { editMessage } = route.params; + const setEditMessage = useCallback( + (value: EditMessage | null) => navigation.setParams({ editMessage: value }), + [navigation], + ); + + const isNarrowValid = useSelector(state => getIsNarrowValid(state, narrow)); + const draft = useSelector(state => getDraftForNarrow(state, narrow)); + + const { fetchError, isFetching, messages, firstUnreadIdInNarrow } = useMessagesWithFetch({ + narrow, + }); + + const showMessagePlaceholders = messages.length === 0 && isFetching; + const sayNoMessages = messages.length === 0 && !isFetching; + const showComposeBox = showComposeBoxOnNarrow(narrow) && !showMessagePlaceholders; + + const auth = useSelector(getAuth); + const dispatch = useDispatch(); + const fetching = useSelector(state => getFetchingForNarrow(state, narrow)); + const _ = useContext(TranslationContext); + + const sendCallback = useCallback( + (message: string, destinationNarrow: Narrow) => { + if (editMessage) { + const content = editMessage.content !== message ? message : undefined; + const subject = caseNarrowDefault( + destinationNarrow, + { topic: (stream, topic) => (topic !== editMessage.topic ? topic : undefined) }, + () => undefined, + ); + + if ( + (content !== undefined && content !== '') + || (subject !== undefined && subject !== '') + ) { + api.updateMessage(auth, { content, subject }, editMessage.id).catch(error => { + showErrorAlert(_('Failed to edit message'), error.message); + }); + } + + setEditMessage(null); + } else { + if (fetching.newer) { + // If we're fetching, that means that (a) we're scrolled near the + // bottom, and likely are scrolled to the very bottom so that it + // looks like we're showing the latest messages, but (b) we don't + // actually have the latest messages. So the user may be misled + // and send a reply that doesn't make sense with the later context. + // + // Ideally in this condition we'd show a warning to make sure the + // user knows what they're getting into, and then let them send + // anyway. We'd also then need to take care with how the + // resulting message appears in the message list: see #3800 and + // https://chat.zulip.org/#narrow/stream/48-mobile/topic/Failed.20to.20send.20on.20Android/near/1158162 + // + // For now, just refuse to send. After all, this condition will + // resolve itself when we complete the fetch, and if that doesn't + // happen soon then it's unlikely we could successfully send a + // message anyway. + showErrorAlert(_('Failed to send message')); + return; + } + + dispatch(addToOutbox(destinationNarrow, message)); + } + }, + [_, auth, fetching.newer, dispatch, editMessage, setEditMessage], + ); + + return ( + + + + + {(() => { + if (!isNarrowValid) { + return ; + } else if (fetchError !== null) { + return ; + } else if (sayNoMessages) { + return ; + } else { + return ( + | void)?.id + ?? null + } + showMessagePlaceholders={showMessagePlaceholders} + startEditMessage={setEditMessage} + /> + ); + } + })()} + {showComposeBox && ( + + )} + + ); +} diff --git a/src/nav/NavBarBackButton.js b/src/nav/NavBarBackButton.js index 8de9449443c..98250434fc7 100644 --- a/src/nav/NavBarBackButton.js +++ b/src/nav/NavBarBackButton.js @@ -3,9 +3,8 @@ import React from 'react'; import type { Node } from 'react'; import { Platform } from 'react-native'; -import { navigateBack } from '../actions'; +import { useNavigation } from '../react-navigation'; import NavButton from './NavButton'; -import * as NavigationService from './NavigationService'; /** * The button for the start of the app bar, to return to previous screen. @@ -26,13 +25,15 @@ export default function NavBarBackButton(props: {| +color?: string |}): Node { const { color } = props; const iconName = Platform.OS === 'android' ? 'arrow-left' : 'chevron-left'; + const navigation = useNavigation(); + return ( { - NavigationService.dispatch(navigateBack()); + navigation.goBack(); }} /> ); diff --git a/src/nav/globalTypes.js b/src/nav/globalTypes.js index 756a2029d8f..f522edebe39 100644 --- a/src/nav/globalTypes.js +++ b/src/nav/globalTypes.js @@ -4,10 +4,12 @@ import type { AppNavigatorParamList } from './AppNavigator'; import type { SharingNavigatorParamList } from '../sharing/SharingScreen'; import type { StreamTabsNavigatorParamList } from '../main/StreamTabsScreen'; import type { MainTabsNavigatorParamList } from '../main/MainTabsScreen'; +import type { HomeDrawerNavigatorParamList } from '../main/home/HomeDrawerNavigator'; export type GlobalParamList = {| ...AppNavigatorParamList, ...SharingNavigatorParamList, ...StreamTabsNavigatorParamList, ...MainTabsNavigatorParamList, + ...HomeDrawerNavigatorParamList, |}; diff --git a/src/pm-conversations/PmConversationsScreen.js b/src/pm-conversations/PmConversationsScreen.js index 300a44a7b45..3543b0fbdd5 100644 --- a/src/pm-conversations/PmConversationsScreen.js +++ b/src/pm-conversations/PmConversationsScreen.js @@ -3,6 +3,7 @@ import React, { useContext } from 'react'; import type { Node } from 'react'; import { View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import type { RouteProp } from '../react-navigation'; import type { MainTabsNavigationProp } from '../main/MainTabsScreen'; @@ -47,7 +48,11 @@ export default function PmConversationsScreen(props: Props): Node { const context = useContext(ThemeContext); return ( - + )} - + ); }