diff --git a/native/navigation/app-navigator.react.js b/native/navigation/app-navigator.react.js index 49db463b2..d7310ee2a 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/profile.react'; +import Profile 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, + ProfileRouteName, 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', +const profileTabOptions = { + tabBarLabel: 'Profile', // 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/navigation/default-state.js b/native/navigation/default-state.js index 771e00e6d..61dd47643 100644 --- a/native/navigation/default-state.js +++ b/native/navigation/default-state.js @@ -1,83 +1,83 @@ // @flow import type { StaleNavigationState } from '@react-navigation/native'; import type { BaseNavInfo } from 'lib/types/nav-types'; import { fifteenDaysEarlier, fifteenDaysLater } from 'lib/utils/date-utils'; import { AppRouteName, TabNavigatorRouteName, LoggedOutModalRouteName, - MoreRouteName, - MoreScreenRouteName, + ProfileRouteName, + ProfileScreenRouteName, ChatRouteName, ChatThreadListRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, CalendarRouteName, } from './route-names'; export type NavInfo = $Exact; const defaultNavigationState: StaleNavigationState = { type: 'stack', index: 1, routes: [ { name: AppRouteName, state: { type: 'stack', index: 0, routes: [ { name: TabNavigatorRouteName, state: { type: 'tab', index: 1, routes: [ { name: CalendarRouteName }, { name: ChatRouteName, state: { type: 'stack', index: 0, routes: [ { name: ChatThreadListRouteName, state: { type: 'tab', index: 0, routes: [ { name: HomeChatThreadListRouteName }, { name: BackgroundChatThreadListRouteName }, ], }, }, ], }, }, { - name: MoreRouteName, + name: ProfileRouteName, state: { type: 'stack', index: 0, - routes: [{ name: MoreScreenRouteName }], + routes: [{ name: ProfileScreenRouteName }], }, }, ], }, }, ], }, }, { name: LoggedOutModalRouteName }, ], }; const defaultNavInfo: NavInfo = { startDate: fifteenDaysEarlier().valueOf(), endDate: fifteenDaysLater().valueOf(), }; export { defaultNavigationState, defaultNavInfo }; diff --git a/native/navigation/route-names.js b/native/navigation/route-names.js index f10ca342b..7f28ecd53 100644 --- a/native/navigation/route-names.js +++ b/native/navigation/route-names.js @@ -1,171 +1,171 @@ // @flow import type { LeafRoute } from '@react-navigation/native'; import type { VerificationModalParams } from '../account/verification-modal.react'; import type { ThreadPickerModalParams } from '../calendar/thread-picker-modal.react'; import type { ComposeThreadParams } from '../chat/compose-thread.react'; import type { ImagePasteModalParams } from '../chat/image-paste-modal.react'; import type { MessageListParams } from '../chat/message-list-types'; import type { MultimediaTooltipModalParams } from '../chat/multimedia-tooltip-modal.react'; import type { RobotextMessageTooltipModalParams } from '../chat/robotext-message-tooltip-modal.react'; import type { AddUsersModalParams } from '../chat/settings/add-users-modal.react'; import type { ColorPickerModalParams } from '../chat/settings/color-picker-modal.react'; import type { ComposeSubthreadModalParams } from '../chat/settings/compose-subthread-modal.react'; import type { DeleteThreadParams } from '../chat/settings/delete-thread.react'; import type { ThreadSettingsMemberTooltipModalParams } from '../chat/settings/thread-settings-member-tooltip-modal.react'; import type { ThreadSettingsParams } from '../chat/settings/thread-settings.react'; import type { SidebarListModalParams } from '../chat/sidebar-list-modal.react'; import type { TextMessageTooltipModalParams } from '../chat/text-message-tooltip-modal.react'; import type { CameraModalParams } from '../media/camera-modal.react'; import type { ImageModalParams } from '../media/image-modal.react'; import type { VideoPlaybackModalParams } from '../media/video-playback-modal.react'; import type { CustomServerModalParams } from '../profile/custom-server-modal.react'; import type { RelationshipListItemTooltipModalParams } from '../profile/relationship-list-item-tooltip-modal.react'; import type { ActionResultModalParams } from './action-result-modal.react'; export const AppRouteName = 'App'; export const TabNavigatorRouteName = 'TabNavigator'; export const ComposeThreadRouteName = 'ComposeThread'; export const DeleteThreadRouteName = 'DeleteThread'; export const ThreadSettingsRouteName = 'ThreadSettings'; export const MessageListRouteName = 'MessageList'; export const VerificationModalRouteName = 'VerificationModal'; export const LoggedOutModalRouteName = 'LoggedOutModal'; -export const MoreRouteName = 'More'; -export const MoreScreenRouteName = 'MoreScreen'; +export const ProfileRouteName = 'Profile'; +export const ProfileScreenRouteName = 'ProfileScreen'; export const RelationshipListItemTooltipModalRouteName = 'RelationshipListItemTooltipModal'; export const ChatRouteName = 'Chat'; export const ChatThreadListRouteName = 'ChatThreadList'; export const HomeChatThreadListRouteName = 'HomeChatThreadList'; export const BackgroundChatThreadListRouteName = 'BackgroundChatThreadList'; export const CalendarRouteName = 'Calendar'; export const BuildInfoRouteName = 'BuildInfo'; export const DeleteAccountRouteName = 'DeleteAccount'; export const DevToolsRouteName = 'DevTools'; export const EditEmailRouteName = 'EditEmail'; export const EditPasswordRouteName = 'EditPassword'; export const AppearancePreferencesRouteName = 'AppearancePreferences'; export const ThreadPickerModalRouteName = 'ThreadPickerModal'; export const AddUsersModalRouteName = 'AddUsersModal'; export const CustomServerModalRouteName = 'CustomServerModal'; export const ColorPickerModalRouteName = 'ColorPickerModal'; export const ComposeSubthreadModalRouteName = 'ComposeSubthreadModal'; export const ImageModalRouteName = 'ImageModal'; export const MultimediaTooltipModalRouteName = 'MultimediaTooltipModal'; export const ActionResultModalRouteName = 'ActionResultModal'; export const TextMessageTooltipModalRouteName = 'TextMessageTooltipModal'; export const ThreadSettingsMemberTooltipModalRouteName = 'ThreadSettingsMemberTooltipModal'; export const CameraModalRouteName = 'CameraModal'; export const VideoPlaybackModalRouteName = 'VideoPlaybackModal'; export const FriendListRouteName = 'FriendList'; export const BlockListRouteName = 'BlockList'; export const SidebarListModalRouteName = 'SidebarListModal'; export const ImagePasteModalRouteName = 'ImagePasteModal'; export const RobotextMessageTooltipModalRouteName = 'RobotextMessageTooltipModal'; export type RootParamList = {| +LoggedOutModal: void, +VerificationModal: VerificationModalParams, +App: void, +ThreadPickerModal: ThreadPickerModalParams, +AddUsersModal: AddUsersModalParams, +CustomServerModal: CustomServerModalParams, +ColorPickerModal: ColorPickerModalParams, +ComposeSubthreadModal: ComposeSubthreadModalParams, +SidebarListModal: SidebarListModalParams, +ImagePasteModal: ImagePasteModalParams, |}; export type TooltipModalParamList = {| +MultimediaTooltipModal: MultimediaTooltipModalParams, +TextMessageTooltipModal: TextMessageTooltipModalParams, +ThreadSettingsMemberTooltipModal: ThreadSettingsMemberTooltipModalParams, +RelationshipListItemTooltipModal: RelationshipListItemTooltipModalParams, +RobotextMessageTooltipModal: RobotextMessageTooltipModalParams, |}; export type OverlayParamList = {| +TabNavigator: void, +ImageModal: ImageModalParams, +ActionResultModal: ActionResultModalParams, +CameraModal: CameraModalParams, +VideoPlaybackModal: VideoPlaybackModalParams, ...TooltipModalParamList, |}; export type TabParamList = {| +Calendar: void, +Chat: void, - +More: void, + +Profile: void, |}; export type ChatParamList = {| +ChatThreadList: void, +MessageList: MessageListParams, +ComposeThread: ComposeThreadParams, +ThreadSettings: ThreadSettingsParams, +DeleteThread: DeleteThreadParams, |}; export type ChatTopTabsParamList = {| +HomeChatThreadList: void, +BackgroundChatThreadList: void, |}; -export type MoreParamList = {| - +MoreScreen: void, +export type ProfileParamList = {| + +ProfileScreen: void, +EditEmail: void, +EditPassword: void, +DeleteAccount: void, +BuildInfo: void, +DevTools: void, +AppearancePreferences: void, +FriendList: void, +BlockList: void, |}; export type ScreenParamList = {| ...RootParamList, ...OverlayParamList, ...TabParamList, ...ChatParamList, ...ChatTopTabsParamList, - ...MoreParamList, + ...ProfileParamList, |}; export type NavigationRoute> = {| ...LeafRoute, +params: $ElementType, |}; export const accountModals = [ LoggedOutModalRouteName, VerificationModalRouteName, ]; export const scrollBlockingModals = [ ImageModalRouteName, MultimediaTooltipModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, RelationshipListItemTooltipModalRouteName, RobotextMessageTooltipModalRouteName, VideoPlaybackModalRouteName, ]; export const chatRootModals = [ AddUsersModalRouteName, ColorPickerModalRouteName, ComposeSubthreadModalRouteName, ]; export const threadRoutes = [ MessageListRouteName, ThreadSettingsRouteName, DeleteThreadRouteName, ComposeThreadRouteName, ]; diff --git a/native/profile/dev-tools.react.js b/native/profile/dev-tools.react.js index f9f3fe209..32d43f628 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 './profile.react'; +import type { ProfileNavigationProp } from './profile.react'; const ServerIcon = () => ( ); type BaseProps = {| - +navigation: MoreNavigationProp<'DevTools'>, + +navigation: ProfileNavigationProp<'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 69196a75b..35169facc 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 './profile.react'; +import type { ProfileNavigationProp } from './profile.react'; type BaseProps = {| - +navigation: MoreNavigationProp<'EditEmail'>, + +navigation: ProfileNavigationProp<'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 28c27ddd9..4da0a4085 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 './profile.react'; +import type { ProfileNavigationProp } from './profile.react'; type BaseProps = {| - +navigation: MoreNavigationProp<'EditPassword'>, + +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 +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/profile-header.react.js b/native/profile/profile-header.react.js index f583c09a9..6db6dcf65 100644 --- a/native/profile/profile-header.react.js +++ b/native/profile/profile-header.react.js @@ -1,19 +1,19 @@ // @flow import type { StackHeaderProps } from '@react-navigation/stack'; import * as React from 'react'; import Header from '../navigation/header.react'; import { createActiveTabSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; -import { MoreRouteName } from '../navigation/route-names'; +import { ProfileRouteName } from '../navigation/route-names'; -const activeTabSelector = createActiveTabSelector(MoreRouteName); +const activeTabSelector = createActiveTabSelector(ProfileRouteName); -export default React.memo(function MoreHeader( +export default React.memo(function ProfileHeader( props: StackHeaderProps, ) { const navContext = React.useContext(NavContext); const activeTab = activeTabSelector(navContext); return
; }); diff --git a/native/profile/profile-screen.react.js b/native/profile/profile-screen.react.js index a183b4fa0..e55584bb7 100644 --- a/native/profile/profile-screen.react.js +++ b/native/profile/profile-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 './profile.react'; +import type { ProfileNavigationProp } from './profile.react'; type BaseProps = {| - +navigation: MoreNavigationProp<'MoreScreen'>, - +route: NavigationRoute<'MoreScreen'>, + +navigation: ProfileNavigationProp<'ProfileScreen'>, + +route: NavigationRoute<'ProfileScreen'>, |}; 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 { +class ProfileScreen 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( +export default React.memo(function ConnectedProfileScreen( 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/profile.react.js b/native/profile/profile.react.js index 6aee598ed..d6413c8bf 100644 --- a/native/profile/profile.react.js +++ b/native/profile/profile.react.js @@ -1,141 +1,141 @@ // @flow import { createStackNavigator, type StackNavigationProp, type StackHeaderProps, } from '@react-navigation/stack'; import * as React from 'react'; import { View } from 'react-native'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import HeaderBackButton from '../navigation/header-back-button.react'; import { - MoreScreenRouteName, + ProfileScreenRouteName, EditEmailRouteName, EditPasswordRouteName, DeleteAccountRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, FriendListRouteName, BlockListRouteName, type ScreenParamList, - type MoreParamList, + type ProfileParamList, } from '../navigation/route-names'; import { useStyles } from '../themes/colors'; import AppearancePreferences from './appearance-preferences.react'; import BuildInfo from './build-info.react'; import DeleteAccount from './delete-account.react'; import DevTools from './dev-tools.react'; import EditEmail from './edit-email.react'; import EditPassword from './edit-password.react'; -import MoreHeader from './profile-header.react'; -import MoreScreen from './profile-screen.react'; +import ProfileHeader from './profile-header.react'; +import ProfileScreen from './profile-screen.react'; import RelationshipList from './relationship-list.react'; -const header = (props: StackHeaderProps) => ; +const header = (props: StackHeaderProps) => ; const headerBackButton = (props) => ; const screenOptions = { header, headerLeft: headerBackButton, }; -const moreScreenOptions = { headerTitle: 'More' }; +const profileScreenOptions = { headerTitle: 'Profile' }; const editEmailOptions = { headerTitle: 'Change email' }; const editPasswordOptions = { headerTitle: 'Change password' }; const deleteAccountOptions = { headerTitle: 'Delete account' }; const buildInfoOptions = { headerTitle: 'Build info' }; const devToolsOptions = { headerTitle: 'Developer tools' }; const appearanceOptions = { headerTitle: 'Appearance' }; const friendListOptions = { headerTitle: 'Friend list', headerBackTitle: 'Back', }; const blockListOptions = { headerTitle: 'Block list', headerBackTitle: 'Back', }; -export type MoreNavigationProp< - RouteName: $Keys = $Keys, +export type ProfileNavigationProp< + RouteName: $Keys = $Keys, > = StackNavigationProp; -const More = createStackNavigator< +const Profile = createStackNavigator< ScreenParamList, - MoreParamList, - MoreNavigationProp<>, + ProfileParamList, + ProfileNavigationProp<>, >(); -function MoreComponent() { +function ProfileComponent() { const styles = useStyles(unboundStyles); return ( - - - - - - - - - - - + ); } const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'panelBackground', }, }; -export default MoreComponent; +export default ProfileComponent; diff --git a/native/profile/relationship-list.react.js b/native/profile/relationship-list.react.js index 167d7c343..7033419c0 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 './profile.react'; +import type { ProfileNavigationProp } from './profile.react'; import RelationshipListItem from './relationship-list-item.react'; const TagInput = createTagInput(); export type RelationshipListNavigate = $PropertyType< - MoreNavigationProp<'FriendList' | 'BlockList'>, + ProfileNavigationProp<'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<>, + +navigation: ProfileNavigationProp<>, +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 ( ); });