diff --git a/native/chat/chat-thread-list-utils.js b/native/chat/chat-thread-list-utils.js index 0f5d96d50..ba6c21eb1 100644 --- a/native/chat/chat-thread-list-utils.js +++ b/native/chat/chat-thread-list-utils.js @@ -1,58 +1,65 @@ // @flow import _sum from 'lodash/fp/sum.js'; import { Platform } from 'react-native'; import { chatThreadListItemHeight, spacerHeight, } from './chat-thread-list-item.react.js'; import type { Item } from './chat-thread-list.react.js'; import { sidebarHeight } from './sidebar-item.react.js'; +import { listLoadingIndicatorHeight } from '../components/list-loading-indicator-utils.js'; function keyExtractor(item: Item): string { if (item.type === 'chatThreadItem') { return item.threadInfo.id; } else if (item.type === 'empty') { return 'empty'; + } else if (item.type === 'loader') { + return 'loader'; } return 'search'; } function itemHeight(item: Item): number { + if (item.type === 'loader') { + return listLoadingIndicatorHeight; + } + if (item.type === 'search') { return Platform.OS === 'ios' ? 54.5 : 55; } // itemHeight for emptyItem might be wrong because of line wrapping // but we don't care because we'll only ever be rendering this item // by itself and it should always be on-screen if (item.type === 'empty') { return 123; } const baseHeight = chatThreadListItemHeight; const sidebarsHeight = item.sidebars.length * sidebarHeight; const spacerHeightAdjustment = item.sidebars.length > 0 ? spacerHeight : 0; return baseHeight + sidebarsHeight + spacerHeightAdjustment; } function heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(itemHeight)); } function getItemLayout( data: ?$ReadOnlyArray, index: number, ): { length: number, offset: number, index: number } { if (!data) { return { length: 0, offset: 0, index }; } const offset = heightOfItems(data.filter((_, i): boolean => i < index)); const item = data[index]; const length = item ? itemHeight(item) : 0; return { length, offset, index }; } export { keyExtractor, itemHeight, heightOfItems, getItemLayout }; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index 6c212a323..076220ec3 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,523 +1,543 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import type { BottomTabNavigationEventMap, BottomTabOptions, StackNavigationEventMap, StackNavigationState, StackOptions, TabNavigationState, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { BackHandler, FlatList, Platform, TextInput, TouchableWithoutFeedback, View, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import { useSharedValue } from 'react-native-reanimated'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useThreadListSearch } from 'lib/hooks/thread-search-hooks.js'; import { type ChatThreadItem, useFlattenedChatListLoaders, } from 'lib/selectors/chat-selectors.js'; import { createPendingThread, getThreadListSearchResults, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { UserInfo } from 'lib/types/user-types.js'; import ChatThreadItemLoaderCache from 'lib/utils/chat-thread-item-loader-cache.js'; import { ChatThreadListItem } from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import { getItemLayout, keyExtractor } from './chat-thread-list-utils.js'; import type { ChatNavigationProp, ChatTopTabsNavigationProp, } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; +import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import { BackgroundChatThreadListRouteName, HomeChatThreadListRouteName, type NavigationRoute, type ScreenParamList, SidebarListModalRouteName, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { indicatorStyleSelector, useStyles } from '../themes/colors.js'; import type { ScrollEvent } from '../types/react-native.js'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; export type Item = | ChatThreadItem | { +type: 'search', +searchText: string } - | { +type: 'empty', +emptyItem: React.ComponentType<{}> }; + | { +type: 'empty', +emptyItem: React.ComponentType<{}> } + | { +type: 'loader' }; type BaseProps = { +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{}>, }; export type SearchStatus = 'inactive' | 'activating' | 'active'; const pageSize = 25; function ChatThreadList(props: BaseProps): React.Node { const chatThreadItemLoaders = useFlattenedChatListLoaders(); const chatThreadItemLoaderCache = React.useMemo( () => new ChatThreadItemLoaderCache(chatThreadItemLoaders), [chatThreadItemLoaders], ); const [boundChatListData, setBoundChatListData] = React.useState< $ReadOnlyArray, >(() => chatThreadItemLoaderCache.getAllChatThreadItems()); const loggedInUserInfo = useLoggedInUserInfo(); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const navigateToThread = useNavigateToThread(); const { navigation, route, filterThreads, emptyItem } = props; const [searchText, setSearchText] = React.useState(''); const [searchStatus, setSearchStatus] = React.useState('inactive'); const { threadSearchResults, usersSearchResults } = useThreadListSearch( searchText, loggedInUserInfo?.id, ); const [openedSwipeableID, setOpenedSwipeableID] = React.useState(''); const [numItemsToDisplay, setNumItemsToDisplay] = React.useState(pageSize); React.useEffect(() => { void (async () => { const results = await chatThreadItemLoaderCache.loadMostRecentChatThreadItems( numItemsToDisplay, ); setBoundChatListData(results); })(); }, [numItemsToDisplay, chatThreadItemLoaderCache]); const onChangeSearchText = React.useCallback((updatedSearchText: string) => { setSearchText(updatedSearchText); setNumItemsToDisplay(pageSize); }, []); const scrollPos = React.useRef(0); const flatListRef = React.useRef>(); const onScroll = React.useCallback( (event: ScrollEvent) => { const oldScrollPos = scrollPos.current; scrollPos.current = event.nativeEvent.contentOffset.y; if (scrollPos.current !== 0 || oldScrollPos === 0) { return; } if (searchStatus === 'activating') { setSearchStatus('active'); } }, [searchStatus], ); const onSwipeableWillOpen = React.useCallback( (threadInfo: ThreadInfo) => setOpenedSwipeableID(threadInfo.id), [], ); const composeThread = React.useCallback(() => { if (!loggedInUserInfo) { return; } const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }); navigateToThread({ threadInfo, searching: true }); }, [loggedInUserInfo, navigateToThread]); const onSearchFocus = React.useCallback(() => { if (searchStatus !== 'inactive') { return; } if (scrollPos.current === 0) { setSearchStatus('active'); } else { setSearchStatus('activating'); } }, [searchStatus]); const clearSearch = React.useCallback(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } setSearchStatus('inactive'); }, []); const onSearchBlur = React.useCallback(() => { if (searchStatus !== 'active') { return; } clearSearch(); }, [clearSearch, searchStatus]); const onSearchCancel = React.useCallback(() => { onChangeSearchText(''); clearSearch(); }, [clearSearch, onChangeSearchText]); const searchInputRef = React.useRef>(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }, [navigateToThread, onChangeSearchText], ); const onPressSeeMoreSidebars = React.useCallback( (threadInfo: ThreadInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }, [navigation, onChangeSearchText], ); const hardwareBack = React.useCallback(() => { if (!navigation.isFocused()) { return false; } const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } onSearchCancel(); return true; }, [navigation, onSearchCancel, searchStatus]); const cancelButtonExpansion = useSharedValue(0); const searchItem = React.useMemo( () => ( ), [ onChangeSearchText, onSearchBlur, onSearchCancel, onSearchFocus, searchStatus, searchText, styles.searchContainer, cancelButtonExpansion, ], ); const renderItem = React.useCallback( (row: { item: Item, ... }) => { const item = row.item; + if (item.type === 'loader') { + return ( + + + + ); + } if (item.type === 'search') { return searchItem; } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }, [ onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, openedSwipeableID, searchItem, + styles.listLoadingIndicator, ], ); const listData: $ReadOnlyArray = React.useMemo(() => { const chatThreadItems = getThreadListSearchResults( boundChatListData, searchText, filterThreads, threadSearchResults, usersSearchResults, loggedInUserInfo, ); const chatItems: Item[] = [...chatThreadItems]; if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } if (searchStatus === 'inactive' || searchStatus === 'activating') { chatItems.unshift({ type: 'search', searchText }); } return chatItems; }, [ boundChatListData, emptyItem, filterThreads, loggedInUserInfo, searchStatus, searchText, threadSearchResults, usersSearchResults, ]); const partialListData: $ReadOnlyArray = React.useMemo( () => listData.slice(0, numItemsToDisplay), [listData, numItemsToDisplay], ); const onEndReached = React.useCallback(() => { if (partialListData.length === listData.length) { return; } setNumItemsToDisplay(prevNumItems => prevNumItems + pageSize); }, [listData.length, partialListData.length]); const floatingAction = React.useMemo(() => { if (Platform.OS !== 'android') { return null; } return ( ); }, [composeThread]); const fixedSearch = React.useMemo(() => { if (searchStatus !== 'active') { return null; } return ( ); }, [ onChangeSearchText, onSearchBlur, onSearchCancel, searchStatus, searchText, styles.searchContainer, cancelButtonExpansion, ]); const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem const viewerID = loggedInUserInfo?.id; const extraData = `${viewerID || ''} ${openedSwipeableID}`; + const finalListData = React.useMemo(() => { + if (partialListData.length === listData.length) { + return partialListData; + } + return [...partialListData, { type: 'loader' }]; + }, [partialListData, listData.length]); + const chatThreadList = React.useMemo( () => ( {fixedSearch} {floatingAction} ), [ extraData, fixedSearch, floatingAction, indicatorStyle, onEndReached, onScroll, - partialListData, + finalListData, renderItem, scrollEnabled, styles.container, styles.flatList, ], ); const onTabPress = React.useCallback(() => { if (!navigation.isFocused()) { return; } if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } else if (route.name === BackgroundChatThreadListRouteName) { navigation.navigate({ name: HomeChatThreadListRouteName }); } }, [navigation, route.name]); React.useEffect(() => { const clearNavigationBlurListener = navigation.addListener('blur', () => { setNumItemsToDisplay(pageSize); }); return () => { // `.addListener` returns function that can be called to unsubscribe. // https://reactnavigation.org/docs/navigation-events/#navigationaddlistener clearNavigationBlurListener(); }; }, [navigation]); React.useEffect(() => { const chatNavigation = navigation.getParent< ScreenParamList, 'ChatThreadList', StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationProp<'ChatThreadList'>, >(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation = chatNavigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', onTabPress); return () => { tabNavigation.removeListener('tabPress', onTabPress); }; }, [navigation, onTabPress]); React.useEffect(() => { BackHandler.addEventListener('hardwareBackPress', hardwareBack); return () => { BackHandler.removeEventListener('hardwareBackPress', hardwareBack); }; }, [hardwareBack]); React.useEffect(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } }, [searchText]); const isSearchActivating = searchStatus === 'activating'; React.useEffect(() => { if (isSearchActivating && scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, [isSearchActivating]); return chatThreadList; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, flatList: { flex: 1, backgroundColor: 'listBackground', }, + listLoadingIndicator: { + flex: 1, + }, }; export default ChatThreadList;