diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index d56434258..f1ca2c3ab 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,428 +1,507 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; -import { View, FlatList, Platform, TextInput } from 'react-native'; +import { + View, + FlatList, + Platform, + TextInput, + TouchableWithoutFeedback, +} from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import IonIcon from 'react-native-vector-icons/Ionicons'; import { createSelector } from 'reselect'; import { searchUsers } from 'lib/actions/user-actions'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors'; import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { createPendingThread, createPendingThreadItem, threadIsTopLevel, } from 'lib/shared/thread-utils'; import type { UserSearchResult } from 'lib/types/search-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { threadTypes } from 'lib/types/thread-types'; import type { GlobalAccountUserInfo, UserInfo } from 'lib/types/user-types'; import { useServerCall } from 'lib/utils/action-utils'; import Search from '../components/search.react'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { MessageListRouteName, SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors'; import ChatThreadListItem from './chat-thread-list-item.react'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; type Item = | ChatThreadItem | {| +type: 'search', +searchText: string |} | {| +type: 'empty', +emptyItem: React.ComponentType<{||}> |}; type BaseProps = {| +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{||}>, |}; type Props = {| ...BaseProps, // Redux state +chatListData: $ReadOnlyArray, +viewerID: ?string, +threadSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, +usersWithPersonalThread: $ReadOnlySet, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, |}; +type SearchStatus = 'inactive' | 'activating' | 'active'; type State = {| + +searchStatus: SearchStatus, +searchText: string, +threadsSearchResults: Set, +usersSearchResults: $ReadOnlyArray, +openedSwipeableId: string, |}; type PropsAndState = {| ...Props, ...State |}; class ChatThreadList extends React.PureComponent { state: State = { + searchStatus: 'inactive', searchText: '', threadsSearchResults: new Set(), usersSearchResults: [], openedSwipeableId: '', }; searchInput: ?React.ElementRef; flatList: ?FlatList; scrollPos = 0; componentDidMount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } + componentDidUpdate(prevProps: Props, prevState: State) { + const { flatList } = this; + if (!flatList) { + return; + } + const { searchStatus } = this.state; + const prevSearchStatus = prevState.searchStatus; + if (searchStatus === 'activating' && prevSearchStatus === 'inactive') { + flatList.scrollToOffset({ offset: 0, animated: true }); + } + } + onTabPress = () => { if (!this.props.navigation.isFocused()) { return; } if (this.scrollPos > 0 && this.flatList) { this.flatList.scrollToOffset({ offset: 0, animated: true }); } else if (this.props.route.name === BackgroundChatThreadListRouteName) { this.props.navigation.navigate({ name: HomeChatThreadListRouteName }); } }; - renderItem = (row: { item: Item }) => { - const item = row.item; - if (item.type === 'search') { - return ( + onSearchFocus = () => { + if (this.state.searchStatus !== 'inactive') { + return; + } + if (this.scrollPos === 0) { + this.setState({ searchStatus: 'active' }); + } else { + this.setState({ searchStatus: 'activating' }); + } + }; + + onSearchBlur = () => { + if (this.state.searchStatus !== 'active') { + return; + } + const { flatList } = this; + flatList && flatList.scrollToOffset({ offset: 0, animated: false }); + this.setState({ searchStatus: 'inactive' }); + }; + + renderSearch(additionalProps?: $Shape>) { + return ( + + + ); + } + + searchInputRef = (searchInput: ?React.ElementRef) => { + this.searchInput = searchInput; + }; + + renderItem = (row: { item: Item }) => { + const item = row.item; + if (item.type === 'search') { + return ( + + {this.renderSearch({ active: false })} + ); } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }; - searchInputRef = (searchInput: ?React.ElementRef) => { - this.searchInput = searchInput; - }; - static keyExtractor(item: Item) { if (item.type === 'chatThreadItem') { return item.threadInfo.id; } else if (item.type === 'empty') { return 'empty'; } else { return 'search'; } } static getItemLayout(data: ?$ReadOnlyArray, index: number) { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatThreadList.heightOfItems( data.filter((_, i) => i < index), ); const item = data[index]; const length = item ? ChatThreadList.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: Item): number { 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; } return 60 + item.sidebars.length * 30; } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(ChatThreadList.itemHeight)); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.chatListData, + (propsAndState: PropsAndState) => propsAndState.searchStatus, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.threadsSearchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, (propsAndState: PropsAndState) => propsAndState.usersSearchResults, ( reduxChatListData: $ReadOnlyArray, + searchStatus: SearchStatus, searchText: string, threadsSearchResults: Set, emptyItem?: React.ComponentType<{||}>, usersSearchResults: $ReadOnlyArray, ): Item[] => { const chatItems = []; if (!searchText) { chatItems.push( ...reduxChatListData.filter( (item) => threadIsTopLevel(item.threadInfo) && this.props.filterThreads(item.threadInfo), ), ); } else { const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of reduxChatListData) { if (!threadsSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } chatItems.push(...privateThreads, ...personalThreads, ...otherThreads); const { viewerID } = this.props; if (viewerID) { chatItems.push( ...usersSearchResults.map((user) => createPendingThreadItem(viewerID, user), ), ); } } if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } - return [{ type: 'search', searchText }, ...chatItems]; + if (searchStatus === 'inactive' || searchStatus === 'activating') { + chatItems.unshift({ type: 'search', searchText }); + } + + return chatItems; }, ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { - let floatingAction = null; + let floatingAction; if (Platform.OS === 'android') { floatingAction = ( ); } + let fixedSearch; + const { searchStatus } = this.state; + if (searchStatus === 'active') { + fixedSearch = this.renderSearch({ autoFocus: true }); + } + const scrollEnabled = + searchStatus === 'inactive' || searchStatus === 'active'; // this.props.viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem return ( + {fixedSearch} {floatingAction} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { + const oldScrollPos = this.scrollPos; this.scrollPos = event.nativeEvent.contentOffset.y; + if (this.scrollPos !== 0 || oldScrollPos === 0) { + return; + } + if (this.state.searchStatus === 'activating') { + this.setState({ searchStatus: 'active' }); + } }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos.filter( (info) => !this.props.usersWithPersonalThread.has(info.id) && info.id !== this.props.viewerID, ); } onChangeSearchText = async (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); this.setState({ searchText, threadsSearchResults: new Set(results) }); const usersSearchResults = await this.searchUsers(searchText); this.setState({ usersSearchResults }); }; onPressItem = ( threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo, pendingPersonalThreadUserInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState((state) => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { if (this.props.viewerID) { this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo: createPendingThread({ viewerID: this.props.viewerID, threadType: threadTypes.CHAT_SECRET, }), searching: true, }, }); } }; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, + searchContainer: { + backgroundColor: 'listBackground', + }, search: { marginBottom: 8, marginHorizontal: 12, marginTop: Platform.OS === 'android' ? 10 : 8, }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default React.memo(function ConnectedChatThreadList( props: BaseProps, ) { const boundChatListData = useFlattenedChatListData(); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useSelector(threadSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const callSearchUsers = useServerCall(searchUsers); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return ( ); });