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 (
);
});