diff --git a/native/calendar/calendar-input-bar.react.js b/native/calendar/calendar-input-bar.react.js index 04f76d895..cd7c9f83c 100644 --- a/native/calendar/calendar-input-bar.react.js +++ b/native/calendar/calendar-input-bar.react.js @@ -1,45 +1,46 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import Button from '../components/button.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +onSave: () => void, +disabled: boolean, }; function CalendarInputBar(props: Props): React.Node { const styles = useStyles(unboundStyles); const inactiveStyle = props.disabled ? styles.inactiveContainer : undefined; return ( ); } const unboundStyles = { container: { alignItems: 'flex-end', backgroundColor: 'listInputBar', }, inactiveContainer: { opacity: 0, + height: 0, }, saveButtonText: { color: 'link', fontSize: 16, fontWeight: 'bold', marginRight: 5, padding: 8, }, }; export default CalendarInputBar; diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar-screen.react.js similarity index 93% copy from native/calendar/calendar.react.js copy to native/calendar/calendar-screen.react.js index f46a4f525..04307fc73 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar-screen.react.js @@ -1,1108 +1,1119 @@ // @flow +import type { + BottomTabNavigationEventMap, + TabNavigationState, + BottomTabOptions, +} from '@react-navigation/core'; import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _find from 'lodash/fp/find.js'; import _findIndex from 'lodash/fp/findIndex.js'; import _map from 'lodash/fp/map.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _size from 'lodash/fp/size.js'; import _sum from 'lodash/fp/sum.js'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { AppState as NativeAppState, FlatList, LayoutAnimation, Platform, Text, TouchableWithoutFeedback, View, } from 'react-native'; import type { UpdateCalendarQueryInput } from 'lib/actions/entry-actions.js'; import { updateCalendarQueryActionTypes, useUpdateCalendarQuery, } from 'lib/actions/entry-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import type { CalendarQuery, CalendarQueryUpdateResult, EntryInfo, } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { dateFromString, dateString, prettyDate, } from 'lib/utils/date-utils.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import sleep from 'lib/utils/sleep.js'; import CalendarInputBar from './calendar-input-bar.react.js'; +import type { CalendarNavigationProp } from './calendar.react.js'; import { dummyNodeForEntryHeightMeasurement, Entry, InternalEntry, } from './entry.react.js'; import SectionFooter from './section-footer.react.js'; import ContentLoading from '../components/content-loading.react.js'; -import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import NodeHeightMeasurer from '../components/node-height-measurer.react.js'; import { addKeyboardDismissListener, addKeyboardShowListener, removeKeyboardListener, } from '../keyboard/keyboard.js'; import DisconnectedBar from '../navigation/disconnected-bar.react.js'; import { createActiveTabSelector, createIsForegroundSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; -import type { NavigationRoute } from '../navigation/route-names.js'; +import type { + NavigationRoute, + ScreenParamList, +} from '../navigation/route-names.js'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { CalendarItem, LoaderItem, SectionFooterItem, SectionHeaderItem, } from '../selectors/calendar-selectors.js'; import { calendarListData } from '../selectors/calendar-selectors.js'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors.js'; import { type Colors, type IndicatorStyle, useColors, useIndicatorStyle, useStyles, } from '../themes/colors.js'; import type { EventSubscription, KeyboardEvent, ScrollEvent, ViewableItemsChange, } from '../types/react-native.js'; 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 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: 'panelSecondaryForeground', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; type BaseProps = { - +navigation: TabNavigationProp<'Calendar'>, - +route: NavigationRoute<'Calendar'>, + +navigation: CalendarNavigationProp<'CalendarScreen'>, + +route: NavigationRoute<'CalendarScreen'>, }; type Props = { ...BaseProps, // Nav state +calendarActive: boolean, // Redux state +listData: ?$ReadOnlyArray, +startDate: string, +endDate: string, +calendarFilters: $ReadOnlyArray, +dimensions: DerivedDimensionsInfo, +loadingStatus: LoadingStatus, +connected: boolean, +colors: Colors, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( input: UpdateCalendarQueryInput, ) => Promise, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, +readyToShowList: boolean, +extraData: ExtraData, +currentlyEditing: $ReadOnlyArray, }; -class Calendar extends React.PureComponent { +class CalendarScreen extends React.PureComponent { flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; appStateListener: ?EventSubscription; 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: Map = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { this.appStateListener = NativeAppState.addEventListener( 'change', this.handleAppStateChange, ); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); - this.props.navigation.addListener('tabPress', this.onTabPress); + this.props.navigation + .getParent< + ScreenParamList, + 'Calendar', + TabNavigationState, + BottomTabOptions, + BottomTabNavigationEventMap, + TabNavigationProp<'Calendar'>, + >() + ?.addListener('tabPress', this.onTabPress); } componentWillUnmount() { if (this.appStateListener) { this.appStateListener.remove(); this.appStateListener = null; } 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); + this.props.navigation + .getParent< + ScreenParamList, + 'Calendar', + TabNavigationState, + BottomTabOptions, + BottomTabNavigationEventMap, + TabNavigationProp<'Calendar'>, + >() + ?.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, connected } = this.props; const { loadingStatus: prevLoadingStatus, connected: prevConnected } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connected && !prevConnected) ) { 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 void 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); + CalendarScreen.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) { void 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, ): { +lastStartDate: Date, +newStartDate: Date, +lastEndDate: Date, +newEndDate: Date, } { 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 existingKeys = new Set(_map(CalendarScreen.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => - !existingKeys.has(Calendar.keyExtractor(item)), + !existingKeys.has(CalendarScreen.keyExtractor(item)), )(newLDWH); - const heightOfNewItems = Calendar.heightOfItems(newItems); + const heightOfNewItems = CalendarScreen.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, }); } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return renderItem = (row: { +item: CalendarItemWithHeight, ... }): React.Node => { 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): React.Node => { 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): React.Node => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = ( item: CalendarItemWithHeight | CalendarItem, // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return ): string => { 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, ): { length: number, offset: number, index: number } => { if (!data) { return { length: 0, offset: 0, index }; } - const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); + const offset = CalendarScreen.heightOfItems( + data.filter((_, i) => i < index), + ); const item = data[index]; - const length = item ? Calendar.itemHeight(item) : 0; + const length = item ? CalendarScreen.itemHeight(item) : 0; return { length, offset, index }; }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return static itemHeight = (item: CalendarItemWithHeight): number => { 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, ): number => { - return _sum(data.map(Calendar.itemHeight)); + return _sum(data.map(CalendarScreen.itemHeight)); }; render(): React.Node { 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(): number { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray): number { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); - const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); + const heightOfTodayHeader = CalendarScreen.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { - heightLeft -= Calendar.itemHeight(data[--returnIndex]); + heightLeft -= CalendarScreen.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: number = 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, + CalendarScreen.keyExtractor(item) === lastEntryKeyActive, ); if (index === -1) { return; } - const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); - const itemHeight = Calendar.itemHeight(data[index]); + const itemStart = CalendarScreen.heightOfItems( + data.filter((_, i) => i < index), + ); + const itemHeight = CalendarScreen.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): ?string => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem): React.MixedElement => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = ( item: CalendarItem, height: ?number, ): CalendarItemWithHeight => { 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), + entryInfo: CalendarScreen.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: { [string]: boolean } = {}; 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) { void this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery({ calendarQuery }), ); } loadMoreAbove: () => void = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connected ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow: () => void = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.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 loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); -const ConnectedCalendar: React.ComponentType = React.memo( - function ConnectedCalendar(props: BaseProps) { +const ConnectedCalendarScreen: React.ComponentType = + React.memo(function ConnectedCalendarScreen(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 connected = useSelector(state => state.connectivity.connected); const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useUpdateCalendarQuery(); return ( - ); - }, -); + }); -export default ConnectedCalendar; +export default ConnectedCalendarScreen; diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index f46a4f525..624d607e0 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1108 +1,85 @@ // @flow -import invariant from 'invariant'; -import _filter from 'lodash/fp/filter.js'; -import _find from 'lodash/fp/find.js'; -import _findIndex from 'lodash/fp/findIndex.js'; -import _map from 'lodash/fp/map.js'; -import _pickBy from 'lodash/fp/pickBy.js'; -import _size from 'lodash/fp/size.js'; -import _sum from 'lodash/fp/sum.js'; -import _throttle from 'lodash/throttle.js'; -import * as React from 'react'; -import { - AppState as NativeAppState, - FlatList, - LayoutAnimation, - Platform, - Text, - TouchableWithoutFeedback, - View, -} from 'react-native'; - -import type { UpdateCalendarQueryInput } from 'lib/actions/entry-actions.js'; -import { - updateCalendarQueryActionTypes, - useUpdateCalendarQuery, -} from 'lib/actions/entry-actions.js'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; -import { entryKey } from 'lib/shared/entry-utils.js'; import type { - CalendarQuery, - CalendarQueryUpdateResult, - EntryInfo, -} from 'lib/types/entry-types.js'; -import type { CalendarFilter } from 'lib/types/filter-types.js'; -import type { LoadingStatus } from 'lib/types/loading-types.js'; -import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import { - dateFromString, - dateString, - prettyDate, -} from 'lib/utils/date-utils.js'; -import { - type DispatchActionPromise, - useDispatchActionPromise, -} from 'lib/utils/redux-promise-utils.js'; -import sleep from 'lib/utils/sleep.js'; + StackNavigationProp, + StackNavigationHelpers, +} from '@react-navigation/core'; +import { createStackNavigator } from '@react-navigation/stack'; +import * as React from 'react'; +import { View } from 'react-native'; -import CalendarInputBar from './calendar-input-bar.react.js'; -import { - dummyNodeForEntryHeightMeasurement, - Entry, - InternalEntry, -} from './entry.react.js'; -import SectionFooter from './section-footer.react.js'; -import ContentLoading from '../components/content-loading.react.js'; +import CalendarScreen from './calendar-screen.react.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; -import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; -import NodeHeightMeasurer from '../components/node-height-measurer.react.js'; -import { - addKeyboardDismissListener, - addKeyboardShowListener, - removeKeyboardListener, -} from '../keyboard/keyboard.js'; -import DisconnectedBar from '../navigation/disconnected-bar.react.js'; -import { - createActiveTabSelector, - createIsForegroundSelector, -} from '../navigation/nav-selectors.js'; -import { NavContext } from '../navigation/navigation-context.js'; -import type { NavigationRoute } from '../navigation/route-names.js'; +import CommunityDrawerButton from '../navigation/community-drawer-button.react.js'; import { - CalendarRouteName, - ThreadPickerModalRouteName, + CalendarScreenRouteName, + type CalendarParamList, + type ScreenParamList, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; -import { useSelector } from '../redux/redux-utils.js'; -import type { - CalendarItem, - LoaderItem, - SectionFooterItem, - SectionHeaderItem, -} from '../selectors/calendar-selectors.js'; -import { calendarListData } from '../selectors/calendar-selectors.js'; -import { - type DerivedDimensionsInfo, - derivedDimensionsInfoSelector, -} from '../selectors/dimensions-selectors.js'; -import { - type Colors, - type IndicatorStyle, - useColors, - useIndicatorStyle, - useStyles, -} from '../themes/colors.js'; -import type { - EventSubscription, - KeyboardEvent, - ScrollEvent, - ViewableItemsChange, -} from '../types/react-native.js'; - -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 }, -}; +import { useStyles, useColors } from '../themes/colors.js'; -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: 'panelSecondaryForeground', - borderBottomWidth: 2, - borderColor: 'listBackground', - height: 31, - }, - sectionHeaderText: { - color: 'listSeparatorLabel', - fontWeight: 'bold', - padding: 5, - }, - weekendSectionHeader: {}, -}; +export type CalendarNavigationProp< + RouteName: $Keys = $Keys, +> = StackNavigationProp; -type BaseProps = { - +navigation: TabNavigationProp<'Calendar'>, - +route: NavigationRoute<'Calendar'>, -}; +const Calendar = createStackNavigator< + ScreenParamList, + CalendarParamList, + StackNavigationHelpers, +>(); type Props = { - ...BaseProps, - // Nav state - +calendarActive: boolean, - // Redux state - +listData: ?$ReadOnlyArray, - +startDate: string, - +endDate: string, - +calendarFilters: $ReadOnlyArray, - +dimensions: DerivedDimensionsInfo, - +loadingStatus: LoadingStatus, - +connected: boolean, - +colors: Colors, - +styles: $ReadOnly, - +indicatorStyle: IndicatorStyle, - // Redux dispatch functions - +dispatchActionPromise: DispatchActionPromise, - // async functions that hit server APIs - +updateCalendarQuery: ( - input: UpdateCalendarQueryInput, - ) => Promise, -}; -type State = { - +listDataWithHeights: ?$ReadOnlyArray, - +readyToShowList: boolean, - +extraData: ExtraData, - +currentlyEditing: $ReadOnlyArray, + +navigation: TabNavigationProp<'Calendar'>, + ... }; -class Calendar extends React.PureComponent { - flatList: ?FlatList = null; - currentState: ?string = NativeAppState.currentState; - appStateListener: ?EventSubscription; - 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: Map = new Map(); - - constructor(props: Props) { - super(props); - this.latestExtraData = { - activeEntries: {}, - visibleEntries: {}, - }; - this.state = { - listDataWithHeights: null, - readyToShowList: false, - extraData: this.latestExtraData, - currentlyEditing: [], - }; - } - - componentDidMount() { - this.appStateListener = NativeAppState.addEventListener( - 'change', - this.handleAppStateChange, - ); - this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); - this.keyboardDismissListener = addKeyboardDismissListener( - this.keyboardDismiss, - ); - this.props.navigation.addListener('tabPress', this.onTabPress); - } - - componentWillUnmount() { - if (this.appStateListener) { - this.appStateListener.remove(); - this.appStateListener = null; - } - 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, connected } = this.props; - const { loadingStatus: prevLoadingStatus, connected: prevConnected } = - prevProps; - if ( - (loadingStatus === 'error' && prevLoadingStatus === 'loading') || - (connected && !prevConnected) - ) { - 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 - void 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) { - void 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, - ): { - +lastStartDate: Date, - +newStartDate: Date, - +lastEndDate: Date, - +newEndDate: Date, - } { - 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, - }); - } - // ESLint doesn't recognize that invariant always throws - // eslint-disable-next-line consistent-return - renderItem = (row: { +item: CalendarItemWithHeight, ... }): React.Node => { - 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): React.Node => { - 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): React.Node => { - return ( - - ); - }; - - onAdd = (dayString: string) => { - this.props.navigation.navigate(ThreadPickerModalRouteName, { - presentedFrom: this.props.route.key, - dateString: dayString, - }); - }; - - static keyExtractor = ( - item: CalendarItemWithHeight | CalendarItem, - // ESLint doesn't recognize that invariant always throws - // eslint-disable-next-line consistent-return - ): string => { - 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, - ): { length: number, offset: number, 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 }; - }; - - // ESLint doesn't recognize that invariant always throws - // eslint-disable-next-line consistent-return - static itemHeight = (item: CalendarItemWithHeight): number => { - 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, - ): number => { - return _sum(data.map(Calendar.itemHeight)); - }; - - render(): React.Node { - 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} - - - , + [props.navigation], + ); + + const options = React.useMemo( + () => ({ + headerTitle: 'Calendar', + headerLeft, + headerStyle: { + backgroundColor: colors.tabBarBackground, + }, + headerShadowVisible: false, + }), + [colors.tabBarBackground, headerLeft], + ); + + return ( + + + + - - - ); - } - - flatListHeight(): number { - const { safeAreaHeight, tabBarHeight } = this.props.dimensions; - return safeAreaHeight - tabBarHeight; - } - - initialScrollIndex(data: $ReadOnlyArray): number { - 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: number = 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): ?string => { - if (item.itemType !== 'entryInfo') { - return null; - } - return item.entryInfo.text; - }; - - heightMeasurerDummy = (item: CalendarItem): React.MixedElement => { - invariant( - item.itemType === 'entryInfo', - 'NodeHeightMeasurer asked for dummy for non-entryInfo item', - ); - return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); - }; - - heightMeasurerMergeItem = ( - item: CalendarItem, - height: ?number, - ): CalendarItemWithHeight => { - 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: { [string]: boolean } = {}; - 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) { - void this.props.dispatchActionPromise( - updateCalendarQueryActionTypes, - this.props.updateCalendarQuery({ calendarQuery }), - ); - } - - loadMoreAbove: () => void = _throttle(() => { - if ( - this.topLoadingFromScroll && - this.topLoaderWaitingToLeaveView && - this.props.connected - ) { - this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); - } - }, 1000); - - loadMoreBelow: () => void = _throttle(() => { - if ( - this.bottomLoadingFromScroll && - this.bottomLoaderWaitingToLeaveView && - this.props.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 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 connected = useSelector(state => state.connectivity.connected); - const colors = useColors(); - const styles = useStyles(unboundStyles); - const indicatorStyle = useIndicatorStyle(); - - const dispatchActionPromise = useDispatchActionPromise(); - const callUpdateCalendarQuery = useUpdateCalendarQuery(); - - return ( - - ); +const unboundStyles = { + keyboardAvoidingView: { + flex: 1, }, -); + view: { + flex: 1, + backgroundColor: 'panelBackground', + }, +}; -export default ConnectedCalendar; +export default CalendarComponent; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 911db4387..5bcaa674b 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,824 +1,824 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _omit from 'lodash/fp/omit.js'; import * as React from 'react'; import { Keyboard, LayoutAnimation, Platform, Text, TextInput as BaseTextInput, TouchableWithoutFeedback, View, } from 'react-native'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { concurrentModificationResetActionType, createEntryActionTypes, deleteEntryActionTypes, saveEntryActionTypes, useCreateEntry, useDeleteEntry, useSaveEntry, } from 'lib/actions/entry-actions.js'; import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import type { CalendarQuery, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, SaveEntryInfo, SaveEntryPayload, SaveEntryResult, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; -import type { EntryInfoWithHeight } from './calendar.react.js'; +import type { EntryInfoWithHeight } from './calendar-screen.react.js'; +import type { CalendarNavigationProp } from './calendar.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import TextInput from '../components/text-input.react.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ThreadPickerModalRouteName } from '../navigation/route-names.js'; -import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import Alert from '../utils/alert.js'; import { waitForInteractions } from '../utils/timers.js'; 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} ); } 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), }, }; type SharedProps = { - +navigation: TabNavigationProp<'Calendar'>, + +navigation: CalendarNavigationProp<'CalendarScreen'>, +entryInfo: EntryInfoWithHeight, +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 BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: $ReadOnly, // 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, +canEditEntry: boolean, }; 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: Partial) { 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; return ( ); } textInputRef: (textInput: ?React.ElementRef) => void = textInput => { this.textInput = textInput; if (textInput && this.state.editing) { void this.enterEditMode(); } }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // If we don't do this, the TextInput focuses // but the soft keyboard doesn't come up 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) { if (!this.props.canEditEntry) { 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) { void this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { void 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; } else if (this.creating) { this.needsDeleteAfterCreation = true; return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; if (serverID) { void this.props.dispatchActionPromise( deleteEntryActionTypes, this.props.deleteEntry({ entryID: serverID, prevText: this.props.entryInfo.text, calendarQuery: this.props.calendarQuery(), }), undefined, { localID, serverID }, ); } } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } 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 styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useCreateEntry(); const callSaveEntry = useSaveEntry(); const callDeleteEntry = useDeleteEntry(); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); const keyserverID = extractKeyserverIDFromID(threadInfo.id); const connection = useSelector(connectionSelector(keyserverID)); invariant( connection, `keyserver ${keyserverID} missing from keyserverStore`, ); const online = connection.status === 'connected'; const canEditEntry = useThreadHasPermission( threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/navigation/community-drawer-button.react.js b/native/navigation/community-drawer-button.react.js index 322e557d1..2023cec58 100644 --- a/native/navigation/community-drawer-button.react.js +++ b/native/navigation/community-drawer-button.react.js @@ -1,35 +1,36 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import * as React from 'react'; import { TouchableOpacity } from 'react-native'; import type { CommunityDrawerNavigationProp } from './community-drawer-navigator.react.js'; import type { TabNavigationProp } from './tab-navigator.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +navigation: | TabNavigationProp<'Chat'> | TabNavigationProp<'Profile'> + | TabNavigationProp<'Calendar'> | CommunityDrawerNavigationProp<'TabNavigator'>, }; function CommunityDrawerButton(props: Props): React.Node { const styles = useStyles(unboundStyles); const { navigation } = props; return ( ); } const unboundStyles = { drawerButton: { color: 'listForegroundSecondaryLabel', marginLeft: 16, }, }; export default CommunityDrawerButton; diff --git a/native/navigation/route-names.js b/native/navigation/route-names.js index 94cae8482..14d42ad0e 100644 --- a/native/navigation/route-names.js +++ b/native/navigation/route-names.js @@ -1,371 +1,377 @@ // @flow import type { RouteProp } from '@react-navigation/core'; import type { ActionResultModalParams } from './action-result-modal.react.js'; import type { InviteLinkModalParams } from './invite-link-modal.react'; import type { AvatarSelectionParams } from '../account/registration/avatar-selection.react.js'; import type { ConnectEthereumParams } from '../account/registration/connect-ethereum.react.js'; import type { ConnectFarcasterParams } from '../account/registration/connect-farcaster.react.js'; import type { EmojiAvatarSelectionParams } from '../account/registration/emoji-avatar-selection.react.js'; import type { ExistingEthereumAccountParams } from '../account/registration/existing-ethereum-account.react.js'; import type { KeyserverSelectionParams } from '../account/registration/keyserver-selection.react.js'; import type { PasswordSelectionParams } from '../account/registration/password-selection.react.js'; import type { RegistrationTermsParams } from '../account/registration/registration-terms.react.js'; import type { CreateSIWEBackupMessageParams } from '../account/registration/siwe-backup-message-creation.react.js'; import type { UsernameSelectionParams } from '../account/registration/username-selection.react.js'; import type { TermsAndPrivacyModalParams } from '../account/terms-and-privacy-modal.react.js'; import type { RestoreSIWEBackupParams } from '../backup/restore-siwe-backup.react.js'; import type { ThreadPickerModalParams } from '../calendar/thread-picker-modal.react.js'; import type { ComposeSubchannelParams } from '../chat/compose-subchannel.react.js'; import type { FullScreenThreadMediaGalleryParams } from '../chat/fullscreen-thread-media-gallery.react.js'; import type { ImagePasteModalParams } from '../chat/image-paste-modal.react.js'; import type { MessageListParams } from '../chat/message-list-types.js'; import type { MessageReactionsModalParams } from '../chat/message-reactions-modal.react.js'; import type { MultimediaMessageTooltipModalParams } from '../chat/multimedia-message-tooltip-modal.react.js'; import type { PinnedMessagesScreenParams } from '../chat/pinned-messages-screen.react.js'; import type { RobotextMessageTooltipModalParams } from '../chat/robotext-message-tooltip-modal.react.js'; import type { AddUsersModalParams } from '../chat/settings/add-users-modal.react.js'; import type { ColorSelectorModalParams } from '../chat/settings/color-selector-modal.react.js'; import type { ComposeSubchannelModalParams } from '../chat/settings/compose-subchannel-modal.react.js'; import type { DeleteThreadParams } from '../chat/settings/delete-thread.react.js'; import type { EmojiThreadAvatarCreationParams } from '../chat/settings/emoji-thread-avatar-creation.react.js'; import type { ThreadSettingsMemberTooltipModalParams } from '../chat/settings/thread-settings-member-tooltip-modal.react.js'; import type { ThreadSettingsParams } from '../chat/settings/thread-settings.react.js'; import type { SidebarListModalParams } from '../chat/sidebar-list-modal.react.js'; import type { SubchannelListModalParams } from '../chat/subchannels-list-modal.react.js'; import type { TextMessageTooltipModalParams } from '../chat/text-message-tooltip-modal.react.js'; import type { TogglePinModalParams } from '../chat/toggle-pin-modal.react.js'; import type { CommunityCreationMembersScreenParams } from '../community-creation/community-creation-members.react.js'; import type { TagFarcasterChannelByNameParams } from '../community-settings/tag-farcaster-channel/tag-farcaster-channel-by-name.react.js'; import type { TagFarcasterChannelParams } from '../community-settings/tag-farcaster-channel/tag-farcaster-channel.react.js'; import type { ManagePublicLinkScreenParams } from '../invite-links/manage-public-link-screen.react.js'; import type { ViewInviteLinksScreenParams } from '../invite-links/view-invite-links-screen.react.js'; import type { ChatCameraModalParams } from '../media/chat-camera-modal.react.js'; import type { ImageModalParams } from '../media/image-modal.react.js'; import type { ThreadAvatarCameraModalParams } from '../media/thread-avatar-camera-modal.react.js'; import type { VideoPlaybackModalParams } from '../media/video-playback-modal.react.js'; import type { CustomServerModalParams } from '../profile/custom-server-modal.react.js'; import type { KeyserverSelectionBottomSheetParams } from '../profile/keyserver-selection-bottom-sheet.react.js'; import type { UserRelationshipTooltipModalParams } from '../profile/user-relationship-tooltip-modal.react.js'; import type { ChangeRolesScreenParams } from '../roles/change-roles-screen.react.js'; import type { CommunityRolesScreenParams } from '../roles/community-roles-screen.react.js'; import type { CreateRolesScreenParams } from '../roles/create-roles-screen.react.js'; import type { MessageSearchParams } from '../search/message-search.react.js'; import type { UserProfileAvatarModalParams } from '../user-profile/user-profile-avatar-modal.react.js'; import type { UserProfileBottomSheetParams } from '../user-profile/user-profile-bottom-sheet.react.js'; export const ActionResultModalRouteName = 'ActionResultModal'; export const AddUsersModalRouteName = 'AddUsersModal'; export const AppearancePreferencesRouteName = 'AppearancePreferences'; export const AppRouteName = 'App'; export const AppsRouteName = 'Apps'; export const BackgroundChatThreadListRouteName = 'BackgroundChatThreadList'; export const BackupMenuRouteName = 'BackupMenu'; export const BlockListRouteName = 'BlockList'; export const BuildInfoRouteName = 'BuildInfo'; export const CalendarRouteName = 'Calendar'; +export const CalendarScreenRouteName = 'CalendarScreen'; export const ChangeRolesScreenRouteName = 'ChangeRolesScreen'; export const ChatCameraModalRouteName = 'ChatCameraModal'; export const ChatRouteName = 'Chat'; export const ChatThreadListRouteName = 'ChatThreadList'; export const ColorSelectorModalRouteName = 'ColorSelectorModal'; export const ComposeSubchannelModalRouteName = 'ComposeSubchannelModal'; export const ComposeSubchannelRouteName = 'ComposeSubchannel'; export const CommunityDrawerNavigatorRouteName = 'CommunityDrawerNavigator'; export const CustomServerModalRouteName = 'CustomServerModal'; export const DefaultNotificationsPreferencesRouteName = 'DefaultNotifications'; export const DeleteAccountRouteName = 'DeleteAccount'; export const DeleteThreadRouteName = 'DeleteThread'; export const DevToolsRouteName = 'DevTools'; export const EditPasswordRouteName = 'EditPassword'; export const EmojiThreadAvatarCreationRouteName = 'EmojiThreadAvatarCreation'; export const EmojiUserAvatarCreationRouteName = 'EmojiUserAvatarCreation'; export const FriendListRouteName = 'FriendList'; export const FullScreenThreadMediaGalleryRouteName = 'FullScreenThreadMediaGallery'; export const HomeChatThreadListRouteName = 'HomeChatThreadList'; export const ImageModalRouteName = 'ImageModal'; export const ImagePasteModalRouteName = 'ImagePasteModal'; export const InviteLinkModalRouteName = 'InviteLinkModal'; export const InviteLinkNavigatorRouteName = 'InviteLinkNavigator'; export const LinkedDevicesRouteName = 'LinkedDevices'; export const LoggedOutModalRouteName = 'LoggedOutModal'; export const ManagePublicLinkRouteName = 'ManagePublicLink'; export const MessageListRouteName = 'MessageList'; export const MessageReactionsModalRouteName = 'MessageReactionsModal'; export const PinnedMessagesScreenRouteName = 'PinnedMessagesScreen'; export const MultimediaMessageTooltipModalRouteName = 'MultimediaMessageTooltipModal'; export const PrivacyPreferencesRouteName = 'PrivacyPreferences'; export const ProfileRouteName = 'Profile'; export const ProfileScreenRouteName = 'ProfileScreen'; export const UserRelationshipTooltipModalRouteName = 'UserRelationshipTooltipModal'; export const RobotextMessageTooltipModalRouteName = 'RobotextMessageTooltipModal'; export const SecondaryDeviceQRCodeScannerRouteName = 'SecondaryDeviceQRCodeScanner'; export const SidebarListModalRouteName = 'SidebarListModal'; export const SubchannelsListModalRouteName = 'SubchannelsListModal'; export const TabNavigatorRouteName = 'TabNavigator'; export const TextMessageTooltipModalRouteName = 'TextMessageTooltipModal'; export const ThreadAvatarCameraModalRouteName = 'ThreadAvatarCameraModal'; export const ThreadPickerModalRouteName = 'ThreadPickerModal'; export const ThreadSettingsMemberTooltipModalRouteName = 'ThreadSettingsMemberTooltipModal'; export const ThreadSettingsRouteName = 'ThreadSettings'; export const TunnelbrokerMenuRouteName = 'TunnelbrokerMenu'; export const UserAvatarCameraModalRouteName = 'UserAvatarCameraModal'; export const TogglePinModalRouteName = 'TogglePinModal'; export const VideoPlaybackModalRouteName = 'VideoPlaybackModal'; export const ViewInviteLinksRouteName = 'ViewInviteLinks'; export const TermsAndPrivacyRouteName = 'TermsAndPrivacyModal'; export const RegistrationRouteName = 'Registration'; export const KeyserverSelectionRouteName = 'KeyserverSelection'; export const CoolOrNerdModeSelectionRouteName = 'CoolOrNerdModeSelection'; export const ConnectEthereumRouteName = 'ConnectEthereum'; export const CreateSIWEBackupMessageRouteName = 'CreateSIWEBackupMessage'; export const CreateMissingSIWEBackupMessageRouteName = 'CreateMissingSIWEBackupMessage'; export const RestoreSIWEBackupRouteName = 'RestoreSIWEBackup'; export const ExistingEthereumAccountRouteName = 'ExistingEthereumAccount'; export const ConnectFarcasterRouteName = 'ConnectFarcaster'; export const UsernameSelectionRouteName = 'UsernameSelection'; export const CommunityCreationRouteName = 'CommunityCreation'; export const CommunityConfigurationRouteName = 'CommunityConfiguration'; export const CommunityCreationMembersRouteName = 'CommunityCreationMembers'; export const MessageSearchRouteName = 'MessageSearch'; export const PasswordSelectionRouteName = 'PasswordSelection'; export const AvatarSelectionRouteName = 'AvatarSelection'; export const EmojiAvatarSelectionRouteName = 'EmojiAvatarSelection'; export const RegistrationUserAvatarCameraModalRouteName = 'RegistrationUserAvatarCameraModal'; export const RegistrationTermsRouteName = 'RegistrationTerms'; export const RolesNavigatorRouteName = 'RolesNavigator'; export const CommunityRolesScreenRouteName = 'CommunityRolesScreen'; export const CreateRolesScreenRouteName = 'CreateRolesScreen'; export const QRCodeSignInNavigatorRouteName = 'QRCodeSignInNavigator'; export const QRCodeScreenRouteName = 'QRCodeScreen'; export const UserProfileBottomSheetNavigatorRouteName = 'UserProfileBottomSheetNavigator'; export const UserProfileBottomSheetRouteName = 'UserProfileBottomSheet'; export const UserProfileAvatarModalRouteName = 'UserProfileAvatarModal'; export const KeyserverSelectionListRouteName = 'KeyserverSelectionList'; export const AddKeyserverRouteName = 'AddKeyserver'; export const KeyserverSelectionBottomSheetRouteName = 'KeyserverSelectionBottomSheet'; export const AccountDoesNotExistRouteName = 'AccountDoesNotExist'; export const FarcasterAccountSettingsRouteName = 'FarcasterAccountSettings'; export const ConnectFarcasterBottomSheetRouteName = 'ConnectFarcasterBottomSheet'; export const TagFarcasterChannelNavigatorRouteName = 'TagFarcasterChannelNavigator'; export const TagFarcasterChannelRouteName = 'TagFarcasterChannel'; export const TagFarcasterChannelByNameRouteName = 'TagFarcasterChannelByName'; export type RootParamList = { +LoggedOutModal: void, +App: void, +ThreadPickerModal: ThreadPickerModalParams, +AddUsersModal: AddUsersModalParams, +CustomServerModal: CustomServerModalParams, +ColorSelectorModal: ColorSelectorModalParams, +ComposeSubchannelModal: ComposeSubchannelModalParams, +SidebarListModal: SidebarListModalParams, +ImagePasteModal: ImagePasteModalParams, +TermsAndPrivacyModal: TermsAndPrivacyModalParams, +SubchannelsListModal: SubchannelListModalParams, +MessageReactionsModal: MessageReactionsModalParams, +Registration: void, +CommunityCreation: void, +InviteLinkModal: InviteLinkModalParams, +InviteLinkNavigator: void, +RolesNavigator: void, +QRCodeSignInNavigator: void, +UserProfileBottomSheetNavigator: void, +TunnelbrokerMenu: void, +KeyserverSelectionBottomSheet: KeyserverSelectionBottomSheetParams, +ConnectFarcasterBottomSheet: void, +TagFarcasterChannelNavigator: void, +CreateMissingSIWEBackupMessage: void, +RestoreSIWEBackup: RestoreSIWEBackupParams, }; export type MessageTooltipRouteNames = | typeof RobotextMessageTooltipModalRouteName | typeof MultimediaMessageTooltipModalRouteName | typeof TextMessageTooltipModalRouteName; export const PinnableMessageTooltipRouteNames = [ TextMessageTooltipModalRouteName, MultimediaMessageTooltipModalRouteName, ]; export type TooltipModalParamList = { +MultimediaMessageTooltipModal: MultimediaMessageTooltipModalParams, +TextMessageTooltipModal: TextMessageTooltipModalParams, +ThreadSettingsMemberTooltipModal: ThreadSettingsMemberTooltipModalParams, +UserRelationshipTooltipModal: UserRelationshipTooltipModalParams, +RobotextMessageTooltipModal: RobotextMessageTooltipModalParams, }; export type OverlayParamList = { +CommunityDrawerNavigator: void, +ImageModal: ImageModalParams, +ActionResultModal: ActionResultModalParams, +ChatCameraModal: ChatCameraModalParams, +UserAvatarCameraModal: void, +ThreadAvatarCameraModal: ThreadAvatarCameraModalParams, +VideoPlaybackModal: VideoPlaybackModalParams, +TogglePinModal: TogglePinModalParams, ...TooltipModalParamList, }; export type TabParamList = { +Calendar: void, +Chat: void, +Profile: void, +Apps: void, }; export type ChatParamList = { +ChatThreadList: void, +MessageList: MessageListParams, +ComposeSubchannel: ComposeSubchannelParams, +ThreadSettings: ThreadSettingsParams, +EmojiThreadAvatarCreation: EmojiThreadAvatarCreationParams, +DeleteThread: DeleteThreadParams, +FullScreenThreadMediaGallery: FullScreenThreadMediaGalleryParams, +PinnedMessagesScreen: PinnedMessagesScreenParams, +MessageSearch: MessageSearchParams, +ChangeRolesScreen: ChangeRolesScreenParams, }; export type ChatTopTabsParamList = { +HomeChatThreadList: void, +BackgroundChatThreadList: void, }; export type ProfileParamList = { +ProfileScreen: void, +EmojiUserAvatarCreation: void, +EditPassword: void, +DeleteAccount: void, +BuildInfo: void, +DevTools: void, +AppearancePreferences: void, +PrivacyPreferences: void, +DefaultNotifications: void, +FriendList: void, +BlockList: void, +LinkedDevices: void, +SecondaryDeviceQRCodeScanner: void, +BackupMenu: void, +TunnelbrokerMenu: void, +KeyserverSelectionList: void, +AddKeyserver: void, +FarcasterAccountSettings: void, }; +export type CalendarParamList = { + +CalendarScreen: void, +}; + export type CommunityDrawerParamList = { +TabNavigator: void }; export type RegistrationParamList = { +CoolOrNerdModeSelection: void, +KeyserverSelection: KeyserverSelectionParams, +ConnectEthereum: ConnectEthereumParams, +ExistingEthereumAccount: ExistingEthereumAccountParams, +ConnectFarcaster: ConnectFarcasterParams, +CreateSIWEBackupMessage: CreateSIWEBackupMessageParams, +UsernameSelection: UsernameSelectionParams, +PasswordSelection: PasswordSelectionParams, +AvatarSelection: AvatarSelectionParams, +EmojiAvatarSelection: EmojiAvatarSelectionParams, +RegistrationUserAvatarCameraModal: void, +RegistrationTerms: RegistrationTermsParams, +AccountDoesNotExist: void, }; export type InviteLinkParamList = { +ViewInviteLinks: ViewInviteLinksScreenParams, +ManagePublicLink: ManagePublicLinkScreenParams, }; export type CommunityCreationParamList = { +CommunityConfiguration: void, +CommunityCreationMembers: CommunityCreationMembersScreenParams, }; export type RolesParamList = { +CommunityRolesScreen: CommunityRolesScreenParams, +CreateRolesScreen: CreateRolesScreenParams, }; export type TagFarcasterChannelParamList = { +TagFarcasterChannel: TagFarcasterChannelParams, +TagFarcasterChannelByName: TagFarcasterChannelByNameParams, }; export type QRCodeSignInParamList = { +QRCodeScreen: void, }; export type UserProfileBottomSheetParamList = { +UserProfileBottomSheet: UserProfileBottomSheetParams, +UserProfileAvatarModal: UserProfileAvatarModalParams, +UserRelationshipTooltipModal: UserRelationshipTooltipModalParams, }; export type ScreenParamList = { ...RootParamList, ...OverlayParamList, ...TabParamList, ...ChatParamList, ...ChatTopTabsParamList, ...ProfileParamList, + ...CalendarParamList, ...CommunityDrawerParamList, ...RegistrationParamList, ...InviteLinkParamList, ...CommunityCreationParamList, ...RolesParamList, ...QRCodeSignInParamList, ...UserProfileBottomSheetParamList, ...TagFarcasterChannelParamList, }; export type NavigationRoute> = RouteProp; export const accountModals = [ LoggedOutModalRouteName, RegistrationRouteName, QRCodeSignInNavigatorRouteName, ]; export const scrollBlockingModals = [ ImageModalRouteName, MultimediaMessageTooltipModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, UserRelationshipTooltipModalRouteName, RobotextMessageTooltipModalRouteName, VideoPlaybackModalRouteName, ]; export const chatRootModals = [ AddUsersModalRouteName, ColorSelectorModalRouteName, ComposeSubchannelModalRouteName, ]; export const threadRoutes = [ MessageListRouteName, ThreadSettingsRouteName, DeleteThreadRouteName, ComposeSubchannelRouteName, FullScreenThreadMediaGalleryRouteName, PinnedMessagesScreenRouteName, MessageSearchRouteName, EmojiThreadAvatarCreationRouteName, CommunityRolesScreenRouteName, ]; diff --git a/native/navigation/tab-navigator.react.js b/native/navigation/tab-navigator.react.js index 3e7062f4f..5ca771a16 100644 --- a/native/navigation/tab-navigator.react.js +++ b/native/navigation/tab-navigator.react.js @@ -1,237 +1,228 @@ // @flow import { BottomTabView } from '@react-navigation/bottom-tabs'; import type { BottomTabNavigationEventMap, BottomTabOptions, CreateNavigator, TabNavigationState, ParamListBase, BottomTabNavigationHelpers, BottomTabNavigationProp, ExtraBottomTabNavigatorProps, BottomTabNavigatorProps, TabAction, TabRouterOptions, } from '@react-navigation/core'; import { createNavigatorFactory, useNavigationBuilder, } from '@react-navigation/native'; import * as React from 'react'; import { unreadCount } from 'lib/selectors/thread-selectors.js'; import CommunityDrawerButton from './community-drawer-button.react.js'; import type { CommunityDrawerNavigationProp } from './community-drawer-navigator.react.js'; import { CalendarRouteName, ChatRouteName, ProfileRouteName, AppsRouteName, type ScreenParamList, type TabParamList, type NavigationRoute, } from './route-names.js'; import { tabBar } from './tab-bar.react.js'; import TabRouter from './tab-router.js'; import AppsDirectory from '../apps/apps-directory.react.js'; import Calendar from '../calendar/calendar.react.js'; import Chat from '../chat/chat.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import Profile from '../profile/profile.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors } from '../themes/colors.js'; const calendarTabOptions = { tabBarLabel: 'Calendar', tabBarIcon: ({ color }: { +color: string, ... }) => ( ), }; const getChatTabOptions = (badge: number) => ({ tabBarLabel: 'Inbox', tabBarIcon: ({ color }: { +color: string, ... }) => ( ), tabBarBadge: badge ? badge : undefined, }); const profileTabOptions = { tabBarLabel: 'Profile', tabBarIcon: ({ color }: { +color: string, ... }) => ( ), }; const appsTabOptions = { tabBarLabel: 'Apps', tabBarIcon: ({ color }: { +color: string, ... }) => ( ), }; export type CustomBottomTabNavigationHelpers< ParamList: ParamListBase = ParamListBase, > = { ...$Exact>, ... }; export type TabNavigationProp< RouteName: $Keys = $Keys, > = BottomTabNavigationProp; type TabNavigatorProps = BottomTabNavigatorProps< CustomBottomTabNavigationHelpers<>, >; const TabNavigator = React.memo(function TabNavigator({ id, initialRouteName, backBehavior, children, screenListeners, screenOptions, defaultScreenOptions, ...rest }: TabNavigatorProps) { const { state, descriptors, navigation } = useNavigationBuilder< TabNavigationState, TabAction, BottomTabOptions, TabRouterOptions, CustomBottomTabNavigationHelpers<>, BottomTabNavigationEventMap, ExtraBottomTabNavigatorProps, >(TabRouter, { id, initialRouteName, backBehavior, children, screenListeners, screenOptions, defaultScreenOptions, }); return ( ); }); const createTabNavigator: CreateNavigator< TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, ExtraBottomTabNavigatorProps, > = createNavigatorFactory< TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, BottomTabNavigationHelpers<>, ExtraBottomTabNavigatorProps, >(TabNavigator); const Tab = createTabNavigator< ScreenParamList, TabParamList, BottomTabNavigationHelpers, >(); type Props = { +navigation: CommunityDrawerNavigationProp<'TabNavigator'>, +route: NavigationRoute<'TabNavigator'>, }; function TabComponent(props: Props): React.Node { const colors = useColors(); const chatBadge = useSelector(unreadCount); const isCalendarEnabled = useSelector(state => state.enabledApps.calendar); + let calendarTab; + if (isCalendarEnabled) { + calendarTab = ( + + ); + } + const headerLeft = React.useCallback( () => , [props.navigation], ); - const headerCommonOptions = React.useMemo( + const appsOptions = React.useMemo( () => ({ + ...appsTabOptions, headerShown: true, headerLeft, headerStyle: { backgroundColor: colors.tabBarBackground, }, headerShadowVisible: false, }), [colors.tabBarBackground, headerLeft], ); - const calendarOptions = React.useMemo( - () => ({ ...calendarTabOptions, ...headerCommonOptions }), - [headerCommonOptions], - ); - - let calendarTab; - if (isCalendarEnabled) { - calendarTab = ( - - ); - } - - const appsOptions = React.useMemo( - () => ({ ...appsTabOptions, ...headerCommonOptions }), - [headerCommonOptions], - ); - const tabBarScreenOptions = React.useMemo( () => ({ headerShown: false, tabBarHideOnKeyboard: false, tabBarActiveTintColor: colors.tabBarActiveTintColor, tabBarStyle: { backgroundColor: colors.tabBarBackground, borderTopWidth: 1, }, lazy: false, }), [colors.tabBarActiveTintColor, colors.tabBarBackground], ); return ( {calendarTab} ); } const styles = { icon: { fontSize: 28, }, }; export default TabComponent;