Page MenuHomePhabricator

No OneTemporary

diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js
index 384a18c9b..771479b9c 100644
--- a/native/calendar/entry.react.js
+++ b/native/calendar/entry.react.js
@@ -1,808 +1,821 @@
// @flow
import Icon from '@expo/vector-icons/FontAwesome';
import invariant from 'invariant';
import _isEqual from 'lodash/fp/isEqual';
import _omit from 'lodash/fp/omit';
import * as React from 'react';
import {
View,
Text,
TextInput as BaseTextInput,
Platform,
TouchableWithoutFeedback,
Alert,
LayoutAnimation,
Keyboard,
} from 'react-native';
import { useDispatch } from 'react-redux';
import shallowequal from 'shallowequal';
import tinycolor from 'tinycolor2';
import {
createEntryActionTypes,
createEntry,
saveEntryActionTypes,
saveEntry,
deleteEntryActionTypes,
deleteEntry,
concurrentModificationResetActionType,
} from 'lib/actions/entry-actions';
import { registerFetchKey } from 'lib/reducers/loading-reducer';
import { entryKey } from 'lib/shared/entry-utils';
import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils';
import type { Shape } from 'lib/types/core';
import type {
CreateEntryInfo,
SaveEntryInfo,
SaveEntryResult,
SaveEntryPayload,
CreateEntryPayload,
DeleteEntryInfo,
DeleteEntryResult,
CalendarQuery,
} from 'lib/types/entry-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import type { Dispatch } from 'lib/types/redux-types';
-import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types';
+import {
+ type ThreadInfo,
+ type ResolvedThreadInfo,
+ threadPermissions,
+} from 'lib/types/thread-types';
import {
useServerCall,
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/action-utils';
import { dateString } from 'lib/utils/date-utils';
+import { useResolvedThreadInfo } from 'lib/utils/entity-helpers';
import { ServerError } from 'lib/utils/errors';
import sleep from 'lib/utils/sleep';
import {
type MessageListParams,
useNavigateToThread,
} from '../chat/message-list-types';
import Button from '../components/button.react';
import { SingleLine } from '../components/single-line.react';
import TextInput from '../components/text-input.react';
import Markdown from '../markdown/markdown.react';
import { inlineMarkdownRules } from '../markdown/rules.react';
import {
createIsForegroundSelector,
nonThreadCalendarQuery,
} from '../navigation/nav-selectors';
import { NavContext } from '../navigation/navigation-context';
import { ThreadPickerModalRouteName } from '../navigation/route-names';
import type { TabNavigationProp } from '../navigation/tab-navigator.react';
import { useSelector } from '../redux/redux-utils';
import { colors, useStyles } from '../themes/colors';
import type { LayoutEvent } from '../types/react-native';
import { waitForInteractions } from '../utils/timers';
import type { EntryInfoWithHeight } from './calendar.react';
import LoadingIndicator from './loading-indicator.react';
function hueDistance(firstColor: string, secondColor: string): number {
const firstHue = tinycolor(firstColor).toHsv().h;
const secondHue = tinycolor(secondColor).toHsv().h;
const distance = Math.abs(firstHue - secondHue);
return distance > 180 ? 360 - distance : distance;
}
const omitEntryInfo = _omit(['entryInfo']);
function dummyNodeForEntryHeightMeasurement(
entryText: string,
): React.Element<typeof View> {
const text = entryText === '' ? ' ' : entryText;
return (
<View style={[unboundStyles.entry, unboundStyles.textContainer]}>
<Text style={unboundStyles.text}>{text}</Text>
</View>
);
}
-type BaseProps = {
+type SharedProps = {
+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 BaseProps = {
+ ...SharedProps,
+ +threadInfo: ThreadInfo,
+};
type Props = {
- ...BaseProps,
+ ...SharedProps,
+ +threadInfo: ResolvedThreadInfo,
// Redux state
+calendarQuery: () => CalendarQuery,
+online: boolean,
+styles: typeof unboundStyles,
// Nav state
+threadPickerActive: boolean,
+navigateToThread: (params: MessageListParams) => void,
// Redux dispatch functions
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+createEntry: (info: CreateEntryInfo) => Promise<CreateEntryPayload>,
+saveEntry: (info: SaveEntryInfo) => Promise<SaveEntryResult>,
+deleteEntry: (info: DeleteEntryInfo) => Promise<DeleteEntryResult>,
};
type State = {
+editing: boolean,
+text: string,
+loadingStatus: LoadingStatus,
+height: number,
};
class InternalEntry extends React.Component<Props, State> {
textInput: ?React.ElementRef<typeof BaseTextInput>;
creating: boolean = false;
needsUpdateAfterCreation: boolean = false;
needsDeleteAfterCreation: boolean = false;
nextSaveAttemptIndex: number = 0;
mounted: boolean = false;
deleted: boolean = false;
currentlySaving: ?string;
constructor(props: Props) {
super(props);
this.state = {
editing: false,
text: props.entryInfo.text,
loadingStatus: 'inactive',
height: props.entryInfo.textHeight,
};
this.state = {
...this.state,
editing: InternalEntry.isActive(props, this.state),
};
}
guardedSetState(input: Shape<State>) {
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 = (
<React.Fragment>
<Icon name="check" size={14} color={actionLinksColor} />
<Text
style={[this.props.styles.leftLinksText, actionLinksTextStyle]}
>
SAVE
</Text>
</React.Fragment>
);
} else {
editButtonContent = (
<React.Fragment>
<Icon
name="pencil"
size={12}
color={actionLinksColor}
style={this.props.styles.pencilIcon}
/>
<Text
style={[this.props.styles.leftLinksText, actionLinksTextStyle]}
>
EDIT
</Text>
</React.Fragment>
);
}
actionLinks = (
<View style={this.props.styles.actionLinks}>
<View style={this.props.styles.leftLinks}>
<Button
onPress={this.delete}
iosFormat="highlight"
iosHighlightUnderlayColor={actionLinksUnderlayColor}
iosActiveOpacity={0.85}
style={this.props.styles.button}
>
<View style={this.props.styles.buttonContents}>
<Icon name="close" size={14} color={actionLinksColor} />
<Text
style={[
this.props.styles.leftLinksText,
actionLinksTextStyle,
]}
>
DELETE
</Text>
</View>
</Button>
<Button
onPress={this.onPressEdit}
iosFormat="highlight"
iosHighlightUnderlayColor={actionLinksUnderlayColor}
iosActiveOpacity={0.85}
style={this.props.styles.button}
>
<View style={this.props.styles.buttonContents}>
{editButtonContent}
</View>
</Button>
</View>
<View style={this.props.styles.rightLinks}>
<LoadingIndicator
loadingStatus={this.state.loadingStatus}
color={actionLinksColor}
canUseRed={loadingIndicatorCanUseRed}
/>
<Button
onPress={this.onPressThreadName}
iosFormat="highlight"
iosHighlightUnderlayColor={actionLinksUnderlayColor}
iosActiveOpacity={0.85}
style={this.props.styles.button}
>
<SingleLine
style={[this.props.styles.rightLinksText, actionLinksTextStyle]}
>
{this.props.threadInfo.uiName}
</SingleLine>
</Button>
</View>
</View>
);
}
const textColor = darkColor ? 'white' : 'black';
let textInput;
if (editing) {
const textInputStyle = {
color: textColor,
backgroundColor: threadColor,
};
const selectionColor = darkColor ? '#129AFF' : '#036AFF';
textInput = (
<TextInput
style={[this.props.styles.textInput, textInputStyle]}
value={this.state.text}
onChangeText={this.onChangeText}
multiline={true}
onFocus={this.onFocus}
onBlur={this.onBlur}
selectionColor={selectionColor}
ref={this.textInputRef}
/>
);
}
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 (
<TouchableWithoutFeedback onPress={this.props.onPressWhitespace}>
<View style={this.props.styles.container}>
<Button
disabled={!canEditEntry}
onPress={this.setActive}
style={[this.props.styles.entry, entryStyle]}
androidFormat="opacity"
iosActiveOpacity={opacity}
>
<View>
<View style={heightStyle} />
<View
style={this.props.styles.textContainer}
onLayout={this.onTextContainerLayout}
>
<Markdown
style={textStyle}
rules={inlineMarkdownRules(darkColor)}
>
{rawText}
</Markdown>
</View>
{textInput}
</View>
{actionLinks}
</Button>
</View>
</TouchableWithoutFeedback>
);
}
textInputRef: (
textInput: ?React.ElementRef<typeof BaseTextInput>,
) => void = textInput => {
this.textInput = textInput;
if (textInput && this.state.editing) {
this.enterEditMode();
}
};
enterEditMode: () => Promise<void> = 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) {
const { threadInfo } = this.props;
if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) {
return;
}
this.props.makeActive(entryKey(this.props.entryInfo), active);
}
dispatchSave(serverID: ?string, newText: string) {
if (this.currentlySaving === newText) {
return;
}
this.currentlySaving = newText;
if (newText.trim() === '') {
// We don't save the empty string, since as soon as the element becomes
// inactive it'll get deleted
return;
}
if (!serverID) {
if (this.creating) {
// We need the first save call to return so we know the ID of the entry
// we're updating, so we'll need to handle this save later
this.needsUpdateAfterCreation = true;
return;
} else {
this.creating = true;
}
}
this.guardedSetState({ loadingStatus: 'loading' });
if (!serverID) {
this.props.dispatchActionPromise(
createEntryActionTypes,
this.createAction(newText),
);
} else {
this.props.dispatchActionPromise(
saveEntryActionTypes,
this.saveAction(serverID, newText),
);
}
}
async createAction(text: string): Promise<CreateEntryPayload> {
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<SaveEntryPayload> {
const curSaveAttempt = this.nextSaveAttemptIndex++;
try {
const response = await this.props.saveEntry({
entryID,
text: newText,
prevText: this.props.entryInfo.text,
timestamp: Date.now(),
calendarQuery: this.props.calendarQuery(),
});
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'inactive' });
}
return { ...response, threadID: this.props.entryInfo.threadID };
} catch (e) {
if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) {
this.guardedSetState({ loadingStatus: 'error' });
}
this.currentlySaving = null;
if (e instanceof ServerError && e.message === 'concurrent_modification') {
const revertedText = e.payload?.db;
const onRefresh = () => {
this.guardedSetState({
loadingStatus: 'inactive',
text: revertedText,
});
this.props.dispatch({
type: concurrentModificationResetActionType,
payload: { id: entryID, dbText: revertedText },
});
};
Alert.alert(
'Concurrent modification',
'It looks like somebody is attempting to modify that field at the ' +
'same time as you! Please try again.',
[{ text: 'OK', onPress: onRefresh }],
{ cancelable: false },
);
}
throw e;
}
}
delete: () => void = () => {
this.dispatchDelete(this.props.entryInfo.id);
};
onPressEdit: () => void = () => {
if (this.state.editing) {
this.completeEdit();
} else {
this.guardedSetState({ editing: true });
}
};
dispatchDelete(serverID: ?string) {
if (this.deleted) {
return;
}
this.deleted = true;
LayoutAnimation.easeInEaseOut();
const { localID } = this.props.entryInfo;
this.props.dispatchActionPromise(
deleteEntryActionTypes,
this.deleteAction(serverID),
undefined,
{ localID, serverID },
);
}
async deleteAction(serverID: ?string): Promise<?DeleteEntryResult> {
if (serverID) {
return await this.props.deleteEntry({
entryID: serverID,
prevText: this.props.entryInfo.text,
calendarQuery: this.props.calendarQuery(),
});
} else if (this.creating) {
this.needsDeleteAfterCreation = true;
}
return null;
}
onPressThreadName: () => void = () => {
Keyboard.dismiss();
this.props.navigateToThread({ threadInfo: this.props.threadInfo });
};
}
const unboundStyles = {
actionLinks: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: -5,
},
button: {
padding: 5,
},
buttonContents: {
flex: 1,
flexDirection: 'row',
},
container: {
backgroundColor: 'listBackground',
},
entry: {
borderRadius: 8,
margin: 5,
overflow: 'hidden',
},
leftLinks: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-start',
paddingHorizontal: 5,
},
leftLinksText: {
fontSize: 12,
fontWeight: 'bold',
paddingLeft: 5,
},
pencilIcon: {
lineHeight: 13,
paddingTop: 1,
},
rightLinks: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: 5,
},
rightLinksText: {
fontSize: 12,
fontWeight: 'bold',
},
text: {
fontFamily: 'System',
fontSize: 16,
},
textContainer: {
position: 'absolute',
top: 0,
paddingBottom: 6,
paddingLeft: 10,
paddingRight: 10,
paddingTop: 5,
transform: (Platform.select({
ios: [{ translateY: -1 / 3 }],
default: [],
}): $ReadOnlyArray<{ +translateY: number }>),
},
textInput: {
fontFamily: 'System',
fontSize: 16,
left: ((Platform.OS === 'android' ? 9.8 : 10): number),
margin: 0,
padding: 0,
position: 'absolute',
right: 10,
top: ((Platform.OS === 'android' ? 4.8 : 0.5): number),
},
};
registerFetchKey(saveEntryActionTypes);
registerFetchKey(deleteEntryActionTypes);
const activeThreadPickerSelector = createIsForegroundSelector(
ThreadPickerModalRouteName,
);
const Entry: React.ComponentType<BaseProps> = React.memo<BaseProps>(
function ConnectedEntry(props: BaseProps) {
const navContext = React.useContext(NavContext);
const threadPickerActive = activeThreadPickerSelector(navContext);
const calendarQuery = useSelector(state =>
nonThreadCalendarQuery({
redux: state,
navContext,
}),
);
const online = useSelector(
state => state.connection.status === 'connected',
);
const styles = useStyles(unboundStyles);
const navigateToThread = useNavigateToThread();
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callCreateEntry = useServerCall(createEntry);
const callSaveEntry = useServerCall(saveEntry);
const callDeleteEntry = useServerCall(deleteEntry);
+ const { threadInfo: unresolvedThreadInfo, ...restProps } = props;
+ const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo);
+
return (
<InternalEntry
- {...props}
+ {...restProps}
threadPickerActive={threadPickerActive}
calendarQuery={calendarQuery}
online={online}
styles={styles}
navigateToThread={navigateToThread}
dispatch={dispatch}
dispatchActionPromise={dispatchActionPromise}
createEntry={callCreateEntry}
saveEntry={callSaveEntry}
deleteEntry={callDeleteEntry}
+ threadInfo={threadInfo}
/>
);
},
);
export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement };

File Metadata

Mime Type
text/x-diff
Expires
Sat, Nov 23, 5:08 AM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2560042
Default Alt Text
(25 KB)

Event Timeline