diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js
index aa3972c5a..29a5aee55 100644
--- a/native/chat/chat-thread-list.react.js
+++ b/native/chat/chat-thread-list.react.js
@@ -1,551 +1,622 @@
// @flow
import invariant from 'invariant';
import _sum from 'lodash/fp/sum';
import * as React from 'react';
import {
View,
FlatList,
Platform,
TextInput,
TouchableWithoutFeedback,
} from 'react-native';
import { FloatingAction } from 'react-native-floating-action';
+import Animated from 'react-native-reanimated';
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 Button from '../components/button.react';
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 { animateTowards } from '../utils/animation-utils';
import ChatThreadListItem from './chat-thread-list-item.react';
import type {
ChatTopTabsNavigationProp,
ChatNavigationProp,
} from './chat.react';
const floatingActions = [
{
text: 'Compose',
icon: ,
name: 'compose',
position: 1,
},
];
+/* eslint-disable import/no-named-as-default-member */
+const { Value, interpolate } = 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 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,
+numItemsToDisplay: number,
|};
type PropsAndState = {| ...Props, ...State |};
class ChatThreadList extends React.PureComponent {
state: State = {
searchStatus: 'inactive',
searchText: '',
threadsSearchResults: new Set(),
usersSearchResults: [],
openedSwipeableId: '',
numItemsToDisplay: 25,
};
searchInput: ?React.ElementRef;
flatList: ?FlatList- ;
scrollPos = 0;
clearNavigationBlurListener: ?() => mixed;
+ searchCancelButtonOpen = new Value(0);
+ searchCancelButtonProgress: Value;
+ searchCancelButtonOffset: Value;
+
+ constructor(props: Props) {
+ super(props);
+ this.searchCancelButtonProgress = animateTowards(
+ this.searchCancelButtonOpen,
+ 100,
+ );
+ this.searchCancelButtonOffset = interpolate(
+ this.searchCancelButtonProgress,
+ { inputRange: [0, 1], outputRange: [0, 56] },
+ );
+ }
componentDidMount() {
this.clearNavigationBlurListener = this.props.navigation.addListener(
'blur',
() => {
this.setState({ numItemsToDisplay: 25 });
},
);
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() {
this.clearNavigationBlurListener && this.clearNavigationBlurListener();
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 { searchStatus } = this.state;
+ const prevSearchStatus = prevState.searchStatus;
+
+ const isActiveOrActivating =
+ searchStatus === 'active' || searchStatus === 'activating';
+ const wasActiveOrActivating =
+ prevSearchStatus === 'active' || prevSearchStatus === 'activating';
+ if (isActiveOrActivating && !wasActiveOrActivating) {
+ this.searchCancelButtonOpen.setValue(1);
+ } else if (!isActiveOrActivating && wasActiveOrActivating) {
+ this.searchCancelButtonOpen.setValue(0);
+ }
+
const { flatList } = this;
if (!flatList) {
return;
}
if (this.state.searchText !== prevState.searchText) {
flatList.scrollToOffset({ offset: 0, animated: false });
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 });
}
};
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>) {
+ const searchBoxStyle = [
+ this.props.styles.searchBox,
+ { marginRight: this.searchCancelButtonOffset },
+ ];
+ const buttonStyle = [
+ this.props.styles.cancelSearchButtonText,
+ { opacity: this.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): 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,
): $ReadOnlyArray
- => {
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 });
}
if (searchStatus === 'inactive' || searchStatus === 'activating') {
chatItems.unshift({ type: 'search', searchText });
}
return chatItems;
},
);
partialListDataSelector = createSelector(
this.listDataSelector,
(propsAndState: PropsAndState) => propsAndState.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.setState((prevState) => ({
numItemsToDisplay: prevState.numItemsToDisplay + 25,
}));
};
render() {
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),
numItemsToDisplay: 25,
});
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',
+ display: 'flex',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ },
+ searchBox: {
+ flex: 1,
},
search: {
marginBottom: 8,
marginHorizontal: 12,
marginTop: Platform.OS === 'android' ? 10 : 8,
},
+ cancelSearchButton: {
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ bottom: 0,
+ display: 'flex',
+ justifyContent: 'center',
+ },
+ cancelSearchButtonText: {
+ color: 'link',
+ fontSize: 16,
+ paddingHorizontal: 10,
+ },
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 (
);
});
diff --git a/native/utils/animation-utils.js b/native/utils/animation-utils.js
index 69092e7bf..50b450aa4 100644
--- a/native/utils/animation-utils.js
+++ b/native/utils/animation-utils.js
@@ -1,217 +1,269 @@
// @flow
import * as React from 'react';
import { Platform } from 'react-native';
import { State as GestureState } from 'react-native-gesture-handler';
import Animated, { Easing } from 'react-native-reanimated';
import type { Shape } from 'lib/types/core';
/* eslint-disable import/no-named-as-default-member */
const {
Clock,
Value,
block,
cond,
not,
and,
or,
greaterThan,
lessThan,
eq,
+ neq,
add,
sub,
multiply,
divide,
abs,
set,
max,
startClock,
stopClock,
clockRunning,
timing,
spring,
SpringUtils,
} = Animated;
/* eslint-enable import/no-named-as-default-member */
function clamp(value: Value, minValue: Value, maxValue: Value): Value {
return cond(
greaterThan(value, maxValue),
maxValue,
cond(greaterThan(minValue, value), minValue, value),
);
}
function dividePastDistance(
value: Value,
distance: number,
factor: number,
): Value {
const absValue = abs(value);
const absFactor = cond(eq(absValue, 0), 1, divide(value, absValue));
return cond(
lessThan(absValue, distance),
value,
multiply(add(distance, divide(sub(absValue, distance), factor)), absFactor),
);
}
function delta(value: Value) {
const prevValue = new Value(0);
const deltaValue = new Value(0);
return [
set(deltaValue, cond(eq(prevValue, 0), 0, sub(value, prevValue))),
set(prevValue, value),
deltaValue,
];
}
function gestureJustStarted(state: Value) {
const prevValue = new Value(-1);
return cond(eq(prevValue, state), 0, [
set(prevValue, state),
eq(state, GestureState.ACTIVE),
]);
}
function gestureJustEnded(state: Value) {
const prevValue = new Value(-1);
return cond(eq(prevValue, state), 0, [
set(prevValue, state),
eq(state, GestureState.END),
]);
}
const defaultTimingConfig = {
duration: 250,
easing: Easing.out(Easing.ease),
};
type TimingConfig = Shape;
function runTiming(
clock: Clock,
initialValue: Value | number,
finalValue: Value | number,
startStopClock: boolean = true,
config: TimingConfig = defaultTimingConfig,
): Value {
const state = {
finished: new Value(0),
position: new Value(0),
frameTime: new Value(0),
time: new Value(0),
};
const timingConfig = {
...defaultTimingConfig,
...config,
toValue: new Value(0),
};
return [
cond(not(clockRunning(clock)), [
set(state.finished, 0),
set(state.frameTime, 0),
set(state.time, 0),
set(state.position, initialValue),
set(timingConfig.toValue, finalValue),
startStopClock && startClock(clock),
]),
timing(clock, state, timingConfig),
cond(state.finished, startStopClock && stopClock(clock)),
state.position,
];
}
const defaultSpringConfig = SpringUtils.makeDefaultConfig();
type SpringConfig = Shape;
type SpringAnimationInitialState = Shape<{|
+velocity: Value | number,
|}>;
function runSpring(
clock: Clock,
initialValue: Value | number,
finalValue: Value | number,
startStopClock: boolean = true,
config: SpringConfig = defaultSpringConfig,
initialState?: SpringAnimationInitialState,
): Value {
const state = {
finished: new Value(0),
position: new Value(0),
velocity: new Value(0),
time: new Value(0),
};
const springConfig = {
...defaultSpringConfig,
...config,
toValue: new Value(0),
};
return [
cond(not(clockRunning(clock)), [
set(state.finished, 0),
set(state.velocity, initialState?.velocity ?? 0),
set(state.time, 0),
set(state.position, initialValue),
set(springConfig.toValue, finalValue),
startStopClock && startClock(clock),
]),
spring(clock, state, springConfig),
cond(state.finished, startStopClock && stopClock(clock)),
state.position,
];
}
// You provide a node that performs a "ratchet",
// and this function will call it as keyboard height increases
function ratchetAlongWithKeyboardHeight(
keyboardHeight: Animated.Node,
ratchetFunction: Animated.Node,
) {
const prevKeyboardHeightValue = new Value(-1);
const whenToUpdate = Platform.select({
// In certain situations, iOS will send multiple keyboardShows in rapid
// succession with increasing height values. Only the final value has any
// semblance of reality. I've encountered this when using the native
// password management integration
ios: greaterThan(keyboardHeight, max(prevKeyboardHeightValue, 0)),
// Android's keyboard can resize due to user interaction sometimes. In these
// cases it can get quite big, in which case we don't want to update
default: and(
eq(prevKeyboardHeightValue, 0),
greaterThan(keyboardHeight, 0),
),
});
const whenToReset = and(
eq(keyboardHeight, 0),
greaterThan(prevKeyboardHeightValue, 0),
);
return block([
cond(
lessThan(prevKeyboardHeightValue, 0),
set(prevKeyboardHeightValue, keyboardHeight),
),
cond(or(whenToUpdate, whenToReset), ratchetFunction),
set(prevKeyboardHeightValue, keyboardHeight),
]);
}
function useReanimatedValueForBoolean(booleanValue: boolean): Value {
const reanimatedValueRef = React.useRef(new Value(booleanValue ? 1 : 0));
React.useEffect(() => {
reanimatedValueRef.current.setValue(booleanValue ? 1 : 0);
}, [booleanValue]);
return reanimatedValueRef.current;
}
+// Target can be either 0 or 1. Caller handles interpolating
+function animateTowards(
+ target: Value,
+ fullAnimationLength: number, // in ms
+): Value {
+ const curValue = new Value(-1);
+ const prevTarget = new Value(-1);
+ const clock = new Clock();
+
+ const prevClockValue = new Value(0);
+ const curDeltaClockValue = new Value(0);
+ const deltaClockValue = [
+ set(
+ curDeltaClockValue,
+ cond(eq(prevClockValue, 0), 0, sub(clock, prevClockValue)),
+ ),
+ set(prevClockValue, clock),
+ curDeltaClockValue,
+ ];
+ const progressPerFrame = divide(deltaClockValue, fullAnimationLength);
+
+ return block([
+ [
+ cond(eq(curValue, -1), set(curValue, target)),
+ cond(eq(prevTarget, -1), set(prevTarget, target)),
+ ],
+ cond(neq(target, prevTarget), [stopClock(clock), set(prevTarget, target)]),
+ cond(neq(curValue, target), [
+ cond(not(clockRunning(clock)), [
+ set(prevClockValue, 0),
+ startClock(clock),
+ ]),
+ set(
+ curValue,
+ cond(
+ eq(target, 1),
+ add(curValue, progressPerFrame),
+ sub(curValue, progressPerFrame),
+ ),
+ ),
+ ]),
+ [
+ cond(greaterThan(curValue, 1), set(curValue, 1)),
+ cond(lessThan(curValue, 0), set(curValue, 0)),
+ ],
+ cond(eq(curValue, target), [stopClock(clock)]),
+ curValue,
+ ]);
+}
+
export {
clamp,
dividePastDistance,
delta,
gestureJustStarted,
gestureJustEnded,
runTiming,
runSpring,
ratchetAlongWithKeyboardHeight,
useReanimatedValueForBoolean,
+ animateTowards,
};