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;