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);