diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index a4f554186..81e4499b0 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,466 +1,466 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, Keyboard, Platform } from 'react-native'; import Animated from 'react-native-reanimated'; import { logInActionTypes, useLogIn, getOlmSessionInitializationDataActionTypes, useIdentityPasswordLogIn, identityLogInActionTypes, } from 'lib/actions/user-actions.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { validEmailRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import { type LogInInfo, type LogInExtraInfo, type LogInResult, type LogInStartingPayload, logInActionSources, } from 'lib/types/account-types.js'; import type { IdentityAuthResult } from 'lib/types/identity-service-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { TextInput } from './modal-components.react.js'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials.js'; import { PanelButton, Panel } from './panel-components.react.js'; import PasswordInput from './password-input.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import type { KeyPressEvent } from '../types/react-native.js'; import { AppOutOfDateAlertDetails, UnknownErrorAlertDetails, UserNotFoundAlertDetails, } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js'; import type { StateContainer } from '../utils/state-container.js'; export type LogInState = { +usernameInputText: ?string, +passwordInputText: ?string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +logInState: StateContainer, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +logInExtraInfo: () => Promise, +dispatchActionPromise: DispatchActionPromise, +legacyLogIn: (logInInfo: LogInInfo) => Promise, +identityPasswordLogIn: ( username: string, password: string, ) => Promise, +getInitialNotificationsEncryptedMessage: ( keyserverID: string, ) => Promise, }; class LogInPanel extends React.PureComponent { usernameInput: ?TextInput; passwordInput: ?PasswordInput; componentDidMount() { void this.attemptToFetchCredentials(); } get usernameInputText(): string { return this.props.logInState.state.usernameInputText || ''; } get passwordInputText(): string { return this.props.logInState.state.passwordInputText || ''; } async attemptToFetchCredentials() { if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } const credentials = await fetchNativeCredentials(); if (!credentials) { return; } if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } this.props.logInState.setState({ usernameInputText: credentials.username, passwordInputText: credentials.password, }); } render(): React.Node { return ( ); } usernameInputRef: (usernameInput: ?TextInput) => void = usernameInput => { this.usernameInput = usernameInput; if (Platform.OS === 'ios' && usernameInput) { setTimeout(() => usernameInput.focus()); } }; focusUsernameInput: () => void = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; passwordInputRef: (passwordInput: ?PasswordInput) => void = passwordInput => { this.passwordInput = passwordInput; }; focusPasswordInput: () => void = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; onChangeUsernameInputText: (text: string) => void = text => { this.props.logInState.setState({ usernameInputText: text.trim() }); }; onUsernameKeyPress: (event: KeyPressEvent) => void = event => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.passwordInputText.length === 0 ) { this.focusPasswordInput(); } }; onChangePasswordInputText: (text: string) => void = text => { this.props.logInState.setState({ passwordInputText: text }); }; onSubmit: () => Promise = async () => { this.props.setActiveAlert(true); if (this.usernameInputText.search(validEmailRegex) > -1) { Alert.alert( 'Can’t log in with email', 'You need to log in with your username now', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.usernameInputText.search(oldValidUsernameRegex) === -1) { Alert.alert( 'Invalid username', 'Alphanumeric usernames only', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = await this.props.logInExtraInfo(); const initialNotificationsEncryptedMessage = await this.props.getInitialNotificationsEncryptedMessage( - ashoatKeyserverID, + authoritativeKeyserverID, ); if (usingCommServicesAccessToken) { void this.props.dispatchActionPromise( identityLogInActionTypes, this.identityPasswordLogInAction(), ); } else { void this.props.dispatchActionPromise( logInActionTypes, this.legacyLogInAction({ ...extraInfo, initialNotificationsEncryptedMessage, }), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); } }; async legacyLogInAction(extraInfo: LogInExtraInfo): Promise { try { const result = await this.props.legacyLogIn({ ...extraInfo, username: this.usernameInputText, password: this.passwordInputText, logInActionSource: logInActionSources.logInFromNativeForm, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( UserNotFoundAlertDetails.title, UserNotFoundAlertDetails.message, [{ text: 'OK', onPress: this.onUnsuccessfulLoginAlertAckowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } async identityPasswordLogInAction(): Promise { try { const result = await this.props.identityPasswordLogIn( this.usernameInputText, this.passwordInputText, ); this.props.setActiveAlert(false); await setNativeCredentials({ username: this.usernameInputText, password: this.passwordInputText, }); return result; } catch (e) { if (e.message === 'user not found') { Alert.alert( UserNotFoundAlertDetails.title, UserNotFoundAlertDetails.message, [{ text: 'OK', onPress: this.onUnsuccessfulLoginAlertAckowledged }], { cancelable: false }, ); } else if (e.message === 'Unsupported version') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onUnsuccessfulLoginAlertAckowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onUsernameAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', }, this.focusUsernameInput, ); }; onPasswordAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { passwordInputText: '', }, this.focusPasswordInput, ); }; onUnknownErrorAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onAppOutOfDateAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); }; } export type InnerLogInPanel = LogInPanel; const styles = StyleSheet.create({ footer: { flexDirection: 'row', justifyContent: 'flex-end', }, icon: { bottom: 10, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, row: { marginHorizontal: 24, }, }); const logInLoadingStatusSelector = createLoadingStatusSelector(logInActionTypes); const olmSessionInitializationDataLoadingStatusSelector = createLoadingStatusSelector(getOlmSessionInitializationDataActionTypes); const ConnectedLogInPanel: React.ComponentType = React.memo(function ConnectedLogInPanel(props: BaseProps) { const logInLoadingStatus = useSelector(logInLoadingStatusSelector); const olmSessionInitializationDataLoadingStatus = useSelector( olmSessionInitializationDataLoadingStatusSelector, ); const loadingStatus = combineLoadingStatuses( logInLoadingStatus, olmSessionInitializationDataLoadingStatus, ); const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector); const dispatchActionPromise = useDispatchActionPromise(); const callLegacyLogIn = useLogIn(); const callIdentityPasswordLogIn = useIdentityPasswordLogIn(); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage( nativeNotificationsSessionCreator, ); return ( ); }); export default ConnectedLogInPanel; diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index b865b1c22..4caf2a05e 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,852 +1,852 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { View, 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 { resetUserStateActionType } from 'lib/actions/user-actions.js'; import { resolveKeyserverSessionInvalidation } from 'lib/keyserver-conn/recovery-utils.js'; import { cookieSelector, urlPrefixSelector, } from 'lib/selectors/keyserver-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import { logInActionSources } from 'lib/types/account-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { splashBackgroundURI } from './background-info.js'; import FullscreenSIWEPanel from './fullscreen-siwe-panel.react.js'; import LogInPanel from './log-in-panel.react.js'; import type { LogInState } from './log-in-panel.react.js'; import LoggedOutStaffInfo from './logged-out-staff-info.react.js'; import RegisterPanel from './register-panel.react.js'; import type { RegisterState } from './register-panel.react.js'; import { enableNewRegistrationMode } from './registration/registration-types.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; import ConnectedStatusBar from '../connected-status-bar.react.js'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard.js'; import { createIsForegroundSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { type NavigationRoute, LoggedOutModalRouteName, RegistrationRouteName, QRCodeSignInNavigatorRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors.js'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors.js'; import { splashStyleSelector } from '../splash.js'; import { useStyles } from '../themes/colors.js'; import type { EventSubscription, KeyboardEvent, } from '../types/react-native.js'; import type { ImageStyle } from '../types/styles.js'; import { runTiming, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils.js'; import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js'; import { type StateContainer, type StateChange, setStateForContainer, } from '../utils/state-container.js'; import EthereumLogo from '../vectors/ethereum-logo.react.js'; let initialAppLoad = true; const safeAreaEdges = ['top', 'bottom']; const { Value, Node, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; export 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']), ); } const unboundStyles = { animationContainer: { flex: 1, }, backButton: { position: 'absolute', top: 13, }, button: { borderRadius: 4, marginBottom: 4, marginTop: 4, marginLeft: 4, marginRight: 4, paddingBottom: 14, paddingLeft: 18, paddingRight: 18, paddingTop: 14, flex: 1, }, buttonContainer: { bottom: 0, left: 0, marginLeft: 26, marginRight: 26, paddingBottom: 20, position: 'absolute', right: 0, }, buttonText: { fontFamily: 'OpenSans-Semibold', fontSize: 17, textAlign: 'center', }, classicAuthButton: { backgroundColor: 'purpleButton', }, classicAuthButtonText: { color: 'whiteText', }, registerButtons: { flexDirection: 'row', }, signInButtons: { flexDirection: 'row', }, 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, }, siweButton: { backgroundColor: 'siweButton', flex: 1, flexDirection: 'row', justifyContent: 'center', }, siweButtonText: { color: 'siweButtonText', }, siweOr: { flex: 1, flexDirection: 'row', marginBottom: 18, marginTop: 14, }, siweOrLeftHR: { borderColor: 'logInSpacer', borderTopWidth: 1, flex: 1, marginRight: 18, marginTop: 10, }, siweOrRightHR: { borderColor: 'logInSpacer', borderTopWidth: 1, flex: 1, marginLeft: 18, marginTop: 10, }, siweOrText: { color: 'whiteText', fontSize: 17, textAlign: 'center', }, siweIcon: { paddingRight: 10, }, }; type BaseProps = { +navigation: RootNavigationProp<'LoggedOutModal'>, +route: NavigationRoute<'LoggedOutModal'>, }; type Props = { ...BaseProps, // Navigation state +isForeground: boolean, // Redux state +persistedStateLoaded: boolean, +rehydrateConcluded: boolean, +cookie: ?string, +urlPrefix: string, +loggedIn: boolean, +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, +styles: $ReadOnly, // Redux dispatch functions +dispatch: Dispatch, // Keyserver olm sessions functions +getInitialNotificationsEncryptedMessage: ( keyserverID: string, ) => Promise, }; type State = { +mode: LoggedOutMode, +nextMode: LoggedOutMode, +logInState: StateContainer, +registerState: StateContainer, }; class LoggedOutModal extends React.PureComponent { keyboardShowListener: ?EventSubscription; keyboardHideListener: ?EventSubscription; mounted = false; nextMode: LoggedOutMode = 'loading'; activeAlert = false; contentHeight: Value; keyboardHeightValue: Value = 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: Partial) => (fullState: State) => ({ logInState: { ...fullState.logInState, state: { ...fullState.logInState.state, ...change }, }, }), ); const setRegisterState = setStateForContainer( this.guardedSetState, (change: Partial) => (fullState: State) => ({ registerState: { ...fullState.registerState, state: { ...fullState.registerState.state, ...change }, }, }), ); const initialMode = props.persistedStateLoaded ? 'prompt' : 'loading'; this.state = { mode: initialMode, nextMode: initialMode, logInState: { state: { usernameInputText: null, passwordInputText: null, }, setState: setLogInState, }, registerState: { state: { usernameInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, setState: setRegisterState, }, }; this.nextMode = initialMode; 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, nextMode: newMode }); this.modeValue.setValue(modeNumbers[newMode]); } proceedToNextMode = () => { this.guardedSetState({ mode: this.nextMode }); }; componentDidMount() { this.mounted = true; if (this.props.rehydrateConcluded) { void 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) { void 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; if (usingCommServicesAccessToken) { return; } 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 resolveKeyserverSessionInvalidation( dispatch, cookie, urlPrefix, actionSource, - ashoatKeyserverID, + authoritativeKeyserverID, this.props.getInitialNotificationsEncryptedMessage, ); 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: () => boolean = () => { if (this.nextMode !== 'prompt') { this.goBackToPrompt(); return true; } return false; }; panelPaddingTop(): Node { 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 siwePanelSize = 250; 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), cond(eq(this.modeValue, modeNumbers['siwe']), siwePanelSize, 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(): Node { 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: number = 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.guardedSetState({ nextMode: 'prompt' }); this.keyboardHeightValue.setValue(0); this.modeValue.setValue(modeNumbers['prompt']); Keyboard.dismiss(); }; render(): React.Node { const { styles } = this.props; const siweButton = ( <> Sign in with Ethereum or ); let panel = null; let buttons = null; 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 }; const registerButtons = []; registerButtons.push( Register , ); if (enableNewRegistrationMode) { registerButtons.push( Register (new) , ); } const signInButtons = []; signInButtons.push( Sign in , ); if (__DEV__) { signInButtons.push( Sign in (QR) , ); } buttons = ( {siweButton} {signInButtons} {registerButtons} ); } 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} ); let siwePanel; if (this.state.mode === 'siwe') { siwePanel = ( ); } const backgroundSource = { uri: splashBackgroundURI }; return ( {animatedContent} {buttons} {siwePanel} ); } 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'); }; onPressQRCodeSignIn = () => { this.props.navigation.navigate(QRCodeSignInNavigatorRouteName); }; onPressRegister = () => { this.keyboardHeightValue.setValue(-1); this.setMode('register'); }; onPressNewRegister = () => { this.props.navigation.navigate(RegistrationRouteName); }; } const isForegroundSelector = createIsForegroundSelector( LoggedOutModalRouteName, ); const ConnectedLoggedOutModal: React.ComponentType = React.memo(function ConnectedLoggedOutModal(props: BaseProps) { 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(cookieSelector(ashoatKeyserverID)); - const urlPrefix = useSelector(urlPrefixSelector(ashoatKeyserverID)); + const cookie = useSelector(cookieSelector(authoritativeKeyserverID)); + const urlPrefix = useSelector(urlPrefixSelector(authoritativeKeyserverID)); invariant(urlPrefix, "missing urlPrefix for ashoat's keyserver"); const loggedIn = useSelector(isLoggedIn); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage( nativeNotificationsSessionCreator, ); return ( ); }); export default ConnectedLoggedOutModal; diff --git a/native/account/register-panel.react.js b/native/account/register-panel.react.js index f1c9864f2..dfe2fef6b 100644 --- a/native/account/register-panel.react.js +++ b/native/account/register-panel.react.js @@ -1,514 +1,514 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, StyleSheet, Platform, Keyboard, Linking, } from 'react-native'; import Animated from 'react-native-reanimated'; import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js'; import { keyserverRegisterActionTypes, keyserverRegister, getOlmSessionInitializationDataActionTypes, } from 'lib/actions/user-actions.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { validUsernameRegex } from 'lib/shared/account-utils.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import type { RegisterInfo, LogInExtraInfo, RegisterResult, LogInStartingPayload, } from 'lib/types/account-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { TextInput } from './modal-components.react.js'; import { setNativeCredentials } from './native-credentials.js'; import { PanelButton, Panel } from './panel-components.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import type { KeyPressEvent } from '../types/react-native.js'; import { AppOutOfDateAlertDetails, UsernameReservedAlertDetails, UsernameTakenAlertDetails, UnknownErrorAlertDetails, } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js'; import { type StateContainer } from '../utils/state-container.js'; type WritableRegisterState = { usernameInputText: string, passwordInputText: string, confirmPasswordInputText: string, }; export type RegisterState = $ReadOnly; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +registerState: StateContainer, }; type Props = { ...BaseProps, +loadingStatus: LoadingStatus, +logInExtraInfo: () => Promise, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +register: (registerInfo: RegisterInfo) => Promise, +getInitialNotificationsEncryptedMessage: ( keyserverID: string, ) => Promise, }; type State = { +confirmPasswordFocused: boolean, }; class RegisterPanel extends React.PureComponent { state: State = { confirmPasswordFocused: false, }; usernameInput: ?TextInput; passwordInput: ?TextInput; confirmPasswordInput: ?TextInput; passwordBeingAutoFilled = false; render(): React.Node { let confirmPasswordTextInputExtraProps; if ( Platform.OS !== 'ios' || this.state.confirmPasswordFocused || this.props.registerState.state.confirmPasswordInputText.length > 0 ) { confirmPasswordTextInputExtraProps = { secureTextEntry: true, textContentType: 'password', }; } let onPasswordKeyPress; if (Platform.OS === 'ios') { onPasswordKeyPress = this.onPasswordKeyPress; } const privatePolicyNotice = ( By signing up, you agree to our{' '} Terms {' & '} Privacy Policy . ); return ( {privatePolicyNotice} ); } usernameInputRef = (usernameInput: ?TextInput) => { this.usernameInput = usernameInput; }; passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; confirmPasswordInputRef = (confirmPasswordInput: ?TextInput) => { this.confirmPasswordInput = confirmPasswordInput; }; focusUsernameInput = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; focusPasswordInput = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; focusConfirmPasswordInput = () => { invariant(this.confirmPasswordInput, 'ref should be set'); this.confirmPasswordInput.focus(); }; onTermsOfUsePressed = () => { void Linking.openURL('https://comm.app/terms'); }; onPrivacyPolicyPressed = () => { void Linking.openURL('https://comm.app/privacy'); }; onChangeUsernameInputText = (text: string) => { this.props.registerState.setState({ usernameInputText: text }); }; onChangePasswordInputText = (text: string) => { const stateUpdate: Partial = {}; stateUpdate.passwordInputText = text; if (this.passwordBeingAutoFilled) { this.passwordBeingAutoFilled = false; stateUpdate.confirmPasswordInputText = text; } this.props.registerState.setState(stateUpdate); }; onPasswordKeyPress = (event: KeyPressEvent) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.props.registerState.state.confirmPasswordInputText.length === 0 ) { this.passwordBeingAutoFilled = true; } }; onChangeConfirmPasswordInputText = (text: string) => { this.props.registerState.setState({ confirmPasswordInputText: text }); }; onConfirmPasswordFocus = () => { this.setState({ confirmPasswordFocused: true }); }; onSubmit = async () => { this.props.setActiveAlert(true); if (this.props.registerState.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.registerState.state.passwordInputText !== this.props.registerState.state.confirmPasswordInputText ) { Alert.alert( 'Passwords don’t match', 'Password fields must contain the same password', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.registerState.state.usernameInputText.search( validUsernameRegex, ) === -1 ) { Alert.alert( 'Invalid username', 'Usernames must be at least six characters long, start with either a ' + 'letter or a number, and may contain only letters, numbers, or the ' + 'characters “-” and “_”', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else { Keyboard.dismiss(); const extraInfo = await this.props.logInExtraInfo(); const initialNotificationsEncryptedMessage = await this.props.getInitialNotificationsEncryptedMessage( - ashoatKeyserverID, + authoritativeKeyserverID, ); void this.props.dispatchActionPromise( keyserverRegisterActionTypes, this.registerAction({ ...extraInfo, initialNotificationsEncryptedMessage, }), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); } }; onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.registerState.setState( { passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.passwordInput, 'ref should exist'); this.passwordInput.focus(); }, ); }; onUsernameAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.registerState.setState( { usernameInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; async registerAction(extraInfo: LogInExtraInfo): Promise { try { const result = await this.props.register({ ...extraInfo, username: this.props.registerState.state.usernameInputText, password: this.props.registerState.state.passwordInputText, }); this.props.setActiveAlert(false); this.props.dispatch({ type: setDataLoadedActionType, payload: { dataLoaded: true, }, }); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.props.registerState.state.passwordInputText, }); return result; } catch (e) { if (e.message === 'username_reserved') { Alert.alert( UsernameReservedAlertDetails.title, UsernameReservedAlertDetails.message, [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'username_taken') { Alert.alert( UsernameTakenAlertDetails.title, UsernameTakenAlertDetails.message, [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onUnknownErrorAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.registerState.setState( { usernameInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } const styles = StyleSheet.create({ container: { zIndex: 2, }, footer: { alignItems: 'stretch', flexDirection: 'row', flexShrink: 1, justifyContent: 'space-between', paddingLeft: 24, }, hyperlinkText: { color: '#036AFF', fontWeight: 'bold', }, icon: { bottom: 10, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, notice: { alignSelf: 'center', display: 'flex', flexShrink: 1, maxWidth: 190, paddingBottom: 18, paddingRight: 8, paddingTop: 12, }, noticeText: { color: '#444', fontSize: 13, lineHeight: 20, textAlign: 'center', }, row: { marginHorizontal: 24, }, }); const registerLoadingStatusSelector = createLoadingStatusSelector( keyserverRegisterActionTypes, ); const olmSessionInitializationDataLoadingStatusSelector = createLoadingStatusSelector(getOlmSessionInitializationDataActionTypes); const ConnectedRegisterPanel: React.ComponentType = React.memo(function ConnectedRegisterPanel(props: BaseProps) { const registerLoadingStatus = useSelector(registerLoadingStatusSelector); const olmSessionInitializationDataLoadingStatus = useSelector( olmSessionInitializationDataLoadingStatusSelector, ); const loadingStatus = combineLoadingStatuses( registerLoadingStatus, olmSessionInitializationDataLoadingStatus, ); const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callRegister = useLegacyAshoatKeyserverCall(keyserverRegister); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage( nativeNotificationsSessionCreator, ); return ( ); }); export default ConnectedRegisterPanel; diff --git a/native/account/siwe-hooks.js b/native/account/siwe-hooks.js index 1878bac33..1210cb189 100644 --- a/native/account/siwe-hooks.js +++ b/native/account/siwe-hooks.js @@ -1,135 +1,138 @@ // @flow import * as React from 'react'; import { siweAuth, siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { identityLogInActionTypes, useIdentityWalletLogIn, identityRegisterActionTypes, useIdentityWalletRegister, } from 'lib/actions/user-actions.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import type { LogInStartingPayload, LogInExtraInfo, } from 'lib/types/account-types.js'; import type { SIWEResult } from 'lib/types/siwe-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; import type { CallSingleKeyserverEndpointOptions } from 'lib/utils/call-single-keyserver-endpoint.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js'; type SIWEServerCallParams = { +message: string, +signature: string, +doNotRegister?: boolean, ... }; function useLegacySIWEServerCall(): ( SIWEServerCallParams, ?CallSingleKeyserverEndpointOptions, ) => Promise { const siweAuthCall = useLegacyAshoatKeyserverCall(siweAuth); const callSIWE = React.useCallback( ( message: string, signature: string, extraInfo: $ReadOnly<{ ...LogInExtraInfo, +doNotRegister?: boolean }>, callSingleKeyserverEndpointOptions: ?CallSingleKeyserverEndpointOptions, ) => siweAuthCall( { message, signature, ...extraInfo, }, callSingleKeyserverEndpointOptions, ), [siweAuthCall], ); const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage(nativeNotificationsSessionCreator); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( async ( { message, signature, doNotRegister }, callSingleKeyserverEndpointOptions, ) => { const extraInfo = await logInExtraInfo(); const initialNotificationsEncryptedMessage = - await getInitialNotificationsEncryptedMessage(ashoatKeyserverID, { - callSingleKeyserverEndpointOptions, - }); + await getInitialNotificationsEncryptedMessage( + authoritativeKeyserverID, + { + callSingleKeyserverEndpointOptions, + }, + ); const siwePromise = callSIWE( message, signature, { ...extraInfo, initialNotificationsEncryptedMessage, doNotRegister, }, callSingleKeyserverEndpointOptions, ); void dispatchActionPromise( siweAuthActionTypes, siwePromise, undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); await siwePromise; }, [ logInExtraInfo, dispatchActionPromise, callSIWE, getInitialNotificationsEncryptedMessage, ], ); } function useIdentityWalletLogInCall(): SIWEResult => Promise { const identityWalletLogIn = useIdentityWalletLogIn(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( async ({ address, message, signature }) => { const siwePromise = identityWalletLogIn(address, message, signature); void dispatchActionPromise(identityLogInActionTypes, siwePromise); await siwePromise; }, [dispatchActionPromise, identityWalletLogIn], ); } function useIdentityWalletRegisterCall(): SIWEResult => Promise { const identityWalletRegister = useIdentityWalletRegister(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( async ({ address, message, signature }) => { const siwePromise = identityWalletRegister(address, message, signature); void dispatchActionPromise(identityRegisterActionTypes, siwePromise); await siwePromise; }, [dispatchActionPromise, identityWalletRegister], ); } export { useLegacySIWEServerCall, useIdentityWalletLogInCall, useIdentityWalletRegisterCall, }; diff --git a/native/avatars/avatar-hooks.js b/native/avatars/avatar-hooks.js index b54567e83..c5d9f4bdb 100644 --- a/native/avatars/avatar-hooks.js +++ b/native/avatars/avatar-hooks.js @@ -1,580 +1,580 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import * as ImagePicker from 'expo-image-picker'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import filesystem from 'react-native-fs'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { uploadMultimedia, useBlobServiceUpload, } from 'lib/actions/upload-actions.js'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; import { extensionFromFilename, filenameFromPathOrURI, } from 'lib/media/file-utils.js'; import type { AvatarDBContent, UpdateUserAvatarRequest, } from 'lib/types/avatar-types.js'; import type { NativeMediaSelection, MediaLibrarySelection, MediaMissionFailure, } from 'lib/types/media-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { encryptMedia } from '../media/encryption-utils.js'; import { getCompatibleMediaURI } from '../media/identifier-utils.js'; import type { MediaResult } from '../media/media-utils.js'; import { processMedia } from '../media/media-utils.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; import blobServiceUploadHandler from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; // TODO: flip the switch const useBlobServiceUploads = false; function displayAvatarUpdateFailureAlert(): void { Alert.alert( 'Couldn’t save avatar', 'Please try again later', [{ text: 'OK' }], { cancelable: true }, ); } function useUploadProcessedMedia(): MediaResult => Promise { const callUploadMultimedia = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const uploadProcessedMultimedia: MediaResult => Promise = React.useCallback( async processedMedia => { if (!useBlobServiceUploads) { const { uploadURI, filename, mime, dimensions } = processedMedia; const { id } = await callUploadMultimedia( { uri: uploadURI, name: filename, type: mime, }, dimensions, ); if (!id) { return undefined; } return { type: 'image', uploadID: id }; } const { result: encryptionResult } = await encryptMedia(processedMedia); if (!encryptionResult.success) { throw new Error('Avatar media encryption failed.'); } invariant( encryptionResult.mediaType === 'encrypted_photo', 'Invalid mediaType after encrypting avatar', ); const { uploadURI, filename, mime, blobHash, encryptionKey, dimensions, thumbHash, } = encryptionResult; const { id } = await callBlobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: uploadURI, filename, mimeType: mime, }, blobHash, encryptionKey, dimensions, thumbHash, loop: false, }, - keyserverOrThreadID: ashoatKeyserverID, + keyserverOrThreadID: authoritativeKeyserverID, callbacks: { blobServiceUploadHandler }, }); if (!id) { return undefined; } return { type: 'encrypted_image', uploadID: id }; }, [callUploadMultimedia, callBlobServiceUpload], ); return uploadProcessedMultimedia; } function useProcessSelectedMedia(): NativeMediaSelection => Promise< MediaMissionFailure | MediaResult, > { const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const staffCanSee = useStaffCanSee(); const processSelectedMedia = React.useCallback( async (selection: NativeMediaSelection) => { const { resultPromise } = processMedia(selection, { hasWiFi, finalFileHeaderCheck: staffCanSee, }); return await resultPromise; }, [hasWiFi, staffCanSee], ); return processSelectedMedia; } async function selectFromGallery(): Promise { try { const { assets, canceled } = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, allowsMultipleSelection: false, quality: 1, }); if (canceled || assets.length === 0) { return undefined; } const asset = assets.pop(); const { width, height, assetId: mediaNativeID } = asset; const assetFilename = asset.fileName || filenameFromPathOrURI(asset.uri) || ''; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(assetFilename), ); const currentTime = Date.now(); const selection: MediaLibrarySelection = { step: 'photo_library', dimensions: { height, width }, uri, filename: assetFilename, mediaNativeID, selectTime: currentTime, sendTime: currentTime, retries: 0, }; return selection; } catch (e) { console.log(e); return undefined; } } function useUploadSelectedMedia( setProcessingOrUploadInProgress?: (inProgress: boolean) => mixed, ): (selection: NativeMediaSelection) => Promise { const processSelectedMedia = useProcessSelectedMedia(); const uploadProcessedMedia = useUploadProcessedMedia(); return React.useCallback( async (selection: NativeMediaSelection) => { setProcessingOrUploadInProgress?.(true); const urisToBeDisposed: Set = new Set([selection.uri]); let processedMedia; try { processedMedia = await processSelectedMedia(selection); if (processedMedia.uploadURI) { urisToBeDisposed.add(processedMedia.uploadURI); } } catch (e) { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress?.(false); return undefined; } if (!processedMedia.success) { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress?.(false); return undefined; } let uploadedMedia: ?AvatarDBContent; try { uploadedMedia = await uploadProcessedMedia(processedMedia); urisToBeDisposed.forEach(filesystem.unlink); } catch { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media upload failed', 'Unable to upload selected media. Please try again.', ); setProcessingOrUploadInProgress?.(false); return undefined; } return uploadedMedia; }, [ processSelectedMedia, setProcessingOrUploadInProgress, uploadProcessedMedia, ], ); } function useNativeSetUserAvatar(): ( request: UpdateUserAvatarRequest, ) => Promise { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext must be defined'); const { baseSetUserAvatar, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, } = editUserAvatarContext; const nativeSetUserAvatar = React.useCallback( async (request: UpdateUserAvatarRequest) => { const registrationModeEnabled = getRegistrationModeEnabled(); if (registrationModeEnabled) { const successCallback = getRegistrationModeSuccessCallback(); invariant( successCallback, 'successCallback must be defined if registrationModeEnabled is true', ); successCallback({ needsUpload: false, updateUserAvatarRequest: request, }); return; } try { await baseSetUserAvatar(request); } catch { displayAvatarUpdateFailureAlert(); } }, [ getRegistrationModeEnabled, getRegistrationModeSuccessCallback, baseSetUserAvatar, ], ); return nativeSetUserAvatar; } function useNativeUpdateUserImageAvatar(): ( selection: NativeMediaSelection, ) => Promise { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext must be defined'); const { baseSetUserAvatar, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, setUserAvatarMediaUploadInProgress, } = editUserAvatarContext; const uploadSelectedMedia = useUploadSelectedMedia( setUserAvatarMediaUploadInProgress, ); const nativeUpdateUserImageAvatar = React.useCallback( async (selection: NativeMediaSelection) => { const registrationModeEnabled = getRegistrationModeEnabled(); if (registrationModeEnabled) { const successCallback = getRegistrationModeSuccessCallback(); invariant( successCallback, 'successCallback must be defined if registrationModeEnabled is true', ); successCallback({ needsUpload: true, mediaSelection: selection, }); return; } const imageAvatarUpdateRequest = await uploadSelectedMedia(selection); if (!imageAvatarUpdateRequest) { return; } try { await baseSetUserAvatar(imageAvatarUpdateRequest); } catch { displayAvatarUpdateFailureAlert(); } }, [ getRegistrationModeEnabled, getRegistrationModeSuccessCallback, baseSetUserAvatar, uploadSelectedMedia, ], ); return nativeUpdateUserImageAvatar; } function useSelectFromGalleryAndUpdateUserAvatar(): () => Promise { const nativeUpdateUserImageAvatar = useNativeUpdateUserImageAvatar(); const selectFromGalleryAndUpdateUserAvatar = React.useCallback(async (): Promise => { const selection = await selectFromGallery(); if (!selection) { return; } await nativeUpdateUserImageAvatar(selection); }, [nativeUpdateUserImageAvatar]); return selectFromGalleryAndUpdateUserAvatar; } function useNativeSetThreadAvatar(): ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ) => Promise { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext must be defined'); const { baseSetThreadAvatar } = editThreadAvatarContext; const nativeSetThreadAvatar = React.useCallback( async ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ): Promise => { try { await baseSetThreadAvatar(threadID, avatarRequest); } catch (e) { displayAvatarUpdateFailureAlert(); throw e; } }, [baseSetThreadAvatar], ); return nativeSetThreadAvatar; } function useNativeUpdateThreadImageAvatar(): ( selection: NativeMediaSelection, threadID: string, ) => Promise { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext must be defined'); const { baseSetThreadAvatar, updateThreadAvatarMediaUploadInProgress } = editThreadAvatarContext; const uploadSelectedMedia = useUploadSelectedMedia( updateThreadAvatarMediaUploadInProgress, ); const nativeUpdateThreadImageAvatar = React.useCallback( async ( selection: NativeMediaSelection, threadID: string, ): Promise => { const imageAvatarUpdateRequest = await uploadSelectedMedia(selection); if (!imageAvatarUpdateRequest) { return; } try { await baseSetThreadAvatar(threadID, imageAvatarUpdateRequest); } catch { displayAvatarUpdateFailureAlert(); } }, [baseSetThreadAvatar, uploadSelectedMedia], ); return nativeUpdateThreadImageAvatar; } function useSelectFromGalleryAndUpdateThreadAvatar(): ( threadID: string, ) => Promise { const nativeUpdateThreadImageAvatar = useNativeUpdateThreadImageAvatar(); const selectFromGalleryAndUpdateThreadAvatar = React.useCallback( async (threadID: string): Promise => { const selection: ?MediaLibrarySelection = await selectFromGallery(); if (!selection) { return; } await nativeUpdateThreadImageAvatar(selection, threadID); }, [nativeUpdateThreadImageAvatar], ); return selectFromGalleryAndUpdateThreadAvatar; } type ShowAvatarActionSheetOptions = { +id: 'emoji' | 'image' | 'camera' | 'ens' | 'cancel' | 'remove', +onPress?: () => mixed, }; function useShowAvatarActionSheet( options: $ReadOnlyArray, ): () => void { options = Platform.OS === 'ios' ? [...options, { id: 'cancel' }] : options; const insets = useSafeAreaInsets(); const { showActionSheetWithOptions } = useActionSheet(); const styles = useStyles(unboundStyles); const showAvatarActionSheet = React.useCallback(() => { const texts = options.map((option: ShowAvatarActionSheetOptions) => { if (option.id === 'emoji') { return 'Select emoji'; } else if (option.id === 'image') { return 'Select image'; } else if (option.id === 'camera') { return 'Open camera'; } else if (option.id === 'ens') { return 'Use ENS avatar'; } else if (option.id === 'remove') { return 'Reset to default'; } else { return 'Cancel'; } }); const cancelButtonIndex = options.findIndex( option => option.id === 'cancel', ); const containerStyle = { paddingBotton: insets.bottom, }; const icons = options.map(option => { if (option.id === 'emoji') { return ( ); } else if (option.id === 'image') { return ( ); } else if (option.id === 'camera') { return ( ); } else if (option.id === 'ens') { return ( ); } else if (option.id === 'remove') { return ( ); } else { return undefined; } }); const onPressAction = (selectedIndex: ?number) => { if ( selectedIndex === null || selectedIndex === undefined || selectedIndex < 0 ) { return; } const option = options[selectedIndex]; if (option.onPress) { option.onPress(); } }; showActionSheetWithOptions( { options: texts, cancelButtonIndex, containerStyle, icons, }, onPressAction, ); }, [ insets.bottom, options, showActionSheetWithOptions, styles.bottomSheetIcon, ]); return showAvatarActionSheet; } const unboundStyles = { bottomSheetIcon: { color: '#000000', }, }; export { displayAvatarUpdateFailureAlert, selectFromGallery, useUploadSelectedMedia, useUploadProcessedMedia, useProcessSelectedMedia, useShowAvatarActionSheet, useSelectFromGalleryAndUpdateUserAvatar, useNativeSetUserAvatar, useNativeUpdateUserImageAvatar, useSelectFromGalleryAndUpdateThreadAvatar, useNativeSetThreadAvatar, useNativeUpdateThreadImageAvatar, }; diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index dd2d98354..28ea220e7 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1115 +1,1117 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _find from 'lodash/fp/find.js'; import _findIndex from 'lodash/fp/findIndex.js'; import _map from 'lodash/fp/map.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _size from 'lodash/fp/size.js'; import _sum from 'lodash/fp/sum.js'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { AppState as NativeAppState, FlatList, LayoutAnimation, Platform, Text, TouchableWithoutFeedback, View, } from 'react-native'; import type { UpdateCalendarQueryInput } from 'lib/actions/entry-actions.js'; import { updateCalendarQueryActionTypes, useUpdateCalendarQuery, } from 'lib/actions/entry-actions.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import type { CalendarQuery, CalendarQueryUpdateResult, EntryInfo, } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ConnectionStatus } from 'lib/types/socket-types.js'; import { dateFromString, dateString, prettyDate, } from 'lib/utils/date-utils.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import sleep from 'lib/utils/sleep.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import CalendarInputBar from './calendar-input-bar.react.js'; import { dummyNodeForEntryHeightMeasurement, Entry, InternalEntry, } from './entry.react.js'; import SectionFooter from './section-footer.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import ContentLoading from '../components/content-loading.react.js'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import NodeHeightMeasurer from '../components/node-height-measurer.react.js'; import { addKeyboardDismissListener, addKeyboardShowListener, removeKeyboardListener, } from '../keyboard/keyboard.js'; import DisconnectedBar from '../navigation/disconnected-bar.react.js'; import { createActiveTabSelector, createIsForegroundSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import type { CalendarItem, LoaderItem, SectionFooterItem, SectionHeaderItem, } from '../selectors/calendar-selectors.js'; import { calendarListData } from '../selectors/calendar-selectors.js'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors.js'; import { type Colors, type IndicatorStyle, useColors, useIndicatorStyle, useStyles, } from '../themes/colors.js'; import type { EventSubscription, KeyboardEvent, ScrollEvent, ViewableItemsChange, } from '../types/react-native.js'; export type EntryInfoWithHeight = { ...EntryInfo, +textHeight: number, }; type CalendarItemWithHeight = | LoaderItem | SectionHeaderItem | SectionFooterItem | { itemType: 'entryInfo', entryInfo: EntryInfoWithHeight, threadInfo: ThreadInfo, }; type ExtraData = { +activeEntries: { +[key: string]: boolean }, +visibleEntries: { +[key: string]: boolean }, }; const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, flatList: { backgroundColor: 'listBackground', flex: 1, }, keyboardAvoidingViewContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, }, keyboardAvoidingView: { position: 'absolute', left: 0, right: 0, bottom: 0, }, sectionHeader: { backgroundColor: 'panelSecondaryForeground', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; type BaseProps = { +navigation: TabNavigationProp<'Calendar'>, +route: NavigationRoute<'Calendar'>, }; type Props = { ...BaseProps, // Nav state +calendarActive: boolean, // Redux state +listData: ?$ReadOnlyArray, +startDate: string, +endDate: string, +calendarFilters: $ReadOnlyArray, +dimensions: DerivedDimensionsInfo, +loadingStatus: LoadingStatus, +connectionStatus: ConnectionStatus, +colors: Colors, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( input: UpdateCalendarQueryInput, ) => Promise, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, +readyToShowList: boolean, +extraData: ExtraData, +currentlyEditing: $ReadOnlyArray, }; class Calendar extends React.PureComponent { flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; appStateListener: ?EventSubscription; lastForegrounded = 0; lastCalendarReset = 0; currentScrollPosition: ?number = null; // We don't always want an extraData update to trigger a state update, so we // cache the most recent value as a member here latestExtraData: ExtraData; // For some reason, we have to delay the scrollToToday call after the first // scroll upwards firstScrollComplete = false; // When an entry becomes active, we make a note of its key so that once the // keyboard event happens, we know where to move the scrollPos to lastEntryKeyActive: ?string = null; keyboardShowListener: ?EventSubscription; keyboardDismissListener: ?EventSubscription; keyboardShownHeight: ?number = null; // If the query fails, we try it again topLoadingFromScroll: ?CalendarQuery = null; bottomLoadingFromScroll: ?CalendarQuery = null; // We wait until the loaders leave view before letting them be triggered again topLoaderWaitingToLeaveView = true; bottomLoaderWaitingToLeaveView = true; // We keep refs to the entries so CalendarInputBar can save them entryRefs: Map = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { this.appStateListener = NativeAppState.addEventListener( 'change', this.handleAppStateChange, ); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { if (this.appStateListener) { this.appStateListener.remove(); this.appStateListener = null; } if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts void sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { void sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ): { +lastStartDate: Date, +newStartDate: Date, +lastEndDate: Date, +newEndDate: Date, } { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); const offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return renderItem = (row: { +item: CalendarItemWithHeight, ... }): React.Node => { const item = row.item; if (item.itemType === 'loader') { return ; } else if (item.itemType === 'header') { return this.renderSectionHeader(item); } else if (item.itemType === 'entryInfo') { const key = entryKey(item.entryInfo); return ( ); } else if (item.itemType === 'footer') { return this.renderSectionFooter(item); } invariant(false, 'renderItem conditions should be exhaustive'); }; renderSectionHeader = (item: SectionHeaderItem): React.Node => { let date = prettyDate(item.dateString); if (dateString(new Date()) === item.dateString) { date += ' (today)'; } const dateObj = dateFromString(item.dateString).getDay(); const weekendStyle = dateObj === 0 || dateObj === 6 ? this.props.styles.weekendSectionHeader : null; return ( {date} ); }; renderSectionFooter = (item: SectionFooterItem): React.Node => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = ( item: CalendarItemWithHeight | CalendarItem, // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return ): string => { if (item.itemType === 'loader') { return item.key; } else if (item.itemType === 'header') { return item.dateString + '/header'; } else if (item.itemType === 'entryInfo') { return entryKey(item.entryInfo); } else if (item.itemType === 'footer') { return item.dateString + '/footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; static getItemLayout = ( data: ?$ReadOnlyArray, index: number, ): { length: number, offset: number, index: number } => { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return static itemHeight = (item: CalendarItemWithHeight): number => { if (item.itemType === 'loader') { return 56; } else if (item.itemType === 'header') { return 31; } else if (item.itemType === 'entryInfo') { const verticalPadding = 10; return verticalPadding + item.entryInfo.textHeight; } else if (item.itemType === 'footer') { return 40; } invariant(false, 'itemHeight conditions should be exhaustive'); }; static heightOfItems = ( data: $ReadOnlyArray, ): number => { return _sum(data.map(Calendar.itemHeight)); }; render(): React.Node { const { listDataWithHeights } = this.state; let flatList = null; if (listDataWithHeights) { const flatListStyle = { opacity: this.state.readyToShowList ? 1 : 0 }; const initialScrollIndex = this.initialScrollIndex(listDataWithHeights); flatList = ( ); } let loadingIndicator = null; if (!listDataWithHeights || !this.state.readyToShowList) { loadingIndicator = ( ); } const disableInputBar = this.state.currentlyEditing.length === 0; return ( <> {loadingIndicator} {flatList} ); } flatListHeight(): number { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray): number { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { heightLeft -= Calendar.itemHeight(data[--returnIndex]); } return returnIndex; } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; entryRef = (inEntryKey: string, entry: ?InternalEntry) => { this.entryRefs.set(inEntryKey, entry); }; makeAllEntriesInactive = () => { if (_size(this.state.extraData.activeEntries) === 0) { if (_size(this.latestExtraData.activeEntries) !== 0) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); }; makeActive = (key: string, active: boolean) => { if (!active) { const activeKeys = Object.keys(this.latestExtraData.activeEntries); if (activeKeys.length === 0) { if (Object.keys(this.state.extraData.activeEntries).length !== 0) { this.setState({ extraData: this.latestExtraData }); } return; } const activeKey = activeKeys[0]; if (activeKey === key) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); } return; } if ( _size(this.state.extraData.activeEntries) === 1 && this.state.extraData.activeEntries[key] ) { if ( _size(this.latestExtraData.activeEntries) !== 1 || !this.latestExtraData.activeEntries[key] ) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: { [key]: true }, }; this.setState({ extraData: this.latestExtraData }); }; onEnterEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const keyboardShownHeight = this.keyboardShownHeight; if (keyboardShownHeight && this.state.listDataWithHeights) { this.scrollToKey(key, keyboardShownHeight); } else { this.lastEntryKeyActive = key; } const newCurrentlyEditing = [ ...new Set([...this.state.currentlyEditing, key]), ]; if (newCurrentlyEditing.length > this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; onConcludeEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const newCurrentlyEditing = this.state.currentlyEditing.filter( k => k !== key, ); if (newCurrentlyEditing.length < this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; keyboardShow = (event: KeyboardEvent) => { // flatListHeight() factors in the size of the tab bar, // but it is hidden by the keyboard since it is at the bottom const { bottomInset, tabBarHeight } = this.props.dimensions; const inputBarHeight = Platform.OS === 'android' ? 37.7 : 35.5; const keyboardHeight: number = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max(event.endCoordinates.height - bottomInset, 0), }); const keyboardShownHeight = inputBarHeight + Math.max(keyboardHeight - tabBarHeight, 0); this.keyboardShownHeight = keyboardShownHeight; const lastEntryKeyActive = this.lastEntryKeyActive; if (lastEntryKeyActive && this.state.listDataWithHeights) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } }; keyboardDismiss = () => { this.keyboardShownHeight = null; }; scrollToKey(lastEntryKeyActive: string, keyboardHeight: number) { const data = this.state.listDataWithHeights; invariant(data, 'should be set'); const index = data.findIndex( (item: CalendarItemWithHeight) => Calendar.keyExtractor(item) === lastEntryKeyActive, ); if (index === -1) { return; } const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); const itemHeight = Calendar.itemHeight(data[index]); const entryAdditionalActiveHeight = Platform.OS === 'android' ? 21 : 20; const itemEnd = itemStart + itemHeight + entryAdditionalActiveHeight; const visibleHeight = this.flatListHeight() - keyboardHeight; if ( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null && itemStart > this.currentScrollPosition && itemEnd < this.currentScrollPosition + visibleHeight ) { return; } const offset = itemStart - (visibleHeight - itemHeight) / 2; invariant(this.flatList, 'flatList should be set'); this.flatList.scrollToOffset({ offset, animated: true }); } heightMeasurerKey = (item: CalendarItem): ?string => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem): React.MixedElement => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = ( item: CalendarItem, height: ?number, ): CalendarItemWithHeight => { if (item.itemType !== 'entryInfo') { return item; } invariant(height !== null && height !== undefined, 'height should be set'); const { entryInfo } = item; return { itemType: 'entryInfo', entryInfo: Calendar.entryInfoWithHeight(entryInfo, height), threadInfo: item.threadInfo, }; }; static entryInfoWithHeight( entryInfo: EntryInfo, textHeight: number, ): EntryInfoWithHeight { // Blame Flow for not accepting object spread on exact types if (entryInfo.id && entryInfo.localID) { return { id: entryInfo.id, localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else if (entryInfo.id) { return { id: entryInfo.id, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else { return { localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { const ldwh = this.state.listDataWithHeights; if (!ldwh) { // This indicates the listData was cleared (set to null) right before this // callback was called. Since this leads to the FlatList getting cleared, // we'll just ignore this callback. return; } const visibleEntries: { [string]: boolean } = {}; for (const token of info.viewableItems) { if (token.item.itemType === 'entryInfo') { visibleEntries[entryKey(token.item.entryInfo)] = true; } } this.latestExtraData = { activeEntries: _pickBy((_, key: string) => { if (visibleEntries[key]) { return true; } // We don't automatically set scrolled-away entries to be inactive // because entries can be out-of-view at creation time if they need to // be scrolled into view (see onEnterEntryEditMode). If Entry could // distinguish the reasons its active prop gets set to false, it could // differentiate the out-of-view case from the something-pressed case, // and then we could set scrolled-away entries to be inactive without // worrying about this edge case. Until then... const foundItem = _find( item => item.entryInfo && entryKey(item.entryInfo) === key, )(ldwh); return !!foundItem; })(this.latestExtraData.activeEntries), visibleEntries, }; const topLoader = _find({ key: 'TopLoader' })(info.viewableItems); if (this.topLoaderWaitingToLeaveView && !topLoader) { this.topLoaderWaitingToLeaveView = false; this.topLoadingFromScroll = null; } const bottomLoader = _find({ key: 'BottomLoader' })(info.viewableItems); if (this.bottomLoaderWaitingToLeaveView && !bottomLoader) { this.bottomLoaderWaitingToLeaveView = false; this.bottomLoadingFromScroll = null; } if ( !this.state.readyToShowList && !this.topLoaderWaitingToLeaveView && !this.bottomLoaderWaitingToLeaveView && info.viewableItems.length > 0 ) { this.setState({ readyToShowList: true, extraData: this.latestExtraData, }); } if ( topLoader && !this.topLoaderWaitingToLeaveView && !this.topLoadingFromScroll ) { this.topLoaderWaitingToLeaveView = true; const start = dateFromString(this.props.startDate); start.setDate(start.getDate() - 31); const startDate = dateString(start); const endDate = this.props.endDate; this.topLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreAbove(); } else if ( bottomLoader && !this.bottomLoaderWaitingToLeaveView && !this.bottomLoadingFromScroll ) { this.bottomLoaderWaitingToLeaveView = true; const end = dateFromString(this.props.endDate); end.setDate(end.getDate() + 31); const endDate = dateString(end); const startDate = this.props.startDate; this.bottomLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreBelow(); } }; dispatchCalendarQueryUpdate(calendarQuery: CalendarQuery) { void this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery({ calendarQuery }), ); } loadMoreAbove: () => void = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow: () => void = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.bottomLoadingFromScroll); } }, 1000); onScroll = (event: ScrollEvent) => { this.currentScrollPosition = event.nativeEvent.contentOffset.y; }; // When the user "flicks" the scroll view, this callback gets triggered after // the scrolling ends onMomentumScrollEnd = () => { this.setState({ extraData: this.latestExtraData }); }; // This callback gets triggered when the user lets go of scrolling the scroll // view, regardless of whether it was a "flick" or a pan onScrollEndDrag = () => { // We need to figure out if this was a flick or not. If it's a flick, we'll // let onMomentumScrollEnd handle it once scroll position stabilizes const currentScrollPosition = this.currentScrollPosition; setTimeout(() => { if (this.currentScrollPosition === currentScrollPosition) { this.setState({ extraData: this.latestExtraData }); } }, 50); }; onSaveEntry = () => { const entryKeys = Object.keys(this.latestExtraData.activeEntries); if (entryKeys.length === 0) { return; } const entryRef = this.entryRefs.get(entryKeys[0]); if (entryRef) { entryRef.completeEdit(); } }; } const loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const ConnectedCalendar: React.ComponentType = React.memo( function ConnectedCalendar(props: BaseProps) { const navContext = React.useContext(NavContext); const calendarActive = activeTabSelector(navContext) || activeThreadPickerSelector(navContext); const listData = useSelector(calendarListData); const startDate = useSelector(state => state.navInfo.startDate); const endDate = useSelector(state => state.navInfo.endDate); const calendarFilters = useSelector(state => state.calendarFilters); const dimensions = useSelector(derivedDimensionsInfoSelector); const loadingStatus = useSelector(loadingStatusSelector); - const connection = useSelector(connectionSelector(ashoatKeyserverID)); + const connection = useSelector( + connectionSelector(authoritativeKeyserverID), + ); invariant(connection, 'keyserver missing from keyserverStore'); const connectionStatus = connection.status; const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useUpdateCalendarQuery(); return ( ); }, ); export default ConnectedCalendar; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 7e7cf198e..1b8440bee 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,821 +1,823 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _omit from 'lodash/fp/omit.js'; import * as React from 'react'; import { Keyboard, LayoutAnimation, Platform, Text, TextInput as BaseTextInput, TouchableWithoutFeedback, View, } from 'react-native'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { concurrentModificationResetActionType, createEntryActionTypes, deleteEntryActionTypes, saveEntryActionTypes, useCreateEntry, useDeleteEntry, useSaveEntry, } from 'lib/actions/entry-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { threadHasPermission } from 'lib/shared/thread-utils.js'; import type { CalendarQuery, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, SaveEntryInfo, SaveEntryPayload, SaveEntryResult, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import type { EntryInfoWithHeight } from './calendar.react.js'; import LoadingIndicator from './loading-indicator.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import TextInput from '../components/text-input.react.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ThreadPickerModalRouteName } from '../navigation/route-names.js'; import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import Alert from '../utils/alert.js'; import { waitForInteractions } from '../utils/timers.js'; 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} ); } 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), }, }; type SharedProps = { +navigation: TabNavigationProp<'Calendar'>, +entryInfo: EntryInfoWithHeight, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: $ReadOnly, // 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: Partial) { 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) { void 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) { void this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { void 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; void 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 }); }; } 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 connection = useSelector(connectionSelector(ashoatKeyserverID)); + const connection = useSelector( + connectionSelector(authoritativeKeyserverID), + ); invariant(connection, 'keyserver missing from keyserverStore'); const online = connection.status === 'connected'; const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useCreateEntry(); const callSaveEntry = useSaveEntry(); const callDeleteEntry = useDeleteEntry(); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/data/sqlite-data-handler.js b/native/data/sqlite-data-handler.js index 9ea7f2ad8..5694be2eb 100644 --- a/native/data/sqlite-data-handler.js +++ b/native/data/sqlite-data-handler.js @@ -1,240 +1,240 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { useStaffContext } from 'lib/components/staff-provider.react.js'; import { resolveKeyserverSessionInvalidation } from 'lib/keyserver-conn/recovery-utils.js'; import { keyserverStoreOpsHandlers } from 'lib/ops/keyserver-store-ops.js'; import { reportStoreOpsHandlers } from 'lib/ops/report-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { userStoreOpsHandlers } from 'lib/ops/user-store-ops.js'; import { cookieSelector, urlPrefixSelector, } from 'lib/selectors/keyserver-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import { logInActionSources, type LogInActionSource, } from 'lib/types/account-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { filesystemMediaCache } from '../media/media-cache.js'; import { commCoreModule } from '../native-modules.js'; import { setStoreLoadedActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import Alert from '../utils/alert.js'; import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; async function clearSensitiveData() { await commCoreModule.clearSensitiveData(); try { await filesystemMediaCache.clearCache(); } catch { throw new Error('clear_media_cache_failed'); } } function SQLiteDataHandler(): React.Node { const storeLoaded = useSelector(state => state.storeLoaded); const dispatch = useDispatch(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); - const cookie = useSelector(cookieSelector(ashoatKeyserverID)); - const urlPrefix = useSelector(urlPrefixSelector(ashoatKeyserverID)); + const cookie = useSelector(cookieSelector(authoritativeKeyserverID)); + const urlPrefix = useSelector(urlPrefixSelector(authoritativeKeyserverID)); invariant(urlPrefix, "missing urlPrefix for ashoat's keyserver"); const staffCanSee = useStaffCanSee(); const { staffUserHasBeenLoggedIn } = useStaffContext(); const loggedIn = useSelector(isLoggedIn); const currentLoggedInUserID = useSelector(state => state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, ); const mediaCacheContext = React.useContext(MediaCacheContext); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage(nativeNotificationsSessionCreator); const callFetchNewCookieFromNativeCredentials = React.useCallback( async (source: LogInActionSource) => { try { await resolveKeyserverSessionInvalidation( dispatch, cookie, urlPrefix, source, - ashoatKeyserverID, + authoritativeKeyserverID, getInitialNotificationsEncryptedMessage, ); dispatch({ type: setStoreLoadedActionType }); } catch (fetchCookieException) { if (staffCanSee) { Alert.alert( `Error fetching new cookie from native credentials: ${ getMessageForException(fetchCookieException) ?? '{no exception message}' }. Please kill the app.`, ); } else { commCoreModule.terminate(); } } }, [ cookie, dispatch, staffCanSee, urlPrefix, getInitialNotificationsEncryptedMessage, ], ); const callClearSensitiveData = React.useCallback( async (triggeredBy: string) => { await clearSensitiveData(); console.log(`SQLite database deletion was triggered by ${triggeredBy}`); }, [], ); const handleSensitiveData = React.useCallback(async () => { try { const databaseCurrentUserInfoID = await commCoreModule.getCurrentUserID(); if ( databaseCurrentUserInfoID && databaseCurrentUserInfoID !== currentLoggedInUserID ) { await callClearSensitiveData('change in logged-in user credentials'); } if (currentLoggedInUserID) { await commCoreModule.setCurrentUserID(currentLoggedInUserID); } } catch (e) { if (isTaskCancelledError(e)) { return; } if (__DEV__) { throw e; } console.log(e); if (e.message !== 'clear_media_cache_failed') { commCoreModule.terminate(); } } }, [callClearSensitiveData, currentLoggedInUserID]); React.useEffect(() => { if (!rehydrateConcluded) { return; } const databaseNeedsDeletion = commCoreModule.checkIfDatabaseNeedsDeletion(); if (databaseNeedsDeletion) { void (async () => { try { await callClearSensitiveData('detecting corrupted database'); } catch (e) { if (__DEV__) { throw e; } console.log(e); if (e.message !== 'clear_media_cache_failed') { commCoreModule.terminate(); } } await callFetchNewCookieFromNativeCredentials( logInActionSources.corruptedDatabaseDeletion, ); })(); return; } const sensitiveDataHandled = handleSensitiveData(); if (storeLoaded) { return; } if (!loggedIn) { dispatch({ type: setStoreLoadedActionType }); return; } void (async () => { await Promise.all([ sensitiveDataHandled, mediaCacheContext?.evictCache(), ]); try { const { threads, messages, drafts, messageStoreThreads, reports, users, keyservers, } = await commCoreModule.getClientDBStore(); const threadInfosFromDB = threadStoreOpsHandlers.translateClientDBData(threads); const reportsFromDB = reportStoreOpsHandlers.translateClientDBData(reports); const usersFromDB = userStoreOpsHandlers.translateClientDBData(users); const keyserverInfosFromDB = keyserverStoreOpsHandlers.translateClientDBData(keyservers); dispatch({ type: setClientDBStoreActionType, payload: { drafts, messages, threadStore: { threadInfos: threadInfosFromDB }, currentUserID: currentLoggedInUserID, messageStoreThreads, reports: reportsFromDB, users: usersFromDB, keyserverInfos: keyserverInfosFromDB, }, }); } catch (setStoreException) { if (isTaskCancelledError(setStoreException)) { dispatch({ type: setStoreLoadedActionType }); return; } if (staffCanSee) { Alert.alert( 'Error setting threadStore or messageStore', getMessageForException(setStoreException) ?? '{no exception message}', ); } await callFetchNewCookieFromNativeCredentials( logInActionSources.sqliteLoadFailure, ); } })(); }, [ currentLoggedInUserID, handleSensitiveData, loggedIn, cookie, dispatch, rehydrateConcluded, staffCanSee, storeLoaded, urlPrefix, staffUserHasBeenLoggedIn, callFetchNewCookieFromNativeCredentials, callClearSensitiveData, mediaCacheContext, ]); return null; } export { SQLiteDataHandler, clearSensitiveData }; diff --git a/native/media/encrypted-image.react.js b/native/media/encrypted-image.react.js index 3d230db61..b140163d4 100644 --- a/native/media/encrypted-image.react.js +++ b/native/media/encrypted-image.react.js @@ -1,123 +1,123 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { MediaCacheContext } from 'lib/components/media-cache-provider.react.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { decryptBase64, useFetchAndDecryptMedia } from './encryption-utils.js'; import LoadableImage from './loadable-image.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ImageSource } from '../types/react-native.js'; import type { ImageStyle } from '../types/styles.js'; type BaseProps = { +blobURI: string, +encryptionKey: string, +onLoad?: (uri: string) => void, +spinnerColor: string, +style: ImageStyle, +invisibleLoad: boolean, +thumbHash?: ?string, }; type Props = { ...BaseProps, }; function EncryptedImage(props: Props): React.Node { const { blobURI, encryptionKey, onLoad: onLoadProp, thumbHash: encryptedThumbHash, } = props; const fetchAndDecryptMedia = useFetchAndDecryptMedia(); const mediaCache = React.useContext(MediaCacheContext); const [source, setSource] = React.useState(null); - const connection = useSelector(connectionSelector(ashoatKeyserverID)); + const connection = useSelector(connectionSelector(authoritativeKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const connectionStatus = connection.status; const prevConnectionStatusRef = React.useRef(connectionStatus); const [attempt, setAttempt] = React.useState(0); const [errorOccured, setErrorOccured] = React.useState(false); if (prevConnectionStatusRef.current !== connectionStatus) { if (!source && connectionStatus === 'connected') { setAttempt(attempt + 1); } prevConnectionStatusRef.current = connectionStatus; } const placeholder = React.useMemo(() => { if (!encryptedThumbHash) { return null; } try { const decryptedThumbHash = decryptBase64( encryptedThumbHash, encryptionKey, ); return { thumbhash: decryptedThumbHash }; } catch (e) { return null; } }, [encryptedThumbHash, encryptionKey]); React.useEffect(() => { let isMounted = true; setSource(null); const loadDecrypted = async () => { const cached = await mediaCache?.get(blobURI); if (cached && isMounted) { setSource({ uri: cached }); return; } const { result } = await fetchAndDecryptMedia(blobURI, encryptionKey, { destination: 'data_uri', }); if (isMounted) { if (result.success) { void mediaCache?.set(blobURI, result.uri); setSource({ uri: result.uri }); } else { setErrorOccured(true); } } }; void loadDecrypted(); return () => { isMounted = false; }; }, [attempt, blobURI, encryptionKey, mediaCache, fetchAndDecryptMedia]); const onLoad = React.useCallback(() => { onLoadProp && onLoadProp(blobURI); }, [blobURI, onLoadProp]); const { style, spinnerColor, invisibleLoad } = props; return ( ); } export default EncryptedImage; diff --git a/native/media/remote-image.react.js b/native/media/remote-image.react.js index b222d446a..8b7853e90 100644 --- a/native/media/remote-image.react.js +++ b/native/media/remote-image.react.js @@ -1,104 +1,104 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { type ConnectionStatus } from 'lib/types/socket-types.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import LoadableImage from './loadable-image.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { useSelector } from '../redux/redux-utils.js'; import type { ImageSource } from '../types/react-native.js'; import type { ImageStyle } from '../types/styles.js'; type BaseProps = { +uri: string, +onLoad?: (uri: string) => void, +spinnerColor: string, +style: ImageStyle, +invisibleLoad: boolean, +placeholder?: ?ImageSource, }; type Props = { ...BaseProps, +connectionStatus: ConnectionStatus, }; type State = { +attempt: number, }; class RemoteImage extends React.PureComponent { loaded: boolean = false; state: State = { attempt: 0, }; componentDidUpdate(prevProps: Props) { if ( !this.loaded && this.props.connectionStatus === 'connected' && prevProps.connectionStatus !== 'connected' ) { this.setState(otherPrevState => ({ attempt: otherPrevState.attempt + 1, })); } } render(): React.Node { const { style, spinnerColor, invisibleLoad, uri, placeholder } = this.props; const source = { uri }; return ( ); } onLoad = () => { this.loaded = true; this.props.onLoad && this.props.onLoad(this.props.uri); }; } function ConnectedRemoteImage(props: BaseProps): React.Node { - const connection = useSelector(connectionSelector(ashoatKeyserverID)); + const connection = useSelector(connectionSelector(authoritativeKeyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const connectionStatus = connection.status; const { uri, onLoad, spinnerColor, style, invisibleLoad, placeholder } = props; const connectedRemoteImage = React.useMemo( () => ( ), [ connectionStatus, invisibleLoad, onLoad, placeholder, spinnerColor, style, uri, ], ); return connectedRemoteImage; } export default ConnectedRemoteImage; diff --git a/native/navigation/navigation-handler.react.js b/native/navigation/navigation-handler.react.js index 1278d710f..bfcd60fbe 100644 --- a/native/navigation/navigation-handler.react.js +++ b/native/navigation/navigation-handler.react.js @@ -1,87 +1,87 @@ // @flow import * as React from 'react'; import { cookieSelector } from 'lib/selectors/keyserver-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { logInActionType, logOutActionType } from './action-types.js'; import ModalPruner from './modal-pruner.react.js'; import NavFromReduxHandler from './nav-from-redux-handler.react.js'; import { useIsAppLoggedIn } from './nav-selectors.js'; import { NavContext, type NavAction } from './navigation-context.js'; import PolicyAcknowledgmentHandler from './policy-acknowledgment-handler.react.js'; import ThreadScreenTracker from './thread-screen-tracker.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import DevTools from '../redux/dev-tools.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors.js'; const NavigationHandler: React.ComponentType<{}> = React.memo<{}>( function NavigationHandler() { const navContext = React.useContext(NavContext); const persistedStateLoaded = usePersistedStateLoaded(); const devTools = __DEV__ ? : null; if (!navContext || !persistedStateLoaded) { if (__DEV__) { return ( <> {devTools} ); } else { return null; } } const { dispatch } = navContext; return ( <> {devTools} ); }, ); NavigationHandler.displayName = 'NavigationHandler'; type LogInHandlerProps = { +dispatch: (action: NavAction) => void, }; const LogInHandler = React.memo(function LogInHandler( props: LogInHandlerProps, ) { const { dispatch } = props; const hasCurrentUserInfo = useSelector(isLoggedIn); - const cookie = useSelector(cookieSelector(ashoatKeyserverID)); + const cookie = useSelector(cookieSelector(authoritativeKeyserverID)); const hasUserCookie = !!(cookie && cookie.startsWith('user=')); const loggedIn = hasCurrentUserInfo && hasUserCookie; const navLoggedIn = useIsAppLoggedIn(); const prevLoggedInRef = React.useRef(); React.useEffect(() => { if (loggedIn === prevLoggedInRef.current) { return; } prevLoggedInRef.current = loggedIn; if (loggedIn && !navLoggedIn) { dispatch({ type: (logInActionType: 'LOG_IN') }); } else if (!loggedIn && navLoggedIn) { dispatch({ type: (logOutActionType: 'LOG_OUT') }); } }, [navLoggedIn, loggedIn, dispatch]); return null; }); LogInHandler.displayName = 'LogInHandler'; export default NavigationHandler; diff --git a/native/profile/custom-server-modal.react.js b/native/profile/custom-server-modal.react.js index d03fa4b5d..ddb44acac 100644 --- a/native/profile/custom-server-modal.react.js +++ b/native/profile/custom-server-modal.react.js @@ -1,140 +1,140 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; import { setCustomServerActionType } from 'lib/actions/custom-server-actions.js'; import { urlPrefixSelector } from 'lib/selectors/keyserver-selectors.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { setURLPrefix } from 'lib/utils/url-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import Button from '../components/button.react.js'; import Modal from '../components/modal.react.js'; import TextInput from '../components/text-input.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type CustomServerModalParams = { +presentedFrom: string, }; const unboundStyles = { button: { backgroundColor: 'vibrantGreenButton', borderRadius: 5, marginHorizontal: 2, marginVertical: 2, paddingHorizontal: 12, paddingVertical: 4, }, buttonText: { color: 'white', fontSize: 18, textAlign: 'center', }, container: { justifyContent: 'flex-end', }, modal: { flex: 0, flexDirection: 'row', }, textInput: { color: 'modalBackgroundLabel', flex: 1, fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; type BaseProps = { +navigation: RootNavigationProp<'CustomServerModal'>, +route: NavigationRoute<'CustomServerModal'>, }; type Props = { ...BaseProps, +urlPrefix: string, +customServer: ?string, +styles: $ReadOnly, +dispatch: Dispatch, }; type State = { +customServer: string, }; class CustomServerModal extends React.PureComponent { constructor(props: Props) { super(props); const { customServer } = props; this.state = { customServer: customServer ? customServer : '', }; } render(): React.Node { return ( ); } onChangeCustomServer = (newCustomServer: string) => { this.setState({ customServer: newCustomServer }); }; onPressGo = () => { const { customServer } = this.state; if (customServer !== this.props.urlPrefix) { this.props.dispatch({ type: setURLPrefix, payload: customServer, }); } if (customServer && customServer !== this.props.customServer) { this.props.dispatch({ type: setCustomServerActionType, payload: customServer, }); } this.props.navigation.goBackOnce(); }; } const ConnectedCustomServerModal: React.ComponentType = React.memo(function ConnectedCustomServerModal(props: BaseProps) { - const urlPrefix = useSelector(urlPrefixSelector(ashoatKeyserverID)); + const urlPrefix = useSelector(urlPrefixSelector(authoritativeKeyserverID)); invariant(urlPrefix, "missing urlPrefix for ashoat's keyserver"); const customServer = useSelector(state => state.customServer); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); return ( ); }); export default ConnectedCustomServerModal; diff --git a/native/profile/dev-tools.react.js b/native/profile/dev-tools.react.js index c0bb78475..f8833178f 100644 --- a/native/profile/dev-tools.react.js +++ b/native/profile/dev-tools.react.js @@ -1,260 +1,260 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { urlPrefixSelector } from 'lib/selectors/keyserver-selectors.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { setURLPrefix } from 'lib/utils/url-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { commCoreModule } from '../native-modules.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { CustomServerModalRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, useStyles, type Colors } from '../themes/colors.js'; import { wipeAndExit } from '../utils/crash-utils.js'; import { checkForMissingNatDevHostname } from '../utils/dev-hostname.js'; import { nodeServerOptions } from '../utils/url-utils.js'; const ServerIcon = () => ( ); const unboundStyles = { container: { flex: 1, }, customServerLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, redText: { color: 'redText', flex: 1, fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, serverContainer: { flex: 1, }, serverText: { color: 'panelForegroundLabel', fontSize: 16, }, slightlyPaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, }; type BaseProps = { +navigation: ProfileNavigationProp<'DevTools'>, +route: NavigationRoute<'DevTools'>, }; type Props = { ...BaseProps, +urlPrefix: string, +customServer: ?string, +colors: Colors, +styles: $ReadOnly, +dispatch: Dispatch, }; class DevTools extends React.PureComponent { render(): React.Node { const { panelIosHighlightUnderlay: underlay } = this.props.colors; const serverButtons: Array = []; for (const server of nodeServerOptions) { const icon = server === this.props.urlPrefix ? : null; serverButtons.push( , ); serverButtons.push( , ); } const customServerLabel = this.props.customServer ? ( {'custom: '} {this.props.customServer} ) : ( custom ); const customServerIcon = this.props.customServer === this.props.urlPrefix ? : null; serverButtons.push( , ); return ( SERVER {serverButtons} ); } onPressCrash = (): empty => { throw new Error('User triggered crash through dev menu!'); }; onPressKill = () => { commCoreModule.terminate(); }; onPressWipe = async () => { await wipeAndExit(); }; onSelectServer = (server: string) => { if (server !== this.props.urlPrefix) { this.props.dispatch({ type: setURLPrefix, payload: server, }); } }; onSelectCustomServer = () => { checkForMissingNatDevHostname(); this.props.navigation.navigate(CustomServerModalRouteName, { presentedFrom: this.props.route.key, }); }; } const ConnectedDevTools: React.ComponentType = React.memo( function ConnectedDevTools(props: BaseProps) { - const urlPrefix = useSelector(urlPrefixSelector(ashoatKeyserverID)); + const urlPrefix = useSelector(urlPrefixSelector(authoritativeKeyserverID)); invariant(urlPrefix, "missing urlPrefix for ashoat's keyserver"); const customServer = useSelector(state => state.customServer); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); return ( ); }, ); export default ConnectedDevTools; diff --git a/native/push/android.js b/native/push/android.js index 7b69033a4..cb049e295 100644 --- a/native/push/android.js +++ b/native/push/android.js @@ -1,104 +1,105 @@ // @flow import { NativeModules, NativeEventEmitter } from 'react-native'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import { convertNonPendingIDToNewSchema, convertNotificationMessageInfoToNewIDSchema, } from 'lib/utils/migration-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; + +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; type CommAndroidNotificationsConstants = { +NOTIFICATIONS_IMPORTANCE_HIGH: number, +COMM_ANDROID_NOTIFICATIONS_TOKEN: 'commAndroidNotificationsToken', +COMM_ANDROID_NOTIFICATIONS_MESSAGE: 'commAndroidNotificationsMessage', +COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED: 'commAndroidNotificationsNotificationOpened', }; type CommAndroidNotificationsModuleType = { +removeAllActiveNotificationsForThread: (threadID: string) => void, +getInitialNotificationThreadID: () => Promise, +createChannel: ( channelID: string, name: string, importance: number, description: ?string, ) => void, +getConstants: () => CommAndroidNotificationsConstants, +setBadge: (count: number) => void, +removeAllDeliveredNotifications: () => void, +hasPermission: () => Promise, +getToken: () => Promise, +requestNotificationsPermission: () => Promise, +canRequestNotificationsPermissionFromUser: () => Promise, ...CommAndroidNotificationsConstants, }; export type AndroidMessage = { +body: string, +title: string, +threadID: string, +prefix?: string, +messageInfos: ?string, }; export type ParsedAndroidMessage = { ...AndroidMessage, +messageInfos: ?$ReadOnlyArray, }; function parseAndroidMessage(message: AndroidMessage): ParsedAndroidMessage { return { ...message, threadID: convertNonPendingIDToNewSchema( message.threadID, - ashoatKeyserverID, + authoritativeKeyserverID, ), messageInfos: convertNotificationMessageInfoToNewIDSchema( message.messageInfos, ), }; } const { CommAndroidNotificationsEventEmitter } = NativeModules; const CommAndroidNotifications: CommAndroidNotificationsModuleType = NativeModules.CommAndroidNotifications; const androidNotificationChannelID = 'default'; function handleAndroidMessage( message: ParsedAndroidMessage, updatesCurrentAsOf: number, handleIfActive?: ( threadID: string, texts: { body: string, title: ?string }, ) => boolean, ) { const { title, prefix, threadID } = message; let { body } = message; ({ body } = mergePrefixIntoBody({ body, title, prefix })); if (handleIfActive) { const texts = { title, body }; const isActive = handleIfActive(threadID, texts); if (isActive) { return; } } } function getCommAndroidNotificationsEventEmitter(): NativeEventEmitter<{ commAndroidNotificationsToken: [string], commAndroidNotificationsMessage: [AndroidMessage], commAndroidNotificationsNotificationOpened: [string], }> { return new NativeEventEmitter(CommAndroidNotificationsEventEmitter); } export { parseAndroidMessage, androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, CommAndroidNotifications, }; diff --git a/native/push/comm-ios-notification.js b/native/push/comm-ios-notification.js index 5a70d8fac..dbfdf9e40 100644 --- a/native/push/comm-ios-notification.js +++ b/native/push/comm-ios-notification.js @@ -1,79 +1,80 @@ // @flow import { NativeModules } from 'react-native'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import { convertNonPendingIDToNewSchema, convertNotificationMessageInfoToNewIDSchema, } from 'lib/utils/migration-utils.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; + +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; const { CommIOSNotifications } = NativeModules; // This is the basic data we receive from Objective-C // Its keys are explained as follow: // `id` - unique ID generated by keyserver // `message` - comes from `alert` property of raw Apple payload // which carries displayable content of the notification // `body` and `title` - actual content of the // message and sender name respectively export type CoreIOSNotificationData = { +id: string, +message: ?string, +threadID: string, +title: ?string, +messageInfos: ?string, +body: ?string, +prefix?: string, }; // Objective-C can also include notification request identifier // associated with certain notification so that we can interact // with notification center from JS. Read for explanation: // https://developer.apple.com/documentation/usernotifications/unnotificationrequest?language=objc export type CoreIOSNotificationDataWithRequestIdentifier = { ...CoreIOSNotificationData, +identifier: string, }; export type ParsedCoreIOSNotificationData = { ...CoreIOSNotificationData, +messageInfos: ?$ReadOnlyArray, }; export class CommIOSNotification { data: ParsedCoreIOSNotificationData; remoteNotificationCompleteCallbackCalled: boolean; constructor(notification: CoreIOSNotificationData) { this.remoteNotificationCompleteCallbackCalled = false; this.data = { ...notification, threadID: convertNonPendingIDToNewSchema( notification.threadID, - ashoatKeyserverID, + authoritativeKeyserverID, ), messageInfos: convertNotificationMessageInfoToNewIDSchema( notification.messageInfos, ), }; } getMessage(): ?string { return this.data.message; } getData(): ParsedCoreIOSNotificationData { return this.data; } finish(fetchResult: string) { if (!this.data.id || this.remoteNotificationCompleteCallbackCalled) { return; } CommIOSNotifications.completeNotif(this.data.id, fetchResult); this.remoteNotificationCompleteCallbackCalled = true; } } diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 8c2229c0f..3eeaae386 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,743 +1,745 @@ // @flow import * as Haptics from 'expo-haptics'; import invariant from 'invariant'; import * as React from 'react'; import { LogBox, Platform } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import type { DeviceTokens, SetDeviceTokenActionPayload, } from 'lib/actions/device-actions.js'; import { setDeviceTokenActionTypes, useSetDeviceToken, useSetDeviceTokenFanout, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import { connectionSelector, deviceTokensSelector, updatesCurrentAsOfSelector, } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector, unreadCount, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ConnectionInfo } from 'lib/types/socket-types.js'; import type { GlobalTheme } from 'lib/types/theme-types.js'; import { convertNonPendingIDToNewSchema, convertNotificationMessageInfoToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { type NotifPermissionAlertInfo, recordNotifPermissionAlertActionType, shouldSkipPushPermissionAlert, } from 'lib/utils/push-alerts.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { type AndroidMessage, androidNotificationChannelID, CommAndroidNotifications, getCommAndroidNotificationsEventEmitter, handleAndroidMessage, parseAndroidMessage, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { CommIOSNotifications, type CoreIOSNotificationBackgroundData, getCommIOSNotificationsEventEmitter, iosPushPermissionResponseReceived, requestIOSPushPermissions, } from './ios.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; import Alert from '../utils/alert.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: number, +deviceTokens: { +[keyserverID: string]: ?string, }, +threadInfos: { +[id: string]: ThreadInfo, }, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +updatesCurrentAsOf: number, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: ( input: DeviceTokens, ) => Promise, +setDeviceTokenFanout: ( deviceToken: ?string, ) => Promise, // withRootContext +rootContext: ?RootContextType, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, this.iosBackgroundNotificationReceived, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } if (this.props.connection.status === 'connected') { this.updateBadgeCount(); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { void this.ensurePushNotifsEnabled(); } else { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for const deviceTokensMap: { [string]: string } = {}; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } if ( this.props.connection.status === 'connected' && (prevProps.connection.status !== 'connected' || this.props.unreadCount !== prevProps.unreadCount) ) { this.updateBadgeCount(); } for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if (this.props.loggedIn && !prevProps.loggedIn) { void this.ensurePushNotifsEnabled(); } else { for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; const prevDeviceToken = prevProps.deviceTokens[keyserverID]; if (!deviceToken && prevDeviceToken) { void this.ensurePushNotifsEnabled(); break; } } } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); this.resetBadgeCount(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } updateBadgeCount() { const curUnreadCount = this.props.unreadCount; if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(curUnreadCount); } } resetBadgeCount() { if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(0); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(0); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { let missingDeviceToken = false; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken === null || deviceToken === undefined) { missingDeviceToken = true; break; } } await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); return await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = (): Promise => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotifThreadID = await CommAndroidNotifications.getInitialNotificationThreadID(); if (initialNotifThreadID) { await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } const deviceTokensMap: { [string]: ?string } = {}; for (const keyserverID in this.props.deviceTokens) { const keyserverDeviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken !== keyserverDeviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap); }; setDeviceToken(deviceTokens: DeviceTokens) { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceTokens), ); } setAllDeviceTokensNull = () => { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceTokenFanout(null), ); }; failedToRegisterPushPermissionsIOS = () => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } this.props.dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(rawMessageInfos: ?$ReadOnlyArray) { if (!rawMessageInfos) { return; } const { updatesCurrentAsOf } = this.props; this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos, updatesCurrentAsOf }, }); } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; iosBackgroundNotificationReceived = ( backgroundData: CoreIOSNotificationBackgroundData, ) => { const convertedMessageInfos = backgroundData.messageInfosArray .flatMap(convertNotificationMessageInfoToNewIDSchema) .filter(Boolean); if (!convertedMessageInfos.length) { return; } this.saveMessageInfos(convertedMessageInfos); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (threadID: string) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, - ashoatKeyserverID, + authoritativeKeyserverID, ); this.onPushNotifBootsApp(); this.onPressNotificationForThread(convertedThreadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); handleAndroidMessage( parsedMessage, this.props.updatesCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ): boolean => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render(): React.Node { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(unreadCount); const deviceTokens = useSelector(deviceTokensSelector); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); - const connection = useSelector(connectionSelector(ashoatKeyserverID)); + const connection = useSelector( + connectionSelector(authoritativeKeyserverID), + ); invariant(connection, 'keyserver missing from keyserverStore'); const updatesCurrentAsOf = useSelector( - updatesCurrentAsOfSelector(ashoatKeyserverID), + updatesCurrentAsOfSelector(authoritativeKeyserverID), ); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceToken(); const callSetDeviceTokenFanout = useSetDeviceTokenFanout(); const rootContext = React.useContext(RootContext); return ( ); }); export default ConnectedPushHandler; diff --git a/native/redux/default-state.js b/native/redux/default-state.js index 40c86998e..4c7a33334 100644 --- a/native/redux/default-state.js +++ b/native/redux/default-state.js @@ -1,85 +1,85 @@ // @flow import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarQuery } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { defaultKeyserverInfo } from 'lib/types/keyserver-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { defaultDimensionsInfo } from './dimensions-updater.react.js'; import type { AppState } from './state-types.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { defaultNavInfo } from '../navigation/default-state.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { defaultConnectivityInfo } from '../types/connectivity.js'; import { defaultURLPrefix, natNodeServer } from '../utils/url-utils.js'; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, draftStore: { drafts: {} }, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, }, messageStore: { messages: {}, threads: {}, local: {}, - currentAsOf: { [ashoatKeyserverID]: 0 }, + currentAsOf: { [authoritativeKeyserverID]: 0 }, }, storeLoaded: false, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, dataLoaded: false, customServer: natNodeServer, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, actualizedCalendarQuery: defaultCalendarQuery(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', enabledApps: defaultEnabledApps, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [], }, nextLocalID: 0, _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, userPolicies: {}, commServicesAccessToken: null, inviteLinksStore: { links: {}, }, keyserverStore: { keyserverInfos: { - [ashoatKeyserverID]: defaultKeyserverInfo(defaultURLPrefix), + [authoritativeKeyserverID]: defaultKeyserverInfo(defaultURLPrefix), }, }, localSettings: { isBackupEnabled: false, }, threadActivityStore: {}, integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, }: AppState); export { defaultState }; diff --git a/native/redux/persist.js b/native/redux/persist.js index 53d8e37bb..dbec17d8a 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,1174 +1,1178 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createTransform } from 'redux-persist'; import type { Transform } from 'redux-persist/es/types.js'; import { convertEntryStoreToNewIDSchema, convertInviteLinksStoreToNewIDSchema, convertMessageStoreToNewIDSchema, convertRawMessageInfoToNewIDSchema, convertCalendarFilterToNewIDSchema, convertConnectionInfoToNewIDSchema, } from 'lib/_generated/migration-utils.js'; import { type ClientDBKeyserverStoreOperation, keyserverStoreOpsHandlers, type ReplaceKeyserverOperation, } from 'lib/ops/keyserver-store-ops.js'; import { type ClientDBMessageStoreOperation, messageStoreOpsHandlers, } from 'lib/ops/message-store-ops.js'; import { type ReportStoreOperation, type ClientDBReportStoreOperation, convertReportsToReplaceReportOps, reportStoreOpsHandlers, } from 'lib/ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { type ClientDBUserStoreOperation, type UserStoreOperation, convertUserInfosToReplaceUserOps, userStoreOpsHandlers, } from 'lib/ops/user-store-ops.js'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors.js'; import { createAsyncMigrate } from 'lib/shared/create-async-migrate.js'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils.js'; import { getContainingThreadID, getCommunity, assertAllThreadInfosAreLegacy, } from 'lib/shared/thread-utils.js'; import { keyserverStoreTransform } from 'lib/shared/transforms/keyserver-store-transform.js'; import { DEPRECATED_unshimMessageStore, unshimFunc, } from 'lib/shared/unshim-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarQuery } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { KeyserverInfo } from 'lib/types/keyserver-types.js'; import { messageTypes, type MessageType, } from 'lib/types/message-types-enum.js'; import { type LocalMessageInfo, type MessageStore, type MessageStoreThreads, type RawMessageInfo, } from 'lib/types/message-types.js'; import { minimallyEncodeRawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ReportStore, ClientReportCreationRequest, } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ClientDBThreadInfo, LegacyRawThreadInfo, MixedRawThreadInfos, } from 'lib/types/thread-types.js'; import { wipeKeyserverStore } from 'lib/utils/keyserver-store-utils.js'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { entries } from 'lib/utils/objects.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { resetUserSpecificState } from 'lib/utils/reducers-utils.js'; import { deprecatedConvertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, } from 'lib/utils/thread-ops-utils.js'; import { getUUID } from 'lib/utils/uuid.js'; -import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { createUpdateDBOpsForMessageStoreMessages, createUpdateDBOpsForMessageStoreThreads, } from './client-db-utils.js'; import { defaultState } from './default-state.js'; import { deprecatedCreateUpdateDBOpsForThreadStoreThreadInfos, deprecatedUpdateClientDBThreadStoreThreadInfos, } from './deprecated-client-db-utils.js'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js'; import { legacyUpdateRolesAndPermissions } from './legacy-update-roles-and-permissions.js'; import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js'; import { persistMigrationToRemoveSelectRolePermissions } from './remove-select-role-permissions.js'; import type { AppState } from './state-types.js'; import { nonUserSpecificFieldsNative } from './state-types.js'; import { unshimClientDB } from './unshim-utils.js'; +import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { commCoreModule } from '../native-modules.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; import { defaultURLPrefix } from '../utils/url-utils.js'; const persistBlacklist = [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'draftStore', 'connectivity', 'deviceOrientation', 'frozen', 'threadStore', 'storeLoaded', 'connection', ]; function handleReduxMigrationFailure(oldState: AppState): AppState { const persistedNonUserSpecificFields = nonUserSpecificFieldsNative.filter( field => !persistBlacklist.includes(field) || field === '_persist', ); const stateAfterReset = resetUserSpecificState( oldState, defaultState, persistedNonUserSpecificFields, ); return { ...stateAfterReset, keyserverStore: wipeKeyserverStore(stateAfterReset.keyserverStore), }; } const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: (state: any) => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: (state: any) => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: (state: AppState) => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: { ...defaultConnectionInfo, actualizedCalendarQuery: defaultCalendarQuery(Platform.OS), }, watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: any) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: any) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.IMAGES, ]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: (state: any) => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: (state: any) => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: (state: any) => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: (state: AppState) => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: (state: any) => { const threadInfos: { [string]: LegacyRawThreadInfo } = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: (state: any) => { for (const key in state.drafts) { const value = state.drafts[key]; try { void commCoreModule.updateDraft(key, value); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: (state: AppState) => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: (state: AppState) => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: (state: any) => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: (state: any) => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: (state: AppState) => { const threadParentToChildren: { [string]: string[] } = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos: { [string]: LegacyRawThreadInfo | RawThreadInfo, } = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const legacyRawThreadInfos: { +[id: string]: LegacyRawThreadInfo, } = assertAllThreadInfosAreLegacy(state.threadStore.threadInfos); const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions(legacyRawThreadInfos); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( threadStoreOpsHandlers.convertOpsToClientDBOps(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [33]: (state: AppState) => unshimClientDB(state, [messageTypes.REACTION]), [34]: (state: any) => { const { threadIDsToNotifIDs, ...stateSansThreadIDsToNotifIDs } = state; return stateSansThreadIDsToNotifIDs; }, [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [36]: (state: AppState) => { // 1. Get threads and messages from SQLite `threads` and `messages` tables. const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and // `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawThreadInfos = clientDBThreadInfos.map( deprecatedConvertClientDBThreadInfoToRawThreadInfo, ); const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])), ); // 4. Filter out non-TOGGLE_PIN messages const filteredRawMessageInfos = unshimmedRawMessageInfos .map(messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN ? messageInfo : null, ) .filter(Boolean); // 5. We want only the last TOGGLE_PIN message for each message ID, // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1. const lastMessageIDToRawMessageInfoMap = new Map(); for (const messageInfo of filteredRawMessageInfos) { const { targetMessageID } = messageInfo; lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo); } const lastMessageIDToRawMessageInfos = Array.from( lastMessageIDToRawMessageInfoMap.values(), ); // 6. Create a Map of threadIDs to pinnedCount const threadIDsToPinnedCount = new Map(); for (const messageInfo of lastMessageIDToRawMessageInfos) { const { threadID, type } = messageInfo; if (type === messageTypes.TOGGLE_PIN) { const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0; threadIDsToPinnedCount.set(threadID, pinnedCount + 1); } } // 7. Include a pinnedCount for each rawThreadInfo const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({ ...threadInfo, pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0, })); // 8. Convert rawThreadInfos to a map of threadID to threadInfo const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce( ( acc: { [string]: LegacyRawThreadInfo }, threadInfo: LegacyRawThreadInfo, ) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); // 9. Add threadPermission to each threadInfo const rawThreadInfosWithThreadPermission = persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo); // 10. Convert the new threadInfos back into an array const rawThreadInfosWithCountAndPermission = Object.keys( rawThreadInfosWithThreadPermission, ).map(id => rawThreadInfosWithThreadPermission[id]); // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. const convertedClientDBThreadInfos = rawThreadInfosWithCountAndPermission.map( convertRawThreadInfoToClientDBThreadInfo, ); // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` // table and repopulate with `ClientDBThreadInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; // 13. Try processing `ClientDBThreadStoreOperation`s and log out if // `processThreadStoreOperationsSync(...)` throws an exception. try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, [37]: (state: AppState) => { const operations = messageStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads: state.messageStore.threads }, }, ]); try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.error(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [38]: (state: AppState) => deprecatedUpdateClientDBThreadStoreThreadInfos( state, legacyUpdateRolesAndPermissions, ), [39]: (state: AppState) => unshimClientDB(state, [messageTypes.EDIT_MESSAGE]), [40]: (state: AppState) => deprecatedUpdateClientDBThreadStoreThreadInfos( state, legacyUpdateRolesAndPermissions, ), [41]: (state: AppState) => { const queuedReports = state.reportStore.queuedReports.map(report => ({ ...report, id: getUUID(), })); return { ...state, reportStore: { ...state.reportStore, queuedReports }, }; }, [42]: (state: AppState) => { const reportStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_reports' }, ...convertReportsToReplaceReportOps(state.reportStore.queuedReports), ]; const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [43]: async (state: any) => { const { messages, drafts, threads, messageStoreThreads } = await commCoreModule.getClientDBStore(); const messageStoreThreadsOperations = createUpdateDBOpsForMessageStoreThreads( messageStoreThreads, convertMessageStoreThreadsToNewIDSchema, ); const messageStoreMessagesOperations = createUpdateDBOpsForMessageStoreMessages(messages, messageInfos => messageInfos.map(convertRawMessageInfoToNewIDSchema), ); const threadOperations = deprecatedCreateUpdateDBOpsForThreadStoreThreadInfos( threads, convertThreadStoreThreadInfosToNewIDSchema, ); const draftOperations = generateIDSchemaMigrationOpsForDrafts(drafts); try { await Promise.all([ commCoreModule.processMessageStoreOperations([ ...messageStoreMessagesOperations, ...messageStoreThreadsOperations, ]), commCoreModule.processThreadStoreOperations(threadOperations), commCoreModule.processDraftStoreOperations(draftOperations), ]); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } const inviteLinksStore = state.inviteLinksStore ?? defaultState.inviteLinksStore; return { ...state, entryStore: convertEntryStoreToNewIDSchema(state.entryStore), messageStore: convertMessageStoreToNewIDSchema(state.messageStore), calendarFilters: state.calendarFilters.map( convertCalendarFilterToNewIDSchema, ), connection: convertConnectionInfoToNewIDSchema(state.connection), watchedThreadIDs: state.watchedThreadIDs.map( - id => `${ashoatKeyserverID}|${id}`, + id => `${authoritativeKeyserverID}|${id}`, ), inviteLinksStore: convertInviteLinksStoreToNewIDSchema(inviteLinksStore), }; }, [44]: async (state: any) => { const { cookie, ...rest } = state; return { ...rest, - keyserverStore: { keyserverInfos: { [ashoatKeyserverID]: { cookie } } }, + keyserverStore: { + keyserverInfos: { [authoritativeKeyserverID]: { cookie } }, + }, }; }, [45]: async (state: any) => { const { updatesCurrentAsOf, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, - [ashoatKeyserverID]: { - ...keyserverStore.keyserverInfos[ashoatKeyserverID], + [authoritativeKeyserverID]: { + ...keyserverStore.keyserverInfos[authoritativeKeyserverID], updatesCurrentAsOf, }, }, }, }; }, [46]: async (state: AppState) => { const { currentAsOf } = state.messageStore; return { ...state, messageStore: { ...state.messageStore, - currentAsOf: { [ashoatKeyserverID]: currentAsOf }, + currentAsOf: { [authoritativeKeyserverID]: currentAsOf }, }, }; }, [47]: async (state: any) => { const { urlPrefix, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, - [ashoatKeyserverID]: { - ...keyserverStore.keyserverInfos[ashoatKeyserverID], + [authoritativeKeyserverID]: { + ...keyserverStore.keyserverInfos[authoritativeKeyserverID], urlPrefix, }, }, }, }; }, [48]: async (state: any) => { const { connection, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, - [ashoatKeyserverID]: { - ...keyserverStore.keyserverInfos[ashoatKeyserverID], + [authoritativeKeyserverID]: { + ...keyserverStore.keyserverInfos[authoritativeKeyserverID], connection, }, }, }, }; }, [49]: async (state: AppState) => { const { keyserverStore, ...rest } = state; const { connection, ...keyserverRest } = - keyserverStore.keyserverInfos[ashoatKeyserverID]; + keyserverStore.keyserverInfos[authoritativeKeyserverID]; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, - [ashoatKeyserverID]: { + [authoritativeKeyserverID]: { ...keyserverRest, }, }, }, connection, }; }, [50]: async (state: any) => { const { connection, ...rest } = state; const { actualizedCalendarQuery, ...connectionRest } = connection; return { ...rest, connection: connectionRest, actualizedCalendarQuery, }; }, [51]: async (state: any) => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, - [ashoatKeyserverID]: { - ...keyserverStore.keyserverInfos[ashoatKeyserverID], + [authoritativeKeyserverID]: { + ...keyserverStore.keyserverInfos[authoritativeKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, [52]: async (state: AppState) => ({ ...state, integrityStore: { threadHashes: {}, threadHashingStatus: 'data_not_loaded', }, }), [53]: (state: any) => { if (!state.userStore.inconsistencyReports) { return state; } const reportStoreOperations = convertReportsToReplaceReportOps( state.userStore.inconsistencyReports, ); const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } const { inconsistencyReports, ...newUserStore } = state.userStore; const queuedReports = reportStoreOpsHandlers.processStoreOperations( state.reportStore.queuedReports, reportStoreOperations, ); return { ...state, userStore: newUserStore, reportStore: { ...state.reportStore, queuedReports, }, }; }, [54]: (state: any) => { let updatedMessageStoreThreads: MessageStoreThreads = {}; for (const threadID: string in state.messageStore.threads) { const { lastNavigatedTo, lastPruned, ...rest } = state.messageStore.threads[threadID]; updatedMessageStoreThreads = { ...updatedMessageStoreThreads, [threadID]: rest, }; } return { ...state, messageStore: { ...state.messageStore, threads: updatedMessageStoreThreads, }, }; }, [55]: async (state: AppState) => __DEV__ ? { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, - [ashoatKeyserverID]: { - ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], + [authoritativeKeyserverID]: { + ...state.keyserverStore.keyserverInfos[ + authoritativeKeyserverID + ], urlPrefix: defaultURLPrefix, }, }, }, } : state, [56]: (state: any) => { const { deviceToken, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, - [ashoatKeyserverID]: { - ...keyserverStore.keyserverInfos[ashoatKeyserverID], + [authoritativeKeyserverID]: { + ...keyserverStore.keyserverInfos[authoritativeKeyserverID], deviceToken, }, }, }, }; }, [57]: async (state: any) => { const { connection, keyserverStore: { keyserverInfos }, ...rest } = state; const newKeyserverInfos: { [string]: KeyserverInfo } = {}; for (const key in keyserverInfos) { newKeyserverInfos[key] = { ...keyserverInfos[key], connection: { ...defaultConnectionInfo }, }; } return { ...rest, keyserverStore: { ...state.keyserverStore, keyserverInfos: newKeyserverInfos, }, }; }, [58]: async (state: AppState) => { const userStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_users' }, ...convertUserInfosToReplaceUserOps(state.userStore.userInfos), ]; const dbOperations: $ReadOnlyArray = userStoreOpsHandlers.convertOpsToClientDBOps(userStoreOperations); try { await commCoreModule.processUserStoreOperations(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, [59]: (state: AppState) => { const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const rawThreadInfos = clientDBThreadInfos.map( deprecatedConvertClientDBThreadInfoToRawThreadInfo, ); const rawThreadInfosObject = rawThreadInfos.reduce( ( acc: { [string]: LegacyRawThreadInfo }, threadInfo: LegacyRawThreadInfo, ) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); const migratedRawThreadInfos = persistMigrationToRemoveSelectRolePermissions(rawThreadInfosObject); const migratedThreadInfosArray = Object.keys(migratedRawThreadInfos).map( id => migratedRawThreadInfos[id], ); const convertedClientDBThreadInfos = migratedThreadInfosArray.map( convertRawThreadInfoToClientDBThreadInfo, ); const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return handleReduxMigrationFailure(state); } return state; }, [60]: (state: AppState) => deprecatedUpdateClientDBThreadStoreThreadInfos( state, legacyUpdateRolesAndPermissions, handleReduxMigrationFailure, ), [61]: (state: AppState) => { const minimallyEncodeThreadInfosFunc = ( threadStoreInfos: MixedRawThreadInfos, ): MixedRawThreadInfos => Object.keys(threadStoreInfos).reduce( ( acc: { [string]: LegacyRawThreadInfo | RawThreadInfo, }, key: string, ) => { const threadInfo = threadStoreInfos[key]; acc[threadInfo.id] = threadInfo.minimallyEncoded ? threadInfo : minimallyEncodeRawThreadInfo(threadInfo); return acc; }, {}, ); return deprecatedUpdateClientDBThreadStoreThreadInfos( state, minimallyEncodeThreadInfosFunc, handleReduxMigrationFailure, ); }, [62]: async (state: AppState) => { const replaceOps: $ReadOnlyArray = entries( state.keyserverStore.keyserverInfos, ).map(([id, keyserverInfo]) => ({ type: 'replace_keyserver', payload: { id, keyserverInfo, }, })); const dbOperations: $ReadOnlyArray = keyserverStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_keyservers' }, ...replaceOps, ]); try { await commCoreModule.processKeyserverStoreOperations(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return handleReduxMigrationFailure(state); } return state; }, }; // After migration 31, we'll no longer want to persist `messageStore.messages` // via redux-persist. However, we DO want to continue persisting everything in // `messageStore` EXCEPT for `messages`. The `blacklist` property in // `persistConfig` allows us to specify top-level keys that shouldn't be // persisted. However, we aren't able to specify nested keys in `blacklist`. // As a result, if we want to prevent nested keys from being persisted we'll // need to use `createTransform(...)` to specify an `inbound` function that // allows us to modify the `state` object before it's passed through // `JSON.stringify(...)` and written to disk. We specify the keys for which // this transformation should be executed in the `whitelist` property of the // `config` object that's passed to `createTransform(...)`. type PersistedMessageStore = { +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: { +[keyserverID: string]: number }, }; const messageStoreMessagesBlocklistTransform: Transform = createTransform( (state: MessageStore): PersistedMessageStore => { const { messages, threads, ...messageStoreSansMessages } = state; return { ...messageStoreSansMessages }; }, (state: MessageStore): MessageStore => { // We typically expect `messageStore.messages` to be `undefined` because // messages are persisted in the SQLite `messages` table rather than via // `redux-persist`. In this case we want to set `messageStore.messages` // to {} so we don't run into issues with `messageStore.messages` being // `undefined` (https://phab.comm.dev/D5545). // // However, in the case that a user is upgrading from a client where // `persistConfig.version` < 31, we expect `messageStore.messages` to // contain messages stored via `redux-persist` that we need in order // to correctly populate the SQLite `messages` table in migration 31 // (https://phab.comm.dev/D2600). // // However, because `messageStoreMessagesBlocklistTransform` modifies // `messageStore` before migrations are run, we need to make sure we aren't // inadvertently clearing `messageStore.messages` (by setting to {}) before // messages are stored in SQLite (https://linear.app/comm/issue/ENG-2377). return { ...state, threads: state.threads ?? {}, messages: state.messages ?? {}, }; }, { whitelist: ['messageStore'] }, ); type PersistedReportStore = $Diff< ReportStore, { +queuedReports: $ReadOnlyArray }, >; const reportStoreTransform: Transform = createTransform( (state: ReportStore): PersistedReportStore => { return { enabledReports: state.enabledReports }; }, (state: PersistedReportStore): ReportStore => { return { ...state, queuedReports: [] }; }, { whitelist: ['reportStore'] }, ); const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: persistBlacklist, debug: __DEV__, version: 62, transforms: [ messageStoreMessagesBlocklistTransform, reportStoreTransform, keyserverStoreTransform, ], migrate: (createAsyncMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): empty { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor };