diff --git a/native/navigation/app-navigator.react.js b/native/navigation/app-navigator.react.js index c705fb7c1..49db463b2 100644 --- a/native/navigation/app-navigator.react.js +++ b/native/navigation/app-navigator.react.js @@ -1,213 +1,213 @@ // @flow import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import * as SplashScreen from 'expo-splash-screen'; import * as React from 'react'; import Icon from 'react-native-vector-icons/FontAwesome'; import { PersistGate } from 'redux-persist/integration/react'; import { unreadCount } from 'lib/selectors/thread-selectors'; import Calendar from '../calendar/calendar.react'; import Chat from '../chat/chat.react'; import { MultimediaTooltipModal } from '../chat/multimedia-tooltip-modal.react'; import { RobotextMessageTooltipModal } from '../chat/robotext-message-tooltip-modal.react'; import ThreadSettingsMemberTooltipModal from '../chat/settings/thread-settings-member-tooltip-modal.react'; import { TextMessageTooltipModal } from '../chat/text-message-tooltip-modal.react'; import KeyboardStateContainer from '../keyboard/keyboard-state-container.react'; import CameraModal from '../media/camera-modal.react'; import ImageModal from '../media/image-modal.react'; import VideoPlaybackModal from '../media/video-playback-modal.react'; -import More from '../profile/more.react'; +import More from '../profile/profile.react'; import RelationshipListItemTooltipModal from '../profile/relationship-list-item-tooltip-modal.react'; import PushHandler from '../push/push-handler.react'; import { getPersistor } from '../redux/persist'; import { useSelector } from '../redux/redux-utils'; import { RootContext } from '../root-context'; import { waitForInteractions } from '../utils/timers'; import ActionResultModal from './action-result-modal.react'; import { createOverlayNavigator } from './overlay-navigator.react'; import type { OverlayRouterNavigationProp } from './overlay-router'; import type { RootNavigationProp } from './root-navigator.react'; import { CalendarRouteName, ChatRouteName, MoreRouteName, TabNavigatorRouteName, ImageModalRouteName, MultimediaTooltipModalRouteName, ActionResultModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, RelationshipListItemTooltipModalRouteName, RobotextMessageTooltipModalRouteName, CameraModalRouteName, VideoPlaybackModalRouteName, type ScreenParamList, type TabParamList, type OverlayParamList, } from './route-names'; import { tabBar } from './tab-bar.react'; let splashScreenHasHidden = false; const calendarTabOptions = { tabBarLabel: 'Calendar', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => ( ), }; const getChatTabOptions = (badge: number) => ({ tabBarLabel: 'Chat', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => ( ), tabBarBadge: badge ? badge : undefined, }); const moreTabOptions = { tabBarLabel: 'More', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => ( ), }; export type TabNavigationProp< RouteName: $Keys = $Keys, > = BottomTabNavigationProp; const Tab = createBottomTabNavigator< ScreenParamList, TabParamList, TabNavigationProp<>, >(); const tabBarOptions = { keyboardHidesTabBar: false }; function TabNavigator() { const chatBadge = useSelector(unreadCount); return ( ); } export type AppNavigationProp< RouteName: $Keys = $Keys, > = OverlayRouterNavigationProp; const App = createOverlayNavigator< ScreenParamList, OverlayParamList, AppNavigationProp<>, >(); type AppNavigatorProps = { navigation: RootNavigationProp<'App'>, }; function AppNavigator(props: AppNavigatorProps) { const { navigation } = props; const rootContext = React.useContext(RootContext); const setNavStateInitialized = rootContext && rootContext.setNavStateInitialized; React.useEffect(() => { setNavStateInitialized && setNavStateInitialized(); }, [setNavStateInitialized]); const [ localSplashScreenHasHidden, setLocalSplashScreenHasHidden, ] = React.useState(splashScreenHasHidden); React.useEffect(() => { if (localSplashScreenHasHidden) { return; } splashScreenHasHidden = true; (async () => { await waitForInteractions(); try { await SplashScreen.hideAsync(); setLocalSplashScreenHasHidden(true); } catch {} })(); }, [localSplashScreenHasHidden]); let pushHandler; if (localSplashScreenHasHidden) { pushHandler = ( ); } return ( {pushHandler} ); } const styles = { icon: { fontSize: 28, }, }; export default AppNavigator; diff --git a/native/profile/dev-tools.react.js b/native/profile/dev-tools.react.js index 71942be3d..f9f3fe209 100644 --- a/native/profile/dev-tools.react.js +++ b/native/profile/dev-tools.react.js @@ -1,246 +1,246 @@ // @flow import * as React from 'react'; import { View, Text, ScrollView, Platform } from 'react-native'; import ExitApp from 'react-native-exit-app'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import type { Dispatch } from 'lib/types/redux-types'; import { setURLPrefix } from 'lib/utils/url-utils'; import Button from '../components/button.react'; import type { NavigationRoute } from '../navigation/route-names'; import { CustomServerModalRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useColors, useStyles, type Colors } from '../themes/colors'; import { wipeAndExit } from '../utils/crash-utils'; import { nodeServerOptions } from '../utils/url-utils'; -import type { MoreNavigationProp } from './more.react'; +import type { MoreNavigationProp } from './profile.react'; const ServerIcon = () => ( ); type BaseProps = {| +navigation: MoreNavigationProp<'DevTools'>, +route: NavigationRoute<'DevTools'>, |}; type Props = {| ...BaseProps, +urlPrefix: string, +customServer: ?string, +colors: Colors, +styles: typeof unboundStyles, +dispatch: Dispatch, |}; class DevTools extends React.PureComponent { render() { const { panelIosHighlightUnderlay: underlay } = this.props.colors; const serverButtons = []; for (const server of nodeServerOptions) { const icon = server === this.props.urlPrefix ? : null; serverButtons.push( , ); serverButtons.push( , ); } const customServerLabel = this.props.customServer ? ( {'custom: '} {this.props.customServer} ) : ( custom ); const customServerIcon = this.props.customServer === this.props.urlPrefix ? : null; serverButtons.push( , ); return ( SERVER {serverButtons} ); } onPressCrash = () => { throw new Error('User triggered crash through dev menu!'); }; onPressKill = () => { ExitApp.exitApp(); }; onPressWipe = async () => { await wipeAndExit(); }; onSelectServer = (server: string) => { if (server !== this.props.urlPrefix) { this.props.dispatch({ type: setURLPrefix, payload: server, }); } }; onSelectCustomServer = () => { this.props.navigation.navigate(CustomServerModalRouteName, { presentedFrom: this.props.route.key, }); }; } const unboundStyles = { container: { flex: 1, }, customServerLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, redText: { color: 'redText', flex: 1, fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, serverContainer: { flex: 1, }, serverText: { color: 'panelForegroundLabel', fontSize: 16, }, slightlyPaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, }; export default React.memo(function ConnectedDevTools( props: BaseProps, ) { const urlPrefix = useSelector((state) => state.urlPrefix); const customServer = useSelector((state) => state.customServer); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); return ( ); }); diff --git a/native/profile/edit-email.react.js b/native/profile/edit-email.react.js index 4724d2d98..69196a75b 100644 --- a/native/profile/edit-email.react.js +++ b/native/profile/edit-email.react.js @@ -1,321 +1,321 @@ // @flow import { CommonActions } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput, ScrollView, Alert, ActivityIndicator, } from 'react-native'; import { changeUserSettingsActionTypes, changeUserSettings, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { validEmailRegex } from 'lib/shared/account-utils'; import type { ChangeUserSettingsResult } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { AccountUpdate } from 'lib/types/user-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import Button from '../components/button.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 { MoreNavigationProp } from './more.react'; +import type { MoreNavigationProp } from './profile.react'; type BaseProps = {| +navigation: MoreNavigationProp<'EditEmail'>, +route: NavigationRoute<'EditEmail'>, |}; type Props = {| ...BaseProps, +email: ?string, +loadingStatus: LoadingStatus, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, +dispatchActionPromise: DispatchActionPromise, +changeUserSettings: ( accountUpdate: AccountUpdate, ) => Promise, |}; type State = {| +email: string, +password: string, |}; class EditEmail extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; emailInput: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { email: props.email ? props.email : '', password: '', }; } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Save ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( EMAIL PASSWORD ); } onChangeEmailText = (newEmail: string) => { this.setState({ email: newEmail }); }; emailInputRef = (emailInput: ?React.ElementRef) => { this.emailInput = emailInput; }; focusEmailInput = () => { invariant(this.emailInput, 'emailInput should be set'); this.emailInput.focus(); }; onChangePasswordText = (newPassword: string) => { this.setState({ password: newPassword }); }; passwordInputRef = (passwordInput: ?React.ElementRef) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; goBackOnce() { this.props.navigation.dispatch((state) => ({ ...CommonActions.goBack(), target: state.key, })); } submitEmail = () => { if (this.state.email.search(validEmailRegex) === -1) { Alert.alert( 'Invalid email address', 'Valid email addresses only', [{ text: 'OK', onPress: this.onEmailAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.password === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.email === this.props.email) { this.goBackOnce(); } else { this.props.dispatchActionPromise( changeUserSettingsActionTypes, this.saveEmail(), ); } }; async saveEmail() { try { const result = await this.props.changeUserSettings({ updatedFields: { email: this.state.email, }, currentPassword: this.state.password, }); this.goBackOnce(); Alert.alert( 'Verify email', "We've sent you an email to verify your email address. Just click on " + 'the link in the email to complete the verification process.', undefined, { cancelable: true }, ); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } } } onEmailAlertAcknowledged = () => { const resetEmail = this.props.email ? this.props.email : ''; this.setState({ email: resetEmail }, this.focusEmailInput); }; onPasswordAlertAcknowledged = () => { this.setState({ password: '' }, this.focusPasswordInput); }; onUnknownErrorAlertAcknowledged = () => { const resetEmail = this.props.email ? this.props.email : ''; this.setState({ email: resetEmail, password: '' }, this.focusEmailInput); }; } const unboundStyles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, saveButton: { backgroundColor: 'greenButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeUserSettingsActionTypes, ); export default React.memo(function ConnectedEditEmail( props: BaseProps, ) { const email = useSelector((state) => state.currentUserInfo && !state.currentUserInfo.anonymous ? state.currentUserInfo.email : undefined, ); const loadingStatus = useSelector(loadingStatusSelector); const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const callChangeUserSettings = useServerCall(changeUserSettings); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }); diff --git a/native/profile/edit-password.react.js b/native/profile/edit-password.react.js index 00f4cbc9b..28c27ddd9 100644 --- a/native/profile/edit-password.react.js +++ b/native/profile/edit-password.react.js @@ -1,374 +1,374 @@ // @flow import { CommonActions } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput, ScrollView, Alert, ActivityIndicator, } from 'react-native'; import { changeUserSettingsActionTypes, changeUserSettings, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { ChangeUserSettingsResult } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { AccountUpdate } 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 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 { MoreNavigationProp } from './more.react'; +import type { MoreNavigationProp } from './profile.react'; type BaseProps = {| +navigation: MoreNavigationProp<'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 +changeUserSettings: ( accountUpdate: AccountUpdate, ) => Promise, |}; type State = {| +currentPassword: string, +newPassword: string, +confirmPassword: string, |}; class EditPassword extends React.PureComponent { state: State = { currentPassword: '', newPassword: '', confirmPassword: '', }; mounted = false; currentPasswordInput: ?React.ElementRef; newPasswordInput: ?React.ElementRef; confirmPasswordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Save ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( CURRENT PASSWORD NEW PASSWORD ); } onChangeCurrentPassword = (currentPassword: string) => { this.setState({ currentPassword }); }; currentPasswordRef = ( currentPasswordInput: ?React.ElementRef, ) => { this.currentPasswordInput = currentPasswordInput; }; focusCurrentPassword = () => { invariant(this.currentPasswordInput, 'currentPasswordInput should be set'); this.currentPasswordInput.focus(); }; onChangeNewPassword = (newPassword: string) => { this.setState({ newPassword }); }; newPasswordRef = (newPasswordInput: ?React.ElementRef) => { this.newPasswordInput = newPasswordInput; }; focusNewPassword = () => { invariant(this.newPasswordInput, 'newPasswordInput should be set'); this.newPasswordInput.focus(); }; onChangeConfirmPassword = (confirmPassword: string) => { this.setState({ confirmPassword }); }; confirmPasswordRef = ( confirmPasswordInput: ?React.ElementRef, ) => { this.confirmPasswordInput = confirmPasswordInput; }; focusConfirmPassword = () => { invariant(this.confirmPasswordInput, 'confirmPasswordInput should be set'); this.confirmPasswordInput.focus(); }; goBackOnce() { this.props.navigation.dispatch((state) => ({ ...CommonActions.goBack(), target: state.key, })); } submitPassword = () => { if (this.state.newPassword === '') { Alert.alert( 'Empty password', 'New password cannot be empty', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword !== this.state.confirmPassword) { Alert.alert( "Passwords don't match", 'New password fields must contain the same password', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword === this.state.currentPassword) { this.goBackOnce(); } else { this.props.dispatchActionPromise( changeUserSettingsActionTypes, this.savePassword(), ); } }; async savePassword() { const { username } = this.props; if (!username) { return; } try { const result = await this.props.changeUserSettings({ updatedFields: { password: this.state.newPassword, }, currentPassword: this.state.currentPassword, }); await setNativeCredentials({ username, password: this.state.newPassword, }); this.goBackOnce(); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The current password you entered is incorrect', [{ text: 'OK', onPress: this.onCurrentPasswordAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } } } onNewPasswordAlertAcknowledged = () => { this.setState( { newPassword: '', confirmPassword: '' }, this.focusNewPassword, ); }; onCurrentPasswordAlertAcknowledged = () => { this.setState({ currentPassword: '' }, this.focusCurrentPassword); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentPassword: '', newPassword: '', confirmPassword: '' }, this.focusCurrentPassword, ); }; } const 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( changeUserSettingsActionTypes, ); export default React.memo(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 callChangeUserSettings = useServerCall(changeUserSettings); return ( ); }); diff --git a/native/profile/more-screen.react.js b/native/profile/more-screen.react.js index 756d92068..a183b4fa0 100644 --- a/native/profile/more-screen.react.js +++ b/native/profile/more-screen.react.js @@ -1,558 +1,558 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Alert, Platform, ScrollView, ActivityIndicator, } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; import { logOutActionTypes, logOut, resendVerificationEmailActionTypes, resendVerificationEmail, } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LogOutResult } from 'lib/types/account-types'; import { type PreRequestUserState } from 'lib/types/session-types'; import { type CurrentUserInfo } from 'lib/types/user-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import { getNativeSharedWebCredentials, deleteNativeCredentialsFor, } from '../account/native-credentials'; import Button from '../components/button.react'; import EditSettingButton from '../components/edit-setting-button.react'; import { SingleLine } from '../components/single-line.react'; import type { NavigationRoute } from '../navigation/route-names'; import { EditEmailRouteName, EditPasswordRouteName, DeleteAccountRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, FriendListRouteName, BlockListRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; -import type { MoreNavigationProp } from './more.react'; +import type { MoreNavigationProp } from './profile.react'; type BaseProps = {| +navigation: MoreNavigationProp<'MoreScreen'>, +route: NavigationRoute<'MoreScreen'>, |}; type Props = {| ...BaseProps, +currentUserInfo: ?CurrentUserInfo, +preRequestUserState: PreRequestUserState, +resendVerificationLoading: boolean, +logOutLoading: boolean, +colors: Colors, +styles: typeof unboundStyles, +dispatchActionPromise: DispatchActionPromise, +logOut: (preRequestUserState: PreRequestUserState) => Promise, +resendVerificationEmail: () => Promise, |}; class MoreScreen extends React.PureComponent { get username() { return this.props.currentUserInfo && !this.props.currentUserInfo.anonymous ? this.props.currentUserInfo.username : undefined; } get email() { return this.props.currentUserInfo && !this.props.currentUserInfo.anonymous ? this.props.currentUserInfo.email : undefined; } get emailVerified() { return this.props.currentUserInfo && !this.props.currentUserInfo.anonymous ? this.props.currentUserInfo.emailVerified : undefined; } get loggedOutOrLoggingOut() { return ( !this.props.currentUserInfo || this.props.currentUserInfo.anonymous || this.props.logOutLoading ); } render() { const { emailVerified } = this; let emailVerifiedNode = null; if (emailVerified === true) { emailVerifiedNode = ( Verified ); } else if (emailVerified === false) { let resendVerificationEmailSpinner; if (this.props.resendVerificationLoading) { resendVerificationEmailSpinner = ( ); } emailVerifiedNode = ( Not verified {' - '} ); } const { panelIosHighlightUnderlay: underlay, link: linkColor, } = this.props.colors; return ( {'Logged in as '} {this.username} ACCOUNT Email {this.email} {emailVerifiedNode} Password •••••••••••••••• PREFERENCES ); } onPressLogOut = () => { if (this.loggedOutOrLoggingOut) { return; } const alertTitle = Platform.OS === 'ios' ? 'Keep Login Info in Keychain' : 'Keep Login Info'; const sharedWebCredentials = getNativeSharedWebCredentials(); const alertDescription = sharedWebCredentials ? 'We will automatically fill out log-in forms with your credentials ' + 'in the app and keep them available on squadcal.org in Safari.' : 'We will automatically fill out log-in forms with your credentials ' + 'in the app.'; Alert.alert( alertTitle, alertDescription, [ { text: 'Cancel', style: 'cancel' }, { text: 'Keep', onPress: this.logOutButKeepNativeCredentialsWrapper }, { text: 'Remove', onPress: this.logOutAndDeleteNativeCredentialsWrapper, style: 'destructive', }, ], { cancelable: true }, ); }; logOutButKeepNativeCredentialsWrapper = () => { if (this.loggedOutOrLoggingOut) { return; } this.props.dispatchActionPromise(logOutActionTypes, this.logOut()); }; logOutAndDeleteNativeCredentialsWrapper = () => { if (this.loggedOutOrLoggingOut) { return; } this.props.dispatchActionPromise( logOutActionTypes, this.logOutAndDeleteNativeCredentials(), ); }; logOut() { return this.props.logOut(this.props.preRequestUserState); } async logOutAndDeleteNativeCredentials() { const { username } = this; invariant(username, "can't log out if not logged in"); await deleteNativeCredentialsFor(username); return await this.logOut(); } onPressResendVerificationEmail = () => { this.props.dispatchActionPromise( resendVerificationEmailActionTypes, this.resendVerificationEmailAction(), ); }; async resendVerificationEmailAction() { await this.props.resendVerificationEmail(); Alert.alert( 'Verify email', "We've sent you an email to verify your email address. Just click on " + 'the link in the email to complete the verification process.', undefined, { cancelable: true }, ); } navigateIfActive(name) { this.props.navigation.navigate({ name }); } onPressEditEmail = () => { this.navigateIfActive(EditEmailRouteName); }; onPressEditPassword = () => { this.navigateIfActive(EditPasswordRouteName); }; onPressDeleteAccount = () => { this.navigateIfActive(DeleteAccountRouteName); }; onPressBuildInfo = () => { this.navigateIfActive(BuildInfoRouteName); }; onPressDevTools = () => { this.navigateIfActive(DevToolsRouteName); }; onPressAppearance = () => { this.navigateIfActive(AppearancePreferencesRouteName); }; onPressFriendList = () => { this.navigateIfActive(FriendListRouteName); }; onPressBlockList = () => { this.navigateIfActive(BlockListRouteName); }; } const unboundStyles = { container: { flex: 1, }, content: { flex: 1, }, deleteAccountButton: { paddingHorizontal: 24, paddingVertical: 12, }, deleteAccountText: { color: 'redText', flex: 1, fontSize: 16, }, editEmailButton: { paddingTop: Platform.OS === 'android' ? 9 : 7, }, editPasswordButton: { paddingTop: Platform.OS === 'android' ? 3 : 2, }, emailNotVerified: { color: 'redText', }, emailVerified: { color: 'greenText', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingRight: 12, }, loggedInLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, logOutText: { color: 'link', fontSize: 16, paddingLeft: 6, }, resendVerificationEmailButton: { flexDirection: 'row', paddingRight: 1, }, resendVerificationEmailSpinner: { marginTop: Platform.OS === 'ios' ? -4 : 0, paddingHorizontal: 4, }, resendVerificationEmailText: { color: 'link', fontStyle: 'italic', }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, slightlyPaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, submenuButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, submenuText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, }, unpaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, }, username: { color: 'panelForegroundLabel', flex: 1, }, value: { color: 'panelForegroundLabel', fontSize: 16, textAlign: 'right', }, verification: { alignSelf: 'flex-end', flexDirection: 'row', height: 20, }, verificationSection: { alignSelf: 'flex-end', flexDirection: 'row', height: 20, }, verificationText: { color: 'panelForegroundLabel', fontSize: 13, fontStyle: 'italic', }, }; const logOutLoadingStatusSelector = createLoadingStatusSelector( logOutActionTypes, ); const resendVerificationLoadingStatusSelector = createLoadingStatusSelector( resendVerificationEmailActionTypes, ); export default React.memo(function ConnectedMoreScreen( props: BaseProps, ) { const currentUserInfo = useSelector((state) => state.currentUserInfo); const preRequestUserState = useSelector(preRequestUserStateSelector); const resendVerificationLoading = useSelector(resendVerificationLoadingStatusSelector) === 'loading'; const logOutLoading = useSelector(logOutLoadingStatusSelector) === 'loading'; const colors = useColors(); const styles = useStyles(unboundStyles); const callLogOut = useServerCall(logOut); const callResendVerificationEmail = useServerCall(resendVerificationEmail); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }); diff --git a/native/profile/more.react.js b/native/profile/profile.react.js similarity index 100% rename from native/profile/more.react.js rename to native/profile/profile.react.js diff --git a/native/profile/relationship-list.react.js b/native/profile/relationship-list.react.js index aba58d398..167d7c343 100644 --- a/native/profile/relationship-list.react.js +++ b/native/profile/relationship-list.react.js @@ -1,572 +1,572 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, FlatList, Alert, Platform } from 'react-native'; import { createSelector } from 'reselect'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions'; import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { type UserRelationships, type RelationshipRequest, type RelationshipErrors, userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types'; import type { UserSearchResult } from 'lib/types/search-types'; import type { UserInfos, GlobalAccountUserInfo, AccountUserInfo, } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import LinkButton from '../components/link-button.react'; import { createTagInput, BaseTagInput } from '../components/tag-input.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { FriendListRouteName, BlockListRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; -import type { MoreNavigationProp } from './more.react'; +import type { MoreNavigationProp } from './profile.react'; import RelationshipListItem from './relationship-list-item.react'; const TagInput = createTagInput(); export type RelationshipListNavigate = $PropertyType< MoreNavigationProp<'FriendList' | 'BlockList'>, 'navigate', >; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; type ListItem = | {| +type: 'empty', +because: 'no-relationships' | 'no-results' |} | {| +type: 'header' |} | {| +type: 'footer' |} | {| +type: 'user', +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, |}; type BaseProps = {| +navigation: MoreNavigationProp<>, +route: NavigationRoute<'FriendList' | 'BlockList'>, |}; type Props = {| ...BaseProps, // Redux state +relationships: UserRelationships, +userInfos: UserInfos, +viewerID: ?string, +userStoreSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, +updateRelationships: ( request: RelationshipRequest, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +verticalBounds: ?VerticalBounds, +searchInputText: string, +serverSearchResults: $ReadOnlyArray, +currentTags: $ReadOnlyArray, +userStoreSearchResults: Set, |}; type PropsAndState = {| ...Props, ...State |}; class RelationshipList extends React.PureComponent { flatListContainerRef = React.createRef(); tagInput: ?BaseTagInput = null; state: State = { verticalBounds: null, searchInputText: '', serverSearchResults: [], userStoreSearchResults: new Set(), currentTags: [], }; componentDidMount() { this.setSaveButton(false); } componentDidUpdate(prevProps: Props, prevState: State) { const prevTags = prevState.currentTags.length; const currentTags = this.state.currentTags.length; if (prevTags !== 0 && currentTags === 0) { this.setSaveButton(false); } else if (prevTags === 0 && currentTags !== 0) { this.setSaveButton(true); } } setSaveButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } static keyExtractor(item: ListItem) { if (item.userInfo) { return item.userInfo.id; } else if (item.type === 'empty') { return 'empty'; } else if (item.type === 'header') { return 'header'; } else if (item.type === 'footer') { return 'footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); } get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'RelationshipList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = RelationshipList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } render() { const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( Search: ); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.relationships, (propsAndState: PropsAndState) => propsAndState.route.name, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (propsAndState: PropsAndState) => propsAndState.searchInputText, (propsAndState: PropsAndState) => propsAndState.serverSearchResults, (propsAndState: PropsAndState) => propsAndState.userStoreSearchResults, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.currentTags, ( relationships: UserRelationships, routeName: 'FriendList' | 'BlockList', verticalBounds: ?VerticalBounds, searchInputText: string, serverSearchResults: $ReadOnlyArray, userStoreSearchResults: Set, userInfos: UserInfos, viewerID: ?string, currentTags: $ReadOnlyArray, ) => { const defaultUsers = { [FriendListRouteName]: relationships.friends, [BlockListRouteName]: relationships.blocked, }[routeName]; const excludeUserIDsArray = currentTags .map((userInfo) => userInfo.id) .concat(viewerID || []); const excludeUserIDs = new Set(excludeUserIDsArray); let displayUsers = defaultUsers; if (searchInputText !== '') { const mergedUserInfos: { [id: string]: AccountUserInfo } = {}; for (const userInfo of serverSearchResults) { mergedUserInfos[userInfo.id] = userInfo; } for (const id of userStoreSearchResults) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedUserInfos[id] = { id, username, relationshipStatus }; } } const sortToEnd = []; const userSearchResults = []; const sortRelationshipTypesToEnd = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], }[routeName]; for (const userID in mergedUserInfos) { if (excludeUserIDs.has(userID)) { continue; } const userInfo = mergedUserInfos[userID]; if ( sortRelationshipTypesToEnd.includes(userInfo.relationshipStatus) ) { sortToEnd.push(userInfo); } else { userSearchResults.push(userInfo); } } displayUsers = userSearchResults.concat(sortToEnd); } let emptyItem; if (displayUsers.length === 0 && searchInputText === '') { emptyItem = { type: 'empty', because: 'no-relationships' }; } else if (displayUsers.length === 0) { emptyItem = { type: 'empty', because: 'no-results' }; } const mappedUsers = displayUsers.map((userInfo, index) => ({ type: 'user', userInfo, lastListItem: displayUsers.length - 1 === index, verticalBounds, })); return [] .concat(emptyItem ? emptyItem : []) .concat(emptyItem ? [] : { type: 'header' }) .concat(mappedUsers) .concat(emptyItem ? [] : { type: 'footer' }); }, ); tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; tagDataLabelExtractor = (userInfo: GlobalAccountUserInfo) => userInfo.username; onChangeTagInput = (currentTags: $ReadOnlyArray) => { this.setState({ currentTags }); }; onChangeSearchText = async (searchText: string) => { const excludeStatuses = { [FriendListRouteName]: [ userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], [BlockListRouteName]: [], }[this.props.route.name]; const results = this.props.userStoreSearchIndex .getSearchResults(searchText) .filter((userID) => { const relationship = this.props.userInfos[userID].relationshipStatus; return !excludeStatuses.includes(relationship); }); this.setState({ searchInputText: searchText, userStoreSearchResults: new Set(results), }); const serverSearchResults = await this.searchUsers(searchText); const filteredServerSearchResults = serverSearchResults.filter( (searchUserInfo) => { const userInfo = this.props.userInfos[searchUserInfo.id]; return ( !userInfo || !excludeStatuses.includes(userInfo.relationshipStatus) ); }, ); this.setState({ serverSearchResults: filteredServerSearchResults }); }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos; } onFlatListContainerLayout = () => { const { flatListContainerRef } = this; if (!flatListContainerRef.current) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainerRef.current.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }, ); }; onSelect = (selectedUser: GlobalAccountUserInfo) => { this.setState((state) => { if (state.currentTags.find((o) => o.id === selectedUser.id)) { return null; } return { searchInputText: '', currentTags: state.currentTags.concat(selectedUser), }; }); }; onPressAdd = () => { if (this.state.currentTags.length === 0) { return; } this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateRelationships(), ); }; async updateRelationships() { const routeName = this.props.route.name; const action = { [FriendListRouteName]: relationshipActions.FRIEND, [BlockListRouteName]: relationshipActions.BLOCK, }[routeName]; const userIDs = this.state.currentTags.map((userInfo) => userInfo.id); try { const result = await this.props.updateRelationships({ action, userIDs, }); this.setState({ currentTags: [], searchInputText: '', }); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: true, onDismiss: this.onUnknownErrorAlertAcknowledged }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentTags: [], searchInputText: '', }, this.onErrorAcknowledged, ); }; renderItem = ({ item }: { item: ListItem }) => { if (item.type === 'empty') { const action = { [FriendListRouteName]: 'added', [BlockListRouteName]: 'blocked', }[this.props.route.name]; const emptyMessage = item.because === 'no-relationships' ? `You haven't ${action} any users yet` : 'No results'; return {emptyMessage}; } else if (item.type === 'header' || item.type === 'footer') { return ; } else if (item.type === 'user') { return ( ); } else { invariant(false, `unexpected RelationshipList item type ${item.type}`); } }; } const unboundStyles = { container: { flex: 1, backgroundColor: 'panelBackground', }, contentContainer: { paddingTop: 12, paddingBottom: 24, }, separator: { backgroundColor: 'panelForegroundBorder', height: Platform.OS === 'android' ? 1.5 : 1, }, emptyText: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, textAlign: 'center', paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 12, }, tagInput: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingLeft: 12, }, tagInputContainer: { alignItems: 'center', backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; registerFetchKey(searchUsersActionTypes); registerFetchKey(updateRelationshipsActionTypes); export default React.memo(function ConnectedRelationshipList( props: BaseProps, ) { const relationships = useSelector(userRelationshipsSelector); const userInfos = useSelector((state) => state.userStore.userInfos); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const dispatchActionPromise = useDispatchActionPromise(); const callSearchUsers = useServerCall(searchUsers); const callUpdateRelationships = useServerCall(updateRelationships); return ( ); });