diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index b74311322..7147a1942 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,677 +1,677 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { View, StyleSheet, Text, TouchableOpacity, Image, Keyboard, Platform, BackHandler, ActivityIndicator, } from 'react-native'; import Animated, { EasingNode } from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; -import Icon from '@expo/vector-icons/FontAwesome'; import { useDispatch } from 'react-redux'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { logInActionSources } from 'lib/types/account-types'; import type { Dispatch } from 'lib/types/redux-types'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { LoggedOutModalRouteName } from '../navigation/route-names'; import { resetUserStateActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { splashStyleSelector } from '../splash'; import type { EventSubscription, KeyboardEvent } from '../types/react-native'; import type { ImageStyle } from '../types/styles'; import { runTiming, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; import { type StateContainer, type StateChange, setStateForContainer, } from '../utils/state-container'; import { splashBackgroundURI } from './background-info'; import LogInPanel from './log-in-panel.react'; import type { LogInState } from './log-in-panel.react'; import LoggedOutStaffInfo from './logged-out-staff-info.react'; import RegisterPanel from './register-panel.react'; import type { RegisterState } from './register-panel.react'; import SIWEPanel from './siwe-panel.react'; let initialAppLoad = true; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ type LoggedOutMode = 'loading' | 'prompt' | 'log-in' | 'register' | 'siwe'; const modeNumbers: { [LoggedOutMode]: number } = { 'loading': 0, 'prompt': 1, 'log-in': 2, 'register': 3, 'siwe': 4, }; function isPastPrompt(modeValue: Node) { return and( neq(modeValue, modeNumbers['loading']), neq(modeValue, modeNumbers['prompt']), ); } type Props = { // Navigation state +isForeground: boolean, // Redux state +persistedStateLoaded: boolean, +rehydrateConcluded: boolean, +cookie: ?string, +urlPrefix: string, +loggedIn: boolean, +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, // Redux dispatch functions +dispatch: Dispatch, ... }; type State = { +mode: LoggedOutMode, +logInState: StateContainer, +registerState: StateContainer, }; class LoggedOutModal extends React.PureComponent { keyboardShowListener: ?EventSubscription; keyboardHideListener: ?EventSubscription; mounted = false; nextMode: LoggedOutMode = 'loading'; activeAlert = false; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; buttonOpacity: Value; panelPaddingTopValue: Node; panelOpacityValue: Node; constructor(props: Props) { super(props); // Man, this is a lot of boilerplate just to containerize some state. // Mostly due to Flow typing requirements... const setLogInState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ logInState: { ...fullState.logInState, state: { ...fullState.logInState.state, ...change }, }, }), ); const setRegisterState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ registerState: { ...fullState.registerState, state: { ...fullState.registerState.state, ...change }, }, }), ); this.state = { mode: props.persistedStateLoaded ? 'prompt' : 'loading', logInState: { state: { usernameInputText: null, passwordInputText: null, }, setState: setLogInState, }, registerState: { state: { usernameInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, setState: setRegisterState, }, }; if (props.persistedStateLoaded) { this.nextMode = 'prompt'; } this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.buttonOpacity = new Value(props.persistedStateLoaded ? 1 : 0); this.panelPaddingTopValue = this.panelPaddingTop(); this.panelOpacityValue = this.panelOpacity(); } guardedSetState = (change: StateChange, callback?: () => mixed) => { if (this.mounted) { this.setState(change, callback); } }; setMode(newMode: LoggedOutMode) { this.nextMode = newMode; this.guardedSetState({ mode: newMode }); this.modeValue.setValue(modeNumbers[newMode]); } proceedToNextMode = () => { this.guardedSetState({ mode: this.nextMode }); }; componentDidMount() { this.mounted = true; if (this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (this.props.isForeground) { this.onForeground(); } } componentWillUnmount() { this.mounted = false; if (this.props.isForeground) { this.onBackground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (!prevProps.persistedStateLoaded && this.props.persistedStateLoaded) { this.setMode('prompt'); } if (!prevProps.rehydrateConcluded && this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (!prevProps.isForeground && this.props.isForeground) { this.onForeground(); } else if (prevProps.isForeground && !this.props.isForeground) { this.onBackground(); } if (this.state.mode === 'prompt' && prevState.mode !== 'prompt') { this.buttonOpacity.setValue(0); Animated.timing(this.buttonOpacity, { easing: EasingNode.out(EasingNode.ease), duration: 250, toValue: 1.0, }).start(); } const newContentHeight = this.props.dimensions.safeAreaHeight; const oldContentHeight = prevProps.dimensions.safeAreaHeight; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); BackHandler.addEventListener('hardwareBackPress', this.hardwareBack); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack); } // This gets triggered when an app is killed and restarted // Not when it is returned from being backgrounded async onInitialAppLoad() { if (!initialAppLoad) { return; } initialAppLoad = false; const { loggedIn, cookie, urlPrefix, dispatch } = this.props; const hasUserCookie = cookie && cookie.startsWith('user='); if (loggedIn === !!hasUserCookie) { return; } if (!__DEV__) { const actionSource = loggedIn ? logInActionSources.appStartReduxLoggedInButInvalidCookie : logInActionSources.appStartCookieLoggedInButInvalidRedux; const sessionChange = await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, actionSource, ); if ( sessionChange && sessionChange.cookie && sessionChange.cookie.startsWith('user=') ) { // success! we can expect subsequent actions to fix up the state return; } } this.props.dispatch({ type: resetUserStateActionType }); } hardwareBack = () => { if (this.nextMode !== 'prompt') { this.goBackToPrompt(); return true; } return false; }; panelPaddingTop() { const headerHeight = Platform.OS === 'ios' ? 62.33 : 58.54; const promptButtonsSize = Platform.OS === 'ios' ? 40 : 61; const logInContainerSize = 140; const registerPanelSize = Platform.OS === 'ios' ? 181 : 180; const containerSize = add( headerHeight, cond(not(isPastPrompt(this.modeValue)), promptButtonsSize, 0), cond(eq(this.modeValue, modeNumbers['log-in']), logInContainerSize, 0), cond(eq(this.modeValue, modeNumbers['register']), registerPanelSize, 0), ); const potentialPanelPaddingTop = divide( max(sub(this.contentHeight, this.keyboardHeightValue, containerSize), 0), 2, ); const panelPaddingTop = new Value(-1); const targetPanelPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[this.nextMode]); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(panelPaddingTop, 0), [ set(panelPaddingTop, potentialPanelPaddingTop), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( lessThan(this.keyboardHeightValue, 0), [ runTiming(keyboardTimeoutClock, 0, 1, true, { duration: 500 }), cond( not(clockRunning(keyboardTimeoutClock)), set(this.keyboardHeightValue, 0), ), ], stopClock(keyboardTimeoutClock), ), cond( and( greaterOrEq(this.keyboardHeightValue, 0), neq(prevModeValue, this.modeValue), ), [ stopClock(clock), cond( neq(isPastPrompt(prevModeValue), isPastPrompt(this.modeValue)), set(targetPanelPaddingTop, potentialPanelPaddingTop), ), set(prevModeValue, this.modeValue), ], ), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( neq(panelPaddingTop, targetPanelPaddingTop), set( panelPaddingTop, runTiming(clock, panelPaddingTop, targetPanelPaddingTop), ), ), panelPaddingTop, ]); } panelOpacity() { const targetPanelOpacity = isPastPrompt(this.modeValue); const panelOpacity = new Value(-1); const prevPanelOpacity = new Value(-1); const prevTargetPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(panelOpacity, 0), [ set(panelOpacity, targetPanelOpacity), set(prevPanelOpacity, targetPanelOpacity), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond(neq(targetPanelOpacity, prevTargetPanelOpacity), [ stopClock(clock), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond( neq(panelOpacity, targetPanelOpacity), set(panelOpacity, runTiming(clock, panelOpacity, targetPanelOpacity)), ), ]), cond( and(eq(panelOpacity, 0), neq(prevPanelOpacity, 0)), call([], this.proceedToNextMode), ), set(prevPanelOpacity, panelOpacity), panelOpacity, ]); } keyboardShow = (event: KeyboardEvent) => { if ( event.startCoordinates && _isEqual(event.startCoordinates)(event.endCoordinates) ) { return; } const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max( event.endCoordinates.height - this.props.dimensions.bottomInset, 0, ), }); this.keyboardHeightValue.setValue(keyboardHeight); }; keyboardHide = () => { if (!this.activeAlert) { this.keyboardHeightValue.setValue(0); } }; setActiveAlert = (activeAlert: boolean) => { this.activeAlert = activeAlert; }; goBackToPrompt = () => { this.nextMode = 'prompt'; this.keyboardHeightValue.setValue(0); this.modeValue.setValue(modeNumbers['prompt']); Keyboard.dismiss(); }; render() { let panel = null; let buttons = null; let siweButton = null; if (__DEV__) { siweButton = ( SIWE ); } if (this.state.mode === 'siwe') { panel = ; } else if (this.state.mode === 'log-in') { panel = ( ); } else if (this.state.mode === 'register') { panel = ( ); } else if (this.state.mode === 'prompt') { const opacityStyle = { opacity: this.buttonOpacity }; buttons = ( {siweButton} LOG IN SIGN UP ); } else if (this.state.mode === 'loading') { panel = ( ); } const windowWidth = this.props.dimensions.width; const buttonStyle = { opacity: this.panelOpacityValue, left: windowWidth < 360 ? 28 : 40, }; const padding = { paddingTop: this.panelPaddingTopValue }; const animatedContent = ( Comm {panel} ); const backgroundSource = { uri: splashBackgroundURI }; return ( {animatedContent} {buttons} ); } onPressSIWE = () => { this.setMode('siwe'); }; onPressLogIn = () => { if (Platform.OS !== 'ios') { // For some strange reason, iOS's password management logic doesn't // realize that the username and password fields in LogInPanel are related // if the username field gets focused on mount. To avoid this issue we // need the username and password fields to both appear on-screen before // we focus the username field. However, when we set keyboardHeightValue // to -1 here, we are telling our Reanimated logic to wait until the // keyboard appears before showing LogInPanel. Since we need LogInPanel // to appear before the username field is focused, we need to avoid this // behavior on iOS. this.keyboardHeightValue.setValue(-1); } this.setMode('log-in'); }; onPressRegister = () => { this.keyboardHeightValue.setValue(-1); this.setMode('register'); }; } const styles = StyleSheet.create({ animationContainer: { flex: 1, }, backButton: { position: 'absolute', top: 13, }, button: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginBottom: 10, marginLeft: 40, marginRight: 40, marginTop: 10, paddingBottom: 6, paddingLeft: 18, paddingRight: 18, paddingTop: 6, }, buttonContainer: { bottom: 0, left: 0, paddingBottom: 20, position: 'absolute', right: 0, }, buttonText: { color: '#000000FF', fontFamily: 'OpenSans-Semibold', fontSize: 22, textAlign: 'center', }, container: { backgroundColor: 'transparent', flex: 1, }, header: { color: 'white', fontFamily: Platform.OS === 'ios' ? 'IBMPlexSans' : 'IBMPlexSans-Medium', fontSize: 56, fontWeight: '500', lineHeight: 66, textAlign: 'center', }, loadingIndicator: { paddingTop: 15, }, modalBackground: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const isForegroundSelector = createIsForegroundSelector( LoggedOutModalRouteName, ); const ConnectedLoggedOutModal: React.ComponentType<{ ... }> = React.memo<{ ... }>(function ConnectedLoggedOutModal(props: { ... }) { const navContext = React.useContext(NavContext); const isForeground = isForegroundSelector(navContext); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated && navContext), ); const persistedStateLoaded = usePersistedStateLoaded(); const cookie = useSelector(state => state.cookie); const urlPrefix = useSelector(state => state.urlPrefix); const loggedIn = useSelector(isLoggedIn); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const dispatch = useDispatch(); return ( ); }); export default ConnectedLoggedOutModal; diff --git a/native/account/panel-components.react.js b/native/account/panel-components.react.js index 2bb01a573..34368985c 100644 --- a/native/account/panel-components.react.js +++ b/native/account/panel-components.react.js @@ -1,127 +1,127 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome'; import * as React from 'react'; import { View, ActivityIndicator, Text, StyleSheet, ScrollView, } from 'react-native'; import Animated from 'react-native-reanimated'; -import Icon from '@expo/vector-icons/FontAwesome'; import type { LoadingStatus } from 'lib/types/loading-types'; import Button from '../components/button.react'; import { useSelector } from '../redux/redux-utils'; import type { ViewStyle } from '../types/styles'; type ButtonProps = { +text: string, +loadingStatus: LoadingStatus, +onSubmit: () => void, }; function PanelButton(props: ButtonProps): React.Node { let buttonIcon; if (props.loadingStatus === 'loading') { buttonIcon = ( ); } else { buttonIcon = ( ); } return ( ); } type PanelProps = { +opacityValue: Animated.Node, +children: React.Node, +style?: ViewStyle, }; function Panel(props: PanelProps): React.Node { const dimensions = useSelector(state => state.dimensions); const containerStyle = React.useMemo( () => [ styles.container, { opacity: props.opacityValue, marginTop: dimensions.height < 641 ? 15 : 40, }, props.style, ], [props.opacityValue, props.style, dimensions.height], ); return ( {props.children} ); } const styles = StyleSheet.create({ container: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginLeft: 20, marginRight: 20, paddingTop: 6, }, innerSubmitButton: { alignItems: 'flex-end', flexDirection: 'row', paddingVertical: 6, }, loadingIndicatorContainer: { paddingBottom: 2, width: 14, }, submitButton: { borderBottomRightRadius: 6, justifyContent: 'center', paddingLeft: 10, paddingRight: 18, }, submitButtonHorizontalContainer: { alignSelf: 'flex-end', }, submitButtonVerticalContainer: { flexGrow: 1, justifyContent: 'center', }, submitContentIconContainer: { paddingBottom: 5, width: 14, }, submitContentText: { color: '#555', fontFamily: 'OpenSans-Semibold', fontSize: 18, paddingRight: 7, }, }); export { PanelButton, Panel }; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index f72f68c52..384a18c9b 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,808 +1,808 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import _omit from 'lodash/fp/omit'; import * as React from 'react'; import { View, Text, TextInput as BaseTextInput, Platform, TouchableWithoutFeedback, Alert, LayoutAnimation, Keyboard, } from 'react-native'; -import Icon from '@expo/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, SaveEntryResult, SaveEntryPayload, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, CalendarQuery, } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; import { 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 { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types'; import Button from '../components/button.react'; import { SingleLine } from '../components/single-line.react'; import TextInput from '../components/text-input.react'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { ThreadPickerModalRouteName } from '../navigation/route-names'; import type { TabNavigationProp } from '../navigation/tab-navigator.react'; import { useSelector } from '../redux/redux-utils'; import { colors, useStyles } from '../themes/colors'; import type { LayoutEvent } from '../types/react-native'; import { waitForInteractions } from '../utils/timers'; import type { EntryInfoWithHeight } from './calendar.react'; import LoadingIndicator from './loading-indicator.react'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement( entryText: string, ): React.Element { 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, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, }; type State = { +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, }; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating: boolean = false; needsUpdateAfterCreation: boolean = false; needsDeleteAfterCreation: boolean = false; nextSaveAttemptIndex: number = 0; mounted: boolean = false; deleted: boolean = false; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: Shape) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State): boolean { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render(): React.Node { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); } textInputRef: ( textInput: ?React.ElementRef, ) => void = textInput => { this.textInput = textInput; if (textInput && this.state.editing) { this.enterEditMode(); } }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // If we don't do this, the TextInput focuses // but the soft keyboard doesn't come up await waitForInteractions(); await sleep(15); } this.focus(); }; focus: () => void = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus: () => void = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive: () => void = () => this.makeActive(true); completeEdit: () => void = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur: () => void = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save: () => void = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout: (event: LayoutEvent) => void = event => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText: (newText: string) => void = newText => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { const { threadInfo } = this.props; if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string): Promise { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.createEntry({ text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterCreation) { this.needsDeleteAfterCreation = false; this.dispatchDelete(response.entryID); } return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction( entryID: string, newText: string, ): Promise { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.saveEntry({ entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload?.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete: () => void = () => { this.dispatchDelete(this.props.entryInfo.id); }; onPressEdit: () => void = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID), undefined, { localID, serverID }, ); } async deleteAction(serverID: ?string): Promise { if (serverID) { return await this.props.deleteEntry({ entryID: serverID, prevText: this.props.entryInfo.text, calendarQuery: this.props.calendarQuery(), }); } else if (this.creating) { this.needsDeleteAfterCreation = true; } return null; } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, transform: (Platform.select({ ios: [{ translateY: -1 / 3 }], default: [], }): $ReadOnlyArray<{ +translateY: number }>), }, textInput: { fontFamily: 'System', fontSize: 16, left: ((Platform.OS === 'android' ? 9.8 : 10): number), margin: 0, padding: 0, position: 'absolute', right: 10, top: ((Platform.OS === 'android' ? 4.8 : 0.5): number), }, }; registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry: React.ComponentType = 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 navigateToThread = useNavigateToThread(); 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/calendar/loading-indicator.react.js b/native/calendar/loading-indicator.react.js index 2ef5d65ca..0eceb50de 100644 --- a/native/calendar/loading-indicator.react.js +++ b/native/calendar/loading-indicator.react.js @@ -1,34 +1,34 @@ // @flow +import Icon from '@expo/vector-icons/Feather'; import * as React from 'react'; import { ActivityIndicator, StyleSheet, Platform } from 'react-native'; -import Icon from '@expo/vector-icons/Feather'; import type { LoadingStatus } from 'lib/types/loading-types'; type Props = { +loadingStatus: LoadingStatus, +color: string, +canUseRed: boolean, }; function LoadingIndicator(props: Props): React.Node { if (props.loadingStatus === 'error') { const colorStyle = props.canUseRed ? { color: 'red' } : { color: props.color }; return ; } else if (props.loadingStatus === 'loading') { return ; } else { return null; } } const styles = StyleSheet.create({ errorIcon: { fontSize: 16, paddingTop: Platform.OS === 'android' ? 6 : 4, }, }); export default LoadingIndicator; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index c229e49d4..242aace43 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1002 +1,1002 @@ // @flow +import Icon from '@expo/vector-icons/Ionicons'; import invariant from 'invariant'; import _throttle from 'lodash/throttle'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode } from 'react-native-reanimated'; -import Icon from '@expo/vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, colorIsDark, } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { PhotoPaste } from 'lib/types/media-types'; import { messageTypes } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; import { type UserInfos } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import ClearableTextInput from '../components/clearable-text-input.react'; import SWMansionIcon from '../components/swmansion-icon.react'; import { type InputState, InputStateContext } from '../input/input-state'; import { getKeyboardHeight } from '../keyboard/keyboard'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { type NavigationRoute, CameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useStyles, useColors } from '../themes/colors'; import type { LayoutEvent } from '../types/react-native'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles'; import { runTiming } from '../utils/animation-utils'; import { ChatContext } from './chat-context'; import type { ChatNavigationProp } from './chat.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, // 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, +textEdited: boolean, +buttonsExpanded: boolean, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { 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 = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.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 = interpolateNode(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) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } 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); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( You don't have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; 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, textEdited: true }); this.saveDraft(text); }; saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateText = (text: string) => { const { textInput } = this; if (!textInput) { return; } const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } 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 = `${localIDPrefix}${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(), }, this.props.threadInfo, ); }; 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 }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, 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: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 12, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector( newThreadActionTypes, ); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector(state => state.nextLocalID); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType = React.memo( function ConnectedChatInputBar(props: ChatInputBarProps) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'CameraModal'>({ name: CameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }, ); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/chat/chat-thread-list-see-more-sidebars.react.js b/native/chat/chat-thread-list-see-more-sidebars.react.js index 7087d8511..d72664d8c 100644 --- a/native/chat/chat-thread-list-see-more-sidebars.react.js +++ b/native/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,70 +1,70 @@ // @flow +import Icon from '@expo/vector-icons/Ionicons'; import * as React from 'react'; import { Text } from 'react-native'; -import Icon from '@expo/vector-icons/Ionicons'; import type { ThreadInfo } from 'lib/types/thread-types'; import Button from '../components/button.react'; import { useColors, useStyles } from '../themes/colors'; import { sidebarHeight } from './sidebar-item.react'; type Props = { +threadInfo: ThreadInfo, +unread: boolean, +onPress: (threadInfo: ThreadInfo) => void, }; function ChatThreadListSeeMoreSidebars(props: Props): React.Node { const { onPress, threadInfo, unread } = props; const onPressButton = React.useCallback(() => onPress(threadInfo), [ onPress, threadInfo, ]); const colors = useColors(); const styles = useStyles(unboundStyles); const unreadStyle = unread ? styles.unread : null; return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, button: { height: sidebarHeight, flexDirection: 'row', display: 'flex', paddingLeft: 28, paddingRight: 18, alignItems: 'center', backgroundColor: 'listBackground', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, text: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 3, paddingBottom: 2, }, }; export default ChatThreadListSeeMoreSidebars; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index 92bba122b..a5056de48 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,647 +1,647 @@ // @flow +import IonIcon from '@expo/vector-icons/Ionicons'; import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { View, FlatList, Platform, TextInput, TouchableWithoutFeedback, BackHandler, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import Animated from 'react-native-reanimated'; -import IonIcon from '@expo/vector-icons/Ionicons'; import { createSelector } from 'reselect'; import { searchUsers } from 'lib/actions/user-actions'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors'; import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { createPendingThread, getThreadListSearchResults, } from 'lib/shared/thread-utils'; import type { UserSearchResult } from 'lib/types/search-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { threadTypes } from 'lib/types/thread-types'; import type { GlobalAccountUserInfo, UserInfo } from 'lib/types/user-types'; import { useServerCall } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import Search from '../components/search.react'; import { SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, } from '../navigation/route-names'; import type { TabNavigationProp } from '../navigation/tab-navigator.react'; import { useSelector } from '../redux/redux-utils'; import { type IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors'; import type { ScrollEvent } from '../types/react-native'; import { AnimatedView, type AnimatedStyleObj } from '../types/styles'; import { animateTowards } from '../utils/animation-utils'; import { ChatThreadListItem, chatThreadListItemHeight, spacerHeight, } from './chat-thread-list-item.react'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react'; import { type MessageListParams, useNavigateToThread, } from './message-list-types'; import { sidebarHeight } from './sidebar-item.react'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, interpolateNode } = Animated; /* eslint-enable import/no-named-as-default-member */ type Item = | ChatThreadItem | { +type: 'search', +searchText: string } | { +type: 'empty', +emptyItem: React.ComponentType<{}> }; type BaseProps = { +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{}>, }; type Props = { ...BaseProps, // Redux state +chatListData: $ReadOnlyArray, +viewerID: ?string, +threadSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, +usersWithPersonalThread: $ReadOnlySet, +navigateToThread: (params: MessageListParams) => void, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, }; type SearchStatus = 'inactive' | 'activating' | 'active'; type State = { +searchStatus: SearchStatus, +searchText: string, +threadsSearchResults: Set, +usersSearchResults: $ReadOnlyArray, +openedSwipeableId: string, +numItemsToDisplay: number, }; type PropsAndState = { ...Props, ...State }; class ChatThreadList extends React.PureComponent { state: State = { searchStatus: 'inactive', searchText: '', threadsSearchResults: new Set(), usersSearchResults: [], openedSwipeableId: '', numItemsToDisplay: 25, }; searchInput: ?React.ElementRef; flatList: ?FlatList; scrollPos = 0; clearNavigationBlurListener: ?() => mixed; searchCancelButtonOpen: Value = new Value(0); searchCancelButtonProgress: Node; searchCancelButtonOffset: Node; constructor(props: Props) { super(props); this.searchCancelButtonProgress = animateTowards( this.searchCancelButtonOpen, 100, ); this.searchCancelButtonOffset = interpolateNode( this.searchCancelButtonProgress, { inputRange: [0, 1], outputRange: [0, 56] }, ); } componentDidMount() { this.clearNavigationBlurListener = this.props.navigation.addListener( 'blur', () => { this.setState({ numItemsToDisplay: 25 }); }, ); const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.getParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); BackHandler.addEventListener('hardwareBackPress', this.hardwareBack); } componentWillUnmount() { this.clearNavigationBlurListener && this.clearNavigationBlurListener(); const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.getParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack); } hardwareBack = () => { if (!this.props.navigation.isFocused()) { return false; } const { searchStatus } = this.state; const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } this.onSearchCancel(); return true; }; componentDidUpdate(prevProps: Props, prevState: State) { const { searchStatus } = this.state; const prevSearchStatus = prevState.searchStatus; const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; const wasActiveOrActivating = prevSearchStatus === 'active' || prevSearchStatus === 'activating'; if (isActiveOrActivating && !wasActiveOrActivating) { this.searchCancelButtonOpen.setValue(1); } else if (!isActiveOrActivating && wasActiveOrActivating) { this.searchCancelButtonOpen.setValue(0); } const { flatList } = this; if (!flatList) { return; } if (this.state.searchText !== prevState.searchText) { flatList.scrollToOffset({ offset: 0, animated: false }); return; } if (searchStatus === 'activating' && prevSearchStatus === 'inactive') { flatList.scrollToOffset({ offset: 0, animated: true }); } } onTabPress = () => { if (!this.props.navigation.isFocused()) { return; } if (this.scrollPos > 0 && this.flatList) { this.flatList.scrollToOffset({ offset: 0, animated: true }); } else if (this.props.route.name === BackgroundChatThreadListRouteName) { this.props.navigation.navigate({ name: HomeChatThreadListRouteName }); } }; onSearchFocus = () => { if (this.state.searchStatus !== 'inactive') { return; } if (this.scrollPos === 0) { this.setState({ searchStatus: 'active' }); } else { this.setState({ searchStatus: 'activating' }); } }; clearSearch() { const { flatList } = this; flatList && flatList.scrollToOffset({ offset: 0, animated: false }); this.setState({ searchStatus: 'inactive' }); } onSearchBlur = () => { if (this.state.searchStatus !== 'active') { return; } this.clearSearch(); }; onSearchCancel = () => { this.onChangeSearchText(''); this.clearSearch(); }; renderSearch(additionalProps?: $Shape>) { const animatedSearchBoxStyle: AnimatedStyleObj = { marginRight: this.searchCancelButtonOffset, }; const searchBoxStyle = [ this.props.styles.searchBox, animatedSearchBoxStyle, ]; const buttonStyle = [ this.props.styles.cancelSearchButtonText, { opacity: this.searchCancelButtonProgress }, ]; return ( ); } searchInputRef = (searchInput: ?React.ElementRef) => { this.searchInput = searchInput; }; renderItem = (row: { item: Item, ... }) => { const item = row.item; if (item.type === 'search') { return ( {this.renderSearch({ active: false })} ); } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }; static keyExtractor = (item: Item) => { if (item.type === 'chatThreadItem') { return item.threadInfo.id; } else if (item.type === 'empty') { return 'empty'; } else { return 'search'; } }; static getItemLayout = (data: ?$ReadOnlyArray, index: number) => { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatThreadList.heightOfItems( data.filter((_, i) => i < index), ); const item = data[index]; const length = item ? ChatThreadList.itemHeight(item) : 0; return { length, offset, index }; }; static itemHeight = (item: Item) => { if (item.type === 'search') { return Platform.OS === 'ios' ? 54.5 : 55; } // itemHeight for emptyItem might be wrong because of line wrapping // but we don't care because we'll only ever be rendering this item // by itself and it should always be on-screen if (item.type === 'empty') { return 123; } let height = chatThreadListItemHeight; height += item.sidebars.length * sidebarHeight; if (item.sidebars.length > 0) { height += spacerHeight; } return height; }; static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(ChatThreadList.itemHeight)); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.chatListData, (propsAndState: PropsAndState) => propsAndState.searchStatus, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.threadsSearchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, (propsAndState: PropsAndState) => propsAndState.usersSearchResults, (propsAndState: PropsAndState) => propsAndState.filterThreads, (propsAndState: PropsAndState) => propsAndState.viewerID, ( reduxChatListData: $ReadOnlyArray, searchStatus: SearchStatus, searchText: string, threadsSearchResults: Set, emptyItem?: React.ComponentType<{}>, usersSearchResults: $ReadOnlyArray, filterThreads: ThreadInfo => boolean, viewerID: ?string, ): $ReadOnlyArray => { const chatThreadItems = getThreadListSearchResults( reduxChatListData, searchText, filterThreads, threadsSearchResults, usersSearchResults, viewerID, ); const chatItems: Item[] = [...chatThreadItems]; if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } if (searchStatus === 'inactive' || searchStatus === 'activating') { chatItems.unshift({ type: 'search', searchText }); } return chatItems; }, ); partialListDataSelector = createSelector( this.listDataSelector, (propsAndState: PropsAndState) => propsAndState.numItemsToDisplay, (items: $ReadOnlyArray, numItemsToDisplay: number) => items.slice(0, numItemsToDisplay), ); get fullListData() { return this.listDataSelector({ ...this.props, ...this.state }); } get listData() { return this.partialListDataSelector({ ...this.props, ...this.state }); } onEndReached = () => { if (this.listData.length === this.fullListData.length) { return; } this.setState(prevState => ({ numItemsToDisplay: prevState.numItemsToDisplay + 25, })); }; render() { let floatingAction; if (Platform.OS === 'android') { floatingAction = ( ); } let fixedSearch; const { searchStatus } = this.state; if (searchStatus === 'active') { fixedSearch = this.renderSearch({ autoFocus: true }); } const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // this.props.viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem return ( {fixedSearch} {floatingAction} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onScroll = (event: ScrollEvent) => { const oldScrollPos = this.scrollPos; this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos !== 0 || oldScrollPos === 0) { return; } if (this.state.searchStatus === 'activating') { this.setState({ searchStatus: 'active' }); } }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos.filter( info => !this.props.usersWithPersonalThread.has(info.id) && info.id !== this.props.viewerID, ); } onChangeSearchText = async (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); this.setState({ searchText, threadsSearchResults: new Set(results), numItemsToDisplay: 25, }); const usersSearchResults = await this.searchUsers(searchText); this.setState({ usersSearchResults }); }; onPressItem = ( threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState(state => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { if (!this.props.viewerID) { return; } const threadInfo = createPendingThread({ viewerID: this.props.viewerID, threadType: threadTypes.PRIVATE, }); this.props.navigateToThread({ threadInfo, searching: true }); }; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, searchBox: { flex: 1, }, search: { marginBottom: 8, marginHorizontal: 18, marginTop: 16, }, cancelSearchButton: { position: 'absolute', right: 0, top: 0, bottom: 0, display: 'flex', justifyContent: 'center', }, cancelSearchButtonText: { color: 'link', fontSize: 16, paddingHorizontal: 16, paddingTop: 8, }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; const ConnectedChatThreadList: React.ComponentType = React.memo( function ConnectedChatThreadList(props: BaseProps) { const boundChatListData = useFlattenedChatListData(); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useSelector(threadSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const callSearchUsers = useServerCall(searchUsers); const usersWithPersonalThread = useSelector( usersWithPersonalThreadSelector, ); const navigateToThread = useNavigateToThread(); return ( ); }, ); export default ConnectedChatThreadList; diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 2167656d2..1de3b8fe8 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,244 +1,244 @@ // @flow +import Icon from '@expo/vector-icons/Feather'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; -import Icon from '@expo/vector-icons/Feather'; import { createMessageReply } from 'lib/shared/message-utils'; import { assertComposableMessageType } from 'lib/types/message-types'; import { type InputState, InputStateContext } from '../input/input-state'; import { type Colors, useColors } from '../themes/colors'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types'; import { type AnimatedStyleObj, AnimatedView } from '../types/styles'; import { clusterEndHeight, inlineSidebarStyle, inlineSidebarLeftStyle, inlineSidebarRightStyle, composedMessageStyle, } from './chat-constants'; import { useComposedMessageMaxWidth } from './composed-message-width'; import { FailedSend } from './failed-send.react'; import { InlineSidebar } from './inline-sidebar.react'; import { MessageHeader } from './message-header.react'; import { useNavigateToSidebar } from './sidebar-navigation'; import SwipeableMessage from './swipeable-message.react'; import { useContentAndHeaderOpacity, useDeliveryIconOpacity } from './utils'; /* eslint-disable import/no-named-as-default-member */ const { Node } = Animated; /* eslint-enable import/no-named-as-default-member */ type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none'; type BaseProps = { ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +swipeOptions: SwipeOptions, +children: React.Node, }; type Props = { ...BaseProps, // Redux state +composedMessageMaxWidth: number, +colors: Colors, +contentAndHeaderOpacity: number | Node, +deliveryIconOpacity: number | Node, // withInputState +inputState: ?InputState, +navigateToSidebar: () => void, }; class ComposedMessage extends React.PureComponent { render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, swipeOptions, children, composedMessageMaxWidth, colors, inputState, navigateToSidebar, contentAndHeaderOpacity, deliveryIconOpacity, ...viewProps } = this.props; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; let containerMarginBottom = 5; if (item.endsCluster) { containerMarginBottom += clusterEndHeight; } const containerStyle = [ styles.alignment, { marginBottom: containerMarginBottom }, ]; const messageBoxStyle = { maxWidth: composedMessageMaxWidth }; let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; if (id !== null && id !== undefined) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; failedSendInfo = ; } else { deliveryIconName = 'circle'; } const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity }; deliveryIcon = ( ); } const triggerReply = swipeOptions === 'reply' || swipeOptions === 'both' ? this.reply : undefined; const triggerSidebar = swipeOptions === 'sidebar' || swipeOptions === 'both' ? navigateToSidebar : undefined; const messageBox = ( {children} ); let inlineSidebar = null; if (item.threadCreatedFromMessage) { const positioning = isViewer ? 'right' : 'left'; const inlineSidebarPositionStyle = positioning === 'left' ? styles.leftInlineSidebar : styles.rightInlineSidebar; inlineSidebar = ( ); } return ( {deliveryIcon} {messageBox} {failedSendInfo} {inlineSidebar} ); } reply = () => { const { inputState, item } = this.props; invariant(inputState, 'inputState should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); inputState.addReply(createMessageReply(item.messageInfo.text)); }; } const styles = StyleSheet.create({ alignment: { marginLeft: composedMessageStyle.marginLeft, marginRight: composedMessageStyle.marginRight, }, content: { alignItems: 'center', flexDirection: 'row-reverse', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, inlineSidebar: { marginBottom: inlineSidebarStyle.marginBottom, marginTop: inlineSidebarStyle.marginTop, }, leftChatBubble: { justifyContent: 'flex-end', }, leftInlineSidebar: { justifyContent: 'flex-start', position: 'relative', top: inlineSidebarLeftStyle.topOffset, }, messageBox: { marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-start', }, rightInlineSidebar: { alignSelf: 'flex-end', position: 'relative', right: inlineSidebarRightStyle.marginRight, top: inlineSidebarRightStyle.topOffset, }, }); const ConnectedComposedMessage: React.ComponentType = React.memo( function ConnectedComposedMessage(props: BaseProps) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const colors = useColors(); const inputState = React.useContext(InputStateContext); const navigateToSidebar = useNavigateToSidebar(props.item); const contentAndHeaderOpacity = useContentAndHeaderOpacity(props.item); const deliveryIconOpacity = useDeliveryIconOpacity(props.item); return ( ); }, ); export default ConnectedComposedMessage; diff --git a/native/chat/inline-multimedia.react.js b/native/chat/inline-multimedia.react.js index d4370d309..a3dc8ac54 100644 --- a/native/chat/inline-multimedia.react.js +++ b/native/chat/inline-multimedia.react.js @@ -1,149 +1,149 @@ // @flow +import Icon from '@expo/vector-icons/Feather'; +import IonIcon from '@expo/vector-icons/Ionicons'; import * as React from 'react'; import { View, StyleSheet, Text } from 'react-native'; import * as Progress from 'react-native-progress'; -import Icon from '@expo/vector-icons/Feather'; -import IonIcon from '@expo/vector-icons/Ionicons'; import tinycolor from 'tinycolor2'; import { isLocalUploadID } from 'lib/media/media-utils'; import type { MediaInfo } from 'lib/types/media-types'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; import type { PendingMultimediaUpload } from '../input/input-state'; import Multimedia from '../media/multimedia.react'; type Props = { +mediaInfo: MediaInfo, +onPress: () => void, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +spinnerColor: string, }; function InlineMultimedia(props: Props): React.Node { const { mediaInfo, pendingUpload, postInProgress } = props; let failed = isLocalUploadID(mediaInfo.id) && !postInProgress; let progressPercent = 1; let processingStep; if (pendingUpload) { ({ progressPercent, failed, processingStep } = pendingUpload); } let progressIndicator; if (failed) { progressIndicator = ( ); } else if (progressPercent !== 1) { const progressOverlay = ( {`${Math.floor(progressPercent * 100).toString()}%`} {processingStep ? processingStep : 'pending'} ); const primaryColor = tinycolor(props.spinnerColor); const secondaryColor = primaryColor.isDark() ? primaryColor.lighten(20).toString() : primaryColor.darken(20).toString(); const progressSpinnerProps = { size: 120, indeterminate: progressPercent === 0, progress: progressPercent, fill: secondaryColor, unfilledColor: secondaryColor, color: props.spinnerColor, thickness: 10, borderWidth: 0, }; let progressSpinner; if (processingStep === 'transcoding') { progressSpinner = ; } else { progressSpinner = ; } progressIndicator = ( {progressSpinner} {progressOverlay} ); } let playButton; if (mediaInfo.type === 'video') { playButton = ( ); } return ( {progressIndicator ? progressIndicator : playButton} ); } const styles = StyleSheet.create({ centerContainer: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0, }, expand: { flex: 1, }, playButton: { color: 'white', opacity: 0.9, textShadowColor: '#000', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, processingStepText: { color: 'white', fontSize: 12, textShadowColor: '#000', textShadowRadius: 1, }, progressOverlay: { alignItems: 'center', justifyContent: 'center', position: 'absolute', }, progressPercentText: { color: 'white', fontSize: 24, fontWeight: 'bold', textShadowColor: '#000', textShadowRadius: 1, }, uploadError: { color: 'white', textShadowColor: '#000', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, }); export default InlineMultimedia; diff --git a/native/chat/message-list-header-title.react.js b/native/chat/message-list-header-title.react.js index 3beb41ed0..52690c55a 100644 --- a/native/chat/message-list-header-title.react.js +++ b/native/chat/message-list-header-title.react.js @@ -1,113 +1,113 @@ // @flow +import Icon from '@expo/vector-icons/Ionicons'; import { HeaderTitle, type StackHeaderTitleInputProps, } from '@react-navigation/elements'; import * as React from 'react'; import { View, Platform } from 'react-native'; -import Icon from '@expo/vector-icons/Ionicons'; import type { ThreadInfo } from 'lib/types/thread-types'; import { firstLine } from 'lib/utils/string-utils'; import Button from '../components/button.react'; import { ThreadSettingsRouteName } from '../navigation/route-names'; import { useStyles } from '../themes/colors'; import type { ChatNavigationProp } from './chat.react'; type BaseProps = { +threadInfo: ThreadInfo, +navigate: $PropertyType, 'navigate'>, +isSearchEmpty: boolean, +areSettingsEnabled: boolean, ...StackHeaderTitleInputProps, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, }; class MessageListHeaderTitle extends React.PureComponent { render() { const { threadInfo, navigate, isSearchEmpty, areSettingsEnabled, styles, ...rest } = this.props; let icon, fakeIcon; if (Platform.OS === 'ios' && areSettingsEnabled) { icon = ( ); fakeIcon = ( ); } const title = isSearchEmpty ? 'New Message' : threadInfo.uiName; return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { button: { flex: 1, }, container: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: Platform.OS === 'android' ? 'flex-start' : 'center', }, fakeIcon: { paddingRight: 3, paddingTop: 3, flex: 1, minWidth: 25, opacity: 0, }, forwardIcon: { paddingLeft: 3, paddingTop: 3, color: 'headerChevron', flex: 1, minWidth: 25, }, }; const ConnectedMessageListHeaderTitle: React.ComponentType = React.memo( function ConnectedMessageListHeaderTitle(props: BaseProps) { const styles = useStyles(unboundStyles); return ; }, ); export default ConnectedMessageListHeaderTitle; diff --git a/native/chat/new-messages-pill.react.js b/native/chat/new-messages-pill.react.js index e3a3f5664..95466a1fa 100644 --- a/native/chat/new-messages-pill.react.js +++ b/native/chat/new-messages-pill.react.js @@ -1,73 +1,73 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome'; import * as React from 'react'; import { TouchableOpacity, View, Text, Platform, Animated } from 'react-native'; -import Icon from '@expo/vector-icons/FontAwesome'; import { useStyles } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; type Props = { +onPress: () => mixed, +newMessageCount: number, +containerStyle?: ViewStyle, +style?: ViewStyle, ...React.ElementConfig, }; function NewMessagesPill(props: Props): React.Node { const { onPress, newMessageCount, containerStyle, style, ...containerProps } = props; const styles = useStyles(unboundStyles); return ( {newMessageCount} ); } const unboundStyles = { countBubble: { alignItems: 'center', backgroundColor: 'vibrantGreenButton', borderRadius: 25, height: 25, justifyContent: 'center', paddingBottom: Platform.OS === 'android' ? 2 : 0, paddingLeft: 1, position: 'absolute', right: -8, top: -8, width: 25, }, countText: { color: 'white', textAlign: 'center', }, button: { backgroundColor: 'floatingButtonBackground', borderColor: 'floatingButtonLabel', borderRadius: 30, borderWidth: 4, paddingHorizontal: 12, paddingVertical: 6, }, icon: { color: 'floatingButtonLabel', fontSize: 32, fontWeight: 'bold', }, }; export default NewMessagesPill; diff --git a/native/chat/relationship-prompt.react.js b/native/chat/relationship-prompt.react.js index c1e2e1393..1d060b3ab 100644 --- a/native/chat/relationship-prompt.react.js +++ b/native/chat/relationship-prompt.react.js @@ -1,166 +1,166 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome5'; import * as React from 'react'; import { Alert, Text, View } from 'react-native'; -import Icon from '@expo/vector-icons/FontAwesome5'; import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import type { UserInfo } from 'lib/types/user-types'; import Button from '../components/button.react'; import { useStyles } from '../themes/colors'; type Props = { +pendingPersonalThreadUserInfo: ?UserInfo, +threadInfo: ThreadInfo, }; const RelationshipPrompt: React.ComponentType = React.memo( function RelationshipPrompt({ pendingPersonalThreadUserInfo, threadInfo, }: Props) { const onErrorCallback = React.useCallback(() => { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); }, []); const { otherUserInfo, callbacks: { blockUser, unblockUser, friendUser, unfriendUser }, } = useRelationshipPrompt( threadInfo, onErrorCallback, pendingPersonalThreadUserInfo, ); const styles = useStyles(unboundStyles); if ( !otherUserInfo || !otherUserInfo.username || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { return null; } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { return ( ); } return ( ); }, ); const unboundStyles = { container: { paddingVertical: 10, paddingHorizontal: 5, backgroundColor: 'panelBackground', flexDirection: 'row', }, button: { padding: 10, borderRadius: 5, flex: 1, flexDirection: 'row', justifyContent: 'center', marginHorizontal: 5, }, greenButton: { backgroundColor: 'vibrantGreenButton', }, redButton: { backgroundColor: 'vibrantRedButton', }, buttonText: { fontSize: 11, color: 'white', fontWeight: 'bold', textAlign: 'center', marginLeft: 5, }, }; export default RelationshipPrompt; diff --git a/native/chat/settings/color-selector-modal.react.js b/native/chat/settings/color-selector-modal.react.js index 7d8323545..3dbde20c3 100644 --- a/native/chat/settings/color-selector-modal.react.js +++ b/native/chat/settings/color-selector-modal.react.js @@ -1,183 +1,183 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome'; import * as React from 'react'; import { TouchableHighlight, Alert } from 'react-native'; -import Icon from '@expo/vector-icons/FontAwesome'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { type ThreadInfo, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import ColorSelector from '../../components/color-selector.react'; import Modal from '../../components/modal.react'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useStyles, useColors } from '../../themes/colors'; export type ColorSelectorModalParams = { +presentedFrom: string, +color: string, +threadInfo: ThreadInfo, +setColor: (color: string) => void, }; type BaseProps = { +navigation: RootNavigationProp<'ColorSelectorModal'>, +route: NavigationRoute<'ColorSelectorModal'>, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, +windowWidth: number, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, }; function ColorSelectorModal(props: Props): React.Node { const { changeThreadSettings: updateThreadSettings, dispatchActionPromise, windowWidth, } = props; const { threadInfo, setColor } = props.route.params; const close = props.navigation.goBackOnce; const onErrorAcknowledged = React.useCallback(() => { setColor(threadInfo.color); }, [setColor, threadInfo.color]); const editColor = React.useCallback( async (newColor: string) => { const threadID = threadInfo.id; try { return await updateThreadSettings({ threadID, changes: { color: newColor }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onErrorAcknowledged }], { cancelable: false }, ); throw e; } }, [onErrorAcknowledged, threadInfo.id, updateThreadSettings], ); const onColorSelected = React.useCallback( (color: string) => { const colorEditValue = color.substr(1); setColor(colorEditValue); close(); dispatchActionPromise( changeThreadSettingsActionTypes, editColor(colorEditValue), { customKeyName: `${changeThreadSettingsActionTypes.started}:color` }, ); }, [setColor, close, dispatchActionPromise, editColor], ); const { colorSelectorContainer, closeButton, closeButtonIcon } = props.styles; // Based on the assumption we are always in portrait, // and consequently width is the lowest dimensions const modalStyle = React.useMemo( () => [colorSelectorContainer, { height: 0.75 * windowWidth }], [colorSelectorContainer, windowWidth], ); const { modalIosHighlightUnderlay } = props.colors; const { color } = props.route.params; return ( ); } const unboundStyles = { closeButton: { borderRadius: 3, height: 18, position: 'absolute', right: 5, top: 5, width: 18, }, closeButtonIcon: { color: 'modalBackgroundSecondaryLabel', left: 3, position: 'absolute', }, colorSelector: { bottom: 10, left: 10, position: 'absolute', right: 10, top: 10, }, colorSelectorContainer: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 0, marginHorizontal: 15, marginVertical: 20, }, }; const ConnectedColorSelectorModal: React.ComponentType = React.memo( function ConnectedColorSelectorModal(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); const windowWidth = useSelector(state => state.dimensions.width); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }, ); export default ConnectedColorSelectorModal; diff --git a/native/chat/settings/compose-subchannel-modal.react.js b/native/chat/settings/compose-subchannel-modal.react.js index 85a30b81e..cb3311ded 100644 --- a/native/chat/settings/compose-subchannel-modal.react.js +++ b/native/chat/settings/compose-subchannel-modal.react.js @@ -1,150 +1,150 @@ // @flow +import IonIcon from '@expo/vector-icons/Ionicons'; import * as React from 'react'; import { Text } from 'react-native'; -import IonIcon from '@expo/vector-icons/Ionicons'; import { threadTypeDescriptions } from 'lib/shared/thread-utils'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; import SWMansionIcon from '../../components/swmansion-icon.react'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import { ComposeSubchannelRouteName } from '../../navigation/route-names'; import { type Colors, useStyles, useColors } from '../../themes/colors'; export type ComposeSubchannelModalParams = { +presentedFrom: string, +threadInfo: ThreadInfo, }; type BaseProps = { +navigation: RootNavigationProp<'ComposeSubchannelModal'>, +route: NavigationRoute<'ComposeSubchannelModal'>, }; type Props = { ...BaseProps, +colors: Colors, +styles: typeof unboundStyles, }; class ComposeSubchannelModal extends React.PureComponent { render() { return ( Chat type ); } onPressOpen = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate<'ComposeSubchannel'>({ name: ComposeSubchannelRouteName, params: { threadType: threadTypes.COMMUNITY_OPEN_SUBTHREAD, parentThreadInfo: threadInfo, }, key: `${ComposeSubchannelRouteName}|` + `${threadInfo.id}|${threadTypes.COMMUNITY_OPEN_SUBTHREAD}`, }); }; onPressSecret = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate<'ComposeSubchannel'>({ name: ComposeSubchannelRouteName, params: { threadType: threadTypes.COMMUNITY_SECRET_SUBTHREAD, parentThreadInfo: threadInfo, }, key: `${ComposeSubchannelRouteName}|` + `${threadInfo.id}|${threadTypes.COMMUNITY_SECRET_SUBTHREAD}`, }); }; } const unboundStyles = { forwardIcon: { color: 'modalForegroundSecondaryLabel', paddingLeft: 10, }, modal: { flex: 0, }, option: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 20, }, optionExplanation: { color: 'modalBackgroundLabel', flex: 1, fontSize: 14, paddingLeft: 20, textAlign: 'center', }, optionText: { color: 'modalBackgroundLabel', fontSize: 20, paddingLeft: 5, }, visibility: { color: 'modalBackgroundLabel', fontSize: 24, textAlign: 'center', }, visibilityIcon: { color: 'modalBackgroundLabel', paddingRight: 3, }, }; const ConnectedComposeSubchannelModal: React.ComponentType = React.memo( function ConnectedComposeSubchannelModal(props: BaseProps) { const styles = useStyles(unboundStyles); const colors = useColors(); return ( ); }, ); export default ConnectedComposeSubchannelModal; diff --git a/native/chat/settings/thread-settings-list-action.react.js b/native/chat/settings/thread-settings-list-action.react.js index f02149a39..62e3d4c16 100644 --- a/native/chat/settings/thread-settings-list-action.react.js +++ b/native/chat/settings/thread-settings-list-action.react.js @@ -1,152 +1,152 @@ // @flow -import * as React from 'react'; -import { View, Text, Platform } from 'react-native'; import type { IoniconsGlyphs } from '@expo/vector-icons'; import Icon from '@expo/vector-icons/Ionicons'; +import * as React from 'react'; +import { View, Text, Platform } from 'react-native'; import Button from '../../components/button.react'; import { useStyles } from '../../themes/colors'; import type { ViewStyle, TextStyle } from '../../types/styles'; type ListActionProps = { +onPress: () => void, +text: string, +iconName: IoniconsGlyphs, +iconSize: number, +iconStyle?: TextStyle, +buttonStyle?: ViewStyle, +styles: typeof unboundStyles, }; function ThreadSettingsListAction(props: ListActionProps) { return ( ); } type SeeMoreProps = { +onPress: () => void, }; function ThreadSettingsSeeMore(props: SeeMoreProps): React.Node { const styles = useStyles(unboundStyles); return ( ); } type AddMemberProps = { +onPress: () => void, }; function ThreadSettingsAddMember(props: AddMemberProps): React.Node { const styles = useStyles(unboundStyles); return ( ); } type AddChildChannelProps = { +onPress: () => void, }; function ThreadSettingsAddSubchannel(props: AddChildChannelProps): React.Node { const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { addSubchannelButton: { paddingTop: Platform.OS === 'ios' ? 4 : 1, }, addIcon: { color: 'link', }, addItemRow: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, addMemberButton: { paddingTop: Platform.OS === 'ios' ? 4 : 1, }, container: { flex: 1, flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 8, justifyContent: 'center', }, icon: { lineHeight: 20, }, seeMoreButton: { paddingBottom: Platform.OS === 'ios' ? 4 : 2, paddingTop: Platform.OS === 'ios' ? 2 : 0, }, seeMoreContents: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, seeMoreIcon: { color: 'link', position: 'absolute', right: 10, top: Platform.OS === 'android' ? 12 : 10, }, seeMoreRow: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'link', flex: 1, fontSize: 16, fontStyle: 'italic', }, }; export { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubchannel, }; diff --git a/native/chat/swipeable-message.react.js b/native/chat/swipeable-message.react.js index f35f8e9a2..8a8abb765 100644 --- a/native/chat/swipeable-message.react.js +++ b/native/chat/swipeable-message.react.js @@ -1,366 +1,366 @@ // @flow +import type { IconProps } from '@expo/vector-icons'; import { GestureHandlerRefContext } from '@react-navigation/stack'; import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { View } from 'react-native'; import { PanGestureHandler } from 'react-native-gesture-handler'; import Animated, { useAnimatedGestureHandler, useSharedValue, useAnimatedStyle, runOnJS, withSpring, interpolate, cancelAnimation, Extrapolate, type SharedValue, } from 'react-native-reanimated'; -import type { IconProps } from '@expo/vector-icons'; import tinycolor from 'tinycolor2'; import CommIcon from '../components/comm-icon.react'; import { colors } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; import { useMessageListScreenWidth } from './composed-message-width'; const primaryThreshold = 40; const secondaryThreshold = 120; function dividePastDistance(value, distance, factor) { 'worklet'; const absValue = Math.abs(value); if (absValue < distance) { return value; } const absFactor = value >= 0 ? 1 : -1; return absFactor * (distance + (absValue - distance) / factor); } function makeSpringConfig(velocity) { 'worklet'; return { stiffness: 257.1370588235294, damping: 19.003038357561845, mass: 1, overshootClamping: true, restDisplacementThreshold: 0.001, restSpeedThreshold: 0.001, velocity, }; } function interpolateOpacityForViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [-20, -5], [1, 0], Extrapolate.CLAMP); } function interpolateOpacityForNonViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [5, 20], [0, 1], Extrapolate.CLAMP); } function interpolateTranslateXForViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [-130, -120, -60, 0], [-130, -120, -5, 20]); } function interpolateTranslateXForNonViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [0, 80, 120, 130], [0, 30, 120, 130]); } type SwipeSnakeProps = { +isViewer: boolean, +translateX: SharedValue, +color: string, +children: React.Element>>, +opacityInterpolator?: number => number, // must be worklet +translateXInterpolator?: number => number, // must be worklet }; function SwipeSnake( props: SwipeSnakeProps, ): React.Node { const { translateX, isViewer, opacityInterpolator, translateXInterpolator, } = props; const transformStyle = useAnimatedStyle(() => { const opacity = opacityInterpolator ? opacityInterpolator(translateX.value) : undefined; const translate = translateXInterpolator ? translateXInterpolator(translateX.value) : translateX.value; return { transform: [ { translateX: translate, }, ], opacity, }; }, [isViewer, translateXInterpolator, opacityInterpolator]); const animationPosition = isViewer ? styles.right0 : styles.left0; const animationContainerStyle = React.useMemo(() => { return [styles.animationContainer, animationPosition]; }, [animationPosition]); const iconPosition = isViewer ? styles.left0 : styles.right0; const swipeSnakeContainerStyle = React.useMemo(() => { return [styles.swipeSnakeContainer, transformStyle, iconPosition]; }, [transformStyle, iconPosition]); const iconAlign = isViewer ? styles.alignStart : styles.alignEnd; const screenWidth = useMessageListScreenWidth(); const { color } = props; const swipeSnakeStyle = React.useMemo(() => { return [ styles.swipeSnake, iconAlign, { width: screenWidth, backgroundColor: color, }, ]; }, [iconAlign, screenWidth, color]); const { children } = props; const iconColor = tinycolor(color).isDark() ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel; const coloredIcon = React.useMemo( () => React.cloneElement(children, { color: iconColor, }), [children, iconColor], ); return ( {coloredIcon} ); } type Props = { +triggerReply?: () => void, +triggerSidebar?: () => void, +isViewer: boolean, +messageBoxStyle: ViewStyle, +threadColor: string, +children: React.Node, }; function SwipeableMessage(props: Props): React.Node { const { isViewer, triggerReply, triggerSidebar } = props; const secondaryActionExists = triggerReply && triggerSidebar; const onPassPrimaryThreshold = React.useCallback(() => { const impactStrength = secondaryActionExists ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Heavy; Haptics.impactAsync(impactStrength); }, [secondaryActionExists]); const onPassSecondaryThreshold = React.useCallback(() => { if (secondaryActionExists) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); } }, [secondaryActionExists]); const primaryAction = React.useCallback(() => { if (triggerReply) { triggerReply(); } else if (triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const secondaryAction = React.useCallback(() => { if (triggerReply && triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const translateX = useSharedValue(0); const swipeEvent = useAnimatedGestureHandler( { onStart: (event, ctx) => { ctx.translationAtStart = translateX.value; cancelAnimation(translateX.value); }, onActive: (event, ctx) => { const translationX = ctx.translationAtStart + event.translationX; const baseActiveTranslation = isViewer ? Math.min(translationX, 0) : Math.max(translationX, 0); translateX.value = dividePastDistance( baseActiveTranslation, primaryThreshold, 2, ); const absValue = Math.abs(translateX.value); const pastPrimaryThreshold = absValue >= primaryThreshold; if (pastPrimaryThreshold && !ctx.prevPastPrimaryThreshold) { runOnJS(onPassPrimaryThreshold)(); } ctx.prevPastPrimaryThreshold = pastPrimaryThreshold; const pastSecondaryThreshold = absValue >= secondaryThreshold; if (pastSecondaryThreshold && !ctx.prevPastSecondaryThreshold) { runOnJS(onPassSecondaryThreshold)(); } ctx.prevPastSecondaryThreshold = pastSecondaryThreshold; }, onEnd: event => { const absValue = Math.abs(translateX.value); if (absValue >= secondaryThreshold && secondaryActionExists) { runOnJS(secondaryAction)(); } else if (absValue >= primaryThreshold) { runOnJS(primaryAction)(); } translateX.value = withSpring(0, makeSpringConfig(event.velocityX)); }, }, [ isViewer, onPassPrimaryThreshold, onPassSecondaryThreshold, primaryAction, secondaryAction, secondaryActionExists, ], ); const transformMessageBoxStyle = useAnimatedStyle( () => ({ transform: [{ translateX: translateX.value }], }), [], ); const { messageBoxStyle, children } = props; const reactNavGestureHandlerRef = React.useContext(GestureHandlerRefContext); const waitFor = reactNavGestureHandlerRef ?? undefined; if (!triggerReply && !triggerSidebar) { return ( {children} ); } const threadColor = `#${props.threadColor}`; const tinyThreadColor = tinycolor(threadColor); const snakes = []; if (triggerReply) { const replySnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } if (triggerReply && triggerSidebar) { const sidebarSnakeTranslateXInterpolator = isViewer ? interpolateTranslateXForViewerSecondarySnake : interpolateTranslateXForNonViewerSecondarySnake; const darkerThreadColor = tinyThreadColor .darken(tinyThreadColor.isDark() ? 10 : 20) .toString(); snakes.push( , ); } else if (triggerSidebar) { const sidebarSnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } snakes.push( {children} , ); return snakes; } const styles = { swipeSnakeContainer: { marginHorizontal: 20, justifyContent: 'center', position: 'absolute', top: 0, bottom: 0, }, animationContainer: { position: 'absolute', top: 0, bottom: 0, }, swipeSnake: { paddingHorizontal: 15, flex: 1, borderRadius: 25, height: 30, justifyContent: 'center', maxHeight: 50, }, left0: { left: 0, }, right0: { right: 0, }, alignStart: { alignItems: 'flex-start', }, alignEnd: { alignItems: 'flex-end', }, }; export default SwipeableMessage; diff --git a/native/chat/swipeable-thread.react.js b/native/chat/swipeable-thread.react.js index f4a0a6993..0edf9aa7b 100644 --- a/native/chat/swipeable-thread.react.js +++ b/native/chat/swipeable-thread.react.js @@ -1,94 +1,94 @@ // @flow +import MaterialIcon from '@expo/vector-icons/MaterialCommunityIcons'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; -import MaterialIcon from '@expo/vector-icons/MaterialCommunityIcons'; import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status'; import type { ThreadInfo } from 'lib/types/thread-types'; import Swipeable from '../components/swipeable'; import { useColors } from '../themes/colors'; type Props = { +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId?: string, +iconSize: number, +children: React.Node, }; function SwipeableThread(props: Props): React.Node { const swipeable = React.useRef(); const navigation = useNavigation(); React.useEffect(() => { return navigation.addListener('blur', () => { if (swipeable.current) { swipeable.current.close(); } }); }, [navigation, swipeable]); const { threadInfo, currentlyOpenedSwipeableId } = props; React.useEffect(() => { if (swipeable.current && threadInfo.id !== currentlyOpenedSwipeableId) { swipeable.current.close(); } }, [currentlyOpenedSwipeableId, swipeable, threadInfo.id]); const { onSwipeableWillOpen } = props; const onSwipeableRightWillOpen = React.useCallback(() => { onSwipeableWillOpen(threadInfo); }, [onSwipeableWillOpen, threadInfo]); const colors = useColors(); const { mostRecentNonLocalMessage, iconSize } = props; const swipeableClose = React.useCallback(() => { if (swipeable.current) { swipeable.current.close(); } }, []); const toggleUnreadStatus = useToggleUnreadStatus( threadInfo, mostRecentNonLocalMessage, swipeableClose, ); const swipeableActions = React.useMemo(() => { const isUnread = threadInfo.currentUser.unread; return [ { key: 'action1', onPress: toggleUnreadStatus, color: isUnread ? colors.vibrantRedButton : colors.vibrantGreenButton, content: ( ), }, ]; }, [ threadInfo.currentUser.unread, toggleUnreadStatus, colors.vibrantRedButton, colors.vibrantGreenButton, iconSize, ]); return ( {props.children} ); } export default SwipeableThread; diff --git a/native/chat/thread-settings-button.react.js b/native/chat/thread-settings-button.react.js index 1e4e1ac7a..e690ad564 100644 --- a/native/chat/thread-settings-button.react.js +++ b/native/chat/thread-settings-button.react.js @@ -1,56 +1,56 @@ // @flow -import * as React from 'react'; import Icon from '@expo/vector-icons/Ionicons'; +import * as React from 'react'; import { type ThreadInfo } from 'lib/types/thread-types'; import Button from '../components/button.react'; import { ThreadSettingsRouteName } from '../navigation/route-names'; import { useStyles } from '../themes/colors'; import type { ChatNavigationProp } from './chat.react'; type BaseProps = { +threadInfo: ThreadInfo, +navigate: $PropertyType, 'navigate'>, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, }; class ThreadSettingsButton extends React.PureComponent { render() { return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate<'ThreadSettings'>({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { button: { color: 'link', paddingHorizontal: 10, }, }; const ConnectedThreadSettingsButton: React.ComponentType = React.memo( function ConnectedThreadSettingsButton(props: BaseProps) { const styles = useStyles(unboundStyles); return ; }, ); export default ConnectedThreadSettingsButton; diff --git a/native/components/action-row.react.js b/native/components/action-row.react.js index 86812ba0e..a948812a5 100644 --- a/native/components/action-row.react.js +++ b/native/components/action-row.react.js @@ -1,87 +1,87 @@ // @flow +import type { IoniconsGlyphs } from '@expo/vector-icons'; +import RawIcon from '@expo/vector-icons/Ionicons'; import * as React from 'react'; import { View, Text as RawText } from 'react-native'; -import RawIcon from '@expo/vector-icons/Ionicons'; -import type { IoniconsGlyphs } from '@expo/vector-icons'; import Button from '../components/button.react'; import { useColors, useStyles } from '../themes/colors'; type TextProps = { +content: string, +danger?: boolean, }; function Text(props: TextProps): React.Node { const { content, danger } = props; const styles = useStyles(actionRowStyles); return ( {content} ); } type IconProps = { +name: IoniconsGlyphs, }; function Icon(props: IconProps): React.Node { const { name } = props; const colors = useColors(); return ; } type RowProps = { +onPress?: () => void, +children: React.Node, }; function Row(props: RowProps): React.Node { const { onPress, children } = props; const styles = useStyles(actionRowStyles); const colors = useColors(); if (!onPress) { return {children}; } return ( ); } const actionRowStyles = { wrapper: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, button: { paddingHorizontal: 24, paddingVertical: 10, flexDirection: 'row', }, text: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, }, dangerText: { color: 'redText', flex: 1, fontSize: 16, }, }; export default { Row, Icon, Text }; diff --git a/native/components/thread-ancestors-label.react.js b/native/components/thread-ancestors-label.react.js index 2218b4dd3..6373d829f 100644 --- a/native/components/thread-ancestors-label.react.js +++ b/native/components/thread-ancestors-label.react.js @@ -1,72 +1,72 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome5'; import * as React from 'react'; import { Text, View } from 'react-native'; -import Icon from '@expo/vector-icons/FontAwesome5'; import { useAncestorThreads } from 'lib/shared/ancestor-threads'; import { type ThreadInfo } from 'lib/types/thread-types'; import { useColors, useStyles } from '../themes/colors'; type Props = { +threadInfo: ThreadInfo, }; function ThreadAncestorsLabel(props: Props): React.Node { const { threadInfo } = props; const { unread } = threadInfo.currentUser; const styles = useStyles(unboundStyles); const colors = useColors(); const ancestorThreads = useAncestorThreads(threadInfo); const chevronIcon = React.useMemo( () => ( ), [colors.listForegroundTertiaryLabel], ); const ancestorPath = React.useMemo(() => { const path = []; for (const thread of ancestorThreads) { path.push({thread.uiName}); path.push( ${thread.id}`} style={styles.chevron}> {chevronIcon} , ); } path.pop(); return path; }, [ancestorThreads, chevronIcon, styles.chevron]); const ancestorPathStyle = React.useMemo(() => { return unread ? [styles.pathText, styles.unread] : styles.pathText; }, [styles.pathText, styles.unread, unread]); return ( {ancestorPath} ); } const unboundStyles = { pathText: { opacity: 0.8, fontSize: 12, color: 'listForegroundTertiaryLabel', }, unread: { color: 'listForegroundLabel', }, chevron: { paddingHorizontal: 3, }, }; export default ThreadAncestorsLabel; diff --git a/native/components/thread-ancestors.react.js b/native/components/thread-ancestors.react.js index ec09c7230..566f8b85e 100644 --- a/native/components/thread-ancestors.react.js +++ b/native/components/thread-ancestors.react.js @@ -1,111 +1,111 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome5'; import * as React from 'react'; import { View, ScrollView } from 'react-native'; -import Icon from '@expo/vector-icons/FontAwesome5'; import { ancestorThreadInfos } from 'lib/selectors/thread-selectors'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useNavigateToThread } from '../chat/message-list-types'; import { useSelector } from '../redux/redux-utils'; import { useColors, useStyles } from '../themes/colors'; import Button from './button.react'; import CommunityPill from './community-pill.react'; import ThreadPill from './thread-pill.react'; type Props = { +threadInfo: ThreadInfo, }; function ThreadAncestors(props: Props): React.Node { const { threadInfo } = props; const styles = useStyles(unboundStyles); const colors = useColors(); const ancestorThreads: $ReadOnlyArray = useSelector( ancestorThreadInfos(threadInfo.id), ); const rightArrow = React.useMemo( () => ( ), [colors.panelForegroundLabel], ); const navigateToThread = useNavigateToThread(); const pathElements = React.useMemo(() => { const elements = []; for (const [idx, ancestorThreadInfo] of ancestorThreads.entries()) { const isLastThread = idx === ancestorThreads.length - 1; const pill = idx === 0 ? ( ) : ( ); elements.push( {!isLastThread ? rightArrow : null} , ); } return {elements}; }, [ ancestorThreads, navigateToThread, rightArrow, styles.pathItem, styles.row, ]); return ( {pathElements} ); } const height = 48; const unboundStyles = { arrowIcon: { paddingHorizontal: 8, }, container: { height, backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', }, contentContainer: { paddingHorizontal: 12, }, pathItem: { alignItems: 'center', flexDirection: 'row', height, }, row: { flexDirection: 'row', }, }; export default ThreadAncestors; diff --git a/native/components/thread-icon.react.js b/native/components/thread-icon.react.js index 7d5ccd423..96fbf906d 100644 --- a/native/components/thread-icon.react.js +++ b/native/components/thread-icon.react.js @@ -1,44 +1,44 @@ // @flow +import EntypoIcon from '@expo/vector-icons/Entypo'; import * as React from 'react'; import { StyleSheet } from 'react-native'; -import EntypoIcon from '@expo/vector-icons/Entypo'; import { threadTypes, type ThreadType } from 'lib/types/thread-types'; import SWMansionIcon from './swmansion-icon.react'; type Props = { +threadType: ThreadType, +color: string, }; function ThreadIcon(props: Props): React.Node { const { threadType, color } = props; if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return ; } else if (threadType === threadTypes.SIDEBAR) { return ( ); } else if (threadType === threadTypes.PERSONAL) { return ; } else { return ; } } const styles = StyleSheet.create({ sidebarIcon: { paddingTop: 2, }, }); export default ThreadIcon; diff --git a/native/crash.react.js b/native/crash.react.js index dd38a6e10..88af89901 100644 --- a/native/crash.react.js +++ b/native/crash.react.js @@ -1,293 +1,293 @@ // @flow +import Icon from '@expo/vector-icons/FontAwesome'; import Clipboard from '@react-native-clipboard/clipboard'; import invariant from 'invariant'; import _shuffle from 'lodash/fp/shuffle'; import * as React from 'react'; import { View, Text, Platform, StyleSheet, ScrollView, ActivityIndicator, } from 'react-native'; import ExitApp from 'react-native-exit-app'; -import Icon from '@expo/vector-icons/FontAwesome'; import { sendReportActionTypes, sendReport } from 'lib/actions/report-actions'; import { logOutActionTypes, logOut } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import type { LogOutResult } from 'lib/types/account-types'; import type { ErrorData } from 'lib/types/report-types'; import { type ClientReportCreationRequest, type ReportCreationResponse, reportTypes, } from 'lib/types/report-types'; import type { PreRequestUserState } from 'lib/types/session-types'; import { actionLogger } from 'lib/utils/action-logger'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { useIsReportEnabled } from 'lib/utils/report-utils'; import { sanitizeReduxReport, type ReduxCrashReport, } from 'lib/utils/sanitization'; import sleep from 'lib/utils/sleep'; import Button from './components/button.react'; import ConnectedStatusBar from './connected-status-bar.react'; import { persistConfig, codeVersion } from './redux/persist'; import { useSelector } from './redux/redux-utils'; import { wipeAndExit } from './utils/crash-utils'; const errorTitles = ['Oh no!!', 'Womp womp womp...']; type BaseProps = { +errorData: $ReadOnlyArray, }; type Props = { ...BaseProps, // Redux state +preRequestUserState: PreRequestUserState, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +sendReport: ( request: ClientReportCreationRequest, ) => Promise, +logOut: (preRequestUserState: PreRequestUserState) => Promise, +crashReportingEnabled: boolean, }; type State = { +errorReportID: ?string, +doneWaiting: boolean, }; class Crash extends React.PureComponent { errorTitle = _shuffle(errorTitles)[0]; constructor(props) { super(props); this.state = { errorReportID: null, doneWaiting: !props.crashReportingEnabled, }; } componentDidMount() { if (this.state.doneWaiting) { return; } this.props.dispatchActionPromise(sendReportActionTypes, this.sendReport()); this.timeOut(); } async timeOut() { // If it takes more than 10s, give up and let the user exit await sleep(10000); this.setState({ doneWaiting: true }); } render() { const errorText = [...this.props.errorData] .reverse() .map(errorData => errorData.error.message) .join('\n'); let crashID; if (!this.state.doneWaiting) { crashID = ; } else if (this.state.doneWaiting && this.state.errorReportID) { crashID = ( Crash report ID: {this.state.errorReportID} ); } else { crashID = ( Crash reporting can be enabled in the Profile tab. ); } const buttonStyle = { opacity: Number(this.state.doneWaiting) }; return ( {this.errorTitle} I'm sorry, but the app crashed. {crashID} Here's some text that's probably not helpful: {errorText} ); } async sendReport() { const sanitizedReduxReport: ReduxCrashReport = sanitizeReduxReport({ preloadedState: actionLogger.preloadedState, currentState: actionLogger.currentState, actions: actionLogger.actions, }); const result = await this.props.sendReport({ type: reportTypes.ERROR, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, errors: this.props.errorData.map(data => ({ errorMessage: data.error.message, stack: data.error.stack, componentStack: data.info && data.info.componentStack, })), ...sanitizedReduxReport, }); this.setState({ errorReportID: result.id, doneWaiting: true, }); } onPressKill = () => { if (!this.state.doneWaiting) { return; } ExitApp.exitApp(); }; onPressWipe = async () => { if (!this.state.doneWaiting) { return; } this.props.dispatchActionPromise(logOutActionTypes, this.logOutAndExit()); }; async logOutAndExit() { try { await this.props.logOut(this.props.preRequestUserState); } catch (e) {} await wipeAndExit(); } onCopyCrashReportID = () => { invariant(this.state.errorReportID, 'should be set'); Clipboard.setString(this.state.errorReportID); }; } const styles = StyleSheet.create({ button: { backgroundColor: '#FF0000', borderRadius: 5, marginHorizontal: 10, paddingHorizontal: 10, paddingVertical: 5, }, buttonText: { color: 'white', fontSize: 16, }, buttons: { flexDirection: 'row', }, container: { alignItems: 'center', backgroundColor: 'white', flex: 1, justifyContent: 'center', }, copyCrashReportIDButtonText: { color: '#036AFF', }, crashID: { flexDirection: 'row', paddingBottom: 12, paddingTop: 2, }, crashIDText: { color: 'black', paddingRight: 8, }, errorReportID: { flexDirection: 'row', height: 20, }, errorReportIDText: { color: 'black', paddingRight: 8, }, errorText: { color: 'black', fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), }, header: { color: 'black', fontSize: 24, paddingBottom: 24, }, scrollView: { flex: 1, marginBottom: 24, marginTop: 12, maxHeight: 200, paddingHorizontal: 50, }, text: { color: 'black', paddingBottom: 12, }, }); const ConnectedCrash: React.ComponentType = React.memo( function ConnectedCrash(props: BaseProps) { const preRequestUserState = useSelector(preRequestUserStateSelector); const dispatchActionPromise = useDispatchActionPromise(); const callSendReport = useServerCall(sendReport); const callLogOut = useServerCall(logOut); const crashReportingEnabled = useIsReportEnabled('crashReports'); return ( ); }, ); export default ConnectedCrash; diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js index 965b961a1..61df5b896 100644 --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -1,1211 +1,1211 @@ // @flow +import Icon from '@expo/vector-icons/Ionicons'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Image, Animated, Easing, } from 'react-native'; import { RNCamera } from 'react-native-camera'; import filesystem from 'react-native-fs'; import { PinchGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import type { Orientations } from 'react-native-orientation-locker'; import Reanimated, { EasingNode as ReanimatedEasing, } from 'react-native-reanimated'; -import Icon from '@expo/vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import type { PhotoCapture } from 'lib/types/media-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import ContentLoading from '../components/content-loading.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { type InputState, InputStateContext } from '../input/input-state'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { updateDeviceCameraInfoActionType } from '../redux/action-types'; import { type DimensionsInfo } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { colors } from '../themes/colors'; import { type DeviceCameraInfo } from '../types/camera'; import type { NativeMethods } from '../types/react-native'; import { AnimatedView, type ViewStyle, type AnimatedViewStyle, } from '../types/styles'; import { clamp, gestureJustEnded } from '../utils/animation-utils'; import SendMediaButton from './send-media-button.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Clock, event, Extrapolate, block, set, call, cond, not, and, or, eq, greaterThan, lessThan, add, sub, multiply, divide, abs, interpolateNode, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Reanimated; /* eslint-enable import/no-named-as-default-member */ const maxZoom = 16; const zoomUpdateFactor = (() => { if (Platform.OS === 'ios') { return 0.002; } if (Platform.OS === 'android' && Platform.Version > 26) { return 0.005; } if (Platform.OS === 'android' && Platform.Version > 23) { return 0.01; } return 0.03; })(); const stagingModeAnimationConfig = { duration: 150, easing: ReanimatedEasing.inOut(ReanimatedEasing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const indicatorSpringConfig = { ...SpringUtils.makeDefaultConfig(), damping: 0, mass: 0.6, toValue: 1, }; const indicatorTimingConfig = { duration: 500, easing: ReanimatedEasing.out(ReanimatedEasing.ease), toValue: 0, }; function runIndicatorAnimation( // Inputs springClock: Clock, delayClock: Clock, timingClock: Clock, animationRunning: Node, // Outputs scale: Value, opacity: Value, ): Node { const delayStart = new Value(0); const springScale = new Value(0.75); const delayScale = new Value(0); const timingScale = new Value(0.75); const animatedScale = cond( clockRunning(springClock), springScale, cond(clockRunning(delayClock), delayScale, timingScale), ); const lastAnimatedScale = new Value(0.75); const numScaleLoops = new Value(0); const springState = { finished: new Value(1), velocity: new Value(0), time: new Value(0), position: springScale, }; const timingState = { finished: new Value(1), frameTime: new Value(0), time: new Value(0), position: timingScale, }; return block([ cond(not(animationRunning), [ set(springState.finished, 0), set(springState.velocity, 0), set(springState.time, 0), set(springScale, 0.75), set(lastAnimatedScale, 0.75), set(numScaleLoops, 0), set(opacity, 1), startClock(springClock), ]), [ cond( clockRunning(springClock), spring(springClock, springState, indicatorSpringConfig), ), timing(timingClock, timingState, indicatorTimingConfig), ], [ cond( and( greaterThan(animatedScale, 1.2), not(greaterThan(lastAnimatedScale, 1.2)), ), [ set(numScaleLoops, add(numScaleLoops, 1)), cond(greaterThan(numScaleLoops, 1), [ set(springState.finished, 1), stopClock(springClock), set(delayScale, springScale), set(delayStart, delayClock), startClock(delayClock), ]), ], ), set(lastAnimatedScale, animatedScale), ], cond( and( clockRunning(delayClock), greaterThan(delayClock, add(delayStart, 400)), ), [ stopClock(delayClock), set(timingState.finished, 0), set(timingState.frameTime, 0), set(timingState.time, 0), set(timingScale, delayScale), startClock(timingClock), ], ), cond( and(springState.finished, timingState.finished), stopClock(timingClock), ), set(scale, animatedScale), cond(clockRunning(timingClock), set(opacity, clamp(animatedScale, 0, 1))), ]); } export type CameraModalParams = { +presentedFrom: string, +thread: ThreadInfo, }; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type BaseProps = { +navigation: AppNavigationProp<'CameraModal'>, +route: NavigationRoute<'CameraModal'>, }; type Props = { ...BaseProps, // Redux state +dimensions: DimensionsInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +foreground: boolean, // Redux dispatch functions +dispatch: Dispatch, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, }; type State = { +zoom: number, +useFrontCamera: boolean, +hasCamerasOnBothSides: boolean, +flashMode: number, +autoFocusPointOfInterest: ?{ x: number, y: number, autoExposure?: boolean, }, +stagingMode: boolean, +pendingPhotoCapture: ?PhotoCapture, }; class CameraModal extends React.PureComponent { camera: ?RNCamera; pinchEvent; pinchHandler = React.createRef(); tapEvent; tapHandler = React.createRef(); animationCode: Node; closeButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); photoButton: ?React.ElementRef; photoButtonX = new Value(-1); photoButtonY = new Value(-1); photoButtonWidth = new Value(0); photoButtonHeight = new Value(0); switchCameraButton: ?React.ElementRef; switchCameraButtonX = new Value(-1); switchCameraButtonY = new Value(-1); switchCameraButtonWidth = new Value(0); switchCameraButtonHeight = new Value(0); flashButton: ?React.ElementRef; flashButtonX = new Value(-1); flashButtonY = new Value(-1); flashButtonWidth = new Value(0); flashButtonHeight = new Value(0); focusIndicatorX = new Value(-1); focusIndicatorY = new Value(-1); focusIndicatorScale = new Value(0); focusIndicatorOpacity = new Value(0); cancelIndicatorAnimation = new Value(0); cameraIDsFetched = false; stagingModeProgress = new Value(0); sendButtonProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; overlayStyle: AnimatedViewStyle; constructor(props: Props) { super(props); this.state = { zoom: 0, useFrontCamera: props.deviceCameraInfo.defaultUseFrontCamera, hasCamerasOnBothSides: props.deviceCameraInfo.hasCamerasOnBothSides, flashMode: RNCamera.Constants.FlashMode.off, autoFocusPointOfInterest: undefined, stagingMode: false, pendingPhotoCapture: undefined, }; const sendButtonScale = this.sendButtonProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.1, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.sendButtonProgress, transform: [{ scale: sendButtonScale }], }; const overlayOpacity = interpolateNode(this.stagingModeProgress, { inputRange: [0, 0.01, 1], outputRange: [0, 0.5, 0], extrapolate: Extrapolate.CLAMP, }); this.overlayStyle = { ...styles.overlay, opacity: overlayOpacity, }; const pinchState = new Value(-1); const pinchScale = new Value(1); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, }, }, ]); const tapState = new Value(-1); const tapX = new Value(0); const tapY = new Value(0); this.tapEvent = event([ { nativeEvent: { state: tapState, x: tapX, y: tapY, }, }, ]); this.animationCode = block([ this.zoomAnimationCode(pinchState, pinchScale), this.focusAnimationCode(tapState, tapX, tapY), ]); } zoomAnimationCode(pinchState: Node, pinchScale: Node): Node { const pinchJustEnded = gestureJustEnded(pinchState); const zoomBase = new Value(1); const zoomReported = new Value(1); const currentZoom = interpolateNode(multiply(zoomBase, pinchScale), { inputRange: [1, 8], outputRange: [1, 8], extrapolate: Extrapolate.CLAMP, }); const cameraZoomFactor = interpolateNode(zoomReported, { inputRange: [1, 8], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const resolvedZoom = cond( eq(pinchState, GestureState.ACTIVE), currentZoom, zoomBase, ); return block([ cond(pinchJustEnded, set(zoomBase, currentZoom)), cond( or( pinchJustEnded, greaterThan( abs(sub(divide(resolvedZoom, zoomReported), 1)), zoomUpdateFactor, ), ), [ set(zoomReported, resolvedZoom), call([cameraZoomFactor], this.updateZoom), ], ), ]); } focusAnimationCode(tapState: Node, tapX: Node, tapY: Node): Node { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(tapState), this.outsideButtons(lastTapX, lastTapY), ); const indicatorSpringClock = new Clock(); const indicatorDelayClock = new Clock(); const indicatorTimingClock = new Clock(); const indicatorAnimationRunning = or( clockRunning(indicatorSpringClock), clockRunning(indicatorDelayClock), clockRunning(indicatorTimingClock), ); return block([ cond(fingerJustReleased, [ call([tapX, tapY], this.focusOnPoint), set(this.focusIndicatorX, tapX), set(this.focusIndicatorY, tapY), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), ]), cond(this.cancelIndicatorAnimation, [ set(this.cancelIndicatorAnimation, 0), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), set(this.focusIndicatorOpacity, 0), ]), cond( or(fingerJustReleased, indicatorAnimationRunning), runIndicatorAnimation( indicatorSpringClock, indicatorDelayClock, indicatorTimingClock, indicatorAnimationRunning, this.focusIndicatorScale, this.focusIndicatorOpacity, ), ), set(lastTapX, tapX), set(lastTapY, tapY), ]); } outsideButtons(x: Node, y: Node): Node { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, photoButtonX, photoButtonY, photoButtonWidth, photoButtonHeight, switchCameraButtonX, switchCameraButtonY, switchCameraButtonWidth, switchCameraButtonHeight, flashButtonX, flashButtonY, flashButtonWidth, flashButtonHeight, } = this; return and( or( lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( lessThan(x, photoButtonX), greaterThan(x, add(photoButtonX, photoButtonWidth)), lessThan(y, photoButtonY), greaterThan(y, add(photoButtonY, photoButtonHeight)), ), or( lessThan(x, switchCameraButtonX), greaterThan(x, add(switchCameraButtonX, switchCameraButtonWidth)), lessThan(y, switchCameraButtonY), greaterThan(y, add(switchCameraButtonY, switchCameraButtonHeight)), ), or( lessThan(x, flashButtonX), greaterThan(x, add(flashButtonX, flashButtonWidth)), lessThan(y, flashButtonY), greaterThan(y, add(flashButtonY, flashButtonHeight)), ), ); } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return !overlayContext.isDismissing; } componentDidMount() { if (CameraModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (CameraModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props, prevState: State) { const isActive = CameraModal.isActive(this.props); const wasActive = CameraModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } if (!this.state.hasCamerasOnBothSides && prevState.hasCamerasOnBothSides) { this.switchCameraButtonX.setValue(-1); this.switchCameraButtonY.setValue(-1); this.switchCameraButtonWidth.setValue(0); this.switchCameraButtonHeight.setValue(0); } if (this.props.deviceOrientation !== prevProps.deviceOrientation) { this.setState({ autoFocusPointOfInterest: null }); this.cancelIndicatorAnimation.setValue(1); } if (this.props.foreground && !prevProps.foreground && this.camera) { this.camera.refreshAuthorizationStatus(); } if (this.state.stagingMode && !prevState.stagingMode) { this.cancelIndicatorAnimation.setValue(1); this.focusIndicatorOpacity.setValue(0); timing(this.stagingModeProgress, { ...stagingModeAnimationConfig, toValue: 1, }).start(); } else if (!this.state.stagingMode && prevState.stagingMode) { this.stagingModeProgress.setValue(0); } if (this.state.pendingPhotoCapture && !prevState.pendingPhotoCapture) { Animated.timing(this.sendButtonProgress, { ...sendButtonAnimationConfig, toValue: 1, }).start(); } else if ( !this.state.pendingPhotoCapture && prevState.pendingPhotoCapture ) { CameraModal.cleanUpPendingPhotoCapture(prevState.pendingPhotoCapture); this.sendButtonProgress.setValue(0); } } static async cleanUpPendingPhotoCapture(pendingPhotoCapture: PhotoCapture) { const path = pathFromURI(pendingPhotoCapture.uri); if (!path) { return; } try { await filesystem.unlink(path); } catch (e) {} } get containerStyle() { const { overlayContext } = this.props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return { ...styles.container, opacity: overlayContext.position, }; } get focusIndicatorStyle() { return { ...styles.focusIndicator, opacity: this.focusIndicatorOpacity, transform: [ { translateX: this.focusIndicatorX }, { translateY: this.focusIndicatorY }, { scale: this.focusIndicatorScale }, ], }; } renderCamera = ({ camera, status }) => { if (camera && camera._cameraHandle) { this.fetchCameraIDs(camera); } if (this.state.stagingMode) { return this.renderStagingView(); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset, 6), }; return ( <> {this.renderCameraContent(status)} × ); }; renderStagingView() { let image = null; const { pendingPhotoCapture } = this.state; if (pendingPhotoCapture) { const imageSource = { uri: pendingPhotoCapture.uri }; image = ; } else { image = ; } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const sendButtonContainerStyle = { bottom: this.props.dimensions.bottomInset + 22, }; return ( <> {image} ); } renderCameraContent(status) { if (status === 'PENDING_AUTHORIZATION') { return ; } else if (status === 'NOT_AUTHORIZED') { return ( {'don’t have permission :('} ); } let switchCameraButton = null; if (this.state.hasCamerasOnBothSides) { switchCameraButton = ( ); } let flashIcon; if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { flashIcon = ; } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { flashIcon = ; } else { flashIcon = ( <> A ); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const bottomButtonsContainerStyle = { bottom: this.props.dimensions.bottomInset + 20, }; return ( {flashIcon} {switchCameraButton} ); } render() { const statusBar = CameraModal.isActive(this.props) ? (