diff --git a/lib/facts/genesis.json b/lib/facts/genesis.json index f6a0fe5b4..a3e4ef108 100644 --- a/lib/facts/genesis.json +++ b/lib/facts/genesis.json @@ -1,11 +1,11 @@ { "id": "1", "name": "GENESIS", - "description": "This is the first community on Comm. In the future it will be possible to create threads outside of a community, but for now all of these threads get set with GENESIS as their parent. GENESIS is hosted on Ashoat's keyserver.", + "description": "This is the first community on Comm. In the future it will be possible to create threads outside of a community, but for now all of these threads get set with GENESIS as their parent. GENESIS is hosted on Ashoat’s keyserver.", "introMessages": [ "welcome to Genesis!", "for now, Genesis is the only community on Comm, and is the parent of all new threads", - "this is meant to be temporary. we're working on support for threads that can exist outside of any community, as well as support for user-hosted communities", + "this is meant to be temporary. we’re working on support for threads that can exist outside of any community, as well as support for user-hosted communities", "to learn more about our roadmap and how Genesis fits in, check out [this document](https://www.notion.so/Comm-Genesis-1059f131fb354250abd1966894b15951)" ] } diff --git a/lib/facts/testers.json b/lib/facts/testers.json index 8706acbf3..7a86b5dae 100644 --- a/lib/facts/testers.json +++ b/lib/facts/testers.json @@ -1,13 +1,13 @@ { "name": "OG SquadCal testers", "description": "this thread contains all of the OG testers that helped tested SquadCal over the years!! THANK YOU :)", "introMessages": [ "hello my dear SquadCal testers 💗", "first of all, thank you SO MUCH for helping to test this app over the last couple years. it means the world to me", - "we have some big changes coming up:\n1. the app is getting renamed to Comm! we hope to submit Comm to the App Store in the next couple weeks\n2. we're working on updating the app to use E2E encryption so that we don't have access to your messages\n3. after that, we're aiming to launch a self-hosted communities feature, where you can use your laptop as a server to host a chat community. sort of like Discord, except totally private", + "we have some big changes coming up:\n1. the app is getting renamed to Comm! we hope to submit Comm to the App Store in the next couple weeks\n2. we’re working on updating the app to use E2E encryption so that we don’t have access to your messages\n3. after that, we’re aiming to launch a self-hosted communities feature, where you can use your laptop as a server to host a chat community. sort of like Discord, except totally private", "as part of moving to E2E encryption, we had to figure out what to do with your existing messages, which are currently stored on my private server", - "we don't want to delete your messages, but we also want to be transparent about the fact that I have access to them", + "we don’t want to delete your messages, but we also want to be transparent about the fact that I have access to them", "the solution we came up with was to move them all into the very first self-hosted community on Comm: Genesis", "check out [this link](https://www.notion.so/Comm-Genesis-1059f131fb354250abd1966894b15951) if you want to read more :)" ] } diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index b02046c76..2f6cfa844 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,143 +1,143 @@ // @flow import genesis from '../facts/genesis'; import { userRelationshipStatus } from '../types/relationship-types'; import { type ThreadInfo, type ThreadType, threadTypes, threadPermissions, } from '../types/thread-types'; import type { AccountUserInfo, UserListItem } from '../types/user-types'; import SearchIndex from './search-index'; import { userIsMember, threadMemberHasPermission } from './thread-utils'; const notFriendNotice = 'not friend'; function getPotentialMemberItems( text: string, userInfos: { +[id: string]: AccountUserInfo }, searchIndex: SearchIndex, excludeUserIDs: $ReadOnlyArray<string>, inputParentThreadInfo: ?ThreadInfo, inputCommunityThreadInfo: ?ThreadInfo, threadType: ?ThreadType, ): UserListItem[] { const communityThreadInfo = inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null; const parentThreadInfo = inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null; let results = []; const appendUserInfo = (userInfo: AccountUserInfo) => { const { id } = userInfo; if (excludeUserIDs.includes(id)) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results.push({ ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), }); }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (text === '') { results = results.filter(userInfo => parentThreadInfo ? userInfo.isMemberOfParentThread && userInfo.relationshipStatus !== userRelationshipStatus.BLOCKED_BY_VIEWER : userInfo.relationshipStatus === userRelationshipStatus.FRIEND, ); } const nonFriends = []; const blockedUsers = []; const friendsAndParentMembers = []; for (const userResult of results) { const relationshipStatus = userResult.relationshipStatus; if ( userResult.isMemberOfParentThread && relationshipStatus !== userRelationshipStatus.BLOCKED_BY_VIEWER ) { friendsAndParentMembers.unshift(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friendsAndParentMembers.push(userResult); } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { blockedUsers.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = friendsAndParentMembers .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfParentThread, relationshipStatus, ...result }) => { if ( isMemberOfParentThread && relationshipStatus !== userRelationshipStatus.BLOCKED_BY_VIEWER ) { return { ...result }; } let notice, alertText, alertTitle; const userText = result.username; if (!isMemberOfParentThread && threadType === threadTypes.SIDEBAR) { notice = 'not in parent thread'; alertTitle = 'Not in parent thread'; alertText = 'You can only add members of the parent thread to a sidebar'; } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { - notice = "you've blocked this user"; + notice = 'you’ve blocked this user'; alertTitle = 'Not a friend'; alertText = `Before you add ${userText} to this thread, ` + - "you'll need to unblock them and send a friend request. " + + 'you’ll need to unblock them and send a friend request. ' + 'You can do this from the Block List and Friend List ' + 'in the Profile tab.'; } else if (relationshipStatus !== userRelationshipStatus.FRIEND) { notice = notFriendNotice; alertTitle = 'Not a friend'; alertText = `Before you add ${userText} to this thread, ` + - "you'll need to send them a friend request. " + + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.'; } else if (parentThreadInfo) { notice = 'not in parent thread'; } return { ...result, notice, alertText, alertTitle }; }, ); } export { getPotentialMemberItems, notFriendNotice }; diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index db52e3006..5033efb9d 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,376 +1,376 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; import Animated from 'react-native-reanimated'; import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { validEmailRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils'; import type { LogInInfo, LogInExtraInfo, LogInResult, LogInStartingPayload, } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import SWMansionIcon from '../components/swmansion-icon.react'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import type { KeyPressEvent } from '../types/react-native'; import type { StateContainer } from '../utils/state-container'; import { TextInput } from './modal-components.react'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials'; import { PanelButton, Panel } from './panel-components.react'; export type LogInState = { +usernameInputText: ?string, +passwordInputText: ?string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +logInState: StateContainer<LogInState>, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logIn: (logInInfo: LogInInfo) => Promise<LogInResult>, }; class LogInPanel extends React.PureComponent<Props> { usernameInput: ?TextInput; passwordInput: ?TextInput; componentDidMount() { 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 ( <Panel opacityValue={this.props.opacityValue}> <View style={styles.row}> <SWMansionIcon name="user" size={22} color="#555" style={styles.icon} /> <TextInput style={styles.input} value={this.usernameInputText} onChangeText={this.onChangeUsernameInputText} onKeyPress={this.onUsernameKeyPress} placeholder="Username" autoFocus={Platform.OS !== 'ios'} autoCorrect={false} autoCapitalize="none" keyboardType="ascii-capable" textContentType="username" autoComplete="username" returnKeyType="next" blurOnSubmit={false} onSubmitEditing={this.focusPasswordInput} editable={this.props.loadingStatus !== 'loading'} ref={this.usernameInputRef} /> </View> <View style={styles.row}> <SWMansionIcon name="lock-on" size={22} color="#555" style={styles.icon} /> <TextInput style={styles.input} value={this.passwordInputText} onChangeText={this.onChangePasswordInputText} placeholder="Password" secureTextEntry={true} textContentType="password" autoComplete="password" returnKeyType="go" blurOnSubmit={false} onSubmitEditing={this.onSubmit} editable={this.props.loadingStatus !== 'loading'} ref={this.passwordInputRef} /> </View> <View style={styles.footer}> <PanelButton text="LOG IN" loadingStatus={this.props.loadingStatus} onSubmit={this.onSubmit} /> </View> </Panel> ); } 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: ?TextInput) => 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 }); }; 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: () => void = () => { this.props.setActiveAlert(true); if (this.usernameInputText.search(validEmailRegex) > -1) { Alert.alert( - "Can't log in with email", + '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 = this.props.logInExtraInfo(); this.props.dispatchActionPromise( logInActionTypes, this.logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; onUsernameAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', }, this.focusUsernameInput, ); }; async logInAction(extraInfo: LogInExtraInfo): Promise<LogInResult> { try { const result = await this.props.logIn({ username: this.usernameInputText, password: this.passwordInputText, ...extraInfo, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_parameters') { Alert.alert( 'Invalid username', - "User doesn't exist", + 'User doesn’t exist', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', - "Your app version is pretty old, and the server doesn't know how " + + 'Your app version is pretty old, and the server doesn’t know how ' + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } 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 loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); const ConnectedLogInPanel: React.ComponentType<BaseProps> = React.memo<BaseProps>( function ConnectedLogInPanel(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callLogIn = useServerCall(logIn); return ( <LogInPanel {...props} loadingStatus={loadingStatus} logInExtraInfo={logInExtraInfo} dispatchActionPromise={dispatchActionPromise} logIn={callLogIn} /> ); }, ); export default ConnectedLogInPanel; diff --git a/native/account/register-panel.react.js b/native/account/register-panel.react.js index d5b1caf75..8b7af5ff2 100644 --- a/native/account/register-panel.react.js +++ b/native/account/register-panel.react.js @@ -1,476 +1,476 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, StyleSheet, Platform, Keyboard, Alert, Linking, } from 'react-native'; import Animated from 'react-native-reanimated'; import { registerActionTypes, register } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { validUsernameRegex } from 'lib/shared/account-utils'; import type { RegisterInfo, LogInExtraInfo, RegisterResult, LogInStartingPayload, } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import SWMansionIcon from '../components/swmansion-icon.react'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import type { KeyPressEvent } from '../types/react-native'; import { type StateContainer } from '../utils/state-container'; import { TextInput } from './modal-components.react'; import { setNativeCredentials } from './native-credentials'; import { PanelButton, Panel } from './panel-components.react'; export type RegisterState = { +usernameInputText: string, +passwordInputText: string, +confirmPasswordInputText: string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +registerState: StateContainer<RegisterState>, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +register: (registerInfo: RegisterInfo) => Promise<RegisterResult>, }; type State = { +confirmPasswordFocused: boolean, }; class RegisterPanel extends React.PureComponent<Props, State> { state: State = { confirmPasswordFocused: false, }; usernameInput: ?TextInput; passwordInput: ?TextInput; confirmPasswordInput: ?TextInput; passwordBeingAutoFilled = false; render() { 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; } /* eslint-disable react-native/no-raw-text */ const privatePolicyNotice = ( <View style={styles.notice}> <Text style={styles.noticeText}> By signing up, you agree to our{' '} <Text style={styles.hyperlinkText} onPress={this.onTermsOfUsePressed}> Terms </Text> {' & '} <Text style={styles.hyperlinkText} onPress={this.onPrivacyPolicyPressed} > Privacy Policy </Text> . </Text> </View> ); /* eslint-enable react-native/no-raw-text */ return ( <Panel opacityValue={this.props.opacityValue} style={styles.container}> <View style={styles.row}> <SWMansionIcon name="user" size={22} color="#555" style={styles.icon} /> <TextInput style={styles.input} value={this.props.registerState.state.usernameInputText} onChangeText={this.onChangeUsernameInputText} placeholder="Username" autoFocus={true} autoCorrect={false} autoCapitalize="none" keyboardType="ascii-capable" textContentType="username" autoComplete="username-new" returnKeyType="next" blurOnSubmit={false} onSubmitEditing={this.focusPasswordInput} editable={this.props.loadingStatus !== 'loading'} ref={this.usernameInputRef} /> </View> <View style={styles.row}> <SWMansionIcon name="lock-on" size={22} color="#555" style={styles.icon} /> <TextInput style={styles.input} value={this.props.registerState.state.passwordInputText} onChangeText={this.onChangePasswordInputText} onKeyPress={onPasswordKeyPress} placeholder="Password" secureTextEntry={true} textContentType="password" autoComplete="password-new" returnKeyType="next" blurOnSubmit={false} onSubmitEditing={this.focusConfirmPasswordInput} editable={this.props.loadingStatus !== 'loading'} ref={this.passwordInputRef} /> </View> <View style={styles.row}> <TextInput style={styles.input} value={this.props.registerState.state.confirmPasswordInputText} onChangeText={this.onChangeConfirmPasswordInputText} placeholder="Confirm password" autoComplete="password-new" returnKeyType="go" blurOnSubmit={false} onSubmitEditing={this.onSubmit} onFocus={this.onConfirmPasswordFocus} editable={this.props.loadingStatus !== 'loading'} ref={this.confirmPasswordInputRef} {...confirmPasswordTextInputExtraProps} /> </View> <View style={styles.footer}> {privatePolicyNotice} <PanelButton text="SIGN UP" loadingStatus={this.props.loadingStatus} onSubmit={this.onSubmit} /> </View> </Panel> ); } 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 = () => { Linking.openURL('https://comm.app/terms'); }; onPrivacyPolicyPressed = () => { Linking.openURL('https://comm.app/privacy'); }; onChangeUsernameInputText = (text: string) => { this.props.registerState.setState({ usernameInputText: text }); }; onChangePasswordInputText = (text: string) => { const stateUpdate = {}; 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 = () => { 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", + '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 = this.props.logInExtraInfo(); this.props.dispatchActionPromise( registerActionTypes, this.registerAction(extraInfo), 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) { try { const result = await this.props.register({ username: this.props.registerState.state.usernameInputText, password: this.props.registerState.state.passwordInputText, ...extraInfo, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.props.registerState.state.passwordInputText, }); return result; } catch (e) { if (e.message === 'username_reserved') { Alert.alert( 'Username reserved', 'This username is currently reserved. Please contact support@' + 'comm.app if you would like to claim this account.', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'username_taken') { Alert.alert( 'Username taken', 'An account with that username already exists', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', - "Your app version is pretty old, and the server doesn't know how " + + 'Your app version is pretty old, and the server doesn’t know how ' + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ 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 loadingStatusSelector = createLoadingStatusSelector(registerActionTypes); const ConnectedRegisterPanel: React.ComponentType<BaseProps> = React.memo<BaseProps>( function ConnectedRegisterPanel(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callRegister = useServerCall(register); return ( <RegisterPanel {...props} loadingStatus={loadingStatus} logInExtraInfo={logInExtraInfo} dispatchActionPromise={dispatchActionPromise} register={callRegister} /> ); }, ); export default ConnectedRegisterPanel; diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js index 6fe6e9c8e..aad45a55e 100644 --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -1,1212 +1,1212 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Image, Animated, Easing, } from 'react-native'; import { RNCamera } from 'react-native-camera'; import filesystem from 'react-native-fs'; import { PinchGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import type { Orientations } from 'react-native-orientation-locker'; import Reanimated, { EasingNode as ReanimatedEasing, } from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import type { PhotoCapture } from 'lib/types/media-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import ContentLoading from '../components/content-loading.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { type InputState, InputStateContext } from '../input/input-state'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { updateDeviceCameraInfoActionType } from '../redux/action-types'; import { type DimensionsInfo } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { colors } from '../themes/colors'; import { type DeviceCameraInfo } from '../types/camera'; import type { NativeMethods } from '../types/react-native'; import { AnimatedView, type ViewStyle, type AnimatedViewStyle, } from '../types/styles'; import { clamp, gestureJustEnded } from '../utils/animation-utils'; import SendMediaButton from './send-media-button.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Clock, event, Extrapolate, block, set, call, cond, not, and, or, eq, greaterThan, lessThan, add, sub, multiply, divide, abs, interpolateNode, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Reanimated; /* eslint-enable import/no-named-as-default-member */ const maxZoom = 16; const zoomUpdateFactor = (() => { if (Platform.OS === 'ios') { return 0.002; } if (Platform.OS === 'android' && Platform.Version > 26) { return 0.005; } if (Platform.OS === 'android' && Platform.Version > 23) { return 0.01; } return 0.03; })(); const stagingModeAnimationConfig = { duration: 150, easing: ReanimatedEasing.inOut(ReanimatedEasing.ease), }; const sendButtonAnimationConfig = { duration: 150, // $FlowFixMe[method-unbinding] easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const indicatorSpringConfig = { ...SpringUtils.makeDefaultConfig(), damping: 0, mass: 0.6, toValue: 1, }; const indicatorTimingConfig = { duration: 500, easing: ReanimatedEasing.out(ReanimatedEasing.ease), toValue: 0, }; function runIndicatorAnimation( // Inputs springClock: Clock, delayClock: Clock, timingClock: Clock, animationRunning: Node, // Outputs scale: Value, opacity: Value, ): Node { const delayStart = new Value(0); const springScale = new Value(0.75); const delayScale = new Value(0); const timingScale = new Value(0.75); const animatedScale = cond( clockRunning(springClock), springScale, cond(clockRunning(delayClock), delayScale, timingScale), ); const lastAnimatedScale = new Value(0.75); const numScaleLoops = new Value(0); const springState = { finished: new Value(1), velocity: new Value(0), time: new Value(0), position: springScale, }; const timingState = { finished: new Value(1), frameTime: new Value(0), time: new Value(0), position: timingScale, }; return block([ cond(not(animationRunning), [ set(springState.finished, 0), set(springState.velocity, 0), set(springState.time, 0), set(springScale, 0.75), set(lastAnimatedScale, 0.75), set(numScaleLoops, 0), set(opacity, 1), startClock(springClock), ]), [ cond( clockRunning(springClock), spring(springClock, springState, indicatorSpringConfig), ), timing(timingClock, timingState, indicatorTimingConfig), ], [ cond( and( greaterThan(animatedScale, 1.2), not(greaterThan(lastAnimatedScale, 1.2)), ), [ set(numScaleLoops, add(numScaleLoops, 1)), cond(greaterThan(numScaleLoops, 1), [ set(springState.finished, 1), stopClock(springClock), set(delayScale, springScale), set(delayStart, delayClock), startClock(delayClock), ]), ], ), set(lastAnimatedScale, animatedScale), ], cond( and( clockRunning(delayClock), greaterThan(delayClock, add(delayStart, 400)), ), [ stopClock(delayClock), set(timingState.finished, 0), set(timingState.frameTime, 0), set(timingState.time, 0), set(timingScale, delayScale), startClock(timingClock), ], ), cond( and(springState.finished, timingState.finished), stopClock(timingClock), ), set(scale, animatedScale), cond(clockRunning(timingClock), set(opacity, clamp(animatedScale, 0, 1))), ]); } export type CameraModalParams = { +presentedFrom: string, +thread: ThreadInfo, }; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig<typeof TouchableOpacity>, NativeMethods, >; type BaseProps = { +navigation: AppNavigationProp<'CameraModal'>, +route: NavigationRoute<'CameraModal'>, }; type Props = { ...BaseProps, // Redux state +dimensions: DimensionsInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +foreground: boolean, // Redux dispatch functions +dispatch: Dispatch, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, }; type State = { +zoom: number, +useFrontCamera: boolean, +hasCamerasOnBothSides: boolean, +flashMode: number, +autoFocusPointOfInterest: ?{ x: number, y: number, autoExposure?: boolean, }, +stagingMode: boolean, +pendingPhotoCapture: ?PhotoCapture, }; class CameraModal extends React.PureComponent<Props, State> { camera: ?RNCamera; pinchEvent; pinchHandler = React.createRef(); tapEvent; tapHandler = React.createRef(); animationCode: Node; closeButton: ?React.ElementRef<TouchableOpacityInstance>; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); photoButton: ?React.ElementRef<TouchableOpacityInstance>; photoButtonX = new Value(-1); photoButtonY = new Value(-1); photoButtonWidth = new Value(0); photoButtonHeight = new Value(0); switchCameraButton: ?React.ElementRef<TouchableOpacityInstance>; switchCameraButtonX = new Value(-1); switchCameraButtonY = new Value(-1); switchCameraButtonWidth = new Value(0); switchCameraButtonHeight = new Value(0); flashButton: ?React.ElementRef<TouchableOpacityInstance>; flashButtonX = new Value(-1); flashButtonY = new Value(-1); flashButtonWidth = new Value(0); flashButtonHeight = new Value(0); focusIndicatorX = new Value(-1); focusIndicatorY = new Value(-1); focusIndicatorScale = new Value(0); focusIndicatorOpacity = new Value(0); cancelIndicatorAnimation = new Value(0); cameraIDsFetched = false; stagingModeProgress = new Value(0); sendButtonProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; overlayStyle: AnimatedViewStyle; constructor(props: Props) { super(props); this.state = { zoom: 0, useFrontCamera: props.deviceCameraInfo.defaultUseFrontCamera, hasCamerasOnBothSides: props.deviceCameraInfo.hasCamerasOnBothSides, flashMode: RNCamera.Constants.FlashMode.off, autoFocusPointOfInterest: undefined, stagingMode: false, pendingPhotoCapture: undefined, }; const sendButtonScale = this.sendButtonProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.1, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.sendButtonProgress, transform: [{ scale: sendButtonScale }], }; const overlayOpacity = interpolateNode(this.stagingModeProgress, { inputRange: [0, 0.01, 1], outputRange: [0, 0.5, 0], extrapolate: Extrapolate.CLAMP, }); this.overlayStyle = { ...styles.overlay, opacity: overlayOpacity, }; const pinchState = new Value(-1); const pinchScale = new Value(1); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, }, }, ]); const tapState = new Value(-1); const tapX = new Value(0); const tapY = new Value(0); this.tapEvent = event([ { nativeEvent: { state: tapState, x: tapX, y: tapY, }, }, ]); this.animationCode = block([ this.zoomAnimationCode(pinchState, pinchScale), this.focusAnimationCode(tapState, tapX, tapY), ]); } zoomAnimationCode(pinchState: Node, pinchScale: Node): Node { const pinchJustEnded = gestureJustEnded(pinchState); const zoomBase = new Value(1); const zoomReported = new Value(1); const currentZoom = interpolateNode(multiply(zoomBase, pinchScale), { inputRange: [1, 8], outputRange: [1, 8], extrapolate: Extrapolate.CLAMP, }); const cameraZoomFactor = interpolateNode(zoomReported, { inputRange: [1, 8], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const resolvedZoom = cond( eq(pinchState, GestureState.ACTIVE), currentZoom, zoomBase, ); return block([ cond(pinchJustEnded, set(zoomBase, currentZoom)), cond( or( pinchJustEnded, greaterThan( abs(sub(divide(resolvedZoom, zoomReported), 1)), zoomUpdateFactor, ), ), [ set(zoomReported, resolvedZoom), call([cameraZoomFactor], this.updateZoom), ], ), ]); } focusAnimationCode(tapState: Node, tapX: Node, tapY: Node): Node { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(tapState), this.outsideButtons(lastTapX, lastTapY), ); const indicatorSpringClock = new Clock(); const indicatorDelayClock = new Clock(); const indicatorTimingClock = new Clock(); const indicatorAnimationRunning = or( clockRunning(indicatorSpringClock), clockRunning(indicatorDelayClock), clockRunning(indicatorTimingClock), ); return block([ cond(fingerJustReleased, [ call([tapX, tapY], this.focusOnPoint), set(this.focusIndicatorX, tapX), set(this.focusIndicatorY, tapY), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), ]), cond(this.cancelIndicatorAnimation, [ set(this.cancelIndicatorAnimation, 0), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), set(this.focusIndicatorOpacity, 0), ]), cond( or(fingerJustReleased, indicatorAnimationRunning), runIndicatorAnimation( indicatorSpringClock, indicatorDelayClock, indicatorTimingClock, indicatorAnimationRunning, this.focusIndicatorScale, this.focusIndicatorOpacity, ), ), set(lastTapX, tapX), set(lastTapY, tapY), ]); } outsideButtons(x: Node, y: Node): Node { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, photoButtonX, photoButtonY, photoButtonWidth, photoButtonHeight, switchCameraButtonX, switchCameraButtonY, switchCameraButtonWidth, switchCameraButtonHeight, flashButtonX, flashButtonY, flashButtonWidth, flashButtonHeight, } = this; return and( or( lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( lessThan(x, photoButtonX), greaterThan(x, add(photoButtonX, photoButtonWidth)), lessThan(y, photoButtonY), greaterThan(y, add(photoButtonY, photoButtonHeight)), ), or( lessThan(x, switchCameraButtonX), greaterThan(x, add(switchCameraButtonX, switchCameraButtonWidth)), lessThan(y, switchCameraButtonY), greaterThan(y, add(switchCameraButtonY, switchCameraButtonHeight)), ), or( lessThan(x, flashButtonX), greaterThan(x, add(flashButtonX, flashButtonWidth)), lessThan(y, flashButtonY), greaterThan(y, add(flashButtonY, flashButtonHeight)), ), ); } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return !overlayContext.isDismissing; } componentDidMount() { if (CameraModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (CameraModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props, prevState: State) { const isActive = CameraModal.isActive(this.props); const wasActive = CameraModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } if (!this.state.hasCamerasOnBothSides && prevState.hasCamerasOnBothSides) { this.switchCameraButtonX.setValue(-1); this.switchCameraButtonY.setValue(-1); this.switchCameraButtonWidth.setValue(0); this.switchCameraButtonHeight.setValue(0); } if (this.props.deviceOrientation !== prevProps.deviceOrientation) { this.setState({ autoFocusPointOfInterest: null }); this.cancelIndicatorAnimation.setValue(1); } if (this.props.foreground && !prevProps.foreground && this.camera) { this.camera.refreshAuthorizationStatus(); } if (this.state.stagingMode && !prevState.stagingMode) { this.cancelIndicatorAnimation.setValue(1); this.focusIndicatorOpacity.setValue(0); timing(this.stagingModeProgress, { ...stagingModeAnimationConfig, toValue: 1, }).start(); } else if (!this.state.stagingMode && prevState.stagingMode) { this.stagingModeProgress.setValue(0); } if (this.state.pendingPhotoCapture && !prevState.pendingPhotoCapture) { Animated.timing(this.sendButtonProgress, { ...sendButtonAnimationConfig, toValue: 1, }).start(); } else if ( !this.state.pendingPhotoCapture && prevState.pendingPhotoCapture ) { CameraModal.cleanUpPendingPhotoCapture(prevState.pendingPhotoCapture); this.sendButtonProgress.setValue(0); } } static async cleanUpPendingPhotoCapture(pendingPhotoCapture: PhotoCapture) { const path = pathFromURI(pendingPhotoCapture.uri); if (!path) { return; } try { await filesystem.unlink(path); } catch (e) {} } get containerStyle() { const { overlayContext } = this.props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return { ...styles.container, opacity: overlayContext.position, }; } get focusIndicatorStyle() { return { ...styles.focusIndicator, opacity: this.focusIndicatorOpacity, transform: [ { translateX: this.focusIndicatorX }, { translateY: this.focusIndicatorY }, { scale: this.focusIndicatorScale }, ], }; } renderCamera = ({ camera, status }) => { if (camera && camera._cameraHandle) { this.fetchCameraIDs(camera); } if (this.state.stagingMode) { return this.renderStagingView(); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset, 6), }; return ( <> {this.renderCameraContent(status)} <TouchableOpacity onPress={this.close} onLayout={this.onCloseButtonLayout} style={[styles.closeButton, topButtonStyle]} ref={this.closeButtonRef} > <Text style={styles.closeIcon}>×</Text> </TouchableOpacity> </> ); }; renderStagingView() { let image = null; const { pendingPhotoCapture } = this.state; if (pendingPhotoCapture) { const imageSource = { uri: pendingPhotoCapture.uri }; image = <Image source={imageSource} style={styles.stagingImage} />; } else { image = <ContentLoading fillType="flex" colors={colors.dark} />; } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const sendButtonContainerStyle = { bottom: this.props.dimensions.bottomInset + 22, }; return ( <> {image} <TouchableOpacity onPress={this.clearPendingImage} style={[styles.retakeButton, topButtonStyle]} > <Icon name="ios-arrow-back" style={styles.retakeIcon} /> </TouchableOpacity> <SendMediaButton onPress={this.sendPhoto} pointerEvents={pendingPhotoCapture ? 'auto' : 'none'} containerStyle={[ styles.sendButtonContainer, sendButtonContainerStyle, ]} style={this.sendButtonStyle} /> </> ); } renderCameraContent(status) { if (status === 'PENDING_AUTHORIZATION') { return <ContentLoading fillType="flex" colors={colors.dark} />; } else if (status === 'NOT_AUTHORIZED') { return ( <View style={styles.authorizationDeniedContainer}> <Text style={styles.authorizationDeniedText}> - {"don't have permission :("} + {'don’t have permission :('} </Text> </View> ); } let switchCameraButton = null; if (this.state.hasCamerasOnBothSides) { switchCameraButton = ( <TouchableOpacity onPress={this.switchCamera} onLayout={this.onSwitchCameraButtonLayout} style={styles.switchCameraButton} ref={this.switchCameraButtonRef} > <Icon name="ios-reverse-camera" style={styles.switchCameraIcon} /> </TouchableOpacity> ); } let flashIcon; if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { flashIcon = <Icon name="ios-flash" style={styles.flashIcon} />; } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { flashIcon = <Icon name="ios-flash-off" style={styles.flashIcon} />; } else { flashIcon = ( <> <Icon name="ios-flash" style={styles.flashIcon} /> <Text style={styles.flashIconAutoText}>A</Text> </> ); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const bottomButtonsContainerStyle = { bottom: this.props.dimensions.bottomInset + 20, }; return ( <PinchGestureHandler onGestureEvent={this.pinchEvent} onHandlerStateChange={this.pinchEvent} simultaneousHandlers={this.tapHandler} ref={this.pinchHandler} > <Reanimated.View style={styles.fill}> <TapGestureHandler onHandlerStateChange={this.tapEvent} simultaneousHandlers={this.pinchHandler} waitFor={this.pinchHandler} ref={this.tapHandler} > <Reanimated.View style={styles.fill}> <Reanimated.View style={this.focusIndicatorStyle} /> <TouchableOpacity onPress={this.changeFlashMode} onLayout={this.onFlashButtonLayout} style={[styles.flashButton, topButtonStyle]} ref={this.flashButtonRef} > {flashIcon} </TouchableOpacity> <View style={[ styles.bottomButtonsContainer, bottomButtonsContainerStyle, ]} > <TouchableOpacity onPress={this.takePhoto} onLayout={this.onPhotoButtonLayout} style={styles.saveButton} ref={this.photoButtonRef} > <View style={styles.saveButtonInner} /> </TouchableOpacity> {switchCameraButton} </View> </Reanimated.View> </TapGestureHandler> </Reanimated.View> </PinchGestureHandler> ); } render() { const statusBar = CameraModal.isActive(this.props) ? ( <ConnectedStatusBar hidden /> ) : null; const type = this.state.useFrontCamera ? RNCamera.Constants.Type.front : RNCamera.Constants.Type.back; return ( <Reanimated.View style={this.containerStyle}> {statusBar} <Reanimated.Code exec={this.animationCode} /> <RNCamera type={type} captureAudio={false} maxZoom={maxZoom} zoom={this.state.zoom} flashMode={this.state.flashMode} autoFocusPointOfInterest={this.state.autoFocusPointOfInterest} style={styles.fill} androidCameraPermissionOptions={null} ref={this.cameraRef} > {this.renderCamera} </RNCamera> <AnimatedView style={this.overlayStyle} pointerEvents="none" /> </Reanimated.View> ); } cameraRef = (camera: ?RNCamera) => { this.camera = camera; }; closeButtonRef = ( closeButton: ?React.ElementRef<typeof TouchableOpacity>, ) => { this.closeButton = (closeButton: any); }; onCloseButtonLayout = () => { const { closeButton } = this; if (!closeButton) { return; } closeButton.measure((x, y, width, height, pageX, pageY) => { this.closeButtonX.setValue(pageX); this.closeButtonY.setValue(pageY); this.closeButtonWidth.setValue(width); this.closeButtonHeight.setValue(height); }); }; photoButtonRef = ( photoButton: ?React.ElementRef<typeof TouchableOpacity>, ) => { this.photoButton = (photoButton: any); }; onPhotoButtonLayout = () => { const { photoButton } = this; if (!photoButton) { return; } photoButton.measure((x, y, width, height, pageX, pageY) => { this.photoButtonX.setValue(pageX); this.photoButtonY.setValue(pageY); this.photoButtonWidth.setValue(width); this.photoButtonHeight.setValue(height); }); }; switchCameraButtonRef = ( switchCameraButton: ?React.ElementRef<typeof TouchableOpacity>, ) => { this.switchCameraButton = (switchCameraButton: any); }; onSwitchCameraButtonLayout = () => { const { switchCameraButton } = this; if (!switchCameraButton) { return; } switchCameraButton.measure((x, y, width, height, pageX, pageY) => { this.switchCameraButtonX.setValue(pageX); this.switchCameraButtonY.setValue(pageY); this.switchCameraButtonWidth.setValue(width); this.switchCameraButtonHeight.setValue(height); }); }; flashButtonRef = ( flashButton: ?React.ElementRef<typeof TouchableOpacity>, ) => { this.flashButton = (flashButton: any); }; onFlashButtonLayout = () => { const { flashButton } = this; if (!flashButton) { return; } flashButton.measure((x, y, width, height, pageX, pageY) => { this.flashButtonX.setValue(pageX); this.flashButtonY.setValue(pageY); this.flashButtonWidth.setValue(width); this.flashButtonHeight.setValue(height); }); }; close = () => { this.props.navigation.goBackOnce(); }; takePhoto = async () => { const { camera } = this; invariant(camera, 'camera ref should be set'); this.setState({ stagingMode: true }); // We avoid flipping this.state.useFrontCamera if we discover we don't // actually have a back camera since it causes a bit of lag, but this // means there are cases where it is false but we are actually using the // front camera const { hasCamerasOnBothSides, defaultUseFrontCamera, } = this.props.deviceCameraInfo; const usingFrontCamera = this.state.useFrontCamera || (!hasCamerasOnBothSides && defaultUseFrontCamera); const startTime = Date.now(); const photoPromise = camera.takePictureAsync({ pauseAfterCapture: Platform.OS === 'android', mirrorImage: usingFrontCamera, fixOrientation: true, }); if (Platform.OS === 'ios') { camera.pausePreview(); } const { uri, width, height } = await photoPromise; const filename = filenameFromPathOrURI(uri); invariant( filename, `unable to parse filename out of react-native-camera URI ${uri}`, ); const now = Date.now(); const pendingPhotoCapture = { step: 'photo_capture', uri, dimensions: { width, height }, filename, time: now - startTime, captureTime: now, selectTime: 0, sendTime: 0, retries: 0, }; this.setState({ pendingPhotoCapture, zoom: 0, autoFocusPointOfInterest: undefined, }); }; sendPhoto = async () => { const { pendingPhotoCapture } = this.state; if (!pendingPhotoCapture) { return; } const now = Date.now(); const capture = { ...pendingPhotoCapture, selectTime: now, sendTime: now, }; this.close(); const { inputState } = this.props; invariant(inputState, 'inputState should be set'); inputState.sendMultimediaMessage([capture], this.props.route.params.thread); }; clearPendingImage = () => { invariant(this.camera, 'camera ref should be set'); this.camera.resumePreview(); this.setState({ stagingMode: false, pendingPhotoCapture: undefined, }); }; switchCamera = () => { this.setState((prevState: State) => ({ useFrontCamera: !prevState.useFrontCamera, })); }; updateZoom = ([zoom]: [number]) => { this.setState({ zoom }); }; changeFlashMode = () => { if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { this.setState({ flashMode: RNCamera.Constants.FlashMode.off }); } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { this.setState({ flashMode: RNCamera.Constants.FlashMode.auto }); } else { this.setState({ flashMode: RNCamera.Constants.FlashMode.on }); } }; focusOnPoint = ([inputX, inputY]: [number, number]) => { const { width: screenWidth, height: screenHeight } = this.props.dimensions; const relativeX = inputX / screenWidth; const relativeY = inputY / screenHeight; // react-native-camera's autoFocusPointOfInterest prop is based on a // LANDSCAPE-LEFT orientation, so we need to map to that let x, y; if (this.props.deviceOrientation === 'LANDSCAPE-LEFT') { x = relativeX; y = relativeY; } else if (this.props.deviceOrientation === 'LANDSCAPE-RIGHT') { x = 1 - relativeX; y = 1 - relativeY; } else if (this.props.deviceOrientation === 'PORTRAIT-UPSIDEDOWN') { x = 1 - relativeY; y = relativeX; } else { x = relativeY; y = 1 - relativeX; } const autoFocusPointOfInterest = Platform.OS === 'ios' ? { x, y, autoExposure: true } : { x, y }; this.setState({ autoFocusPointOfInterest }); }; fetchCameraIDs = async (camera: RNCamera) => { if (this.cameraIDsFetched) { return; } this.cameraIDsFetched = true; const deviceCameras = await camera.getCameraIdsAsync(); let hasFront = false, hasBack = false, i = 0; while ((!hasFront || !hasBack) && i < deviceCameras.length) { const deviceCamera = deviceCameras[i]; if (deviceCamera.type === RNCamera.Constants.Type.front) { hasFront = true; } else if (deviceCamera.type === RNCamera.Constants.Type.back) { hasBack = true; } i++; } const hasCamerasOnBothSides = hasFront && hasBack; const defaultUseFrontCamera = !hasBack && hasFront; if (hasCamerasOnBothSides !== this.state.hasCamerasOnBothSides) { this.setState({ hasCamerasOnBothSides }); } const { hasCamerasOnBothSides: oldHasCamerasOnBothSides, defaultUseFrontCamera: oldDefaultUseFrontCamera, } = this.props.deviceCameraInfo; if ( hasCamerasOnBothSides !== oldHasCamerasOnBothSides || defaultUseFrontCamera !== oldDefaultUseFrontCamera ) { this.props.dispatch({ type: updateDeviceCameraInfoActionType, payload: { hasCamerasOnBothSides, defaultUseFrontCamera }, }); } }; } const styles = StyleSheet.create({ authorizationDeniedContainer: { alignItems: 'center', flex: 1, justifyContent: 'center', }, authorizationDeniedText: { color: colors.dark.listSeparatorLabel, fontSize: 28, textAlign: 'center', }, bottomButtonsContainer: { alignItems: 'center', flexDirection: 'row', justifyContent: 'center', left: 0, position: 'absolute', right: 0, }, closeButton: { left: 16, paddingBottom: 2, paddingHorizontal: 8, position: 'absolute', }, closeIcon: { color: 'white', fontSize: 36, textShadowColor: 'black', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, container: { backgroundColor: 'black', flex: 1, }, fill: { flex: 1, }, flashButton: { marginTop: Platform.select({ android: 15, default: 13 }), paddingHorizontal: 10, paddingVertical: 3, position: 'absolute', right: 15, }, flashIcon: { color: 'white', fontSize: 24, textShadowColor: 'black', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, flashIconAutoText: { color: 'white', fontSize: 10, fontWeight: 'bold', position: 'absolute', right: 5, textShadowColor: 'black', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, top: 0, }, focusIndicator: { borderColor: 'white', borderRadius: 24, borderWidth: 1, height: 24, left: -12, position: 'absolute', top: -12, width: 24, }, overlay: { backgroundColor: 'white', bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, retakeButton: { left: 20, marginTop: Platform.select({ android: 15, default: 15 }), paddingBottom: 3, paddingHorizontal: 10, position: 'absolute', }, retakeIcon: { color: 'white', fontSize: 24, textShadowColor: 'black', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, saveButton: { alignItems: 'center', borderColor: 'white', borderRadius: 75, borderWidth: 4, height: 75, justifyContent: 'center', width: 75, }, saveButtonInner: { backgroundColor: '#FFFFFF88', borderRadius: 60, height: 60, width: 60, }, sendButtonContainer: { position: 'absolute', right: 32, }, stagingImage: { backgroundColor: 'black', flex: 1, resizeMode: 'contain', }, switchCameraButton: { justifyContent: 'center', paddingHorizontal: 8, paddingVertical: 2, position: 'absolute', right: 18, }, switchCameraIcon: { color: 'white', fontSize: 36, textShadowColor: 'black', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, }); const ConnectedCameraModal: React.ComponentType<BaseProps> = React.memo<BaseProps>( function ConnectedCameraModal(props: BaseProps) { const dimensions = useSelector(state => state.dimensions); const deviceCameraInfo = useSelector(state => state.deviceCameraInfo); const deviceOrientation = useSelector(state => state.deviceOrientation); const foreground = useIsAppForegrounded(); const overlayContext = React.useContext(OverlayContext); const inputState = React.useContext(InputStateContext); const dispatch = useDispatch(); return ( <CameraModal {...props} dimensions={dimensions} deviceCameraInfo={deviceCameraInfo} deviceOrientation={deviceOrientation} foreground={foreground} dispatch={dispatch} overlayContext={overlayContext} inputState={inputState} /> ); }, ); export default ConnectedCameraModal; diff --git a/native/media/save-media.js b/native/media/save-media.js index d5ece0a36..7dc6a2786 100644 --- a/native/media/save-media.js +++ b/native/media/save-media.js @@ -1,454 +1,454 @@ // @flow import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import { Platform, PermissionsAndroid } from 'react-native'; import filesystem from 'react-native-fs'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { readableFilename, pathFromURI } from 'lib/media/file-utils'; import { isLocalUploadID } from 'lib/media/media-utils'; import type { MediaMissionStep, MediaMissionResult, MediaMissionFailure, } from 'lib/types/media-types'; import { reportTypes, type MediaMissionReportCreationRequest, } from 'lib/types/report-types'; import { getConfig } from 'lib/utils/config'; import { getMessageForException } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { dispatch } from '../redux/redux-setup'; import { requestAndroidPermission } from '../utils/android-permissions'; import { fetchBlob } from './blob-utils'; import { fetchAssetInfo, fetchFileInfo, disposeTempFile, mkdir, androidScanFile, fetchFileHash, copyFile, temporaryDirectoryPath, } from './file-utils'; import { getMediaLibraryIdentifier } from './identifier-utils'; async function intentionalSaveMedia( uri: string, ids: { uploadID: string, messageServerID: ?string, messageLocalID: ?string, }, options: { mediaReportsEnabled: boolean, }, ): Promise<void> { const start = Date.now(); const steps = [{ step: 'save_media', uri, time: start }]; const { resultPromise, reportPromise } = saveMedia(uri, 'request'); const result = await resultPromise; const userTime = Date.now() - start; let message; if (result.success) { message = 'saved!'; } else if (result.reason === 'save_unsupported') { const os = Platform.select({ ios: 'iOS', android: 'Android', default: Platform.OS, }); message = `saving media is unsupported on ${os}`; } else if (result.reason === 'missing_permission') { - message = "don't have permission :("; + message = 'don’t have permission :('; } else if ( result.reason === 'resolve_failed' || result.reason === 'data_uri_failed' ) { message = 'failed to resolve :('; } else if (result.reason === 'fetch_failed') { message = 'failed to download :('; } else { message = 'failed to save :('; } displayActionResultModal(message); if (!options.mediaReportsEnabled) { return; } const reportSteps = await reportPromise; steps.push(...reportSteps); const totalTime = Date.now() - start; const mediaMission = { steps, result, userTime, totalTime }; const { uploadID, messageServerID, messageLocalID } = ids; const uploadIDIsLocal = isLocalUploadID(uploadID); const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: uploadIDIsLocal ? undefined : uploadID, uploadLocalID: uploadIDIsLocal ? uploadID : undefined, messageServerID, messageLocalID, }; dispatch({ type: queueReportsActionType, payload: { reports: [report] }, }); } type Permissions = 'check' | 'request'; function saveMedia( uri: string, permissions?: Permissions = 'check', ): { resultPromise: Promise<MediaMissionResult>, reportPromise: Promise<$ReadOnlyArray<MediaMissionStep>>, } { let resolveResult; const sendResult = result => { if (resolveResult) { resolveResult(result); } }; const reportPromise = innerSaveMedia(uri, permissions, sendResult); const resultPromise = new Promise(resolve => { resolveResult = resolve; }); return { reportPromise, resultPromise }; } async function innerSaveMedia( uri: string, permissions: Permissions, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray<MediaMissionStep>> { if (Platform.OS === 'android') { return await saveMediaAndroid(uri, permissions, sendResult); } else if (Platform.OS === 'ios') { return await saveMediaIOS(uri, sendResult); } else { sendResult({ success: false, reason: 'save_unsupported' }); return []; } } const androidSavePermission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE; // On Android, we save the media to our own Comm folder in the // Pictures directory, and then trigger the media scanner to pick it up async function saveMediaAndroid( inputURI: string, permissions: Permissions, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray<MediaMissionStep>> { const steps = []; let hasPermission = false, permissionCheckExceptionMessage; const permissionCheckStart = Date.now(); try { hasPermission = await requestAndroidPermission( androidSavePermission, 'throw', ); } catch (e) { permissionCheckExceptionMessage = getMessageForException(e); } steps.push({ step: 'permissions_check', success: hasPermission, exceptionMessage: permissionCheckExceptionMessage, time: Date.now() - permissionCheckStart, platform: Platform.OS, permissions: [androidSavePermission], }); if (!hasPermission) { sendResult({ success: false, reason: 'missing_permission' }); return steps; } const promises = []; let success = true; const saveFolder = `${filesystem.PicturesDirectoryPath}/Comm/`; promises.push( (async () => { const makeDirectoryStep = await mkdir(saveFolder); if (!makeDirectoryStep.success) { success = false; sendResult({ success, reason: 'make_directory_failed' }); } steps.push(makeDirectoryStep); })(), ); let uri = inputURI; let tempFile, mime; if (uri.startsWith('http')) { promises.push( (async () => { const { result: tempSaveResult, steps: tempSaveSteps, } = await saveRemoteMediaToDisk(uri, temporaryDirectoryPath); steps.push(...tempSaveSteps); if (!tempSaveResult.success) { success = false; sendResult(tempSaveResult); } else { tempFile = tempSaveResult.path; uri = `file://${tempFile}`; mime = tempSaveResult.mime; } })(), ); } await Promise.all(promises); if (!success) { return steps; } const { result: copyResult, steps: copySteps } = await copyToSortedDirectory( uri, saveFolder, mime, ); steps.push(...copySteps); if (!copyResult.success) { sendResult(copyResult); return steps; } sendResult({ success: true }); const postResultPromises = []; postResultPromises.push( (async () => { const scanFileStep = await androidScanFile(copyResult.path); steps.push(scanFileStep); })(), ); if (tempFile) { postResultPromises.push( (async (file: string) => { const disposeStep = await disposeTempFile(file); steps.push(disposeStep); })(tempFile), ); } await Promise.all(postResultPromises); return steps; } // On iOS, we save the media to the camera roll async function saveMediaIOS( inputURI: string, sendResult: (result: MediaMissionResult) => void, ): Promise<$ReadOnlyArray<MediaMissionStep>> { const steps = []; let uri = inputURI; let tempFile; if (uri.startsWith('http')) { const { result: tempSaveResult, steps: tempSaveSteps, } = await saveRemoteMediaToDisk(uri, temporaryDirectoryPath); steps.push(...tempSaveSteps); if (!tempSaveResult.success) { sendResult(tempSaveResult); return steps; } tempFile = tempSaveResult.path; uri = `file://${tempFile}`; } else if (!uri.startsWith('file://')) { const mediaNativeID = getMediaLibraryIdentifier(uri); if (mediaNativeID) { const { result: fetchAssetInfoResult, steps: fetchAssetInfoSteps, } = await fetchAssetInfo(mediaNativeID); steps.push(...fetchAssetInfoSteps); const { localURI } = fetchAssetInfoResult; if (localURI) { uri = localURI; } } } if (!uri.startsWith('file://')) { sendResult({ success: false, reason: 'resolve_failed', uri }); return steps; } let success = false, exceptionMessage; const start = Date.now(); try { await MediaLibrary.saveToLibraryAsync(uri); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } steps.push({ step: 'ios_save_to_library', success, exceptionMessage, time: Date.now() - start, uri, }); if (success) { sendResult({ success: true }); } else { sendResult({ success: false, reason: 'save_to_library_failed', uri }); } if (tempFile) { const disposeStep = await disposeTempFile(tempFile); steps.push(disposeStep); } return steps; } type IntermediateSaveResult = { result: { success: true, path: string, mime: string } | MediaMissionFailure, steps: $ReadOnlyArray<MediaMissionStep>, }; async function saveRemoteMediaToDisk( inputURI: string, directory: string, // should end with a / ): Promise<IntermediateSaveResult> { const steps = []; const { result: fetchBlobResult, steps: fetchBlobSteps } = await fetchBlob( inputURI, ); steps.push(...fetchBlobSteps); if (!fetchBlobResult.success) { return { result: fetchBlobResult, steps }; } const { mime, base64 } = fetchBlobResult; const tempName = readableFilename('', mime); if (!tempName) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const tempPath = `${directory}tempsave.${tempName}`; const start = Date.now(); let success = false, exceptionMessage; try { await filesystem.writeFile(tempPath, base64, 'base64'); success = true; } catch (e) { exceptionMessage = getMessageForException(e); } steps.push({ step: 'write_file', success, exceptionMessage, time: Date.now() - start, path: tempPath, length: base64.length, }); if (!success) { return { result: { success: false, reason: 'write_file_failed' }, steps }; } return { result: { success: true, path: tempPath, mime }, steps }; } async function copyToSortedDirectory( localURI: string, directory: string, // should end with a / inputMIME: ?string, ): Promise<IntermediateSaveResult> { const steps = []; const path = pathFromURI(localURI); if (!path) { return { result: { success: false, reason: 'resolve_failed', uri: localURI }, steps, }; } let mime = inputMIME; const promises = {}; promises.hashStep = fetchFileHash(path); if (!mime) { promises.fileInfoResult = fetchFileInfo(localURI, undefined, { mime: true, }); } const { hashStep, fileInfoResult } = await promiseAll(promises); steps.push(hashStep); if (!hashStep.success) { return { result: { success: false, reason: 'fetch_file_hash_failed' }, steps, }; } const { hash } = hashStep; invariant(hash, 'hash should be truthy if hashStep.success is truthy'); if (fileInfoResult) { steps.push(...fileInfoResult.steps); if (fileInfoResult.result.success && fileInfoResult.result.mime) { ({ mime } = fileInfoResult.result); } } if (!mime) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const name = readableFilename(hash, mime); if (!name) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } const newPath = `${directory}${name}`; const copyStep = await copyFile(path, newPath); steps.push(copyStep); if (!copyStep.success) { return { result: { success: false, reason: 'copy_file_failed' }, steps, }; } return { result: { success: true, path: newPath, mime }, steps, }; } export { intentionalSaveMedia, saveMedia }; diff --git a/native/profile/edit-password.react.js b/native/profile/edit-password.react.js index ccd0a2966..d21a32e17 100644 --- a/native/profile/edit-password.react.js +++ b/native/profile/edit-password.react.js @@ -1,376 +1,376 @@ // @flow import { CommonActions } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, ScrollView, Alert, ActivityIndicator, } from 'react-native'; import { changeUserPasswordActionTypes, changeUserPassword, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { PasswordUpdate } from 'lib/types/user-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { setNativeCredentials } from '../account/native-credentials'; import Button from '../components/button.react'; import TextInput from '../components/text-input.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { GlobalTheme } from '../types/themes'; import type { ProfileNavigationProp } from './profile.react'; type BaseProps = { +navigation: ProfileNavigationProp<'EditPassword'>, +route: NavigationRoute<'EditPassword'>, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +username: ?string, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeUserPassword: (passwordUpdate: PasswordUpdate) => Promise<void>, }; type State = { +currentPassword: string, +newPassword: string, +confirmPassword: string, }; class EditPassword extends React.PureComponent<Props, State> { state: State = { currentPassword: '', newPassword: '', confirmPassword: '', }; mounted = false; currentPasswordInput: ?React.ElementRef<typeof BaseTextInput>; newPasswordInput: ?React.ElementRef<typeof BaseTextInput>; confirmPasswordInput: ?React.ElementRef<typeof BaseTextInput>; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( <ActivityIndicator size="small" color="white" /> ) : ( <Text style={this.props.styles.saveText}>Save</Text> ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( <ScrollView contentContainerStyle={this.props.styles.scrollViewContentContainer} style={this.props.styles.scrollView} > <Text style={this.props.styles.header}>CURRENT PASSWORD</Text> <View style={this.props.styles.section}> <View style={this.props.styles.row}> <TextInput style={this.props.styles.input} value={this.state.currentPassword} onChangeText={this.onChangeCurrentPassword} placeholder="Current password" placeholderTextColor={panelForegroundTertiaryLabel} secureTextEntry={true} textContentType="password" autoComplete="password" autoFocus={true} returnKeyType="next" onSubmitEditing={this.focusNewPassword} ref={this.currentPasswordRef} /> </View> </View> <Text style={this.props.styles.header}>NEW PASSWORD</Text> <View style={this.props.styles.section}> <View style={this.props.styles.row}> <TextInput style={this.props.styles.input} value={this.state.newPassword} onChangeText={this.onChangeNewPassword} placeholder="New password" placeholderTextColor={panelForegroundTertiaryLabel} secureTextEntry={true} textContentType="newPassword" autoComplete="password-new" returnKeyType="next" onSubmitEditing={this.focusConfirmPassword} ref={this.newPasswordRef} /> </View> <View style={this.props.styles.hr} /> <View style={this.props.styles.row}> <TextInput style={this.props.styles.input} value={this.state.confirmPassword} onChangeText={this.onChangeConfirmPassword} placeholder="Confirm password" placeholderTextColor={panelForegroundTertiaryLabel} secureTextEntry={true} textContentType="newPassword" returnKeyType="go" onSubmitEditing={this.submitPassword} ref={this.confirmPasswordRef} /> </View> </View> <Button onPress={this.submitPassword} style={this.props.styles.saveButton} > {buttonContent} </Button> </ScrollView> ); } onChangeCurrentPassword = (currentPassword: string) => { this.setState({ currentPassword }); }; currentPasswordRef = ( currentPasswordInput: ?React.ElementRef<typeof BaseTextInput>, ) => { this.currentPasswordInput = currentPasswordInput; }; focusCurrentPassword = () => { invariant(this.currentPasswordInput, 'currentPasswordInput should be set'); this.currentPasswordInput.focus(); }; onChangeNewPassword = (newPassword: string) => { this.setState({ newPassword }); }; newPasswordRef = ( newPasswordInput: ?React.ElementRef<typeof BaseTextInput>, ) => { this.newPasswordInput = newPasswordInput; }; focusNewPassword = () => { invariant(this.newPasswordInput, 'newPasswordInput should be set'); this.newPasswordInput.focus(); }; onChangeConfirmPassword = (confirmPassword: string) => { this.setState({ confirmPassword }); }; confirmPasswordRef = ( confirmPasswordInput: ?React.ElementRef<typeof BaseTextInput>, ) => { this.confirmPasswordInput = confirmPasswordInput; }; focusConfirmPassword = () => { invariant(this.confirmPasswordInput, 'confirmPasswordInput should be set'); this.confirmPasswordInput.focus(); }; goBackOnce() { this.props.navigation.dispatch(state => ({ ...CommonActions.goBack(), target: state.key, })); } submitPassword = () => { if (this.state.newPassword === '') { Alert.alert( 'Empty password', 'New password cannot be empty', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword !== this.state.confirmPassword) { Alert.alert( - "Passwords don't match", + 'Passwords don’t match', 'New password fields must contain the same password', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword === this.state.currentPassword) { this.goBackOnce(); } else { this.props.dispatchActionPromise( changeUserPasswordActionTypes, this.savePassword(), ); } }; async savePassword() { const { username } = this.props; if (!username) { return; } try { await this.props.changeUserPassword({ updatedFields: { password: this.state.newPassword, }, currentPassword: this.state.currentPassword, }); await setNativeCredentials({ username, password: this.state.newPassword, }); this.goBackOnce(); } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The current password you entered is incorrect', [{ text: 'OK', onPress: this.onCurrentPasswordAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } } } onNewPasswordAlertAcknowledged = () => { this.setState( { newPassword: '', confirmPassword: '' }, this.focusNewPassword, ); }; onCurrentPasswordAlertAcknowledged = () => { this.setState({ currentPassword: '' }, this.focusCurrentPassword); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentPassword: '', newPassword: '', confirmPassword: '' }, this.focusCurrentPassword, ); }; } const unboundStyles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 9, }, saveButton: { backgroundColor: 'greenButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 3, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeUserPasswordActionTypes, ); const ConnectedEditPassword: React.ComponentType<BaseProps> = React.memo<BaseProps>( function ConnectedEditPassword(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const username = useSelector(state => { if (state.currentUserInfo && !state.currentUserInfo.anonymous) { return state.currentUserInfo.username; } return undefined; }); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeUserPassword = useServerCall(changeUserPassword); return ( <EditPassword {...props} loadingStatus={loadingStatus} username={username} activeTheme={activeTheme} colors={colors} styles={styles} dispatchActionPromise={dispatchActionPromise} changeUserPassword={callChangeUserPassword} /> ); }, ); export default ConnectedEditPassword; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index d29caac03..b14ada5ff 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,493 +1,493 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import ExitApp from 'react-native-exit-app'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import thunk from 'redux-thunk'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, sqliteOpFailure, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { processThreadStoreOperations } from 'lib/reducers/thread-reducer'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils'; import { defaultEnabledApps } from 'lib/types/enabled-apps'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import type { Dispatch, BaseAction } from 'lib/types/redux-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import { defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import type { ThreadStoreOperation } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { convertMessageStoreOperationsToClientDBOperations } from 'lib/utils/message-ops-utils'; import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils'; import { defaultNavInfo } from '../navigation/default-state'; import { getGlobalNavContext } from '../navigation/icky-global'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import reactotron from '../reactotron'; import { defaultDeviceCameraInfo } from '../types/camera'; import { defaultConnectivityInfo } from '../types/connectivity'; import { defaultGlobalThemeInfo } from '../types/themes'; import { defaultURLPrefix, natNodeServer, setCustomServer, getDevServerHostname, } from '../utils/url-utils'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, recordAndroidNotificationActionType, clearAndroidNotificationsActionType, rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, type Action, } from './action-types'; import { remoteReduxDevServerConfig } from './dev-tools'; import { defaultDimensionsInfo } from './dimensions-updater.react'; import { persistConfig, setPersistor } from './persist'; import type { AppState } from './state-types'; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix, customServer: natNodeServer, threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(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, }: AppState); function reducer(state: AppState = defaultState, action: Action) { if (action.type === setReduxStateActionType) { return action.payload.state; } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.source, )) || (action.type === logInActionTypes.success && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.source, )) ) { return state; } const threadIDsToNotifIDs = reduceThreadIDsToNotifIDs( state.threadIDsToNotifIDs, action, ); state = { ...state, threadIDsToNotifIDs }; if ( action.type === recordAndroidNotificationActionType || action.type === clearAndroidNotificationsActionType || action.type === rescindAndroidNotificationActionType ) { return state; } if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.success) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } return state; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } const baseReducerResult = baseReducer(state, (action: BaseAction)); state = baseReducerResult.state; const { storeOperations } = baseReducerResult; const { threadStoreOperations, messageStoreOperations } = storeOperations; const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action); state = fixUnreadActiveThreadResult.state; const threadStoreOperationsWithUnreadFix = [ ...threadStoreOperations, ...fixUnreadActiveThreadResult.threadStoreOperations, ]; const convertedThreadStoreOperations = convertThreadStoreOperationsToClientDBOperations( threadStoreOperationsWithUnreadFix, ); const convertedMessageStoreOperations = convertMessageStoreOperationsToClientDBOperations( messageStoreOperations, ); (async () => { try { const promises = []; if (convertedThreadStoreOperations.length > 0) { promises.push( global.CommCoreModule.processThreadStoreOperations( convertedThreadStoreOperations, ), ); } if (convertedMessageStoreOperations.length > 0) { promises.push( global.CommCoreModule.processMessageStoreOperations( convertedMessageStoreOperations, ), ); } await Promise.all(promises); } catch { dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookie: null, cookieInvalidated: false, currentUserInfo: state.currentUserInfo, }, preRequestUserState: { currentUserInfo: state.currentUserInfo, sessionID: undefined, cookie: state.cookie, }, error: null, source: sqliteOpFailure, }, }); await persistor.flush(); ExitApp.exitApp(); } })(); return state; } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', - "Your app version is pretty old, and the server doesn't know how to " + + 'Your app version is pretty old, and the server doesn’t know how to ' + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', - "We're sorry, but your session was invalidated by the server. " + + 'We’re sorry, but your session was invalidated by the server. ' + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. type FixUnreadActiveThreadResult = { +state: AppState, +threadStoreOperations: $ReadOnlyArray<ThreadStoreOperation>, }; function fixUnreadActiveThread( state: AppState, action: *, ): FixUnreadActiveThreadResult { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( !activeThread || !state.threadStore.threadInfos[activeThread]?.currentUser.unread || (NativeAppState.currentState !== 'active' && (appLastBecameInactive + 10000 >= Date.now() || backgroundActionTypes.has(action.type))) ) { return { state, threadStoreOperations: [] }; } const updatedActiveThreadInfo = { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state.threadStore, threadStoreOperations, ); return { state: { ...state, threadStore: updatedThreadStore }, threadStoreOperations, }; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middlewares = [thunk, reduxLoggerMiddleware]; if (__DEV__) { const createDebugger = require('redux-flipper').default; middlewares.push(createDebugger()); } const middleware = applyMiddleware(...middlewares); let composeFunc = compose; if (__DEV__ && global.HermesInternal) { const { composeWithDevTools } = require('remote-redux-devtools/src'); composeFunc = composeWithDevTools({ name: 'Redux', hostname: getDevServerHostname(), ...remoteReduxDevServerConfig, }); } else if (global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeFunc = global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux', }); } let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store<AppState, *> = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/web/settings/password-change-modal.js b/web/settings/password-change-modal.js index 49495570a..70b7ac807 100644 --- a/web/settings/password-change-modal.js +++ b/web/settings/password-change-modal.js @@ -1,261 +1,261 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { changeUserPasswordActionTypes, changeUserPassword, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { type PasswordUpdate, type CurrentUserInfo, } from 'lib/types/user-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import Input from '../modals/input.react'; import { useModalContext } from '../modals/modal-provider.react'; import Modal from '../modals/modal.react'; import { useSelector } from '../redux/redux-utils'; import css from './password-change-modal.css'; type Props = { +currentUserInfo: ?CurrentUserInfo, +inputDisabled: boolean, +dispatchActionPromise: DispatchActionPromise, +changeUserPassword: (passwordUpdate: PasswordUpdate) => Promise<void>, +popModal: () => void, }; type State = { +newPassword: string, +confirmNewPassword: string, +currentPassword: string, +errorMessage: string, }; class PasswordChangeModal extends React.PureComponent<Props, State> { newPasswordInput: ?HTMLInputElement; currentPasswordInput: ?HTMLInputElement; constructor(props: Props) { super(props); this.state = { newPassword: '', confirmNewPassword: '', currentPassword: '', errorMessage: '', }; } componentDidMount() { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); } get username() { return this.props.currentUserInfo && !this.props.currentUserInfo.anonymous ? this.props.currentUserInfo.username : undefined; } render() { let errorMsg; if (this.state.errorMessage) { errorMsg = ( <div className={css['modal-form-error']}>{this.state.errorMessage}</div> ); } const { inputDisabled } = this.props; return ( <Modal name="Change Password" onClose={this.props.popModal} size="large"> <div className={css['modal-body']}> <form method="POST"> <div className={css['form-content']}> <p className={css['username-container']}> <span className={css['username-label']}>{'Logged in as '}</span> <span className={css['username']}>{this.username}</span> </p> <div className={css['form-content']}> <Input type="password" placeholder="Current password" value={this.state.currentPassword} onChange={this.onChangeCurrentPassword} disabled={inputDisabled} ref={this.currentPasswordInputRef} label="Current password" /> </div> <Input type="password" placeholder="New password" value={this.state.newPassword} onChange={this.onChangeNewPassword} ref={this.newPasswordInputRef} disabled={inputDisabled} label="New password" /> <Input type="password" placeholder="Confirm new password" value={this.state.confirmNewPassword} onChange={this.onChangeConfirmNewPassword} disabled={inputDisabled} /> </div> <div className={css['form-footer']}> <Button type="submit" variant="primary" onClick={this.onSubmit} disabled={inputDisabled} > Change Password </Button> {errorMsg} </div> </form> </div> </Modal> ); } newPasswordInputRef = (newPasswordInput: ?HTMLInputElement) => { this.newPasswordInput = newPasswordInput; }; currentPasswordInputRef = (currentPasswordInput: ?HTMLInputElement) => { this.currentPasswordInput = currentPasswordInput; }; onChangeNewPassword = (event: SyntheticEvent<HTMLInputElement>) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ newPassword: target.value }); }; onChangeConfirmNewPassword = (event: SyntheticEvent<HTMLInputElement>) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ confirmNewPassword: target.value }); }; onChangeCurrentPassword = (event: SyntheticEvent<HTMLInputElement>) => { const target = event.target; invariant(target instanceof HTMLInputElement, 'target not input'); this.setState({ currentPassword: target.value }); }; onSubmit = (event: SyntheticEvent<HTMLButtonElement>) => { event.preventDefault(); if (this.state.newPassword === '') { this.setState( { newPassword: '', confirmNewPassword: '', errorMessage: 'empty password', }, () => { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); }, ); } else if (this.state.newPassword !== this.state.confirmNewPassword) { this.setState( { newPassword: '', confirmNewPassword: '', - errorMessage: "passwords don't match", + errorMessage: 'passwords don’t match', }, () => { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); }, ); return; } this.props.dispatchActionPromise( changeUserPasswordActionTypes, this.changeUserSettingsAction(), ); }; async changeUserSettingsAction() { try { await this.props.changeUserPassword({ updatedFields: { password: this.state.newPassword, }, currentPassword: this.state.currentPassword, }); this.props.popModal(); } catch (e) { if (e.message === 'invalid_credentials') { this.setState( { currentPassword: '', errorMessage: 'wrong current password', }, () => { invariant( this.currentPasswordInput, 'currentPasswordInput ref unset', ); this.currentPasswordInput.focus(); }, ); } else { this.setState( { newPassword: '', confirmNewPassword: '', currentPassword: '', errorMessage: 'unknown error', }, () => { invariant(this.newPasswordInput, 'newPasswordInput ref unset'); this.newPasswordInput.focus(); }, ); } throw e; } } } const changeUserPasswordLoadingStatusSelector = createLoadingStatusSelector( changeUserPasswordActionTypes, ); const ConnectedPasswordChangeModal: React.ComponentType<{}> = React.memo<{}>( function ConnectedPasswordChangeModal(): React.Node { const currentUserInfo = useSelector(state => state.currentUserInfo); const inputDisabled = useSelector( state => changeUserPasswordLoadingStatusSelector(state) === 'loading', ); const callChangeUserPassword = useServerCall(changeUserPassword); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); return ( <PasswordChangeModal currentUserInfo={currentUserInfo} inputDisabled={inputDisabled} changeUserPassword={callChangeUserPassword} dispatchActionPromise={dispatchActionPromise} popModal={modalContext.popModal} /> ); }, ); export default ConnectedPasswordChangeModal;