diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js
index 7b0fbef1d..a801c3168 100644
--- a/native/chat/chat-thread-list.react.js
+++ b/native/chat/chat-thread-list.react.js
@@ -1,678 +1,686 @@
// @flow
import IonIcon from '@expo/vector-icons/Ionicons.js';
import invariant from 'invariant';
import _sum from 'lodash/fp/sum.js';
import * as React from 'react';
import {
View,
FlatList,
Platform,
TextInput,
TouchableWithoutFeedback,
BackHandler,
} from 'react-native';
import { FloatingAction } from 'react-native-floating-action';
import Animated from 'react-native-reanimated';
import { createSelector } from 'reselect';
-import { searchUsers } from 'lib/actions/user-actions.js';
+import { searchUsers as searchUsersEndpoint } from 'lib/actions/user-actions.js';
import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
import {
type ChatThreadItem,
useFlattenedChatListData,
} from 'lib/selectors/chat-selectors.js';
import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js';
import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors.js';
import SearchIndex from 'lib/shared/search-index.js';
import {
createPendingThread,
getThreadListSearchResults,
} from 'lib/shared/thread-utils.js';
import type { SetState } from 'lib/types/hook-types.js';
-import type { UserSearchResult } from 'lib/types/search-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import type {
GlobalAccountUserInfo,
UserInfo,
LoggedInUserInfo,
} from 'lib/types/user-types.js';
import { useServerCall } from 'lib/utils/action-utils.js';
import {
ChatThreadListItem,
chatThreadListItemHeight,
spacerHeight,
} from './chat-thread-list-item.react.js';
import type {
ChatTopTabsNavigationProp,
ChatNavigationProp,
} from './chat.react.js';
import {
type MessageListParams,
useNavigateToThread,
} from './message-list-types.js';
import { sidebarHeight } from './sidebar-item.react.js';
import Button from '../components/button.react.js';
import Search from '../components/search.react.js';
import {
SidebarListModalRouteName,
HomeChatThreadListRouteName,
BackgroundChatThreadListRouteName,
type NavigationRoute,
} from '../navigation/route-names.js';
import type { TabNavigationProp } from '../navigation/tab-navigator.react.js';
import { useSelector } from '../redux/redux-utils.js';
import {
type IndicatorStyle,
indicatorStyleSelector,
useStyles,
} from '../themes/colors.js';
import type { ScrollEvent } from '../types/react-native.js';
import { AnimatedView, type AnimatedStyleObj } from '../types/styles.js';
import { animateTowards } from '../utils/animation-utils.js';
const floatingActions = [
{
text: 'Compose',
icon: ,
name: 'compose',
position: 1,
},
];
/* eslint-disable import/no-named-as-default-member */
const { Value, Node, interpolateNode, useValue } = Animated;
/* eslint-enable import/no-named-as-default-member */
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 SearchStatus = 'inactive' | 'activating' | 'active';
type Props = {
...BaseProps,
// Redux state
+chatListData: $ReadOnlyArray,
+loggedInUserInfo: ?LoggedInUserInfo,
+threadSearchIndex: SearchIndex,
+styles: typeof unboundStyles,
+indicatorStyle: IndicatorStyle,
+usersWithPersonalThread: $ReadOnlySet,
+navigateToThread: (params: MessageListParams) => void,
- // async functions that hit server APIs
- +searchUsers: (usernamePrefix: string) => Promise,
+searchText: string,
+setSearchText: SetState,
+searchStatus: SearchStatus,
+setSearchStatus: SetState,
+threadsSearchResults: Set,
+setThreadsSearchResults: SetState>,
+usersSearchResults: $ReadOnlyArray,
+setUsersSearchResults: SetState<$ReadOnlyArray>,
+openedSwipeableID: string,
+setOpenedSwipeableID: SetState,
+numItemsToDisplay: number,
+setNumItemsToDisplay: SetState,
+searchCancelButtonOpen: Value,
+searchCancelButtonProgress: Node,
+searchCancelButtonOffset: Node,
+ +searchUsers: (
+ usernamePrefix: string,
+ ) => Promise<$ReadOnlyArray>,
+ +onChangeSearchText: (searchText: string) => Promise,
};
class ChatThreadList extends React.PureComponent {
searchInput: ?React.ElementRef;
flatList: ?FlatList- ;
scrollPos = 0;
clearNavigationBlurListener: ?() => mixed;
constructor(props: Props) {
super(props);
}
componentDidMount() {
this.clearNavigationBlurListener = this.props.navigation.addListener(
'blur',
() => {
this.props.setNumItemsToDisplay(25);
},
);
const chatNavigation: ?ChatNavigationProp<'ChatThreadList'> =
this.props.navigation.getParent();
invariant(chatNavigation, 'ChatNavigator should be within TabNavigator');
const tabNavigation: ?TabNavigationProp<'Chat'> =
chatNavigation.getParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.addListener('tabPress', this.onTabPress);
BackHandler.addEventListener('hardwareBackPress', this.hardwareBack);
}
componentWillUnmount() {
this.clearNavigationBlurListener && this.clearNavigationBlurListener();
const chatNavigation: ?ChatNavigationProp<'ChatThreadList'> =
this.props.navigation.getParent();
invariant(chatNavigation, 'ChatNavigator should be within TabNavigator');
const tabNavigation: ?TabNavigationProp<'Chat'> =
chatNavigation.getParent();
invariant(tabNavigation, 'ChatNavigator should be within TabNavigator');
tabNavigation.removeListener('tabPress', this.onTabPress);
BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack);
}
hardwareBack = () => {
if (!this.props.navigation.isFocused()) {
return false;
}
const { searchStatus } = this.props;
const isActiveOrActivating =
searchStatus === 'active' || searchStatus === 'activating';
if (!isActiveOrActivating) {
return false;
}
this.onSearchCancel();
return true;
};
componentDidUpdate(prevProps: Props) {
const { searchStatus } = this.props;
const prevSearchStatus = prevProps.searchStatus;
const isActiveOrActivating =
searchStatus === 'active' || searchStatus === 'activating';
const wasActiveOrActivating =
prevSearchStatus === 'active' || prevSearchStatus === 'activating';
if (isActiveOrActivating && !wasActiveOrActivating) {
this.props.searchCancelButtonOpen.setValue(1);
} else if (!isActiveOrActivating && wasActiveOrActivating) {
this.props.searchCancelButtonOpen.setValue(0);
}
const { flatList } = this;
if (!flatList) {
return;
}
if (this.props.searchText !== prevProps.searchText) {
flatList.scrollToOffset({ offset: 0, animated: false });
return;
}
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 });
}
};
onSearchFocus = () => {
if (this.props.searchStatus !== 'inactive') {
return;
}
if (this.scrollPos === 0) {
this.props.setSearchStatus('active');
} else {
this.props.setSearchStatus('activating');
}
};
clearSearch() {
const { flatList } = this;
flatList && flatList.scrollToOffset({ offset: 0, animated: false });
this.props.setSearchStatus('inactive');
}
onSearchBlur = () => {
if (this.props.searchStatus !== 'active') {
return;
}
this.clearSearch();
};
onSearchCancel = () => {
- this.onChangeSearchText('');
+ this.props.onChangeSearchText('');
this.clearSearch();
};
renderSearch(additionalProps?: $Shape>) {
const animatedSearchBoxStyle: AnimatedStyleObj = {
marginRight: this.props.searchCancelButtonOffset,
};
const searchBoxStyle = [
this.props.styles.searchBox,
animatedSearchBoxStyle,
];
const buttonStyle = [
this.props.styles.cancelSearchButtonText,
{ opacity: this.props.searchCancelButtonProgress },
];
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 (
);
};
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) => {
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;
}
let height = chatThreadListItemHeight;
height += item.sidebars.length * sidebarHeight;
if (item.sidebars.length > 0) {
height += spacerHeight;
}
return height;
};
static heightOfItems(data: $ReadOnlyArray
- ): number {
return _sum(data.map(ChatThreadList.itemHeight));
}
listDataSelector = createSelector(
(props: Props) => props.chatListData,
(props: Props) => props.searchStatus,
(props: Props) => props.searchText,
(props: Props) => props.threadsSearchResults,
(props: Props) => props.emptyItem,
(props: Props) => props.usersSearchResults,
(props: Props) => props.filterThreads,
(props: Props) => props.loggedInUserInfo,
(
reduxChatListData: $ReadOnlyArray,
searchStatus: SearchStatus,
searchText: string,
threadsSearchResults: Set,
emptyItem?: React.ComponentType<{}>,
usersSearchResults: $ReadOnlyArray,
filterThreads: ThreadInfo => boolean,
loggedInUserInfo: ?LoggedInUserInfo,
): $ReadOnlyArray
- => {
const chatThreadItems = getThreadListSearchResults(
reduxChatListData,
searchText,
filterThreads,
threadsSearchResults,
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;
},
);
partialListDataSelector = createSelector(
this.listDataSelector,
(props: Props) => props.numItemsToDisplay,
(items: $ReadOnlyArray
- , numItemsToDisplay: number) =>
items.slice(0, numItemsToDisplay),
);
get fullListData() {
return this.listDataSelector({ ...this.props, ...this.state });
}
get listData() {
return this.partialListDataSelector({ ...this.props, ...this.state });
}
onEndReached = () => {
if (this.listData.length === this.fullListData.length) {
return;
}
this.props.setNumItemsToDisplay(prevNumItems => prevNumItems + 25);
};
render() {
let floatingAction;
if (Platform.OS === 'android') {
floatingAction = (
);
}
let fixedSearch;
const { searchStatus } = this.props;
if (searchStatus === 'active') {
fixedSearch = this.renderSearch({ autoFocus: true });
}
const scrollEnabled =
searchStatus === 'inactive' || searchStatus === 'active';
// viewerID is in extraData since it's used by MessagePreview
// within ChatThreadListItem
const viewerID = this.props.loggedInUserInfo?.id;
const extraData = `${viewerID || ''} ${this.props.openedSwipeableID}`;
return (
{fixedSearch}
{floatingAction}
);
}
flatListRef = (flatList: ?FlatList
- ) => {
this.flatList = flatList;
};
onScroll = (event: ScrollEvent) => {
const oldScrollPos = this.scrollPos;
this.scrollPos = event.nativeEvent.contentOffset.y;
if (this.scrollPos !== 0 || oldScrollPos === 0) {
return;
}
if (this.props.searchStatus === 'activating') {
this.props.setSearchStatus('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.loggedInUserInfo?.id,
- );
- }
-
- onChangeSearchText = async (searchText: string) => {
- const results = this.props.threadSearchIndex.getSearchResults(searchText);
- this.props.setSearchText(searchText);
- this.props.setThreadsSearchResults(new Set(results));
- this.props.setNumItemsToDisplay(25);
- const usersSearchResults = await this.searchUsers(searchText);
- this.props.setUsersSearchResults(usersSearchResults);
- };
-
onPressItem = (
threadInfo: ThreadInfo,
pendingPersonalThreadUserInfo?: UserInfo,
) => {
- this.onChangeSearchText('');
+ this.props.onChangeSearchText('');
if (this.searchInput) {
this.searchInput.blur();
}
this.props.navigateToThread({ threadInfo, pendingPersonalThreadUserInfo });
};
onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => {
- this.onChangeSearchText('');
+ this.props.onChangeSearchText('');
if (this.searchInput) {
this.searchInput.blur();
}
this.props.navigation.navigate<'SidebarListModal'>({
name: SidebarListModalRouteName,
params: { threadInfo },
});
};
onSwipeableWillOpen = (threadInfo: ThreadInfo) => {
this.props.setOpenedSwipeableID(threadInfo.id);
};
composeThread = () => {
const { loggedInUserInfo } = this.props;
if (!loggedInUserInfo) {
return;
}
const threadInfo = createPendingThread({
viewerID: loggedInUserInfo.id,
threadType: threadTypes.PRIVATE,
members: [loggedInUserInfo],
});
this.props.navigateToThread({ threadInfo, searching: true });
};
}
const unboundStyles = {
icon: {
fontSize: 28,
},
container: {
flex: 1,
},
searchContainer: {
backgroundColor: 'listBackground',
display: 'flex',
justifyContent: 'center',
flexDirection: 'row',
},
searchBox: {
flex: 1,
},
search: {
marginBottom: 8,
marginHorizontal: 18,
marginTop: 16,
},
cancelSearchButton: {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
},
cancelSearchButtonText: {
color: 'link',
fontSize: 16,
paddingHorizontal: 16,
paddingTop: 8,
},
flatList: {
flex: 1,
backgroundColor: 'listBackground',
},
};
function ConnectedChatThreadList(props: BaseProps): React.Node {
const boundChatListData = useFlattenedChatListData();
const loggedInUserInfo = useLoggedInUserInfo();
const threadSearchIndex = useGlobalThreadSearchIndex();
const styles = useStyles(unboundStyles);
const indicatorStyle = useSelector(indicatorStyleSelector);
- const callSearchUsers = useServerCall(searchUsers);
+ const callSearchUsers = useServerCall(searchUsersEndpoint);
const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector);
const navigateToThread = useNavigateToThread();
const { navigation, route, filterThreads, emptyItem } = props;
const [searchText, setSearchText] = React.useState('');
const [searchStatus, setSearchStatus] =
React.useState('inactive');
const [threadsSearchResults, setThreadsSearchResults] = React.useState<
Set,
>(new Set());
const [usersSearchResults, setUsersSearchResults] = React.useState<
$ReadOnlyArray,
>([]);
const [openedSwipeableID, setOpenedSwipeableID] = React.useState('');
const [numItemsToDisplay, setNumItemsToDisplay] = React.useState(25);
const searchCancelButtonOpen: Value = useValue(0);
const searchCancelButtonProgress: Node = React.useMemo(
() => animateTowards(searchCancelButtonOpen, 100),
[searchCancelButtonOpen],
);
const searchCancelButtonOffset: Node = React.useMemo(
() =>
interpolateNode(searchCancelButtonProgress, {
inputRange: [0, 1],
outputRange: [0, 56],
}),
[searchCancelButtonProgress],
);
+ const searchUsers = React.useCallback(
+ async (usernamePrefix: string) => {
+ if (usernamePrefix.length === 0) {
+ return [];
+ }
+
+ const { userInfos } = await callSearchUsers(usernamePrefix);
+ return userInfos.filter(
+ info =>
+ !usersWithPersonalThread.has(info.id) &&
+ info.id !== loggedInUserInfo?.id,
+ );
+ },
+ [callSearchUsers, loggedInUserInfo?.id, usersWithPersonalThread],
+ );
+
+ const onChangeSearchText = React.useCallback(
+ async (updatedSearchText: string) => {
+ const results = threadSearchIndex.getSearchResults(updatedSearchText);
+ setSearchText(updatedSearchText);
+ setThreadsSearchResults(new Set(results));
+ setNumItemsToDisplay(25);
+ const searchResults = await searchUsers(updatedSearchText);
+ setUsersSearchResults(searchResults);
+ },
+ [searchUsers, threadSearchIndex],
+ );
+
return (
);
}
export default ConnectedChatThreadList;