diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js
index 3b1e0b51e..e001c1a95 100644
--- a/native/calendar/entry.react.js
+++ b/native/calendar/entry.react.js
@@ -1,792 +1,796 @@
// @flow
import invariant from 'invariant';
import _isEqual from 'lodash/fp/isEqual';
import _omit from 'lodash/fp/omit';
import * as React from 'react';
import {
View,
Text,
TextInput,
Platform,
TouchableWithoutFeedback,
Alert,
LayoutAnimation,
Keyboard,
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import { useDispatch } from 'react-redux';
import shallowequal from 'shallowequal';
import tinycolor from 'tinycolor2';
import {
createEntryActionTypes,
createEntry,
saveEntryActionTypes,
saveEntry,
deleteEntryActionTypes,
deleteEntry,
concurrentModificationResetActionType,
} from 'lib/actions/entry-actions';
import { registerFetchKey } from 'lib/reducers/loading-reducer';
import { entryKey } from 'lib/shared/entry-utils';
import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils';
import type { Shape } from 'lib/types/core';
import type {
CreateEntryInfo,
SaveEntryInfo,
SaveEntryResponse,
CreateEntryPayload,
DeleteEntryInfo,
DeleteEntryResponse,
CalendarQuery,
} from 'lib/types/entry-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import type { Dispatch } from 'lib/types/redux-types';
import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types';
import {
useServerCall,
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/action-utils';
import { dateString } from 'lib/utils/date-utils';
import { ServerError } from 'lib/utils/errors';
import sleep from 'lib/utils/sleep';
import Button from '../components/button.react';
import { SingleLine } from '../components/single-line.react';
import Markdown from '../markdown/markdown.react';
import { inlineMarkdownRules } from '../markdown/rules.react';
import type { TabNavigationProp } from '../navigation/app-navigator.react';
import {
createIsForegroundSelector,
nonThreadCalendarQuery,
} from '../navigation/nav-selectors';
import { NavContext } from '../navigation/navigation-context';
import {
MessageListRouteName,
ThreadPickerModalRouteName,
} from '../navigation/route-names';
import { useSelector } from '../redux/redux-utils';
import { colors, useStyles } from '../themes/colors';
import type { LayoutEvent } from '../types/react-native';
import { waitForInteractions } from '../utils/timers';
import type { EntryInfoWithHeight } from './calendar.react';
import LoadingIndicator from './loading-indicator.react';
function hueDistance(firstColor: string, secondColor: string): number {
const firstHue = tinycolor(firstColor).toHsv().h;
const secondHue = tinycolor(secondColor).toHsv().h;
const distance = Math.abs(firstHue - secondHue);
return distance > 180 ? 360 - distance : distance;
}
const omitEntryInfo = _omit(['entryInfo']);
function dummyNodeForEntryHeightMeasurement(entryText: string) {
const text = entryText === '' ? ' ' : entryText;
return (
{text}
);
}
type BaseProps = {|
+navigation: TabNavigationProp<'Calendar'>,
+entryInfo: EntryInfoWithHeight,
+threadInfo: ThreadInfo,
+visible: boolean,
+active: boolean,
+makeActive: (entryKey: string, active: boolean) => void,
+onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void,
+onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void,
+onPressWhitespace: () => void,
+entryRef: (entryKey: string, entry: ?InternalEntry) => void,
|};
type Props = {|
...BaseProps,
// Redux state
+calendarQuery: () => CalendarQuery,
+online: boolean,
+styles: typeof unboundStyles,
// Nav state
+threadPickerActive: boolean,
// Redux dispatch functions
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+createEntry: (info: CreateEntryInfo) => Promise,
+saveEntry: (info: SaveEntryInfo) => Promise,
+deleteEntry: (info: DeleteEntryInfo) => Promise,
|};
type State = {|
+editing: boolean,
+text: string,
+loadingStatus: LoadingStatus,
+height: number,
|};
class InternalEntry extends React.Component {
textInput: ?React.ElementRef;
creating = false;
needsUpdateAfterCreation = false;
needsDeleteAfterCreation = false;
nextSaveAttemptIndex = 0;
mounted = false;
deleted = false;
currentlySaving: ?string;
constructor(props: Props) {
super(props);
this.state = {
editing: false,
text: props.entryInfo.text,
loadingStatus: 'inactive',
height: props.entryInfo.textHeight,
};
this.state = {
...this.state,
editing: InternalEntry.isActive(props, this.state),
};
}
guardedSetState(input: Shape) {
if (this.mounted) {
this.setState(input);
}
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
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) {
return (
props.active ||
state.editing ||
!props.entryInfo.id ||
state.loadingStatus !== 'inactive'
);
}
render() {
const active = InternalEntry.isActive(this.props, this.state);
const { editing } = this.state;
const threadColor = `#${this.props.threadInfo.color}`;
const darkColor = colorIsDark(this.props.threadInfo.color);
let actionLinks = null;
if (active) {
const actionLinksColor = darkColor ? '#D3D3D3' : '#404040';
const actionLinksTextStyle = { color: actionLinksColor };
const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor
? colors.dark
: colors.light;
const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50;
let editButtonContent = null;
if (editing && this.state.text.trim() === '') {
// nothing
} else if (editing) {
editButtonContent = (
SAVE
);
} else {
editButtonContent = (
EDIT
);
}
actionLinks = (
);
}
const textColor = darkColor ? 'white' : 'black';
let textInput;
if (editing) {
const textInputStyle = {
color: textColor,
backgroundColor: threadColor,
};
const selectionColor = darkColor ? '#129AFF' : '#036AFF';
textInput = (
);
}
let rawText = this.state.text;
if (rawText === '' || rawText.slice(-1) === '\n') {
rawText += ' ';
}
const textStyle = {
...this.props.styles.text,
color: textColor,
opacity: textInput ? 0 : 1,
};
// We use an empty View to set the height of the entry, and then position
// the Text and TextInput absolutely. This allows to measure height changes
// to the Text while controlling the actual height of the entry.
const heightStyle = { height: this.state.height };
const entryStyle = { backgroundColor: threadColor };
const opacity = editing ? 1.0 : 0.6;
const canEditEntry = threadHasPermission(
this.props.threadInfo,
threadPermissions.EDIT_ENTRIES,
);
return (
);
}
textInputRef = (textInput: ?React.ElementRef) => {
this.textInput = textInput;
if (textInput && this.state.editing) {
this.enterEditMode();
}
};
enterEditMode = async () => {
this.setActive();
this.props.onEnterEditMode(this.props.entryInfo);
if (Platform.OS === 'android') {
// For some reason if we don't do this the scroll stops halfway through
await waitForInteractions();
await sleep(15);
}
this.focus();
};
focus = () => {
const { textInput } = this;
if (!textInput) {
return;
}
textInput.focus();
};
onFocus = () => {
if (this.props.threadPickerActive) {
this.props.navigation.goBack();
}
};
setActive = () => this.makeActive(true);
completeEdit = () => {
// 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 = () => {
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 = () => {
this.dispatchSave(this.props.entryInfo.id, this.state.text);
};
onTextContainerLayout = (event: LayoutEvent) => {
this.guardedSetState({
height: Math.ceil(event.nativeEvent.layout.height),
});
};
onChangeText = (newText: string) => {
this.guardedSetState({ text: newText });
};
makeActive(active: boolean) {
const { threadInfo } = this.props;
if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) {
return;
}
this.props.makeActive(entryKey(this.props.entryInfo), active);
}
dispatchSave(serverID: ?string, newText: string) {
if (this.currentlySaving === newText) {
return;
}
this.currentlySaving = newText;
if (newText.trim() === '') {
// We don't save the empty string, since as soon as the element becomes
// inactive it'll get deleted
return;
}
if (!serverID) {
if (this.creating) {
// We need the first save call to return so we know the ID of the entry
// we're updating, so we'll need to handle this save later
this.needsUpdateAfterCreation = true;
return;
} else {
this.creating = true;
}
}
this.guardedSetState({ loadingStatus: 'loading' });
if (!serverID) {
this.props.dispatchActionPromise(
createEntryActionTypes,
this.createAction(newText),
);
} else {
this.props.dispatchActionPromise(
saveEntryActionTypes,
this.saveAction(serverID, newText),
);
}
}
async createAction(text: string) {
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) {
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 = () => {
this.dispatchDelete(this.props.entryInfo.id);
};
onPressEdit = () => {
if (this.state.editing) {
this.completeEdit();
} else {
this.guardedSetState({ editing: true });
}
};
dispatchDelete(serverID: ?string) {
if (this.deleted) {
return;
}
this.deleted = true;
LayoutAnimation.easeInEaseOut();
const { localID } = this.props.entryInfo;
this.props.dispatchActionPromise(
deleteEntryActionTypes,
this.deleteAction(serverID),
undefined,
{ localID, serverID },
);
}
async deleteAction(serverID: ?string) {
if (serverID) {
return await this.props.deleteEntry({
entryID: serverID,
prevText: this.props.entryInfo.text,
calendarQuery: this.props.calendarQuery(),
});
} else if (this.creating) {
this.needsDeleteAfterCreation = true;
}
return null;
}
onPressThreadName = () => {
Keyboard.dismiss();
const { threadInfo } = this.props;
this.props.navigation.navigate({
name: MessageListRouteName,
params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
};
}
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: undefined,
+ }),
},
textInput: {
fontFamily: 'System',
fontSize: 16,
left: Platform.OS === 'android' ? 9.8 : 10,
margin: 0,
padding: 0,
position: 'absolute',
right: 10,
top: Platform.OS === 'android' ? 4.8 : 0.5,
},
};
registerFetchKey(saveEntryActionTypes);
registerFetchKey(deleteEntryActionTypes);
const activeThreadPickerSelector = createIsForegroundSelector(
ThreadPickerModalRouteName,
);
const Entry = React.memo(function ConnectedEntry(props: BaseProps) {
const navContext = React.useContext(NavContext);
const threadPickerActive = activeThreadPickerSelector(navContext);
const calendarQuery = useSelector((state) =>
nonThreadCalendarQuery({
redux: state,
navContext,
}),
);
const online = useSelector(
(state) => state.connection.status === 'connected',
);
const styles = useStyles(unboundStyles);
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callCreateEntry = useServerCall(createEntry);
const callSaveEntry = useServerCall(saveEntry);
const callDeleteEntry = useServerCall(deleteEntry);
return (
);
});
export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement };
diff --git a/native/components/search.react.js b/native/components/search.react.js
index 28bf22ebb..dbffcff9d 100644
--- a/native/components/search.react.js
+++ b/native/components/search.react.js
@@ -1,107 +1,125 @@
// @flow
import * as React from 'react';
-import { View, TouchableOpacity, TextInput, Text } from 'react-native';
+import {
+ View,
+ TouchableOpacity,
+ TextInput,
+ Text,
+ Platform,
+} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import { isLoggedIn } from 'lib/selectors/user-selectors';
import { useSelector } from '../redux/redux-utils';
import { useStyles, useColors } from '../themes/colors';
import type { ViewStyle } from '../types/styles';
type Props = {|
...React.ElementConfig,
+searchText: string,
+onChangeText: (searchText: string) => mixed,
+containerStyle?: ViewStyle,
+active?: boolean,
|};
const Search = React.forwardRef(
function ForwardedSearch(props: Props, ref: React.Ref) {
const { onChangeText, searchText, containerStyle, active, ...rest } = props;
const clearSearch = React.useCallback(() => {
onChangeText('');
}, [onChangeText]);
const loggedIn = useSelector(isLoggedIn);
const styles = useStyles(unboundStyles);
const colors = useColors();
const prevLoggedInRef = React.useRef();
React.useEffect(() => {
const prevLoggedIn = prevLoggedInRef.current;
prevLoggedInRef.current = loggedIn;
if (!loggedIn && prevLoggedIn) {
clearSearch();
}
}, [loggedIn, clearSearch]);
const { listSearchIcon: iconColor } = colors;
let clearSearchInputIcon = null;
if (searchText) {
clearSearchInputIcon = (
);
}
const inactive = active === false;
const usingPlaceholder = !searchText && rest.placeholder;
const inactiveTextStyle = React.useMemo(
() =>
inactive && usingPlaceholder
- ? [styles.searchText, { color: iconColor }]
- : styles.searchText,
- [inactive, usingPlaceholder, styles.searchText, iconColor],
+ ? [styles.searchText, styles.inactiveSearchText, { color: iconColor }]
+ : [styles.searchText, styles.inactiveSearchText],
+ [
+ inactive,
+ usingPlaceholder,
+ styles.searchText,
+ styles.inactiveSearchText,
+ iconColor,
+ ],
);
let textNode;
if (!inactive) {
const textInputProps: React.ElementProps = {
style: styles.searchText,
value: searchText,
onChangeText: onChangeText,
placeholderTextColor: iconColor,
returnKeyType: 'go',
};
textNode = ;
} else {
const text = usingPlaceholder ? rest.placeholder : searchText;
textNode = {text};
}
return (
{textNode}
{clearSearchInputIcon}
);
},
);
const unboundStyles = {
search: {
alignItems: 'center',
backgroundColor: 'listSearchBackground',
borderRadius: 6,
flexDirection: 'row',
paddingLeft: 14,
paddingRight: 12,
paddingVertical: 6,
},
+ inactiveSearchText: {
+ transform: Platform.select({
+ ios: [{ translateY: 1 / 3 }],
+ default: undefined,
+ }),
+ },
searchText: {
color: 'listForegroundLabel',
flex: 1,
fontSize: 16,
marginLeft: 8,
marginVertical: 0,
padding: 0,
borderBottomColor: 'transparent',
},
};
export default React.memo(Search);