diff --git a/native/account/modal-components.react.js b/native/account/modal-components.react.js index a5cca651f..81b9f0061 100644 --- a/native/account/modal-components.react.js +++ b/native/account/modal-components.react.js @@ -1,60 +1,60 @@ // @flow import type { AppState } from '../redux/redux-setup'; import * as React from 'react'; import { TextInput as BaseTextInput, View, StyleSheet } from 'react-native'; import invariant from 'invariant'; import { createSelector } from 'reselect'; class TextInput extends React.PureComponent<*> { innerTextInput: ?React.ElementRef; render() { const style = [styles.textInput, this.props.style]; return ( ); } innerTextInputRef = ( innerTextInput: ?React.ElementRef, ) => { this.innerTextInput = innerTextInput; }; focus() { invariant(this.innerTextInput, 'ref should exist'); this.innerTextInput.focus(); } } const styles = StyleSheet.create({ textInput: { color: 'black', fontSize: 20, height: 40, margin: 0, padding: 0, + borderBottomColor: 'transparent', }, textInputWrapperView: { borderBottomColor: '#BBBBBB', borderBottomWidth: 1, }, }); const usernamePlaceholderSelector: (state: AppState) => string = createSelector( (state: AppState) => state.dimensions.width, (windowWidth: number): string => windowWidth < 360 ? 'Username or email' : 'Username or email address', ); export { TextInput, usernamePlaceholderSelector }; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 55b9288b3..82b1bbf11 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,782 +1,781 @@ // @flow import type { EntryInfoWithHeight } from './calendar.react'; import { entryInfoPropType, type CreateEntryInfo, type SaveEntryInfo, type SaveEntryResponse, type CreateEntryPayload, type DeleteEntryInfo, type DeleteEntryResponse, type CalendarQuery, } from 'lib/types/entry-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { threadInfoPropType } from 'lib/types/thread-types'; import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPayload, DispatchActionPromise, } from 'lib/utils/action-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { LayoutEvent } from '../types/react-native'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import * as React from 'react'; import { View, Text, TextInput, Platform, TouchableWithoutFeedback, Alert, LayoutAnimation, Keyboard, } from 'react-native'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import shallowequal from 'shallowequal'; import _omit from 'lodash/fp/omit'; import _isEqual from 'lodash/fp/isEqual'; import Icon from 'react-native-vector-icons/FontAwesome'; import tinycolor from 'tinycolor2'; import { colorIsDark } from 'lib/shared/thread-utils'; import { createEntryActionTypes, createEntry, saveEntryActionTypes, saveEntry, deleteEntryActionTypes, deleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions'; import { connect } from 'lib/utils/redux-utils'; import { ServerError } from 'lib/utils/errors'; import { entryKey } from 'lib/shared/entry-utils'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { dateString } from 'lib/utils/date-utils'; import sleep from 'lib/utils/sleep'; import Button from '../components/button.react'; import { MessageListRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors'; import LoadingIndicator from './loading-indicator.react'; import { colors, styleSelector } from '../themes/colors'; import { connectNav, type NavContextType, } from '../navigation/navigation-context'; import { waitForInteractions } from '../utils/interactions'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; import { SingleLine } from '../components/single-line.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 Props = {| 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, // Redux state calendarQuery: () => CalendarQuery, online: boolean, styles: typeof styles, // Nav state threadPickerActive: boolean, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, 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 { static propTypes = { navigation: PropTypes.shape({ navigate: PropTypes.func.isRequired, goBack: PropTypes.func.isRequired, }).isRequired, entryInfo: entryInfoPropType.isRequired, threadInfo: threadInfoPropType.isRequired, visible: PropTypes.bool.isRequired, active: PropTypes.bool.isRequired, makeActive: PropTypes.func.isRequired, onEnterEditMode: PropTypes.func.isRequired, onConcludeEditMode: PropTypes.func.isRequired, onPressWhitespace: PropTypes.func.isRequired, entryRef: PropTypes.func.isRequired, calendarQuery: PropTypes.func.isRequired, online: PropTypes.bool.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, threadPickerActive: PropTypes.bool.isRequired, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, createEntry: PropTypes.func.isRequired, saveEntry: PropTypes.func.isRequired, deleteEntry: PropTypes.func.isRequired, }; 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.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); } 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; 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.props.makeActive(entryKey(this.props.entryInfo), 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.props.makeActive(entryKey(this.props.entryInfo), 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 }); }; 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.dispatchActionPayload( concurrentModificationResetActionType, { 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 styles = { 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, }, 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, }, }; const stylesSelector = styleSelector(styles); registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry = connectNav((context: ?NavContextType) => ({ navContext: context, threadPickerActive: activeThreadPickerSelector(context), }))( connect( (state: AppState, ownProps: { navContext: ?NavContextType }) => ({ calendarQuery: nonThreadCalendarQuery({ redux: state, navContext: ownProps.navContext, }), online: state.connection.status === 'connected', styles: stylesSelector(state), }), { createEntry, saveEntry, deleteEntry }, )(InternalEntry), ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 8a6836d76..6f912879a 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,819 +1,818 @@ // @flow import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo, threadInfoPropType, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { CalendarQuery } from 'lib/types/entry-types'; import { type KeyboardState, keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import { messageListRoutePropType, messageListNavPropType, } from './message-list-types'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; import type { ChatNavigationProp } from './chat.react'; import type { NavigationRoute } from '../navigation/route-names'; import { NavContext } from '../navigation/navigation-context'; import type { Dispatch } from 'lib/types/redux-types'; import type { ViewStyle } from '../types/styles'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; import FAIcon from 'react-native-vector-icons/FontAwesome'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import Animated, { Easing } from 'react-native-reanimated'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import _throttle from 'lodash/throttle'; import { useDispatch } from 'react-redux'; import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; import { threadHasPermission, viewerIsMember } from 'lib/shared/thread-utils'; import { joinThreadActionTypes, joinThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { trimMessage } from 'lib/shared/message-utils'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors'; import { getKeyboardHeight } from '../keyboard/keyboard'; import { type Colors, colorsPropType, useStyles, useColors, } from '../themes/colors'; import { CameraModalRouteName } from '../navigation/route-names'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import ClearableTextInput from '../components/clearable-text-input.react'; import { runTiming } from '../utils/animation-utils'; import { useSelector } from '../redux/redux-utils'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolate, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 500, easing: Easing.inOut(Easing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), }; const draftKeyFromThreadID = (threadID: string) => `${threadID}/message_composer`; type BaseProps = {| +threadInfo: ThreadInfo, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +colors: Colors, +styles: typeof unboundStyles, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, // withInputState +inputState: ?InputState, |}; type State = {| +text: string, +buttonsExpanded: boolean, |}; class ChatInputBar extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, isActive: PropTypes.bool.isRequired, viewerID: PropTypes.string, draft: PropTypes.string.isRequired, joinThreadLoadingStatus: loadingStatusPropType.isRequired, calendarQuery: PropTypes.func.isRequired, nextLocalID: PropTypes.number.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, keyboardState: keyboardStatePropType, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, joinThread: PropTypes.func.isRequired, inputState: inputStatePropType, }; textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: ViewStyle; cameraRollIconStyle: ViewStyle; cameraIconStyle: ViewStyle; expandIconStyle: ViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, buttonsExpanded: true, }; this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolate(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [22, 60], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; const initialSendButtonContainerOpen = trimMessage(props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolate(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( prevProps, ); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } render() { const isMember = viewerIsMember(this.props.threadInfo); let joinButton = null; if ( !isMember && threadHasPermission(this.props.threadInfo, threadPermissions.JOIN_THREAD) ) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( Join Thread ); } joinButton = ( ); } let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { content = this.renderInput(); } else if (isMember) { content = ( You don't have permission to send messages. ); } else { const defaultRoleID = Object.keys(this.props.threadInfo.roles).find( roleID => this.props.threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = this.props.threadInfo.roles[defaultRoleID]; const membersAreVoiced = !!defaultRole.permissions[ threadPermissions.VOICED ]; if (membersAreVoiced) { content = ( Join this thread to send messages. ); } else { content = ( You don't have permission to send messages. ); } } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; updateText = (text: string) => { this.setState({ text }); this.saveDraft(text); }; saveDraft = _throttle((text: string) => { this.props.dispatch({ type: saveDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), draft: text, }, }); }, 400); focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } invariant(this.textInput, 'textInput should be set in focusAndUpdateText'); this.textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `local${this.props.nextLocalID}`; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage({ type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } openCamera = async () => { this.dismissKeyboard(); this.props.navigation.navigate({ name: CameraModalRouteName, params: { presentedFrom: this.props.route.key, threadID: this.props.threadInfo.id, }, }); }; showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo.id); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: 10, paddingRight: 3, }, cameraRollIcon: { paddingBottom: Platform.OS === 'ios' ? 5 : 8, paddingRight: 8, }, container: { backgroundColor: 'listBackground', }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'ios' ? 10 : 12, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { backgroundColor: 'mintButton', borderRadius: 5, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, paddingBottom: 5, paddingTop: 3, }, joinButtonContainer: { flexDirection: 'row', height: 36, }, joinButtonText: { color: 'listBackground', fontSize: 20, textAlign: 'center', }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: Platform.OS === 'android' ? 3 : 0, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 5, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 10, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginVertical: 5, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); export default React.memo(function ConnectedChatInputBar( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const draftKey = draftKeyFromThreadID(props.threadInfo.id); const draft = useSelector(state => state.drafts[draftKey] || ''); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector(state => state.nextLocalID); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); return ( ); }); diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index 747dbdebf..4eca21280 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,324 +1,324 @@ // @flow import type { AppState } from '../../redux/redux-setup'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, type LeaveThreadPayload, } from 'lib/types/thread-types'; import { type GlobalTheme, globalThemePropType } from '../../types/themes'; import type { ChatNavigationProp } from '../chat.react'; import type { NavigationRoute } from '../../navigation/route-names'; import * as React from 'react'; import PropTypes from 'prop-types'; import { Text, View, TextInput, ScrollView, Alert, ActivityIndicator, } from 'react-native'; import invariant from 'invariant'; import { connect } from 'lib/utils/redux-utils'; import { deleteThreadActionTypes, deleteThread, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils'; import Button from '../../components/button.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; import { withNavContext, type NavContextType, navContextPropType, } from '../../navigation/navigation-context'; import { clearThreadsActionType } from '../../navigation/action-types'; export type DeleteThreadParams = {| threadInfo: ThreadInfo, |}; type Props = {| navigation: ChatNavigationProp<'DeleteThread'>, route: NavigationRoute<'DeleteThread'>, // Redux state threadInfo: ?ThreadInfo, loadingStatus: LoadingStatus, activeTheme: ?GlobalTheme, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs deleteThread: ( threadID: string, currentAccountPassword: string, ) => Promise, // withNavContext navContext: ?NavContextType, |}; type State = {| password: string, |}; class DeleteThread extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ navigate: PropTypes.func.isRequired, setParams: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ threadInfo: threadInfoPropType.isRequired, }).isRequired, }).isRequired, threadInfo: threadInfoPropType, loadingStatus: loadingStatusPropType.isRequired, activeTheme: globalThemePropType, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, deleteThread: PropTypes.func.isRequired, navContext: navContextPropType, }; state = { password: '', }; mounted = false; passwordInput: ?React.ElementRef; static getThreadInfo(props: Props): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } guardedSetState(change, callback) { if (this.mounted) { this.setState(change, callback); } } componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete thread ); const threadInfo = DeleteThread.getThreadInfo(this.props); const { panelForegroundTertiaryLabel } = this.props.colors; return ( {`The thread "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. PASSWORD ); } onChangePasswordText = (newPassword: string) => { this.guardedSetState({ password: newPassword }); }; passwordInputRef = (passwordInput: ?React.ElementRef) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; async deleteThread() { const threadInfo = DeleteThread.getThreadInfo(this.props); const { navContext } = this.props; invariant(navContext, 'navContext should exist in deleteThread'); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread( threadInfo.id, this.state.password, ); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if ( e.message === 'invalid_credentials' || e.message === 'invalid_parameters' ) { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } } } onErrorAlertAcknowledged = () => { this.guardedSetState({ password: '' }, this.focusPasswordInput); }; } const styles = { deleteButton: { backgroundColor: 'redButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, + borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); export default connect( ( state: AppState, ownProps: { route: NavigationRoute<'DeleteThread'>, }, ): * => { const threadID = ownProps.route.params.threadInfo.id; return { threadInfo: threadInfoSelector(state)[threadID], loadingStatus: loadingStatusSelector(state), activeTheme: state.globalThemeInfo.activeTheme, colors: colorsSelector(state), styles: stylesSelector(state), }; }, { deleteThread }, )(withNavContext(DeleteThread)); diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js index 67142749a..a8c813c38 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,305 +1,305 @@ // @flow import { type ThreadInfo, threadInfoPropType, threadPermissions, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { AppState } from '../../redux/redux-setup'; import type { LayoutEvent, ContentSizeChangeEvent, } from '../../types/react-native'; import * as React from 'react'; import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import Icon from 'react-native-vector-icons/FontAwesome'; import { connect } from 'lib/utils/redux-utils'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadHasPermission } from 'lib/shared/thread-utils'; import EditSettingButton from '../../components/edit-setting-button.react'; import SaveSettingButton from './save-setting-button.react'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react'; import Button from '../../components/button.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; type Props = {| threadInfo: ThreadInfo, descriptionEditValue: ?string, setDescriptionEditValue: (value: ?string, callback?: () => void) => void, descriptionTextHeight: ?number, setDescriptionTextHeight: (number: number) => void, canChangeSettings: boolean, // Redux state loadingStatus: LoadingStatus, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsDescription extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, descriptionEditValue: PropTypes.string, setDescriptionEditValue: PropTypes.func.isRequired, descriptionTextHeight: PropTypes.number, setDescriptionTextHeight: PropTypes.func.isRequired, canChangeSettings: PropTypes.bool.isRequired, loadingStatus: loadingStatusPropType.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; textInput: ?React.ElementRef; render() { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } const textInputStyle = {}; if ( this.props.descriptionTextHeight !== undefined && this.props.descriptionTextHeight !== null ) { textInputStyle.height = this.props.descriptionTextHeight; } return ( {button} ); } if (this.props.threadInfo.description) { return ( {this.props.threadInfo.description} ); } const canEditThread = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_THREAD, ); const { panelIosHighlightUnderlay } = this.props.colors; if (canEditThread) { return ( ); } return null; } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.contentSize.height); }; onPressEdit = () => { this.props.setDescriptionEditValue(this.props.threadInfo.description); }; onSubmit = () => { invariant( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined, 'should be set', ); const description = this.props.descriptionEditValue.trim(); if (description === this.props.threadInfo.description) { this.props.setDescriptionEditValue(null); return; } const editDescriptionPromise = this.editDescription(description); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:description`, }, ); editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription(newDescription: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { description: newDescription }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setDescriptionEditValue( this.props.threadInfo.description, () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }, ); }; } const styles = { addDescriptionButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, addDescriptionText: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 16, }, editIcon: { color: 'panelForegroundTertiaryLabel', paddingLeft: 10, textAlign: 'right', }, outlineCategory: { backgroundColor: 'panelSecondaryForeground', borderColor: 'panelSecondaryForegroundBorder', borderRadius: 1, borderStyle: 'dashed', borderWidth: 1, marginLeft: -1, marginRight: -1, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, padding: 0, + borderBottomColor: 'transparent', }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); export default connect( (state: AppState) => ({ loadingStatus: loadingStatusSelector(state), colors: colorsSelector(state), styles: stylesSelector(state), }), { changeThreadSettings }, )(ThreadSettingsDescription); diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js index 5e2c59d13..873097921 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,249 +1,249 @@ // @flow import { type ThreadInfo, threadInfoPropType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { AppState } from '../../redux/redux-setup'; import type { LayoutEvent, ContentSizeChangeEvent, } from '../../types/react-native'; import * as React from 'react'; import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import { connect } from 'lib/utils/redux-utils'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import EditSettingButton from '../../components/edit-setting-button.react'; import SaveSettingButton from './save-setting-button.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; type Props = {| threadInfo: ThreadInfo, nameEditValue: ?string, setNameEditValue: (value: ?string, callback?: () => void) => void, nameTextHeight: ?number, setNameTextHeight: (number: number) => void, canChangeSettings: boolean, // Redux state loadingStatus: LoadingStatus, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsName extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, nameEditValue: PropTypes.string, setNameEditValue: PropTypes.func.isRequired, nameTextHeight: PropTypes.number, setNameTextHeight: PropTypes.func.isRequired, canChangeSettings: PropTypes.bool.isRequired, loadingStatus: loadingStatusPropType.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; textInput: ?React.ElementRef; render() { return ( Name {this.renderContent()} ); } renderContent() { if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( {this.props.threadInfo.uiName} ); } let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } const textInputStyle = {}; if ( this.props.nameTextHeight !== undefined && this.props.nameTextHeight !== null ) { textInputStyle.height = this.props.nameTextHeight; } return ( {button} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setNameTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setNameTextHeight(event.nativeEvent.contentSize.height); }; threadEditName() { return this.props.threadInfo.name ? this.props.threadInfo.name : ''; } onPressEdit = () => { this.props.setNameEditValue(this.threadEditName()); }; onSubmit = () => { invariant( this.props.nameEditValue !== null && this.props.nameEditValue !== undefined, 'should be set', ); const name = this.props.nameEditValue.trim(); if (name === this.threadEditName()) { this.props.setNameEditValue(null); return; } const editNamePromise = this.editName(name); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:name` }, ); editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setNameEditValue(this.threadEditName(), () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }); }; } const styles = { currentValue: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, + borderBottomColor: 'transparent', }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); export default connect( (state: AppState) => ({ loadingStatus: loadingStatusSelector(state), colors: colorsSelector(state), styles: stylesSelector(state), }), { changeThreadSettings }, )(ThreadSettingsName); diff --git a/native/components/search.react.js b/native/components/search.react.js index 642d09319..7ef61b374 100644 --- a/native/components/search.react.js +++ b/native/components/search.react.js @@ -1,136 +1,136 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { ViewStyle } from '../types/styles'; import * as React from 'react'; import PropTypes from 'prop-types'; import { View, ViewPropTypes, TouchableOpacity, TextInput } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import { connect } from 'lib/utils/redux-utils'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; type Props = {| ...React.ElementConfig, searchText: string, onChangeText: (searchText: string) => void, containerStyle?: ViewStyle, textInputRef?: React.Ref, // Redux state colors: Colors, styles: typeof styles, loggedIn: boolean, |}; class Search extends React.PureComponent { static propTypes = { searchText: PropTypes.string.isRequired, onChangeText: PropTypes.func.isRequired, containerStyle: ViewPropTypes.style, textInputRef: PropTypes.func, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, loggedIn: PropTypes.bool.isRequired, }; componentDidUpdate(prevProps: Props) { if (!this.props.loggedIn && prevProps.loggedIn) { this.clearSearch(); } } render() { const { searchText, onChangeText, containerStyle, textInputRef, colors, styles, loggedIn, ...rest } = this.props; const { listSearchIcon: iconColor } = colors; let clearSearchInputIcon = null; if (searchText) { clearSearchInputIcon = ( ); } const textInputProps: React.ElementProps = { style: styles.searchInput, - underlineColorAndroid: 'transparent', value: searchText, onChangeText: onChangeText, placeholderTextColor: iconColor, returnKeyType: 'go', }; return ( {clearSearchInputIcon} ); } clearSearch = () => { this.props.onChangeText(''); }; } const styles = { search: { alignItems: 'center', backgroundColor: 'listSearchBackground', borderRadius: 6, flexDirection: 'row', paddingLeft: 14, paddingRight: 12, paddingVertical: 6, }, searchInput: { color: 'listForegroundLabel', flex: 1, fontSize: 16, marginLeft: 8, marginVertical: 0, padding: 0, + borderBottomColor: 'transparent', }, }; const stylesSelector = styleSelector(styles); const ConnectedSearch = connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), loggedIn: isLoggedIn(state), }))(Search); type ConnectedProps = $Diff< Props, {| colors: Colors, styles: typeof styles, loggedIn: boolean, |}, >; export default React.forwardRef( function ForwardedConnectedSearch( props: ConnectedProps, ref: React.Ref, ) { return ; }, ); diff --git a/native/components/tag-input.react.js b/native/components/tag-input.react.js index f5f08b232..12f7b8238 100644 --- a/native/components/tag-input.react.js +++ b/native/components/tag-input.react.js @@ -1,493 +1,493 @@ // @flow import type { ViewStyle, TextStyle } from '../types/styles'; import type { AppState } from '../redux/redux-setup'; import type { LayoutEvent } from '../types/react-native'; import * as React from 'react'; import PropTypes from 'prop-types'; import { View, Text, TextInput, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, ScrollView, ViewPropTypes, Platform, } from 'react-native'; import invariant from 'invariant'; import { connect } from 'lib/utils/redux-utils'; import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; type Props = {| /** * An array of tags, which can be any type, as long as labelExtractor below * can extract a string from it. */ value: $ReadOnlyArray, /** * A handler to be called when array of tags change. */ onChange: (items: $ReadOnlyArray) => void, /** * Function to extract string value for label from item */ labelExtractor: (tagData: T) => string, /** * The text currently being displayed in the TextInput following the list of * tags. */ text: string, /** * This callback gets called when the user in the TextInput. The caller should * update the text prop when this is called if they want to access input. */ onChangeText: (text: string) => void, /** * If `true`, text and tags are not editable. The default value is `false`. */ disabled?: boolean, /** * Background color of tags */ tagColor?: string, /** * Text color of tags */ tagTextColor?: string, /** * Styling override for container surrounding tag text */ tagContainerStyle?: ViewStyle, /** * Styling override for tag's text component */ tagTextStyle?: TextStyle, /** * Color of text input */ inputColor?: string, /** * Any misc. TextInput props (autoFocus, placeholder, returnKeyType, etc.) */ inputProps?: React.ElementConfig, /** * Min height of the tag input on screen */ minHeight: number, /** * Max height of the tag input on screen (will scroll if max height reached) */ maxHeight: number, /** * Callback that gets passed the new component height when it changes */ onHeightChange?: (height: number) => void, /** * inputWidth if text === "". we want this number explicitly because if we're * forced to measure the component, there can be a short jump between the old * value and the new value, which looks sketchy. */ defaultInputWidth: number, innerRef?: (tagInput: ?TagInput) => void, // Redux state windowWidth: number, colors: Colors, |}; type State = {| wrapperHeight: number, contentHeight: number, wrapperWidth: number, spaceLeft: number, |}; class TagInput extends React.PureComponent, State> { static propTypes = { value: PropTypes.array.isRequired, onChange: PropTypes.func.isRequired, labelExtractor: PropTypes.func.isRequired, text: PropTypes.string.isRequired, onChangeText: PropTypes.func.isRequired, tagColor: PropTypes.string, tagTextColor: PropTypes.string, tagContainerStyle: ViewPropTypes.style, tagTextStyle: Text.propTypes.style, inputColor: PropTypes.string, inputProps: PropTypes.shape(TextInput.propTypes), minHeight: PropTypes.number, maxHeight: PropTypes.number, onHeightChange: PropTypes.func, defaultInputWidth: PropTypes.number, innerRef: PropTypes.func, windowWidth: PropTypes.number.isRequired, colors: colorsPropType.isRequired, }; // scroll to bottom scrollViewHeight = 0; scrollToBottomAfterNextScrollViewLayout = false; // refs tagInput: ?React.ElementRef = null; scrollView: ?React.ElementRef = null; lastChange: ?{| time: number, prevText: string |}; static defaultProps = { minHeight: 30, maxHeight: 75, defaultInputWidth: 90, }; constructor(props: Props) { super(props); this.state = { wrapperHeight: 30, // was wrapperHeight: 36, contentHeight: 0, wrapperWidth: props.windowWidth, spaceLeft: 0, }; } componentDidMount() { if (this.props.innerRef) { this.props.innerRef(this); } } componentWillUnmount() { if (this.props.innerRef) { this.props.innerRef(null); } } static getDerivedStateFromProps(props: Props, state: State) { const wrapperHeight = Math.max( Math.min(props.maxHeight, state.contentHeight), props.minHeight, ); return { wrapperHeight }; } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.props.onHeightChange && this.state.wrapperHeight !== prevState.wrapperHeight ) { this.props.onHeightChange(this.state.wrapperHeight); } } measureWrapper = (event: LayoutEvent) => { const wrapperWidth = event.nativeEvent.layout.width; if (wrapperWidth !== this.state.wrapperWidth) { this.setState({ wrapperWidth }); } }; onChangeText = (text: string) => { this.lastChange = { time: Date.now(), prevText: this.props.text }; this.props.onChangeText(text); }; onBlur = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ target: number }> }>, ) => { invariant(Platform.OS === 'ios', 'only iOS gets text on TextInput.onBlur'); const nativeEvent: $ReadOnly<{ target: number, text: string, }> = (event.nativeEvent: any); this.onChangeText(nativeEvent.text); }; onKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { lastChange } = this; let { text } = this.props; if ( Platform.OS === 'android' && lastChange !== null && lastChange !== undefined && Date.now() - lastChange.time < 150 ) { text = lastChange.prevText; } if (text !== '' || event.nativeEvent.key !== 'Backspace') { return; } const tags = [...this.props.value]; tags.pop(); this.props.onChange(tags); this.focus(); }; focus = () => { invariant(this.tagInput, 'should be set'); this.tagInput.focus(); }; removeIndex = (index: number) => { const tags = [...this.props.value]; tags.splice(index, 1); this.props.onChange(tags); }; scrollToBottom = () => { const scrollView = this.scrollView; invariant( scrollView, 'this.scrollView ref should exist before scrollToBottom called', ); scrollView.scrollToEnd(); }; render() { const tagColor = this.props.tagColor || this.props.colors.modalSubtext; const tagTextColor = this.props.tagTextColor || this.props.colors.modalForegroundLabel; const inputColor = this.props.inputColor || this.props.colors.modalForegroundLabel; const placeholderColor = this.props.colors.modalForegroundTertiaryLabel; const tags = this.props.value.map((tag, index) => ( )); let inputWidth; if (this.props.text === '') { inputWidth = this.props.defaultInputWidth; } else if (this.state.spaceLeft >= 100) { inputWidth = this.state.spaceLeft - 10; } else { inputWidth = this.state.wrapperWidth; } const defaultTextInputProps: React.ElementConfig = { blurOnSubmit: false, style: [ styles.textInput, { width: inputWidth, color: inputColor, }, ], autoCapitalize: 'none', autoCorrect: false, placeholder: 'Start typing', placeholderTextColor: placeholderColor, returnKeyType: 'done', keyboardType: 'default', - underlineColorAndroid: 'rgba(0,0,0,0)', }; const textInputProps: React.ElementConfig = { ...defaultTextInputProps, ...this.props.inputProps, // should not be overridden onKeyPress: this.onKeyPress, value: this.props.text, onBlur: Platform.OS === 'ios' ? this.onBlur : undefined, onChangeText: this.onChangeText, editable: !this.props.disabled, }; return ( {tags} ); } tagInputRef = (tagInput: ?React.ElementRef) => { this.tagInput = tagInput; }; scrollViewRef = (scrollView: ?React.ElementRef) => { this.scrollView = scrollView; }; onScrollViewContentSizeChange = (w: number, h: number) => { const oldContentHeight = this.state.contentHeight; if (h === oldContentHeight) { return; } let callback; if (h > oldContentHeight) { callback = () => { if (this.scrollViewHeight === this.props.maxHeight) { this.scrollToBottom(); } else { this.scrollToBottomAfterNextScrollViewLayout = true; } }; } this.setState({ contentHeight: h }, callback); }; onScrollViewLayout = (event: LayoutEvent) => { this.scrollViewHeight = event.nativeEvent.layout.height; if (this.scrollToBottomAfterNextScrollViewLayout) { this.scrollToBottom(); this.scrollToBottomAfterNextScrollViewLayout = false; } }; onLayoutLastTag = (endPosOfTag: number) => { const margin = 3; const spaceLeft = this.state.wrapperWidth - endPosOfTag - margin - 10; if (spaceLeft !== this.state.spaceLeft) { this.setState({ spaceLeft }); } }; } type TagProps = {| index: number, label: string, isLastTag: boolean, onLayoutLastTag: (endPosOfTag: number) => void, removeIndex: (index: number) => void, tagColor: string, tagTextColor: string, tagContainerStyle?: ViewStyle, tagTextStyle?: TextStyle, disabled?: boolean, |}; class Tag extends React.PureComponent { static propTypes = { index: PropTypes.number.isRequired, label: PropTypes.string.isRequired, isLastTag: PropTypes.bool.isRequired, onLayoutLastTag: PropTypes.func.isRequired, removeIndex: PropTypes.func.isRequired, tagColor: PropTypes.string.isRequired, tagTextColor: PropTypes.string.isRequired, tagContainerStyle: ViewPropTypes.style, tagTextStyle: Text.propTypes.style, }; curPos: ?number = null; componentDidUpdate(prevProps: TagProps) { if ( !prevProps.isLastTag && this.props.isLastTag && this.curPos !== null && this.curPos !== undefined ) { this.props.onLayoutLastTag(this.curPos); } } render() { return ( {this.props.label}  × ); } onPress = () => { this.props.removeIndex(this.props.index); }; onLayoutLastTag = (event: LayoutEvent) => { const layout = event.nativeEvent.layout; this.curPos = layout.width + layout.x; if (this.props.isLastTag) { this.props.onLayoutLastTag(this.curPos); } }; } const styles = StyleSheet.create({ tag: { borderRadius: 2, justifyContent: 'center', marginBottom: 3, marginRight: 3, paddingHorizontal: 6, paddingVertical: 2, }, tagInputContainer: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', }, tagInputContainerScroll: { flex: 1, }, tagText: { fontSize: 16, margin: 0, padding: 0, }, textInput: { flex: 0.6, fontSize: 16, height: 24, marginBottom: 3, marginHorizontal: 0, marginTop: 3, padding: 0, + borderBottomColor: 'transparent', }, textInputContainer: {}, wrapper: {}, }); export default connect((state: AppState) => ({ windowWidth: state.dimensions.width, colors: colorsSelector(state), }))(TagInput); diff --git a/native/more/custom-server-modal.react.js b/native/more/custom-server-modal.react.js index 6b7e5917f..8aaffa863 100644 --- a/native/more/custom-server-modal.react.js +++ b/native/more/custom-server-modal.react.js @@ -1,130 +1,130 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPayload } from 'lib/utils/action-utils'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import * as React from 'react'; import { Text, TextInput } from 'react-native'; import PropTypes from 'prop-types'; import { connect } from 'lib/utils/redux-utils'; import { setURLPrefix } from 'lib/utils/url-utils'; import Button from '../components/button.react'; import Modal from '../components/modal.react'; import { setCustomServer } from '../utils/url-utils'; import { styleSelector } from '../themes/colors'; export type CustomServerModalParams = {| presentedFrom: string, |}; type Props = {| navigation: RootNavigationProp<'CustomServerModal'>, // Redux state urlPrefix: string, customServer: ?string, styles: typeof styles, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, |}; type State = {| customServer: string, |}; class CustomServerModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, urlPrefix: PropTypes.string.isRequired, customServer: PropTypes.string, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; constructor(props: Props) { super(props); const { customServer } = props; this.state = { customServer: customServer ? customServer : '', }; } render() { return ( ); } onChangeCustomServer = (newCustomServer: string) => { this.setState({ customServer: newCustomServer }); }; onPressGo = () => { const { customServer } = this.state; if (customServer !== this.props.urlPrefix) { this.props.dispatchActionPayload(setURLPrefix, customServer); } if (customServer && customServer !== this.props.customServer) { this.props.dispatchActionPayload(setCustomServer, customServer); } this.props.navigation.goBackOnce(); }; } const styles = { button: { backgroundColor: 'greenButton', borderRadius: 5, marginHorizontal: 2, marginVertical: 2, paddingHorizontal: 12, paddingVertical: 4, }, buttonText: { color: 'white', fontSize: 18, textAlign: 'center', }, container: { justifyContent: 'flex-end', }, modal: { flex: 0, flexDirection: 'row', }, textInput: { color: 'modalBackgroundLabel', flex: 1, fontSize: 16, margin: 0, padding: 0, + borderBottomColor: 'transparent', }, }; const stylesSelector = styleSelector(styles); export default connect( (state: AppState) => ({ urlPrefix: state.urlPrefix, customServer: state.customServer, styles: stylesSelector(state), }), null, true, )(CustomServerModal); diff --git a/native/more/delete-account.react.js b/native/more/delete-account.react.js index 092f10b93..19fc2810f 100644 --- a/native/more/delete-account.react.js +++ b/native/more/delete-account.react.js @@ -1,270 +1,270 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LogOutResult } from 'lib/types/account-types'; import { type GlobalTheme, globalThemePropType } from '../types/themes'; import { type PreRequestUserState, preRequestUserStatePropType, } from 'lib/types/session-types'; import * as React from 'react'; import PropTypes from 'prop-types'; import { Text, View, TextInput, ScrollView, Alert, ActivityIndicator, } from 'react-native'; import invariant from 'invariant'; import { connect } from 'lib/utils/redux-utils'; import { deleteAccountActionTypes, deleteAccount, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import Button from '../components/button.react'; import { deleteNativeCredentialsFor } from '../account/native-credentials'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; type Props = {| // Redux state loadingStatus: LoadingStatus, username: ?string, preRequestUserState: PreRequestUserState, activeTheme: ?GlobalTheme, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs deleteAccount: ( password: string, preRequestUserState: PreRequestUserState, ) => Promise, |}; type State = {| password: string, |}; class DeleteAccount extends React.PureComponent { static propTypes = { loadingStatus: loadingStatusPropType.isRequired, username: PropTypes.string, preRequestUserState: preRequestUserStatePropType.isRequired, activeTheme: globalThemePropType, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, deleteAccount: PropTypes.func.isRequired, }; state = { password: '', }; mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete account ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( Your account will be permanently deleted. There is no way to reverse this. PASSWORD ); } onChangePasswordText = (newPassword: string) => { this.setState({ password: newPassword }); }; passwordInputRef = (passwordInput: ?React.ElementRef) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteAccountActionTypes, this.deleteAccount(), ); }; async deleteAccount() { try { if (this.props.username) { await deleteNativeCredentialsFor(this.props.username); } const result = await this.props.deleteAccount( this.state.password, this.props.preRequestUserState, ); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } } } onErrorAlertAcknowledged = () => { this.setState({ password: '' }, this.focusPasswordInput); }; } const styles = { deleteButton: { backgroundColor: 'redButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, + borderBottomColor: 'transparent', }, lastWarningText: { marginBottom: 24, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginHorizontal: 24, textAlign: 'center', }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( deleteAccountActionTypes, ); export default connect( (state: AppState) => ({ loadingStatus: loadingStatusSelector(state), username: state.currentUserInfo && !state.currentUserInfo.anonymous ? state.currentUserInfo.username : undefined, preRequestUserState: preRequestUserStateSelector(state), activeTheme: state.globalThemeInfo.activeTheme, colors: colorsSelector(state), styles: stylesSelector(state), }), { deleteAccount }, )(DeleteAccount); diff --git a/native/more/edit-email.react.js b/native/more/edit-email.react.js index fbbc698da..ea4df0580 100644 --- a/native/more/edit-email.react.js +++ b/native/more/edit-email.react.js @@ -1,323 +1,322 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { AccountUpdate } from 'lib/types/user-types'; import type { ChangeUserSettingsResult } from 'lib/types/account-types'; import { type GlobalTheme, globalThemePropType } from '../types/themes'; import type { MoreNavigationProp } from './more.react'; import * as React from 'react'; import PropTypes from 'prop-types'; import { Text, View, TextInput, ScrollView, Alert, ActivityIndicator, } from 'react-native'; import invariant from 'invariant'; import { CommonActions } from '@react-navigation/native'; import { connect } from 'lib/utils/redux-utils'; import { changeUserSettingsActionTypes, changeUserSettings, } from 'lib/actions/user-actions'; import { validEmailRegex } from 'lib/shared/account-utils'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import Button from '../components/button.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; type Props = {| navigation: MoreNavigationProp<'EditEmail'>, // Redux state email: ?string, loadingStatus: LoadingStatus, activeTheme: ?GlobalTheme, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeUserSettings: ( accountUpdate: AccountUpdate, ) => Promise, |}; type State = {| email: string, password: string, |}; class EditEmail extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ dispatch: PropTypes.func.isRequired, }).isRequired, email: PropTypes.string, loadingStatus: loadingStatusPropType.isRequired, activeTheme: globalThemePropType, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeUserSettings: PropTypes.func.isRequired, }; mounted = false; passwordInput: ?React.ElementRef; emailInput: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { email: props.email ? props.email : '', password: '', }; } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Save ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( EMAIL PASSWORD ); } onChangeEmailText = (newEmail: string) => { this.setState({ email: newEmail }); }; emailInputRef = (emailInput: ?React.ElementRef) => { this.emailInput = emailInput; }; focusEmailInput = () => { invariant(this.emailInput, 'emailInput should be set'); this.emailInput.focus(); }; onChangePasswordText = (newPassword: string) => { this.setState({ password: newPassword }); }; passwordInputRef = (passwordInput: ?React.ElementRef) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; goBackOnce() { this.props.navigation.dispatch(state => ({ ...CommonActions.goBack(), target: state.key, })); } submitEmail = () => { if (this.state.email.search(validEmailRegex) === -1) { Alert.alert( 'Invalid email address', 'Valid email addresses only', [{ text: 'OK', onPress: this.onEmailAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.password === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.email === this.props.email) { this.goBackOnce(); } else { this.props.dispatchActionPromise( changeUserSettingsActionTypes, this.saveEmail(), ); } }; async saveEmail() { try { const result = await this.props.changeUserSettings({ updatedFields: { email: this.state.email, }, currentPassword: this.state.password, }); this.goBackOnce(); Alert.alert( 'Verify email', "We've sent you an email to verify your email address. Just click on " + 'the link in the email to complete the verification process.', undefined, { cancelable: true }, ); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } } } onEmailAlertAcknowledged = () => { const resetEmail = this.props.email ? this.props.email : ''; this.setState({ email: resetEmail }, this.focusEmailInput); }; onPasswordAlertAcknowledged = () => { this.setState({ password: '' }, this.focusPasswordInput); }; onUnknownErrorAlertAcknowledged = () => { const resetEmail = this.props.email ? this.props.email : ''; this.setState({ email: resetEmail, password: '' }, this.focusEmailInput); }; } const styles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, + borderBottomColor: 'transparent', }, saveButton: { backgroundColor: 'greenButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( changeUserSettingsActionTypes, ); export default connect( (state: AppState) => ({ email: state.currentUserInfo && !state.currentUserInfo.anonymous ? state.currentUserInfo.email : undefined, loadingStatus: loadingStatusSelector(state), activeTheme: state.globalThemeInfo.activeTheme, colors: colorsSelector(state), styles: stylesSelector(state), }), { changeUserSettings }, )(EditEmail); diff --git a/native/more/edit-password.react.js b/native/more/edit-password.react.js index 424b6cb87..9755bdc75 100644 --- a/native/more/edit-password.react.js +++ b/native/more/edit-password.react.js @@ -1,361 +1,359 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { AccountUpdate } from 'lib/types/user-types'; import type { ChangeUserSettingsResult } from 'lib/types/account-types'; import { type GlobalTheme, globalThemePropType } from '../types/themes'; import type { MoreNavigationProp } from './more.react'; import * as React from 'react'; import PropTypes from 'prop-types'; import { Text, View, TextInput, ScrollView, Alert, ActivityIndicator, } from 'react-native'; import invariant from 'invariant'; import { CommonActions } from '@react-navigation/native'; import { connect } from 'lib/utils/redux-utils'; import { changeUserSettingsActionTypes, changeUserSettings, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import Button from '../components/button.react'; import { setNativeCredentials } from '../account/native-credentials'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; type Props = { navigation: MoreNavigationProp<'EditPassword'>, // Redux state loadingStatus: LoadingStatus, activeTheme: ?GlobalTheme, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeUserSettings: ( accountUpdate: AccountUpdate, ) => Promise, }; type State = {| currentPassword: string, newPassword: string, confirmPassword: string, |}; class EditPassword extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ dispatch: PropTypes.func.isRequired, }).isRequired, loadingStatus: loadingStatusPropType.isRequired, activeTheme: globalThemePropType, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeUserSettings: PropTypes.func.isRequired, }; state = { currentPassword: '', newPassword: '', confirmPassword: '', }; mounted = false; currentPasswordInput: ?React.ElementRef; newPasswordInput: ?React.ElementRef; confirmPasswordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Save ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( CURRENT PASSWORD NEW PASSWORD ); } onChangeCurrentPassword = (currentPassword: string) => { this.setState({ currentPassword }); }; currentPasswordRef = ( currentPasswordInput: ?React.ElementRef, ) => { this.currentPasswordInput = currentPasswordInput; }; focusCurrentPassword = () => { invariant(this.currentPasswordInput, 'currentPasswordInput should be set'); this.currentPasswordInput.focus(); }; onChangeNewPassword = (newPassword: string) => { this.setState({ newPassword }); }; newPasswordRef = (newPasswordInput: ?React.ElementRef) => { this.newPasswordInput = newPasswordInput; }; focusNewPassword = () => { invariant(this.newPasswordInput, 'newPasswordInput should be set'); this.newPasswordInput.focus(); }; onChangeConfirmPassword = (confirmPassword: string) => { this.setState({ confirmPassword }); }; confirmPasswordRef = ( confirmPasswordInput: ?React.ElementRef, ) => { this.confirmPasswordInput = confirmPasswordInput; }; focusConfirmPassword = () => { invariant(this.confirmPasswordInput, 'confirmPasswordInput should be set'); this.confirmPasswordInput.focus(); }; goBackOnce() { this.props.navigation.dispatch(state => ({ ...CommonActions.goBack(), target: state.key, })); } submitPassword = () => { if (this.state.newPassword === '') { Alert.alert( 'Empty password', 'New password cannot be empty', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword !== this.state.confirmPassword) { Alert.alert( "Passwords don't match", 'New password fields must contain the same password', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword === this.state.currentPassword) { this.goBackOnce(); } else { this.props.dispatchActionPromise( changeUserSettingsActionTypes, this.savePassword(), ); } }; async savePassword() { try { const result = await this.props.changeUserSettings({ updatedFields: { password: this.state.newPassword, }, currentPassword: this.state.currentPassword, }); await setNativeCredentials({ password: this.state.newPassword, }); this.goBackOnce(); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The current password you entered is incorrect', [{ text: 'OK', onPress: this.onCurrentPasswordAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } } } onNewPasswordAlertAcknowledged = () => { this.setState( { newPassword: '', confirmPassword: '' }, this.focusNewPassword, ); }; onCurrentPasswordAlertAcknowledged = () => { this.setState({ currentPassword: '' }, this.focusCurrentPassword); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentPassword: '', newPassword: '', confirmPassword: '' }, this.focusCurrentPassword, ); }; } const styles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, + borderBottomColor: 'transparent', }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 9, }, saveButton: { backgroundColor: 'greenButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 3, }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( changeUserSettingsActionTypes, ); export default connect( (state: AppState) => ({ loadingStatus: loadingStatusSelector(state), activeTheme: state.globalThemeInfo.activeTheme, colors: colorsSelector(state), styles: stylesSelector(state), }), { changeUserSettings }, )(EditPassword);