diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index 98f574b5a..fbffe95ac 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1096 +1,1096 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _find from 'lodash/fp/find'; import _findIndex from 'lodash/fp/findIndex'; import _map from 'lodash/fp/map'; import _pickBy from 'lodash/fp/pickBy'; import _size from 'lodash/fp/size'; import _sum from 'lodash/fp/sum'; import _throttle from 'lodash/throttle'; import * as React from 'react'; import { View, Text, FlatList, AppState as NativeAppState, Platform, LayoutAnimation, TouchableWithoutFeedback, } from 'react-native'; import SafeAreaView from 'react-native-safe-area-view'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { entryKey } from 'lib/shared/entry-utils'; import type { EntryInfo, CalendarQuery, CalendarQueryUpdateResult, } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ConnectionStatus } from 'lib/types/socket-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { dateString, prettyDate, dateFromString } from 'lib/utils/date-utils'; import sleep from 'lib/utils/sleep'; import ContentLoading from '../components/content-loading.react'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; -import type { TabNavigationProp } from '../navigation/app-navigator.react'; import DisconnectedBar from '../navigation/disconnected-bar.react'; import { createIsForegroundSelector, createActiveTabSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names'; import type { NavigationRoute } from '../navigation/route-names'; +import type { TabNavigationProp } from '../navigation/tab-navigator.react'; import { useSelector } from '../redux/redux-utils'; import { calendarListData } from '../selectors/calendar-selectors'; import type { CalendarItem, SectionHeaderItem, SectionFooterItem, LoaderItem, } from '../selectors/calendar-selectors'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { useColors, useStyles, useIndicatorStyle, type Colors, type IndicatorStyle, } from '../themes/colors'; import type { EventSubscription, ScrollEvent, ViewableItemsChange, KeyboardEvent, } from '../types/react-native'; import CalendarInputBar from './calendar-input-bar.react'; import { Entry, InternalEntry, dummyNodeForEntryHeightMeasurement, } from './entry.react'; import SectionFooter from './section-footer.react'; export type EntryInfoWithHeight = { ...EntryInfo, +textHeight: number, }; type CalendarItemWithHeight = | LoaderItem | SectionHeaderItem | SectionFooterItem | { itemType: 'entryInfo', entryInfo: EntryInfoWithHeight, threadInfo: ThreadInfo, }; type ExtraData = { +activeEntries: { +[key: string]: boolean }, +visibleEntries: { +[key: string]: boolean }, }; const safeAreaViewForceInset = { top: 'always', bottom: 'never', }; type BaseProps = { +navigation: TabNavigationProp<'Calendar'>, +route: NavigationRoute<'Calendar'>, }; type Props = { ...BaseProps, // Nav state +calendarActive: boolean, // Redux state +listData: ?$ReadOnlyArray, +startDate: string, +endDate: string, +calendarFilters: $ReadOnlyArray, +dimensions: DerivedDimensionsInfo, +loadingStatus: LoadingStatus, +connectionStatus: ConnectionStatus, +colors: Colors, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, +readyToShowList: boolean, +extraData: ExtraData, +currentlyEditing: $ReadOnlyArray, }; class Calendar extends React.PureComponent { flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; lastForegrounded = 0; lastCalendarReset = 0; currentScrollPosition: ?number = null; // We don't always want an extraData update to trigger a state update, so we // cache the most recent value as a member here latestExtraData: ExtraData; // For some reason, we have to delay the scrollToToday call after the first // scroll upwards firstScrollComplete = false; // When an entry becomes active, we make a note of its key so that once the // keyboard event happens, we know where to move the scrollPos to lastEntryKeyActive: ?string = null; keyboardShowListener: ?EventSubscription; keyboardDismissListener: ?EventSubscription; keyboardShownHeight: ?number = null; // If the query fails, we try it again topLoadingFromScroll: ?CalendarQuery = null; bottomLoadingFromScroll: ?CalendarQuery = null; // We wait until the loaders leave view before letting them be triggered again topLoaderWaitingToLeaveView = true; bottomLoaderWaitingToLeaveView = true; // We keep refs to the entries so CalendarInputBar can save them entryRefs = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { NativeAppState.addEventListener('change', this.handleAppStateChange); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { NativeAppState.removeEventListener('change', this.handleAppStateChange); if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate, } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); const offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } renderItem = (row: { item: CalendarItemWithHeight, ... }) => { const item = row.item; if (item.itemType === 'loader') { return ; } else if (item.itemType === 'header') { return this.renderSectionHeader(item); } else if (item.itemType === 'entryInfo') { const key = entryKey(item.entryInfo); return ( ); } else if (item.itemType === 'footer') { return this.renderSectionFooter(item); } invariant(false, 'renderItem conditions should be exhaustive'); }; renderSectionHeader = (item: SectionHeaderItem) => { let date = prettyDate(item.dateString); if (dateString(new Date()) === item.dateString) { date += ' (today)'; } const dateObj = dateFromString(item.dateString).getDay(); const weekendStyle = dateObj === 0 || dateObj === 6 ? this.props.styles.weekendSectionHeader : null; return ( {date} ); }; renderSectionFooter = (item: SectionFooterItem) => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = (item: CalendarItemWithHeight | CalendarItem) => { if (item.itemType === 'loader') { return item.key; } else if (item.itemType === 'header') { return item.dateString + '/header'; } else if (item.itemType === 'entryInfo') { return entryKey(item.entryInfo); } else if (item.itemType === 'footer') { return item.dateString + '/footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; static getItemLayout = ( data: ?$ReadOnlyArray, index: number, ) => { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; }; static itemHeight = (item: CalendarItemWithHeight) => { if (item.itemType === 'loader') { return 56; } else if (item.itemType === 'header') { return 31; } else if (item.itemType === 'entryInfo') { const verticalPadding = 10; return verticalPadding + item.entryInfo.textHeight; } else if (item.itemType === 'footer') { return 40; } invariant(false, 'itemHeight conditions should be exhaustive'); }; static heightOfItems = (data: $ReadOnlyArray) => { return _sum(data.map(Calendar.itemHeight)); }; render() { const { listDataWithHeights } = this.state; let flatList = null; if (listDataWithHeights) { const flatListStyle = { opacity: this.state.readyToShowList ? 1 : 0 }; const initialScrollIndex = this.initialScrollIndex(listDataWithHeights); flatList = ( ); } let loadingIndicator = null; if (!listDataWithHeights || !this.state.readyToShowList) { loadingIndicator = ( ); } const disableInputBar = this.state.currentlyEditing.length === 0; return ( <> {loadingIndicator} {flatList} ); } flatListHeight() { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray) { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { heightLeft -= Calendar.itemHeight(data[--returnIndex]); } return returnIndex; } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; entryRef = (inEntryKey: string, entry: ?InternalEntry) => { this.entryRefs.set(inEntryKey, entry); }; makeAllEntriesInactive = () => { if (_size(this.state.extraData.activeEntries) === 0) { if (_size(this.latestExtraData.activeEntries) !== 0) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); }; makeActive = (key: string, active: boolean) => { if (!active) { const activeKeys = Object.keys(this.latestExtraData.activeEntries); if (activeKeys.length === 0) { if (Object.keys(this.state.extraData.activeEntries).length !== 0) { this.setState({ extraData: this.latestExtraData }); } return; } const activeKey = activeKeys[0]; if (activeKey === key) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); } return; } if ( _size(this.state.extraData.activeEntries) === 1 && this.state.extraData.activeEntries[key] ) { if ( _size(this.latestExtraData.activeEntries) !== 1 || !this.latestExtraData.activeEntries[key] ) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: { [key]: true }, }; this.setState({ extraData: this.latestExtraData }); }; onEnterEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const keyboardShownHeight = this.keyboardShownHeight; if (keyboardShownHeight && this.state.listDataWithHeights) { this.scrollToKey(key, keyboardShownHeight); } else { this.lastEntryKeyActive = key; } const newCurrentlyEditing = [ ...new Set([...this.state.currentlyEditing, key]), ]; if (newCurrentlyEditing.length > this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; onConcludeEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const newCurrentlyEditing = this.state.currentlyEditing.filter( k => k !== key, ); if (newCurrentlyEditing.length < this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; keyboardShow = (event: KeyboardEvent) => { // flatListHeight() factors in the size of the tab bar, // but it is hidden by the keyboard since it is at the bottom const { bottomInset, tabBarHeight } = this.props.dimensions; const inputBarHeight = Platform.OS === 'android' ? 37.7 : 35.5; const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max(event.endCoordinates.height - bottomInset, 0), }); const keyboardShownHeight = inputBarHeight + Math.max(keyboardHeight - tabBarHeight, 0); this.keyboardShownHeight = keyboardShownHeight; const lastEntryKeyActive = this.lastEntryKeyActive; if (lastEntryKeyActive && this.state.listDataWithHeights) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } }; keyboardDismiss = () => { this.keyboardShownHeight = null; }; scrollToKey(lastEntryKeyActive: string, keyboardHeight: number) { const data = this.state.listDataWithHeights; invariant(data, 'should be set'); const index = data.findIndex( (item: CalendarItemWithHeight) => Calendar.keyExtractor(item) === lastEntryKeyActive, ); if (index === -1) { return; } const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); const itemHeight = Calendar.itemHeight(data[index]); const entryAdditionalActiveHeight = Platform.OS === 'android' ? 21 : 20; const itemEnd = itemStart + itemHeight + entryAdditionalActiveHeight; const visibleHeight = this.flatListHeight() - keyboardHeight; if ( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null && itemStart > this.currentScrollPosition && itemEnd < this.currentScrollPosition + visibleHeight ) { return; } const offset = itemStart - (visibleHeight - itemHeight) / 2; invariant(this.flatList, 'flatList should be set'); this.flatList.scrollToOffset({ offset, animated: true }); } heightMeasurerKey = (item: CalendarItem) => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem) => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = (item: CalendarItem, height: ?number) => { if (item.itemType !== 'entryInfo') { return item; } invariant(height !== null && height !== undefined, 'height should be set'); const { entryInfo } = item; return { itemType: 'entryInfo', entryInfo: Calendar.entryInfoWithHeight(entryInfo, height), threadInfo: item.threadInfo, }; }; static entryInfoWithHeight( entryInfo: EntryInfo, textHeight: number, ): EntryInfoWithHeight { // Blame Flow for not accepting object spread on exact types if (entryInfo.id && entryInfo.localID) { return { id: entryInfo.id, localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else if (entryInfo.id) { return { id: entryInfo.id, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else { return { localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { const ldwh = this.state.listDataWithHeights; if (!ldwh) { // This indicates the listData was cleared (set to null) right before this // callback was called. Since this leads to the FlatList getting cleared, // we'll just ignore this callback. return; } const visibleEntries = {}; for (const token of info.viewableItems) { if (token.item.itemType === 'entryInfo') { visibleEntries[entryKey(token.item.entryInfo)] = true; } } this.latestExtraData = { activeEntries: _pickBy((_, key: string) => { if (visibleEntries[key]) { return true; } // We don't automatically set scrolled-away entries to be inactive // because entries can be out-of-view at creation time if they need to // be scrolled into view (see onEnterEntryEditMode). If Entry could // distinguish the reasons its active prop gets set to false, it could // differentiate the out-of-view case from the something-pressed case, // and then we could set scrolled-away entries to be inactive without // worrying about this edge case. Until then... const foundItem = _find( item => item.entryInfo && entryKey(item.entryInfo) === key, )(ldwh); return !!foundItem; })(this.latestExtraData.activeEntries), visibleEntries, }; const topLoader = _find({ key: 'TopLoader' })(info.viewableItems); if (this.topLoaderWaitingToLeaveView && !topLoader) { this.topLoaderWaitingToLeaveView = false; this.topLoadingFromScroll = null; } const bottomLoader = _find({ key: 'BottomLoader' })(info.viewableItems); if (this.bottomLoaderWaitingToLeaveView && !bottomLoader) { this.bottomLoaderWaitingToLeaveView = false; this.bottomLoadingFromScroll = null; } if ( !this.state.readyToShowList && !this.topLoaderWaitingToLeaveView && !this.bottomLoaderWaitingToLeaveView && info.viewableItems.length > 0 ) { this.setState({ readyToShowList: true, extraData: this.latestExtraData, }); } if ( topLoader && !this.topLoaderWaitingToLeaveView && !this.topLoadingFromScroll ) { this.topLoaderWaitingToLeaveView = true; const start = dateFromString(this.props.startDate); start.setDate(start.getDate() - 31); const startDate = dateString(start); const endDate = this.props.endDate; this.topLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreAbove(); } else if ( bottomLoader && !this.bottomLoaderWaitingToLeaveView && !this.bottomLoadingFromScroll ) { this.bottomLoaderWaitingToLeaveView = true; const end = dateFromString(this.props.endDate); end.setDate(end.getDate() + 31); const endDate = dateString(end); const startDate = this.props.startDate; this.bottomLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreBelow(); } }; dispatchCalendarQueryUpdate(calendarQuery: CalendarQuery) { this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery), ); } loadMoreAbove = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.bottomLoadingFromScroll); } }, 1000); onScroll = (event: ScrollEvent) => { this.currentScrollPosition = event.nativeEvent.contentOffset.y; }; // When the user "flicks" the scroll view, this callback gets triggered after // the scrolling ends onMomentumScrollEnd = () => { this.setState({ extraData: this.latestExtraData }); }; // This callback gets triggered when the user lets go of scrolling the scroll // view, regardless of whether it was a "flick" or a pan onScrollEndDrag = () => { // We need to figure out if this was a flick or not. If it's a flick, we'll // let onMomentumScrollEnd handle it once scroll position stabilizes const currentScrollPosition = this.currentScrollPosition; setTimeout(() => { if (this.currentScrollPosition === currentScrollPosition) { this.setState({ extraData: this.latestExtraData }); } }, 50); }; onSaveEntry = () => { const entryKeys = Object.keys(this.latestExtraData.activeEntries); if (entryKeys.length === 0) { return; } const entryRef = this.entryRefs.get(entryKeys[0]); if (entryRef) { entryRef.completeEdit(); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, flatList: { backgroundColor: 'listBackground', flex: 1, }, keyboardAvoidingViewContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, }, keyboardAvoidingView: { position: 'absolute', left: 0, right: 0, bottom: 0, }, sectionHeader: { backgroundColor: 'panelForeground', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; const loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const ConnectedCalendar: React.ComponentType = React.memo( function ConnectedCalendar(props: BaseProps) { const navContext = React.useContext(NavContext); const calendarActive = activeTabSelector(navContext) || activeThreadPickerSelector(navContext); const listData = useSelector(calendarListData); const startDate = useSelector(state => state.navInfo.startDate); const endDate = useSelector(state => state.navInfo.endDate); const calendarFilters = useSelector(state => state.calendarFilters); const dimensions = useSelector(derivedDimensionsInfoSelector); const loadingStatus = useSelector(loadingStatusSelector); const connectionStatus = useSelector(state => state.connection.status); const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); return ( ); }, ); export default ConnectedCalendar; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 3ee669040..359f3ef24 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,807 +1,807 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import _omit from 'lodash/fp/omit'; import * as React from 'react'; import { View, Text, TextInput as BaseTextInput, Platform, TouchableWithoutFeedback, Alert, LayoutAnimation, Keyboard, } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useDispatch } from 'react-redux'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { createEntryActionTypes, createEntry, saveEntryActionTypes, saveEntry, deleteEntryActionTypes, deleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { entryKey } from 'lib/shared/entry-utils'; import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils'; import type { Shape } from 'lib/types/core'; import type { CreateEntryInfo, SaveEntryInfo, SaveEntryResult, SaveEntryPayload, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, CalendarQuery, } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { dateString } from 'lib/utils/date-utils'; import { ServerError } from 'lib/utils/errors'; import sleep from 'lib/utils/sleep'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types'; import Button from '../components/button.react'; import { SingleLine } from '../components/single-line.react'; import TextInput from '../components/text-input.react'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; -import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { ThreadPickerModalRouteName } from '../navigation/route-names'; +import type { TabNavigationProp } from '../navigation/tab-navigator.react'; import { useSelector } from '../redux/redux-utils'; import { colors, useStyles } from '../themes/colors'; import type { LayoutEvent } from '../types/react-native'; import { waitForInteractions } from '../utils/timers'; import type { EntryInfoWithHeight } from './calendar.react'; import LoadingIndicator from './loading-indicator.react'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement( entryText: string, ): React.Element { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } type BaseProps = { +navigation: TabNavigationProp<'Calendar'>, +entryInfo: EntryInfoWithHeight, +threadInfo: ThreadInfo, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, }; type Props = { ...BaseProps, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: typeof unboundStyles, // Nav state +threadPickerActive: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, }; type State = { +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, }; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating: boolean = false; needsUpdateAfterCreation: boolean = false; needsDeleteAfterCreation: boolean = false; nextSaveAttemptIndex: number = 0; mounted: boolean = false; deleted: boolean = false; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: Shape) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State): boolean { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render(): React.Node { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); } textInputRef: ( textInput: ?React.ElementRef, ) => void = textInput => { this.textInput = textInput; if (textInput && this.state.editing) { this.enterEditMode(); } }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // For some reason if we don't do this the scroll stops halfway through await waitForInteractions(); await sleep(15); } this.focus(); }; focus: () => void = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus: () => void = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive: () => void = () => this.makeActive(true); completeEdit: () => void = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur: () => void = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save: () => void = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout: (event: LayoutEvent) => void = event => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText: (newText: string) => void = newText => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { const { threadInfo } = this.props; if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string): Promise { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.createEntry({ text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterCreation) { this.needsDeleteAfterCreation = false; this.dispatchDelete(response.entryID); } return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction( entryID: string, newText: string, ): Promise { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.saveEntry({ entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete: () => void = () => { this.dispatchDelete(this.props.entryInfo.id); }; onPressEdit: () => void = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID), undefined, { localID, serverID }, ); } async deleteAction(serverID: ?string): Promise { if (serverID) { return await this.props.deleteEntry({ entryID: serverID, prevText: this.props.entryInfo.text, calendarQuery: this.props.calendarQuery(), }); } else if (this.creating) { this.needsDeleteAfterCreation = true; } return null; } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, transform: (Platform.select({ ios: [{ translateY: -1 / 3 }], default: [], }): $ReadOnlyArray<{ +translateY: number }>), }, textInput: { fontFamily: 'System', fontSize: 16, left: ((Platform.OS === 'android' ? 9.8 : 10): number), margin: 0, padding: 0, position: 'absolute', right: 10, top: ((Platform.OS === 'android' ? 4.8 : 0.5): number), }, }; registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry: React.ComponentType = React.memo( function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const online = useSelector( state => state.connection.status === 'connected', ); const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useServerCall(createEntry); const callSaveEntry = useServerCall(saveEntry); const callDeleteEntry = useServerCall(deleteEntry); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js index de2a0ed93..a86db9c7e 100644 --- a/native/chat/chat-list.react.js +++ b/native/chat/chat-list.react.js @@ -1,296 +1,296 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { FlatList, Animated, Easing, StyleSheet, TouchableWithoutFeedback, View, } from 'react-native'; import { localIDPrefix } from 'lib/shared/message-utils'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; -import type { TabNavigationProp } from '../navigation/app-navigator.react'; +import type { TabNavigationProp } from '../navigation/tab-navigator.react'; import { useSelector } from '../redux/redux-utils'; import type { ChatMessageItemWithHeight } from '../types/chat-types'; import type { ScrollEvent } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import NewMessagesPill from './new-messages-pill.react'; import { chatMessageItemHeight, chatMessageItemKey } from './utils'; const animationSpec = { duration: 150, useNativeDriver: true, }; type BaseProps = { ...React.ElementConfig, +navigation: ChatNavigationProp<'MessageList'>, +data: $ReadOnlyArray, ... }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, // withKeyboardState +keyboardState: ?KeyboardState, ... }; type State = { +newMessageCount: number, }; class ChatList extends React.PureComponent { state: State = { newMessageCount: 0, }; flatList: ?React.ElementRef; scrollPos = 0; newMessagesPillProgress = new Animated.Value(0); newMessagesPillStyle: ViewStyle; constructor(props: Props) { super(props); const sendButtonTranslateY = this.newMessagesPillProgress.interpolate({ inputRange: [0, 1], outputRange: ([10, 0]: number[]), // Flow... }); this.newMessagesPillStyle = { opacity: this.newMessagesPillProgress, transform: [{ translateY: sendButtonTranslateY }], }; } componentDidMount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { const { flatList } = this; if (!this.props.navigation.isFocused() || !flatList) { return; } if (this.scrollPos > 0) { flatList.scrollToOffset({ offset: 0 }); } else { this.props.navigation.popToTop(); } }; get scrolledToBottom() { return this.scrollPos <= 0; } componentDidUpdate(prevProps: Props) { const { flatList } = this; if (!flatList || this.props.data === prevProps.data) { return; } if (this.props.data.length < prevProps.data.length) { // This should only happen due to MessageStorePruner, // which will only prune a thread when it is off-screen flatList.scrollToOffset({ offset: 0, animated: false }); return; } const { scrollPos } = this; let curDataIndex = 0, prevDataIndex = 0, heightSoFar = 0; let adjustScrollPos = 0, newLocalMessage = false, newRemoteMessageCount = 0; while (prevDataIndex < prevProps.data.length && heightSoFar <= scrollPos) { const prevItem = prevProps.data[prevDataIndex]; invariant(prevItem, 'prevItem should exist'); const prevItemKey = chatMessageItemKey(prevItem); const prevItemHeight = chatMessageItemHeight(prevItem); let curItem = this.props.data[curDataIndex]; while (curItem) { const curItemKey = chatMessageItemKey(curItem); if (curItemKey === prevItemKey) { break; } if (curItemKey.startsWith(localIDPrefix)) { newLocalMessage = true; } else if ( curItem.itemType === 'message' && curItem.messageInfo.creator.id !== this.props.viewerID ) { newRemoteMessageCount++; } adjustScrollPos += chatMessageItemHeight(curItem); curDataIndex++; curItem = this.props.data[curDataIndex]; } if (!curItem) { // Should never happen... console.log( `items not removed from ChatList, but ${prevItemKey} now missing`, ); return; } const curItemHeight = chatMessageItemHeight(curItem); adjustScrollPos += curItemHeight - prevItemHeight; heightSoFar += prevItemHeight; prevDataIndex++; curDataIndex++; } if (adjustScrollPos === 0) { return; } flatList.scrollToOffset({ offset: scrollPos + adjustScrollPos, animated: false, }); if (newLocalMessage || scrollPos <= 0) { flatList.scrollToOffset({ offset: 0 }); } else if (newRemoteMessageCount > 0) { this.setState(prevState => ({ newMessageCount: prevState.newMessageCount + newRemoteMessageCount, })); this.toggleNewMessagesPill(true); } } render() { const { navigation, viewerID, ...rest } = this.props; const { newMessageCount } = this.state; return ( 0 ? 'auto' : 'none'} containerStyle={styles.newMessagesPillContainer} style={this.newMessagesPillStyle} /> ); } flatListRef = (flatList: ?React.ElementRef) => { this.flatList = flatList; }; static getItemLayout = ( data: ?$ReadOnlyArray, index: number, ) => { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatList.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? chatMessageItemHeight(item) : 0; return { length, offset, index }; }; static heightOfItems( data: $ReadOnlyArray, ): number { return _sum(data.map(chatMessageItemHeight)); } toggleNewMessagesPill(show: boolean) { Animated.timing(this.newMessagesPillProgress, { ...animationSpec, // $FlowFixMe[method-unbinding] easing: show ? Easing.ease : Easing.out(Easing.ease), toValue: show ? 1 : 0, }).start(({ finished }) => { if (finished && !show) { this.setState({ newMessageCount: 0 }); } }); } onScroll = (event: ScrollEvent) => { this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos <= 0) { this.toggleNewMessagesPill(false); } this.props.onScroll && this.props.onScroll(event); }; onPressNewMessagesPill = () => { const { flatList } = this; if (!flatList) { return; } flatList.scrollToOffset({ offset: 0 }); this.toggleNewMessagesPill(false); }; onPressBackground = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const styles = StyleSheet.create({ container: { flex: 1, }, newMessagesPillContainer: { bottom: 30, position: 'absolute', right: 30, }, }); const ConnectedChatList: React.ComponentType = React.memo( function ConnectedChatList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); }, ); export default ConnectedChatList; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index a38315898..9dd692284 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,647 +1,647 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; 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 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, getThreadListSearchResults, } 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 { SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, } from '../navigation/route-names'; +import type { TabNavigationProp } from '../navigation/tab-navigator.react'; import { useSelector } from '../redux/redux-utils'; import { type IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors'; import type { ScrollEvent } from '../types/react-native'; import { AnimatedView, type AnimatedStyleObj } from '../types/styles'; import { animateTowards } from '../utils/animation-utils'; import { ChatThreadListItem, chatThreadListItemHeight, spacerHeight, } from './chat-thread-list-item.react'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react'; import { type MessageListParams, useNavigateToThread, } from './message-list-types'; import { sidebarHeight } from './sidebar-item.react'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, interpolateNode } = 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, +navigateToThread: (params: MessageListParams) => void, // 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: Value = new Value(0); searchCancelButtonProgress: Node; searchCancelButtonOffset: Node; constructor(props: Props) { super(props); this.searchCancelButtonProgress = animateTowards( this.searchCancelButtonOpen, 100, ); this.searchCancelButtonOffset = interpolateNode( 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.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.state; const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } this.onSearchCancel(); return true; }; 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; } 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' }); } }; clearSearch() { const { flatList } = this; flatList && flatList.scrollToOffset({ offset: 0, animated: false }); this.setState({ searchStatus: 'inactive' }); } onSearchBlur = () => { if (this.state.searchStatus !== 'active') { return; } this.clearSearch(); }; onSearchCancel = () => { this.onChangeSearchText(''); this.clearSearch(); }; renderSearch(additionalProps?: $Shape>) { const animatedSearchBoxStyle: AnimatedStyleObj = { marginRight: this.searchCancelButtonOffset, }; const searchBoxStyle = [ this.props.styles.searchBox, animatedSearchBoxStyle, ]; 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) => { 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( (propsAndState: PropsAndState) => propsAndState.chatListData, (propsAndState: PropsAndState) => propsAndState.searchStatus, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.threadsSearchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, (propsAndState: PropsAndState) => propsAndState.usersSearchResults, (propsAndState: PropsAndState) => propsAndState.filterThreads, (propsAndState: PropsAndState) => propsAndState.viewerID, ( reduxChatListData: $ReadOnlyArray, searchStatus: SearchStatus, searchText: string, threadsSearchResults: Set, emptyItem?: React.ComponentType<{}>, usersSearchResults: $ReadOnlyArray, filterThreads: ThreadInfo => boolean, viewerID: ?string, ): $ReadOnlyArray => { const chatThreadItems = getThreadListSearchResults( reduxChatListData, searchText, filterThreads, threadsSearchResults, usersSearchResults, viewerID, ); 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, (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: ScrollEvent) => { 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.navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState(state => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { if (!this.props.viewerID) { return; } const threadInfo = createPendingThread({ viewerID: this.props.viewerID, threadType: threadTypes.PRIVATE, }); 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', }, }; const ConnectedChatThreadList: React.ComponentType = 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, ); const navigateToThread = useNavigateToThread(); return ( ); }, ); export default ConnectedChatThreadList; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 1bd8a1635..5cf58bbc3 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1124 +1,1124 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, FlatList, Platform } from 'react-native'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils'; import { threadHasPermission, viewerIsMember, threadInChatList, getSingleOtherUser, threadIsChannel, } from 'lib/shared/thread-utils'; import threadWatcher from 'lib/shared/thread-watcher'; import type { RelationshipButton } from 'lib/types/relationship-types'; import { type ThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, } from 'lib/types/thread-types'; import type { UserInfos } from 'lib/types/user-types'; import ThreadAncestors from '../../components/thread-ancestors.react'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; -import type { TabNavigationProp } from '../../navigation/app-navigator.react'; import { defaultStackScreenOptions } from '../../navigation/options'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { AddUsersModalRouteName, ComposeSubchannelModalRouteName, } from '../../navigation/route-names'; +import type { TabNavigationProp } from '../../navigation/tab-navigator.react'; import { useSelector } from '../../redux/redux-utils'; import type { AppState } from '../../redux/state-types'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors'; import type { VerticalBounds } from '../../types/layout-types'; import type { ViewStyle } from '../../types/styles'; import type { ChatNavigationProp } from '../chat.react'; import type { CategoryType } from './thread-settings-category.react'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react'; import ThreadSettingsChildThread from './thread-settings-child-thread.react'; import ThreadSettingsColor from './thread-settings-color.react'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react'; import ThreadSettingsDescription from './thread-settings-description.react'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubchannel, } from './thread-settings-list-action.react'; import ThreadSettingsMember from './thread-settings-member.react'; import ThreadSettingsName from './thread-settings-name.react'; import ThreadSettingsParent from './thread-settings-parent.react'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react'; import ThreadSettingsVisibility from './thread-settings-visibility.react'; const itemPageLength = 5; export type ThreadSettingsParams = { +threadInfo: ThreadInfo, }; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | { +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'name', +key: string, +threadInfo: ThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, +threadInfo: ThreadInfo, +parentThreadInfo: ?ThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ThreadInfo, } | { +itemType: 'homeNotifs', +key: string, +threadInfo: ThreadInfo, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, +threadInfo: ThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ThreadInfo, +parentThreadInfo: ?ThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: boolean, }; type State = { +numMembersShowing: number, +numSubchannelsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, }; type PropsAndState = { ...Props, ...State }; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { numMembersShowing: itemPageLength, numSubchannelsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: props.threadInfo.color, verticalBounds: null, }; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const prevThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if ( !tinycolor.equals(newThreadInfo.color, prevThreadInfo.color) && tinycolor.equals(this.state.colorEditValue, prevThreadInfo.color) ) { this.setState({ colorEditValue: newThreadInfo.color }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); if (threadInfo.type !== threadTypes.SIDEBAR) { listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); } listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubchannelsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; const canCreateSubchannels = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); if (subchannels.length === 0 && !canCreateSubchannels) { return listData; } listData.push({ itemType: 'header', key: 'subchannelHeader', title: 'Subchannels', categoryType: 'unpadded', }); if (canCreateSubchannels) { listData.push({ itemType: 'addSubchannel', key: 'addSubchannel', }); } const numItems = Math.min(numSubchannelsShowing, subchannels.length); for (let i = 0; i < numItems; i++) { const subchannelInfo = subchannels[i]; listData.push({ itemType: 'childThread', key: `childThread${subchannelInfo.id}`, threadInfo: subchannelInfo, firstListItem: i === 0 && !canCreateSubchannels, lastListItem: i === numItems - 1 && numItems === subchannels.length, }); } if (numItems < subchannels.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubchannels', onPress: this.onPressSeeMoreSubchannels, }); } listData.push({ itemType: 'footer', key: 'subchannelFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( childThreadInfo => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Threads', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadInfo.members.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; if (threadIsPersonal && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { const otherUserInfo = userInfos[otherMemberID]; const availableRelationshipActions = getAvailableRelationshipButtons( otherUserInfo, ); for (const action of availableRelationshipActions) { buttons.push({ itemType: 'editRelationship', key: action, threadInfo, navigate, relationshipButton: action, }); } } } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { // Necessary for Flow... if (buttons[i].itemType === 'editRelationship') { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } else { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subchannelsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; renderItem = (row: { item: ChatSettingsItem, ... }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else if (item.itemType === 'editRelationship') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressAddMember = () => { this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } for (const threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; const ConnectedThreadSettings: React.ComponentType = React.memo( function ConnectedThreadSettings(props: BaseProps) { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadID = props.route.params.threadInfo.id; const reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); React.useEffect(() => { invariant( reduxThreadInfo, 'ReduxThreadInfo should exist when ThreadSettings is opened', ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo: ThreadInfo = reduxThreadInfo ?? props.route.params.threadInfo; React.useEffect(() => { if (threadInChatList(threadInfo)) { return undefined; } threadWatcher.watchID(threadInfo.id); return () => { threadWatcher.removeID(threadInfo.id); }; }, [threadInfo]); const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const boundSomethingIsSaving = useSelector(state => somethingIsSaving(state, threadMembers), ); const { navigation } = props; React.useEffect(() => { const tabNavigation: ?TabNavigationProp<'Chat'> = navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !boundSomethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, boundSomethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); return ( ); }, ); export default ConnectedThreadSettings; diff --git a/native/navigation/app-navigator.react.js b/native/navigation/app-navigator.react.js index 5545f10b9..3b6a4c885 100644 --- a/native/navigation/app-navigator.react.js +++ b/native/navigation/app-navigator.react.js @@ -1,259 +1,144 @@ // @flow -import type { - BottomTabNavigationHelpers, - BottomTabNavigationProp, -} from '@react-navigation/bottom-tabs'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import * as SplashScreen from 'expo-splash-screen'; import * as React from 'react'; import { PersistGate } from 'redux-persist/integration/react'; -import { unreadCount } from 'lib/selectors/thread-selectors'; - -import AppsDirectory from '../apps/apps-directory.react'; -import Calendar from '../calendar/calendar.react'; -import Chat from '../chat/chat.react'; import { MultimediaMessageTooltipModal } from '../chat/multimedia-message-tooltip-modal.react'; import { RobotextMessageTooltipModal } from '../chat/robotext-message-tooltip-modal.react'; import ThreadSettingsMemberTooltipModal from '../chat/settings/thread-settings-member-tooltip-modal.react'; import { TextMessageTooltipModal } from '../chat/text-message-tooltip-modal.react'; -import SWMansionIcon from '../components/swmansion-icon.react'; import { type SQLiteContextType, SQLiteContext } from '../data/sqlite-context'; import KeyboardStateContainer from '../keyboard/keyboard-state-container.react'; import CameraModal from '../media/camera-modal.react'; import ImageModal from '../media/image-modal.react'; import VideoPlaybackModal from '../media/video-playback-modal.react'; -import Profile from '../profile/profile.react'; import RelationshipListItemTooltipModal from '../profile/relationship-list-item-tooltip-modal.react'; import PushHandler from '../push/push-handler.react'; import { getPersistor } from '../redux/persist'; -import { useSelector } from '../redux/redux-utils'; import { RootContext } from '../root-context'; import { waitForInteractions } from '../utils/timers'; import ActionResultModal from './action-result-modal.react'; import { createOverlayNavigator } from './overlay-navigator.react'; import type { OverlayNavigationProp, OverlayNavigationHelpers, } from './overlay-navigator.react'; import type { RootNavigationProp } from './root-navigator.react'; import { - CalendarRouteName, - ChatRouteName, - ProfileRouteName, TabNavigatorRouteName, ImageModalRouteName, MultimediaMessageTooltipModalRouteName, ActionResultModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, RelationshipListItemTooltipModalRouteName, RobotextMessageTooltipModalRouteName, CameraModalRouteName, VideoPlaybackModalRouteName, - AppsRouteName, type ScreenParamList, - type TabParamList, type OverlayParamList, } from './route-names'; -import { tabBar } from './tab-bar.react'; +import TabNavigator from './tab-navigator.react'; let splashScreenHasHidden = false; -const calendarTabOptions = { - tabBarLabel: 'Calendar', - // eslint-disable-next-line react/display-name - tabBarIcon: ({ color }) => ( - - ), -}; -const getChatTabOptions = (badge: number) => ({ - tabBarLabel: 'Inbox', - // eslint-disable-next-line react/display-name - tabBarIcon: ({ color }) => ( - - ), - tabBarBadge: badge ? badge : undefined, -}); -const profileTabOptions = { - tabBarLabel: 'Profile', - // eslint-disable-next-line react/display-name - tabBarIcon: ({ color }) => ( - - ), -}; -const appsTabOptions = { - tabBarLabel: 'Apps', - // eslint-disable-next-line react/display-name - tabBarIcon: ({ color }) => ( - - ), -}; - -export type TabNavigationProp< - RouteName: $Keys = $Keys, -> = BottomTabNavigationProp; - -const Tab = createBottomTabNavigator< - ScreenParamList, - TabParamList, - BottomTabNavigationHelpers, ->(); -const tabBarScreenOptions = { - headerShown: false, - tabBarHideOnKeyboard: false, - tabBarActiveTintColor: '#AE94DB', - tabBarStyle: { - backgroundColor: '#0A0A0A', - borderTopWidth: 1, - }, - lazy: false, -}; -function TabNavigator() { - const chatBadge = useSelector(unreadCount); - const isCalendarEnabled = useSelector(state => state.enabledApps.calendar); - - let calendarTab; - if (isCalendarEnabled) { - calendarTab = ( - - ); - } - - return ( - - {calendarTab} - - - - - ); -} - export type AppNavigationProp< RouteName: $Keys = $Keys, > = OverlayNavigationProp; const App = createOverlayNavigator< ScreenParamList, OverlayParamList, OverlayNavigationHelpers, >(); type AppNavigatorProps = { navigation: RootNavigationProp<'App'>, ... }; function AppNavigator(props: AppNavigatorProps): React.Node { const { navigation } = props; const rootContext = React.useContext(RootContext); const localDatabaseContext: ?SQLiteContextType = React.useContext( SQLiteContext, ); const storeLoadedFromLocalDatabase = localDatabaseContext?.storeLoaded; const setNavStateInitialized = rootContext && rootContext.setNavStateInitialized; React.useEffect(() => { setNavStateInitialized && setNavStateInitialized(); }, [setNavStateInitialized]); const [ localSplashScreenHasHidden, setLocalSplashScreenHasHidden, ] = React.useState(splashScreenHasHidden); React.useEffect(() => { if (localSplashScreenHasHidden) { return; } splashScreenHasHidden = true; (async () => { await waitForInteractions(); try { await SplashScreen.hideAsync(); } finally { setLocalSplashScreenHasHidden(true); } })(); }, [localSplashScreenHasHidden]); let pushHandler; if (localSplashScreenHasHidden) { pushHandler = ( ); } if (!storeLoadedFromLocalDatabase) { return null; } return ( {pushHandler} ); } -const styles = { - icon: { - fontSize: 28, - }, -}; - export default AppNavigator; diff --git a/native/navigation/tab-navigator.react.js b/native/navigation/tab-navigator.react.js new file mode 100644 index 000000000..77c54dd3f --- /dev/null +++ b/native/navigation/tab-navigator.react.js @@ -0,0 +1,131 @@ +// @flow + +import type { + BottomTabNavigationHelpers, + BottomTabNavigationProp, +} from '@react-navigation/bottom-tabs'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import * as React from 'react'; + +import { unreadCount } from 'lib/selectors/thread-selectors'; + +import AppsDirectory from '../apps/apps-directory.react'; +import Calendar from '../calendar/calendar.react'; +import Chat from '../chat/chat.react'; +import SWMansionIcon from '../components/swmansion-icon.react'; +import Profile from '../profile/profile.react'; +import { useSelector } from '../redux/redux-utils'; +import { + CalendarRouteName, + ChatRouteName, + ProfileRouteName, + AppsRouteName, + type ScreenParamList, + type TabParamList, +} from './route-names'; +import type { NavigationRoute } from './route-names'; +import { tabBar } from './tab-bar.react'; + +const calendarTabOptions = { + tabBarLabel: 'Calendar', + // eslint-disable-next-line react/display-name + tabBarIcon: ({ color }) => ( + + ), +}; +const getChatTabOptions = (badge: number) => ({ + tabBarLabel: 'Inbox', + // eslint-disable-next-line react/display-name + tabBarIcon: ({ color }) => ( + + ), + tabBarBadge: badge ? badge : undefined, +}); +const profileTabOptions = { + tabBarLabel: 'Profile', + // eslint-disable-next-line react/display-name + tabBarIcon: ({ color }) => ( + + ), +}; +const appsTabOptions = { + tabBarLabel: 'Apps', + // eslint-disable-next-line react/display-name + tabBarIcon: ({ color }) => ( + + ), +}; + +export type TabNavigationProp< + RouteName: $Keys = $Keys, +> = BottomTabNavigationProp; + +const Tab = createBottomTabNavigator< + ScreenParamList, + TabParamList, + BottomTabNavigationHelpers, +>(); +const tabBarScreenOptions = { + headerShown: false, + tabBarHideOnKeyboard: false, + tabBarActiveTintColor: '#AE94DB', + tabBarStyle: { + backgroundColor: '#0A0A0A', + borderTopWidth: 1, + }, + lazy: false, +}; +type Props = { + +navigation: TabNavigationProp<'TabNavigator'>, + +route: NavigationRoute<'TabNavigator'>, +}; +// eslint-disable-next-line no-unused-vars +function TabNavigator(props: Props): React.Node { + const chatBadge = useSelector(unreadCount); + const isCalendarEnabled = useSelector(state => state.enabledApps.calendar); + + let calendarTab; + if (isCalendarEnabled) { + calendarTab = ( + + ); + } + + return ( + + {calendarTab} + + + + + ); +} + +const styles = { + icon: { + fontSize: 28, + }, +}; + +export default TabNavigator;