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, };