diff --git a/native/account/fullscreen-siwe-panel.react.js b/native/account/fullscreen-siwe-panel.react.js
index 9f312a1a8..348208cf4 100644
--- a/native/account/fullscreen-siwe-panel.react.js
+++ b/native/account/fullscreen-siwe-panel.react.js
@@ -1,123 +1,124 @@
// @flow
import { useNavigation } from '@react-navigation/native';
import invariant from 'invariant';
import * as React from 'react';
import { ActivityIndicator, View } from 'react-native';
import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js';
import type { SIWEResult } from 'lib/types/siwe-types.js';
import { ServerError } from 'lib/utils/errors.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { useGetEthereumAccountFromSIWEResult } from './registration/ethereum-utils.js';
import { RegistrationContext } from './registration/registration-context.js';
import { enableNewRegistrationMode } from './registration/registration-types.js';
import { useSIWEServerCall } from './siwe-hooks.js';
import SIWEPanel from './siwe-panel.react.js';
import {
AccountDoesNotExistRouteName,
RegistrationRouteName,
} from '../navigation/route-names.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
type Props = {
+goBackToPrompt: () => mixed,
+closing: boolean,
};
function FullscreenSIWEPanel(props: Props): React.Node {
const [loading, setLoading] = React.useState(true);
const activity = loading ? : null;
const activityContainer = React.useMemo(
() => ({
flex: 1,
}),
[],
);
const registrationContext = React.useContext(RegistrationContext);
invariant(registrationContext, 'registrationContext should be set');
const { setSkipEthereumLoginOnce } = registrationContext;
const getEthereumAccountFromSIWEResult =
useGetEthereumAccountFromSIWEResult();
const { navigate } = useNavigation();
const { goBackToPrompt } = props;
const onAccountDoesNotExist = React.useCallback(
async (result: SIWEResult) => {
await getEthereumAccountFromSIWEResult(result);
setSkipEthereumLoginOnce(true);
goBackToPrompt();
navigate<'Registration'>(RegistrationRouteName, {
screen: AccountDoesNotExistRouteName,
});
},
[
getEthereumAccountFromSIWEResult,
navigate,
goBackToPrompt,
setSkipEthereumLoginOnce,
],
);
const siweServerCall = useSIWEServerCall();
const successRef = React.useRef(false);
const dispatch = useDispatch();
const onSuccess = React.useCallback(
async (result: SIWEResult) => {
successRef.current = true;
try {
await siweServerCall({
...result,
doNotRegister: enableNewRegistrationMode,
});
} catch (e) {
if (
e instanceof ServerError &&
e.message === 'account_does_not_exist'
) {
await onAccountDoesNotExist(result);
return;
}
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: goBackToPrompt }],
{ cancelable: false },
);
throw e;
}
dispatch({
type: setDataLoadedActionType,
payload: {
dataLoaded: true,
},
});
},
[siweServerCall, dispatch, goBackToPrompt, onAccountDoesNotExist],
);
const ifBeforeSuccessGoBackToPrompt = React.useCallback(() => {
if (!successRef.current) {
goBackToPrompt();
}
}, [goBackToPrompt]);
const { closing } = props;
return (
<>
{activity}
>
);
}
export default FullscreenSIWEPanel;
diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js
index 2814bd095..6a795b0f0 100644
--- a/native/account/log-in-panel.react.js
+++ b/native/account/log-in-panel.react.js
@@ -1,403 +1,407 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { View, StyleSheet, Keyboard, Platform } from 'react-native';
import Animated from 'react-native-reanimated';
import {
logInActionTypes,
useLogIn,
getOlmSessionInitializationDataActionTypes,
} from 'lib/actions/user-actions.js';
import {
createLoadingStatusSelector,
combineLoadingStatuses,
} from 'lib/selectors/loading-selectors.js';
import {
validEmailRegex,
oldValidUsernameRegex,
} from 'lib/shared/account-utils.js';
import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js';
import {
type LogInInfo,
type LogInExtraInfo,
type LogInResult,
type LogInStartingPayload,
logInActionSources,
} from 'lib/types/account-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import {
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import { TextInput } from './modal-components.react.js';
import {
fetchNativeCredentials,
setNativeCredentials,
} from './native-credentials.js';
import { PanelButton, Panel } from './panel-components.react.js';
import PasswordInput from './password-input.react.js';
import SWMansionIcon from '../components/swmansion-icon.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js';
import type { KeyPressEvent } from '../types/react-native.js';
-import { AppOutOfDateAlertDetails } from '../utils/alert-messages.js';
+import {
+ AppOutOfDateAlertDetails,
+ UnknownErrorAlertDetails,
+ UserNotFoundAlertDetails,
+} from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js';
import type { StateContainer } from '../utils/state-container.js';
export type LogInState = {
+usernameInputText: ?string,
+passwordInputText: ?string,
};
type BaseProps = {
+setActiveAlert: (activeAlert: boolean) => void,
+opacityValue: Animated.Node,
+logInState: StateContainer,
};
type Props = {
...BaseProps,
+loadingStatus: LoadingStatus,
+logInExtraInfo: () => Promise,
+dispatchActionPromise: DispatchActionPromise,
+logIn: (logInInfo: LogInInfo) => Promise,
+getInitialNotificationsEncryptedMessage: (
keyserverID: string,
) => Promise,
};
class LogInPanel extends React.PureComponent {
usernameInput: ?TextInput;
passwordInput: ?PasswordInput;
componentDidMount() {
void this.attemptToFetchCredentials();
}
get usernameInputText(): string {
return this.props.logInState.state.usernameInputText || '';
}
get passwordInputText(): string {
return this.props.logInState.state.passwordInputText || '';
}
async attemptToFetchCredentials() {
if (
this.props.logInState.state.usernameInputText !== null &&
this.props.logInState.state.usernameInputText !== undefined
) {
return;
}
const credentials = await fetchNativeCredentials();
if (!credentials) {
return;
}
if (
this.props.logInState.state.usernameInputText !== null &&
this.props.logInState.state.usernameInputText !== undefined
) {
return;
}
this.props.logInState.setState({
usernameInputText: credentials.username,
passwordInputText: credentials.password,
});
}
render(): React.Node {
return (
);
}
usernameInputRef: (usernameInput: ?TextInput) => void = usernameInput => {
this.usernameInput = usernameInput;
if (Platform.OS === 'ios' && usernameInput) {
setTimeout(() => usernameInput.focus());
}
};
focusUsernameInput: () => void = () => {
invariant(this.usernameInput, 'ref should be set');
this.usernameInput.focus();
};
passwordInputRef: (passwordInput: ?PasswordInput) => void = passwordInput => {
this.passwordInput = passwordInput;
};
focusPasswordInput: () => void = () => {
invariant(this.passwordInput, 'ref should be set');
this.passwordInput.focus();
};
onChangeUsernameInputText: (text: string) => void = text => {
this.props.logInState.setState({ usernameInputText: text.trim() });
};
onUsernameKeyPress: (event: KeyPressEvent) => void = event => {
const { key } = event.nativeEvent;
if (
key.length > 1 &&
key !== 'Backspace' &&
key !== 'Enter' &&
this.passwordInputText.length === 0
) {
this.focusPasswordInput();
}
};
onChangePasswordInputText: (text: string) => void = text => {
this.props.logInState.setState({ passwordInputText: text });
};
onSubmit: () => Promise = async () => {
this.props.setActiveAlert(true);
if (this.usernameInputText.search(validEmailRegex) > -1) {
Alert.alert(
'Can’t log in with email',
'You need to log in with your username now',
[{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }],
{ cancelable: false },
);
return;
} else if (this.usernameInputText.search(oldValidUsernameRegex) === -1) {
Alert.alert(
'Invalid username',
'Alphanumeric usernames only',
[{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }],
{ cancelable: false },
);
return;
} else if (this.passwordInputText === '') {
Alert.alert(
'Empty password',
'Password cannot be empty',
[{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }],
{ cancelable: false },
);
return;
}
Keyboard.dismiss();
const extraInfo = await this.props.logInExtraInfo();
const initialNotificationsEncryptedMessage =
await this.props.getInitialNotificationsEncryptedMessage(
ashoatKeyserverID,
);
void this.props.dispatchActionPromise(
logInActionTypes,
this.logInAction({ ...extraInfo, initialNotificationsEncryptedMessage }),
undefined,
({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload),
);
};
async logInAction(extraInfo: LogInExtraInfo): Promise {
try {
const result = await this.props.logIn({
...extraInfo,
username: this.usernameInputText,
password: this.passwordInputText,
logInActionSource: logInActionSources.logInFromNativeForm,
});
this.props.setActiveAlert(false);
await setNativeCredentials({
username: result.currentUserInfo.username,
password: this.passwordInputText,
});
return result;
} catch (e) {
if (e.message === 'invalid_credentials') {
Alert.alert(
- 'Incorrect username or password',
- "Either that user doesn't exist, or the password is incorrect",
+ UserNotFoundAlertDetails.title,
+ UserNotFoundAlertDetails.message,
[{ text: 'OK', onPress: this.onUnsuccessfulLoginAlertAckowledged }],
{ cancelable: false },
);
} else if (e.message === 'client_version_unsupported') {
Alert.alert(
AppOutOfDateAlertDetails.title,
AppOutOfDateAlertDetails.message,
[{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }],
{ cancelable: false },
);
} else {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }],
{ cancelable: false },
);
}
throw e;
}
}
onUnsuccessfulLoginAlertAckowledged: () => void = () => {
this.props.setActiveAlert(false);
this.props.logInState.setState(
{
usernameInputText: '',
passwordInputText: '',
},
this.focusUsernameInput,
);
};
onUsernameAlertAcknowledged: () => void = () => {
this.props.setActiveAlert(false);
this.props.logInState.setState(
{
usernameInputText: '',
},
this.focusUsernameInput,
);
};
onPasswordAlertAcknowledged: () => void = () => {
this.props.setActiveAlert(false);
this.props.logInState.setState(
{
passwordInputText: '',
},
this.focusPasswordInput,
);
};
onUnknownErrorAlertAcknowledged: () => void = () => {
this.props.setActiveAlert(false);
this.props.logInState.setState(
{
usernameInputText: '',
passwordInputText: '',
},
this.focusUsernameInput,
);
};
onAppOutOfDateAlertAcknowledged: () => void = () => {
this.props.setActiveAlert(false);
};
}
export type InnerLogInPanel = LogInPanel;
const styles = StyleSheet.create({
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
},
icon: {
bottom: 10,
left: 4,
position: 'absolute',
},
input: {
paddingLeft: 35,
},
row: {
marginHorizontal: 24,
},
});
const logInLoadingStatusSelector =
createLoadingStatusSelector(logInActionTypes);
const olmSessionInitializationDataLoadingStatusSelector =
createLoadingStatusSelector(getOlmSessionInitializationDataActionTypes);
const ConnectedLogInPanel: React.ComponentType =
React.memo(function ConnectedLogInPanel(props: BaseProps) {
const logInLoadingStatus = useSelector(logInLoadingStatusSelector);
const olmSessionInitializationDataLoadingStatus = useSelector(
olmSessionInitializationDataLoadingStatusSelector,
);
const loadingStatus = combineLoadingStatuses(
logInLoadingStatus,
olmSessionInitializationDataLoadingStatus,
);
const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector);
const dispatchActionPromise = useDispatchActionPromise();
const callLogIn = useLogIn();
const getInitialNotificationsEncryptedMessage =
useInitialNotificationsEncryptedMessage(
nativeNotificationsSessionCreator,
);
return (
);
});
export default ConnectedLogInPanel;
diff --git a/native/account/register-panel.react.js b/native/account/register-panel.react.js
index 03af91dfe..f1c9864f2 100644
--- a/native/account/register-panel.react.js
+++ b/native/account/register-panel.react.js
@@ -1,513 +1,514 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
Text,
View,
StyleSheet,
Platform,
Keyboard,
Linking,
} from 'react-native';
import Animated from 'react-native-reanimated';
import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js';
import {
keyserverRegisterActionTypes,
keyserverRegister,
getOlmSessionInitializationDataActionTypes,
} from 'lib/actions/user-actions.js';
import {
createLoadingStatusSelector,
combineLoadingStatuses,
} from 'lib/selectors/loading-selectors.js';
import { validUsernameRegex } from 'lib/shared/account-utils.js';
import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js';
import type {
RegisterInfo,
LogInExtraInfo,
RegisterResult,
LogInStartingPayload,
} from 'lib/types/account-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import {
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { ashoatKeyserverID } from 'lib/utils/validation-utils.js';
import { TextInput } from './modal-components.react.js';
import { setNativeCredentials } from './native-credentials.js';
import { PanelButton, Panel } from './panel-components.react.js';
import SWMansionIcon from '../components/swmansion-icon.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js';
import type { KeyPressEvent } from '../types/react-native.js';
import {
AppOutOfDateAlertDetails,
UsernameReservedAlertDetails,
UsernameTakenAlertDetails,
+ UnknownErrorAlertDetails,
} from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js';
import { type StateContainer } from '../utils/state-container.js';
type WritableRegisterState = {
usernameInputText: string,
passwordInputText: string,
confirmPasswordInputText: string,
};
export type RegisterState = $ReadOnly;
type BaseProps = {
+setActiveAlert: (activeAlert: boolean) => void,
+opacityValue: Animated.Node,
+registerState: StateContainer,
};
type Props = {
...BaseProps,
+loadingStatus: LoadingStatus,
+logInExtraInfo: () => Promise,
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
+register: (registerInfo: RegisterInfo) => Promise,
+getInitialNotificationsEncryptedMessage: (
keyserverID: string,
) => Promise,
};
type State = {
+confirmPasswordFocused: boolean,
};
class RegisterPanel extends React.PureComponent {
state: State = {
confirmPasswordFocused: false,
};
usernameInput: ?TextInput;
passwordInput: ?TextInput;
confirmPasswordInput: ?TextInput;
passwordBeingAutoFilled = false;
render(): React.Node {
let confirmPasswordTextInputExtraProps;
if (
Platform.OS !== 'ios' ||
this.state.confirmPasswordFocused ||
this.props.registerState.state.confirmPasswordInputText.length > 0
) {
confirmPasswordTextInputExtraProps = {
secureTextEntry: true,
textContentType: 'password',
};
}
let onPasswordKeyPress;
if (Platform.OS === 'ios') {
onPasswordKeyPress = this.onPasswordKeyPress;
}
const privatePolicyNotice = (
By signing up, you agree to our{' '}
Terms
{' & '}
Privacy Policy
.
);
return (
{privatePolicyNotice}
);
}
usernameInputRef = (usernameInput: ?TextInput) => {
this.usernameInput = usernameInput;
};
passwordInputRef = (passwordInput: ?TextInput) => {
this.passwordInput = passwordInput;
};
confirmPasswordInputRef = (confirmPasswordInput: ?TextInput) => {
this.confirmPasswordInput = confirmPasswordInput;
};
focusUsernameInput = () => {
invariant(this.usernameInput, 'ref should be set');
this.usernameInput.focus();
};
focusPasswordInput = () => {
invariant(this.passwordInput, 'ref should be set');
this.passwordInput.focus();
};
focusConfirmPasswordInput = () => {
invariant(this.confirmPasswordInput, 'ref should be set');
this.confirmPasswordInput.focus();
};
onTermsOfUsePressed = () => {
void Linking.openURL('https://comm.app/terms');
};
onPrivacyPolicyPressed = () => {
void Linking.openURL('https://comm.app/privacy');
};
onChangeUsernameInputText = (text: string) => {
this.props.registerState.setState({ usernameInputText: text });
};
onChangePasswordInputText = (text: string) => {
const stateUpdate: Partial = {};
stateUpdate.passwordInputText = text;
if (this.passwordBeingAutoFilled) {
this.passwordBeingAutoFilled = false;
stateUpdate.confirmPasswordInputText = text;
}
this.props.registerState.setState(stateUpdate);
};
onPasswordKeyPress = (event: KeyPressEvent) => {
const { key } = event.nativeEvent;
if (
key.length > 1 &&
key !== 'Backspace' &&
key !== 'Enter' &&
this.props.registerState.state.confirmPasswordInputText.length === 0
) {
this.passwordBeingAutoFilled = true;
}
};
onChangeConfirmPasswordInputText = (text: string) => {
this.props.registerState.setState({ confirmPasswordInputText: text });
};
onConfirmPasswordFocus = () => {
this.setState({ confirmPasswordFocused: true });
};
onSubmit = async () => {
this.props.setActiveAlert(true);
if (this.props.registerState.state.passwordInputText === '') {
Alert.alert(
'Empty password',
'Password cannot be empty',
[{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }],
{ cancelable: false },
);
} else if (
this.props.registerState.state.passwordInputText !==
this.props.registerState.state.confirmPasswordInputText
) {
Alert.alert(
'Passwords don’t match',
'Password fields must contain the same password',
[{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }],
{ cancelable: false },
);
} else if (
this.props.registerState.state.usernameInputText.search(
validUsernameRegex,
) === -1
) {
Alert.alert(
'Invalid username',
'Usernames must be at least six characters long, start with either a ' +
'letter or a number, and may contain only letters, numbers, or the ' +
'characters “-” and “_”',
[{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }],
{ cancelable: false },
);
} else {
Keyboard.dismiss();
const extraInfo = await this.props.logInExtraInfo();
const initialNotificationsEncryptedMessage =
await this.props.getInitialNotificationsEncryptedMessage(
ashoatKeyserverID,
);
void this.props.dispatchActionPromise(
keyserverRegisterActionTypes,
this.registerAction({
...extraInfo,
initialNotificationsEncryptedMessage,
}),
undefined,
({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload),
);
}
};
onPasswordAlertAcknowledged = () => {
this.props.setActiveAlert(false);
this.props.registerState.setState(
{
passwordInputText: '',
confirmPasswordInputText: '',
},
() => {
invariant(this.passwordInput, 'ref should exist');
this.passwordInput.focus();
},
);
};
onUsernameAlertAcknowledged = () => {
this.props.setActiveAlert(false);
this.props.registerState.setState(
{
usernameInputText: '',
},
() => {
invariant(this.usernameInput, 'ref should exist');
this.usernameInput.focus();
},
);
};
async registerAction(extraInfo: LogInExtraInfo): Promise {
try {
const result = await this.props.register({
...extraInfo,
username: this.props.registerState.state.usernameInputText,
password: this.props.registerState.state.passwordInputText,
});
this.props.setActiveAlert(false);
this.props.dispatch({
type: setDataLoadedActionType,
payload: {
dataLoaded: true,
},
});
await setNativeCredentials({
username: result.currentUserInfo.username,
password: this.props.registerState.state.passwordInputText,
});
return result;
} catch (e) {
if (e.message === 'username_reserved') {
Alert.alert(
UsernameReservedAlertDetails.title,
UsernameReservedAlertDetails.message,
[{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }],
{ cancelable: false },
);
} else if (e.message === 'username_taken') {
Alert.alert(
UsernameTakenAlertDetails.title,
UsernameTakenAlertDetails.message,
[{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }],
{ cancelable: false },
);
} else if (e.message === 'client_version_unsupported') {
Alert.alert(
AppOutOfDateAlertDetails.title,
AppOutOfDateAlertDetails.message,
[{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }],
{ cancelable: false },
);
} else {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }],
{ cancelable: false },
);
}
throw e;
}
}
onUnknownErrorAlertAcknowledged = () => {
this.props.setActiveAlert(false);
this.props.registerState.setState(
{
usernameInputText: '',
passwordInputText: '',
confirmPasswordInputText: '',
},
() => {
invariant(this.usernameInput, 'ref should exist');
this.usernameInput.focus();
},
);
};
onAppOutOfDateAlertAcknowledged = () => {
this.props.setActiveAlert(false);
};
}
const styles = StyleSheet.create({
container: {
zIndex: 2,
},
footer: {
alignItems: 'stretch',
flexDirection: 'row',
flexShrink: 1,
justifyContent: 'space-between',
paddingLeft: 24,
},
hyperlinkText: {
color: '#036AFF',
fontWeight: 'bold',
},
icon: {
bottom: 10,
left: 4,
position: 'absolute',
},
input: {
paddingLeft: 35,
},
notice: {
alignSelf: 'center',
display: 'flex',
flexShrink: 1,
maxWidth: 190,
paddingBottom: 18,
paddingRight: 8,
paddingTop: 12,
},
noticeText: {
color: '#444',
fontSize: 13,
lineHeight: 20,
textAlign: 'center',
},
row: {
marginHorizontal: 24,
},
});
const registerLoadingStatusSelector = createLoadingStatusSelector(
keyserverRegisterActionTypes,
);
const olmSessionInitializationDataLoadingStatusSelector =
createLoadingStatusSelector(getOlmSessionInitializationDataActionTypes);
const ConnectedRegisterPanel: React.ComponentType =
React.memo(function ConnectedRegisterPanel(props: BaseProps) {
const registerLoadingStatus = useSelector(registerLoadingStatusSelector);
const olmSessionInitializationDataLoadingStatus = useSelector(
olmSessionInitializationDataLoadingStatusSelector,
);
const loadingStatus = combineLoadingStatuses(
registerLoadingStatus,
olmSessionInitializationDataLoadingStatus,
);
const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector);
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callRegister = useLegacyAshoatKeyserverCall(keyserverRegister);
const getInitialNotificationsEncryptedMessage =
useInitialNotificationsEncryptedMessage(
nativeNotificationsSessionCreator,
);
return (
);
});
export default ConnectedRegisterPanel;
diff --git a/native/account/registration/existing-ethereum-account.react.js b/native/account/registration/existing-ethereum-account.react.js
index 1e5727943..63dc2e83b 100644
--- a/native/account/registration/existing-ethereum-account.react.js
+++ b/native/account/registration/existing-ethereum-account.react.js
@@ -1,139 +1,145 @@
// @flow
import * as React from 'react';
import { Text, View } from 'react-native';
import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js';
import { siweAuthActionTypes } from 'lib/actions/siwe-actions.js';
import { useENSName } from 'lib/hooks/ens-cache.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { SIWEResult } from 'lib/types/siwe-types.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import RegistrationButtonContainer from './registration-button-container.react.js';
import RegistrationButton from './registration-button.react.js';
import RegistrationContainer from './registration-container.react.js';
import RegistrationContentContainer from './registration-content-container.react.js';
import type { RegistrationNavigationProp } from './registration-navigator.react.js';
import type { NavigationRoute } from '../../navigation/route-names.js';
import { useSelector } from '../../redux/redux-utils.js';
import { useStyles } from '../../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
import { useSIWEServerCall } from '../siwe-hooks.js';
const siweAuthLoadingStatusSelector =
createLoadingStatusSelector(siweAuthActionTypes);
export type ExistingEthereumAccountParams = SIWEResult;
type Props = {
+navigation: RegistrationNavigationProp<'ExistingEthereumAccount'>,
+route: NavigationRoute<'ExistingEthereumAccount'>,
};
function ExistingEthereumAccount(props: Props): React.Node {
const siweServerCall = useSIWEServerCall();
const { params } = props.route;
const dispatch = useDispatch();
const onProceedToLogIn = React.useCallback(async () => {
try {
await siweServerCall({ ...params, doNotRegister: true });
} catch (e) {
- Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], {
- cancelable: false,
- });
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ [{ text: 'OK' }],
+ {
+ cancelable: false,
+ },
+ );
throw e;
}
dispatch({
type: setDataLoadedActionType,
payload: {
dataLoaded: true,
},
});
}, [siweServerCall, params, dispatch]);
const siweAuthCallLoading = useSelector(
state => siweAuthLoadingStatusSelector(state) === 'loading',
);
const { address } = params;
const walletIdentifier = useENSName(address);
const walletIdentifierTitle =
walletIdentifier === address ? 'Ethereum wallet' : 'ENS name';
const { goBack } = props.navigation;
const styles = useStyles(unboundStyles);
return (
Account already exists for wallet
You can proceed to log in with this wallet, or go back and use a
different wallet.
{walletIdentifierTitle}
{walletIdentifier}
);
}
const unboundStyles = {
header: {
fontSize: 24,
color: 'panelForegroundLabel',
paddingBottom: 16,
},
body: {
fontFamily: 'Arial',
fontSize: 15,
lineHeight: 20,
color: 'panelForegroundSecondaryLabel',
paddingBottom: 40,
},
walletTile: {
backgroundColor: 'panelForeground',
borderRadius: 8,
padding: 24,
alignItems: 'center',
},
walletIdentifierTitleText: {
fontSize: 17,
color: 'panelForegroundLabel',
textAlign: 'center',
},
walletIdentifier: {
backgroundColor: 'panelSecondaryForeground',
paddingVertical: 8,
paddingHorizontal: 24,
borderRadius: 56,
marginTop: 8,
alignItems: 'center',
},
walletIdentifierText: {
fontSize: 15,
color: 'panelForegroundLabel',
},
};
export default ExistingEthereumAccount;
diff --git a/native/account/registration/registration-server-call.js b/native/account/registration/registration-server-call.js
index 1209a0eea..afc043764 100644
--- a/native/account/registration/registration-server-call.js
+++ b/native/account/registration/registration-server-call.js
@@ -1,287 +1,297 @@
// @flow
import * as React from 'react';
import { setDataLoadedActionType } from 'lib/actions/client-db-store-actions.js';
import {
keyserverRegisterActionTypes,
keyserverRegister,
useIdentityRegister,
identityRegisterActionTypes,
} from 'lib/actions/user-actions.js';
import type { LogInStartingPayload } from 'lib/types/account-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import { useDispatch } from 'lib/utils/redux-utils.js';
import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js';
import { setURLPrefix } from 'lib/utils/url-utils.js';
import type {
RegistrationServerCallInput,
UsernameAccountSelection,
AvatarData,
} from './registration-types.js';
import {
useNativeSetUserAvatar,
useUploadSelectedMedia,
} from '../../avatars/avatar-hooks.js';
import { useSelector } from '../../redux/redux-utils.js';
import { nativeLogInExtraInfoSelector } from '../../selectors/account-selectors.js';
import {
AppOutOfDateAlertDetails,
UsernameReservedAlertDetails,
UsernameTakenAlertDetails,
+ UnknownErrorAlertDetails,
} from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
import { setNativeCredentials } from '../native-credentials.js';
import { useSIWEServerCall } from '../siwe-hooks.js';
// We can't just do everything in one async callback, since the server calls
// would get bound to Redux state from before the registration. The registration
// flow has multiple steps where critical Redux state is changed, where
// subsequent steps depend on accessing the updated Redux state.
// To address this, we break the registration process up into multiple steps.
// When each step completes we update the currentStep state, and we have Redux
// selectors that trigger useEffects for subsequent steps when relevant data
// starts to appear in Redux.
type CurrentStep =
| { +step: 'inactive' }
| {
+step: 'waiting_for_registration_call',
+avatarData: ?AvatarData,
+resolve: () => void,
+reject: Error => void,
};
const inactiveStep = { step: 'inactive' };
function useRegistrationServerCall(): RegistrationServerCallInput => Promise {
const [currentStep, setCurrentStep] =
React.useState(inactiveStep);
// STEP 1: ACCOUNT REGISTRATION
const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector);
const dispatchActionPromise = useDispatchActionPromise();
const callKeyserverRegister = useLegacyAshoatKeyserverCall(keyserverRegister);
const callIdentityRegister = useIdentityRegister();
const identityRegisterUsernameAccount = React.useCallback(
async (accountSelection: UsernameAccountSelection) => {
const identityRegisterPromise = (async () => {
try {
const result = await callIdentityRegister(
accountSelection.username,
accountSelection.password,
);
await setNativeCredentials({
username: accountSelection.username,
password: accountSelection.password,
});
return result;
} catch (e) {
if (e.message === 'username reserved') {
Alert.alert(
UsernameReservedAlertDetails.title,
UsernameReservedAlertDetails.message,
);
} else if (e.message === 'username already exists') {
Alert.alert(
UsernameTakenAlertDetails.title,
UsernameTakenAlertDetails.message,
);
} else if (e.message === 'Unsupported version') {
Alert.alert(
AppOutOfDateAlertDetails.title,
AppOutOfDateAlertDetails.message,
);
} else {
- Alert.alert('Unknown error', 'Uhh... try again?');
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ );
}
throw e;
}
})();
void dispatchActionPromise(
identityRegisterActionTypes,
identityRegisterPromise,
);
await identityRegisterPromise;
},
[callIdentityRegister, dispatchActionPromise],
);
const keyserverRegisterUsernameAccount = React.useCallback(
async (
accountSelection: UsernameAccountSelection,
keyserverURL: string,
) => {
const extraInfo = await logInExtraInfo();
const keyserverRegisterPromise = (async () => {
try {
const result = await callKeyserverRegister(
{
...extraInfo,
username: accountSelection.username,
password: accountSelection.password,
},
{
urlPrefixOverride: keyserverURL,
},
);
await setNativeCredentials({
username: result.currentUserInfo.username,
password: accountSelection.password,
});
return result;
} catch (e) {
if (e.message === 'username_reserved') {
Alert.alert(
UsernameReservedAlertDetails.title,
UsernameReservedAlertDetails.message,
);
} else if (e.message === 'username_taken') {
Alert.alert(
UsernameTakenAlertDetails.title,
UsernameTakenAlertDetails.message,
);
} else if (e.message === 'client_version_unsupported') {
Alert.alert(
AppOutOfDateAlertDetails.title,
AppOutOfDateAlertDetails.message,
);
} else {
- Alert.alert('Unknown error', 'Uhh... try again?');
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ );
}
throw e;
}
})();
void dispatchActionPromise(
keyserverRegisterActionTypes,
keyserverRegisterPromise,
undefined,
({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload),
);
await keyserverRegisterPromise;
},
[logInExtraInfo, callKeyserverRegister, dispatchActionPromise],
);
const siweServerCall = useSIWEServerCall();
const dispatch = useDispatch();
const returnedFunc = React.useCallback(
(input: RegistrationServerCallInput) =>
new Promise(
// eslint-disable-next-line no-async-promise-executor
async (resolve, reject) => {
try {
if (currentStep.step !== 'inactive') {
return;
}
const { accountSelection, avatarData, keyserverURL } = input;
if (
accountSelection.accountType === 'username' &&
!usingCommServicesAccessToken
) {
await keyserverRegisterUsernameAccount(
accountSelection,
keyserverURL,
);
} else if (accountSelection.accountType === 'username') {
await identityRegisterUsernameAccount(accountSelection);
} else {
try {
await siweServerCall(accountSelection, {
urlPrefixOverride: keyserverURL,
});
} catch (e) {
- Alert.alert('Unknown error', 'Uhh... try again?');
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ );
throw e;
}
}
dispatch({
type: setURLPrefix,
payload: keyserverURL,
});
setCurrentStep({
step: 'waiting_for_registration_call',
avatarData,
resolve,
reject,
});
} catch (e) {
reject(e);
}
},
),
[
currentStep,
keyserverRegisterUsernameAccount,
identityRegisterUsernameAccount,
siweServerCall,
dispatch,
],
);
// STEP 2: SETTING AVATAR
const uploadSelectedMedia = useUploadSelectedMedia();
const nativeSetUserAvatar = useNativeSetUserAvatar();
const hasCurrentUserInfo = useSelector(
state => !!state.currentUserInfo && !state.currentUserInfo.anonymous,
);
const avatarBeingSetRef = React.useRef(false);
React.useEffect(() => {
if (
!hasCurrentUserInfo ||
currentStep.step !== 'waiting_for_registration_call' ||
avatarBeingSetRef.current
) {
return;
}
avatarBeingSetRef.current = true;
const { avatarData, resolve } = currentStep;
void (async () => {
try {
if (!avatarData) {
return;
}
let updateUserAvatarRequest;
if (!avatarData.needsUpload) {
({ updateUserAvatarRequest } = avatarData);
} else {
const { mediaSelection } = avatarData;
updateUserAvatarRequest = await uploadSelectedMedia(mediaSelection);
if (!updateUserAvatarRequest) {
return;
}
}
await nativeSetUserAvatar(updateUserAvatarRequest);
} finally {
dispatch({
type: setDataLoadedActionType,
payload: {
dataLoaded: true,
},
});
setCurrentStep(inactiveStep);
avatarBeingSetRef.current = false;
resolve();
}
})();
}, [
currentStep,
hasCurrentUserInfo,
uploadSelectedMedia,
nativeSetUserAvatar,
dispatch,
]);
return returnedFunc;
}
export { useRegistrationServerCall };
diff --git a/native/account/siwe-panel.react.js b/native/account/siwe-panel.react.js
index 7e655776f..8de6b57b9 100644
--- a/native/account/siwe-panel.react.js
+++ b/native/account/siwe-panel.react.js
@@ -1,226 +1,227 @@
// @flow
import BottomSheet from '@gorhom/bottom-sheet';
import * as React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import WebView from 'react-native-webview';
import {
getSIWENonce,
getSIWENonceActionTypes,
siweAuthActionTypes,
} from 'lib/actions/siwe-actions.js';
import type { ServerCallSelectorParams } from 'lib/keyserver-conn/call-keyserver-endpoint-provider.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { SIWEWebViewMessage, SIWEResult } from 'lib/types/siwe-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import { useKeyboardHeight } from '../keyboard/keyboard-hooks.js';
import { useSelector } from '../redux/redux-utils.js';
import type { BottomSheetRef } from '../types/bottom-sheet.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
import { getContentSigningKey } from '../utils/crypto-utils.js';
import { defaultLandingURLPrefix } from '../utils/url-utils.js';
const commSIWE = `${defaultLandingURLPrefix}/siwe`;
const getSIWENonceLoadingStatusSelector = createLoadingStatusSelector(
getSIWENonceActionTypes,
);
const siweAuthLoadingStatusSelector =
createLoadingStatusSelector(siweAuthActionTypes);
type WebViewMessageEvent = {
+nativeEvent: {
+data: string,
...
},
...
};
type Props = {
+onClosed: () => mixed,
+onClosing: () => mixed,
+onSuccessfulWalletSignature: SIWEResult => mixed,
+closing: boolean,
+setLoading: boolean => mixed,
+keyserverCallParamOverride?: Partial,
};
function SIWEPanel(props: Props): React.Node {
const dispatchActionPromise = useDispatchActionPromise();
const getSIWENonceCall = useLegacyAshoatKeyserverCall(
getSIWENonce,
props.keyserverCallParamOverride,
);
const getSIWENonceCallFailed = useSelector(
state => getSIWENonceLoadingStatusSelector(state) === 'error',
);
const { onClosing } = props;
const siweAuthCallLoading = useSelector(
state => siweAuthLoadingStatusSelector(state) === 'loading',
);
const [nonce, setNonce] = React.useState(null);
const [primaryIdentityPublicKey, setPrimaryIdentityPublicKey] =
React.useState(null);
React.useEffect(() => {
void (async () => {
void dispatchActionPromise(
getSIWENonceActionTypes,
(async () => {
try {
const response = await getSIWENonceCall();
setNonce(response);
} catch (e) {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: onClosing }],
{ cancelable: false },
);
throw e;
}
})(),
);
const ed25519 = await getContentSigningKey();
setPrimaryIdentityPublicKey(ed25519);
})();
}, [dispatchActionPromise, getSIWENonceCall, onClosing]);
const [isLoading, setLoading] = React.useState(true);
const [walletConnectModalHeight, setWalletConnectModalHeight] =
React.useState(0);
const insets = useSafeAreaInsets();
const keyboardHeight = useKeyboardHeight();
const bottomInset = insets.bottom;
const snapPoints = React.useMemo(() => {
if (isLoading) {
return [1];
} else if (walletConnectModalHeight) {
const baseHeight =
bottomInset + walletConnectModalHeight + keyboardHeight;
if (baseHeight < 400) {
return [baseHeight - 10];
} else {
return [baseHeight + 5];
}
} else {
const baseHeight = bottomInset + keyboardHeight;
return [baseHeight + 435, baseHeight + 600];
}
}, [isLoading, walletConnectModalHeight, bottomInset, keyboardHeight]);
const bottomSheetRef = React.useRef();
const snapToIndex = bottomSheetRef.current?.snapToIndex;
React.useEffect(() => {
// When the snapPoints change, always reset to the first one
// Without this, when we close the WalletConnect modal we don't resize
snapToIndex?.(0);
}, [snapToIndex, snapPoints]);
const closeBottomSheet = bottomSheetRef.current?.close;
const { closing, onSuccessfulWalletSignature } = props;
const handleMessage = React.useCallback(
async (event: WebViewMessageEvent) => {
const data: SIWEWebViewMessage = JSON.parse(event.nativeEvent.data);
if (data.type === 'siwe_success') {
const { address, message, signature } = data;
if (address && signature) {
closeBottomSheet?.();
await onSuccessfulWalletSignature({ address, message, signature });
}
} else if (data.type === 'siwe_closed') {
onClosing();
closeBottomSheet?.();
} else if (data.type === 'walletconnect_modal_update') {
const height = data.state === 'open' ? data.height : 0;
setWalletConnectModalHeight(height);
}
},
[onSuccessfulWalletSignature, onClosing, closeBottomSheet],
);
const prevClosingRef = React.useRef();
React.useEffect(() => {
if (closing && !prevClosingRef.current) {
closeBottomSheet?.();
}
prevClosingRef.current = closing;
}, [closing, closeBottomSheet]);
const source = React.useMemo(
() => ({
uri: commSIWE,
headers: {
'siwe-nonce': nonce,
'siwe-primary-identity-public-key': primaryIdentityPublicKey,
},
}),
[nonce, primaryIdentityPublicKey],
);
const onWebViewLoaded = React.useCallback(() => {
setLoading(false);
}, []);
const walletConnectModalOpen = walletConnectModalHeight !== 0;
const backgroundStyle = React.useMemo(
() => ({
backgroundColor: walletConnectModalOpen ? '#3396ff' : '#242529',
}),
[walletConnectModalOpen],
);
const bottomSheetHandleIndicatorStyle = React.useMemo(
() => ({
backgroundColor: 'white',
}),
[],
);
const { onClosed } = props;
const onBottomSheetChange = React.useCallback(
(index: number) => {
if (index === -1) {
onClosed();
}
},
[onClosed],
);
let bottomSheet;
if (nonce && primaryIdentityPublicKey) {
bottomSheet = (
);
}
const setLoadingProp = props.setLoading;
const loading = !getSIWENonceCallFailed && (isLoading || siweAuthCallLoading);
React.useEffect(() => {
setLoadingProp(loading);
}, [setLoadingProp, loading]);
return bottomSheet;
}
export default SIWEPanel;
diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js
index 5d320ec0c..29f120739 100644
--- a/native/chat/compose-subchannel.react.js
+++ b/native/chat/compose-subchannel.react.js
@@ -1,368 +1,369 @@
// @flow
import invariant from 'invariant';
import _filter from 'lodash/fp/filter.js';
import _flow from 'lodash/fp/flow.js';
import _sortBy from 'lodash/fp/sortBy.js';
import * as React from 'react';
import { Text, View } from 'react-native';
import {
newThreadActionTypes,
useNewThread,
} from 'lib/actions/thread-actions.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js';
import { usePotentialMemberItems } from 'lib/shared/search-utils.js';
import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { type ThreadType, threadTypes } from 'lib/types/thread-types-enum.js';
import { type AccountUserInfo } from 'lib/types/user-types.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import type { ChatNavigationProp } from './chat.react.js';
import { useNavigateToThread } from './message-list-types.js';
import ParentThreadHeader from './parent-thread-header.react.js';
import LinkButton from '../components/link-button.react.js';
import {
type BaseTagInput,
createTagInput,
} from '../components/tag-input.react.js';
import ThreadList from '../components/thread-list.react.js';
import UserList from '../components/user-list.react.js';
import { useCalendarQuery } from '../navigation/nav-selectors.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { useStyles } from '../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
const TagInput = createTagInput();
const tagInputProps = {
placeholder: 'username',
autoFocus: true,
returnKeyType: 'go',
};
const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username;
export type ComposeSubchannelParams = {
+threadType: ThreadType,
+parentThreadInfo: ThreadInfo,
};
type Props = {
+navigation: ChatNavigationProp<'ComposeSubchannel'>,
+route: NavigationRoute<'ComposeSubchannel'>,
};
function ComposeSubchannel(props: Props): React.Node {
const [usernameInputText, setUsernameInputText] = React.useState('');
const [userInfoInputArray, setUserInfoInputArray] = React.useState<
$ReadOnlyArray,
>([]);
const [createButtonEnabled, setCreateButtonEnabled] =
React.useState(true);
const tagInputRef = React.useRef>();
const onUnknownErrorAlertAcknowledged = React.useCallback(() => {
setUsernameInputText('');
tagInputRef.current?.focus();
}, []);
const waitingOnThreadIDRef = React.useRef();
const { threadType, parentThreadInfo } = props.route.params;
const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id);
const callNewThread = useNewThread();
const calendarQuery = useCalendarQuery();
const newChatThreadAction = React.useCallback(async () => {
try {
const assumedThreadType =
threadType ?? threadTypes.COMMUNITY_SECRET_SUBTHREAD;
const query = calendarQuery();
invariant(
assumedThreadType === 3 ||
assumedThreadType === 4 ||
assumedThreadType === 6 ||
assumedThreadType === 7,
"Sidebars and communities can't be created from the thread composer",
);
const result = await callNewThread({
type: assumedThreadType,
parentThreadID: parentThreadInfo.id,
initialMemberIDs: userInfoInputIDs,
color: parentThreadInfo.color,
calendarQuery: query,
});
waitingOnThreadIDRef.current = result.newThreadID;
return result;
} catch (e) {
setCreateButtonEnabled(true);
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }],
{ cancelable: false },
);
throw e;
}
}, [
threadType,
userInfoInputIDs,
calendarQuery,
parentThreadInfo,
callNewThread,
onUnknownErrorAlertAcknowledged,
]);
const dispatchActionPromise = useDispatchActionPromise();
const dispatchNewChatThreadAction = React.useCallback(() => {
setCreateButtonEnabled(false);
void dispatchActionPromise(newThreadActionTypes, newChatThreadAction());
}, [dispatchActionPromise, newChatThreadAction]);
const userInfoInputArrayEmpty = userInfoInputArray.length === 0;
const onPressCreateThread = React.useCallback(() => {
if (!createButtonEnabled) {
return;
}
if (userInfoInputArrayEmpty) {
Alert.alert(
'Chatting to yourself?',
'Are you sure you want to create a channel containing only yourself?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Confirm', onPress: dispatchNewChatThreadAction },
],
{ cancelable: true },
);
} else {
dispatchNewChatThreadAction();
}
}, [
createButtonEnabled,
userInfoInputArrayEmpty,
dispatchNewChatThreadAction,
]);
const { navigation } = props;
const { setOptions } = navigation;
React.useEffect(() => {
setOptions({
headerRight: () => (
),
});
}, [setOptions, onPressCreateThread, createButtonEnabled]);
const { setParams } = navigation;
const parentThreadInfoID = parentThreadInfo.id;
const reduxParentThreadInfo = useSelector(
state => threadInfoSelector(state)[parentThreadInfoID],
);
React.useEffect(() => {
if (reduxParentThreadInfo) {
setParams({ parentThreadInfo: reduxParentThreadInfo });
}
}, [reduxParentThreadInfo, setParams]);
const threadInfos = useSelector(threadInfoSelector);
const newlyCreatedThreadInfo = waitingOnThreadIDRef.current
? threadInfos[waitingOnThreadIDRef.current]
: null;
const { pushNewThread } = navigation;
React.useEffect(() => {
if (!newlyCreatedThreadInfo) {
return;
}
const waitingOnThreadID = waitingOnThreadIDRef.current;
if (waitingOnThreadID === null || waitingOnThreadID === undefined) {
return;
}
waitingOnThreadIDRef.current = undefined;
pushNewThread(newlyCreatedThreadInfo);
}, [newlyCreatedThreadInfo, pushNewThread]);
const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers);
const { community } = parentThreadInfo;
const communityThreadInfo = useSelector(state =>
community ? threadInfoSelector(state)[community] : null,
);
const userSearchResults = usePotentialMemberItems({
text: usernameInputText,
userInfos: otherUserInfos,
excludeUserIDs: userInfoInputIDs,
inputParentThreadInfo: parentThreadInfo,
inputCommunityThreadInfo: communityThreadInfo,
threadType,
});
const existingThreads: $ReadOnlyArray = React.useMemo(() => {
if (userInfoInputIDs.length === 0) {
return [];
}
return _flow(
_filter(
(threadInfo: ThreadInfo) =>
threadInFilterList(threadInfo) &&
threadInfo.parentThreadID === parentThreadInfo.id &&
userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)),
),
_sortBy(
([
'members.length',
(threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0),
]: $ReadOnlyArray mixed)>),
),
)(threadInfos);
}, [userInfoInputIDs, threadInfos, parentThreadInfo]);
const navigateToThread = useNavigateToThread();
const onSelectExistingThread = React.useCallback(
(threadID: string) => {
const threadInfo = threadInfos[threadID];
navigateToThread({ threadInfo });
},
[threadInfos, navigateToThread],
);
const onUserSelect = React.useCallback(
({ id }: AccountUserInfo) => {
if (userInfoInputIDs.some(existingUserID => id === existingUserID)) {
return;
}
setUserInfoInputArray(oldUserInfoInputArray => [
...oldUserInfoInputArray,
otherUserInfos[id],
]);
setUsernameInputText('');
},
[userInfoInputIDs, otherUserInfos],
);
const styles = useStyles(unboundStyles);
let existingThreadsSection = null;
if (existingThreads.length > 0) {
existingThreadsSection = (
Existing channels
);
}
const inputProps = React.useMemo(
() => ({
...tagInputProps,
onSubmitEditing: onPressCreateThread,
}),
[onPressCreateThread],
);
const userSearchResultWithENSNames = useENSNames(userSearchResults);
const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray);
return (
To:
{existingThreadsSection}
);
}
const unboundStyles = {
container: {
flex: 1,
},
existingThreadList: {
backgroundColor: 'modalBackground',
flex: 1,
paddingRight: 12,
},
existingThreads: {
flex: 1,
},
existingThreadsLabel: {
color: 'modalForegroundSecondaryLabel',
fontSize: 16,
paddingLeft: 12,
textAlign: 'center',
},
existingThreadsRow: {
backgroundColor: 'modalForeground',
borderBottomWidth: 1,
borderColor: 'modalForegroundBorder',
borderTopWidth: 1,
paddingVertical: 6,
},
listItem: {
color: 'modalForegroundLabel',
},
tagInputContainer: {
flex: 1,
marginLeft: 8,
paddingRight: 12,
},
tagInputLabel: {
color: 'modalForegroundSecondaryLabel',
fontSize: 16,
paddingLeft: 12,
},
userList: {
backgroundColor: 'modalBackground',
flex: 1,
paddingLeft: 35,
paddingRight: 12,
},
userSelectionRow: {
alignItems: 'center',
backgroundColor: 'modalForeground',
borderBottomWidth: 1,
borderColor: 'modalForegroundBorder',
flexDirection: 'row',
paddingVertical: 6,
},
};
const MemoizedComposeSubchannel: React.ComponentType =
React.memo(ComposeSubchannel);
export default MemoizedComposeSubchannel;
diff --git a/native/chat/relationship-prompt.react.js b/native/chat/relationship-prompt.react.js
index 744a392f5..813e535b7 100644
--- a/native/chat/relationship-prompt.react.js
+++ b/native/chat/relationship-prompt.react.js
@@ -1,167 +1,172 @@
// @flow
import Icon from '@expo/vector-icons/FontAwesome5.js';
import * as React from 'react';
import { Text, View } from 'react-native';
import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { userRelationshipStatus } from 'lib/types/relationship-types.js';
import type { UserInfo } from 'lib/types/user-types.js';
import Button from '../components/button.react.js';
import { useStyles } from '../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
type Props = {
+pendingPersonalThreadUserInfo: ?UserInfo,
+threadInfo: ThreadInfo,
};
const RelationshipPrompt: React.ComponentType = React.memo(
function RelationshipPrompt({
pendingPersonalThreadUserInfo,
threadInfo,
}: Props) {
const onErrorCallback = React.useCallback(() => {
- Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]);
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ [{ text: 'OK' }],
+ );
}, []);
const {
otherUserInfo,
callbacks: { blockUser, unblockUser, friendUser, unfriendUser },
} = useRelationshipPrompt(
threadInfo,
onErrorCallback,
pendingPersonalThreadUserInfo,
);
const styles = useStyles(unboundStyles);
if (
!otherUserInfo ||
!otherUserInfo.username ||
otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND
) {
return null;
}
if (
otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER
) {
return (
);
}
if (
otherUserInfo.relationshipStatus ===
userRelationshipStatus.BOTH_BLOCKED ||
otherUserInfo.relationshipStatus ===
userRelationshipStatus.BLOCKED_BY_VIEWER
) {
return (
);
}
if (
otherUserInfo.relationshipStatus ===
userRelationshipStatus.REQUEST_RECEIVED
) {
return (
);
}
if (
otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT
) {
return (
);
}
return (
);
},
);
const unboundStyles = {
container: {
paddingVertical: 10,
paddingHorizontal: 5,
backgroundColor: 'panelBackground',
flexDirection: 'row',
},
button: {
padding: 10,
borderRadius: 5,
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
marginHorizontal: 5,
},
greenButton: {
backgroundColor: 'vibrantGreenButton',
},
redButton: {
backgroundColor: 'vibrantRedButton',
},
buttonText: {
fontSize: 11,
color: 'white',
fontWeight: 'bold',
textAlign: 'center',
marginLeft: 5,
},
};
export default RelationshipPrompt;
diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js
index 54ad7940f..149f5a4f7 100644
--- a/native/chat/settings/add-users-modal.react.js
+++ b/native/chat/settings/add-users-modal.react.js
@@ -1,285 +1,286 @@
// @flow
import * as React from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} from 'lib/actions/thread-actions.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js';
import { usePotentialMemberItems } from 'lib/shared/search-utils.js';
import { threadActualMembers } from 'lib/shared/thread-utils.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { type AccountUserInfo } from 'lib/types/user-types.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import Button from '../../components/button.react.js';
import Modal from '../../components/modal.react.js';
import {
type BaseTagInput,
createTagInput,
} from '../../components/tag-input.react.js';
import UserList from '../../components/user-list.react.js';
import type { RootNavigationProp } from '../../navigation/root-navigator.react.js';
import type { NavigationRoute } from '../../navigation/route-names.js';
import { useSelector } from '../../redux/redux-utils.js';
import { useStyles } from '../../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
const TagInput = createTagInput();
const tagInputProps = {
placeholder: 'Select users to add',
autoFocus: true,
returnKeyType: 'go',
};
const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username;
export type AddUsersModalParams = {
+presentedFrom: string,
+threadInfo: ThreadInfo,
};
type Props = {
+navigation: RootNavigationProp<'AddUsersModal'>,
+route: NavigationRoute<'AddUsersModal'>,
};
function AddUsersModal(props: Props): React.Node {
const [usernameInputText, setUsernameInputText] = React.useState('');
const [userInfoInputArray, setUserInfoInputArray] = React.useState<
$ReadOnlyArray,
>([]);
const tagInputRef = React.useRef>();
const onUnknownErrorAlertAcknowledged = React.useCallback(() => {
setUsernameInputText('');
setUserInfoInputArray([]);
tagInputRef.current?.focus();
}, []);
const { navigation } = props;
const { goBackOnce } = navigation;
const close = React.useCallback(() => {
goBackOnce();
}, [goBackOnce]);
const callChangeThreadSettings = useChangeThreadSettings();
const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id);
const { route } = props;
const { threadInfo } = route.params;
const threadID = threadInfo.id;
const addUsersToThread = React.useCallback(async () => {
try {
const result = await callChangeThreadSettings({
threadID: threadID,
changes: { newMemberIDs: userInfoInputIDs },
});
close();
return result;
} catch (e) {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }],
{ cancelable: false },
);
throw e;
}
}, [
callChangeThreadSettings,
threadID,
userInfoInputIDs,
close,
onUnknownErrorAlertAcknowledged,
]);
const inputLength = userInfoInputArray.length;
const dispatchActionPromise = useDispatchActionPromise();
const userInfoInputArrayEmpty = inputLength === 0;
const onPressAdd = React.useCallback(() => {
if (userInfoInputArrayEmpty) {
return;
}
void dispatchActionPromise(
changeThreadSettingsActionTypes,
addUsersToThread(),
);
}, [userInfoInputArrayEmpty, dispatchActionPromise, addUsersToThread]);
const changeThreadSettingsLoadingStatus = useSelector(
createLoadingStatusSelector(changeThreadSettingsActionTypes),
);
const isLoading = changeThreadSettingsLoadingStatus === 'loading';
const styles = useStyles(unboundStyles);
let addButton = null;
if (inputLength > 0) {
let activityIndicator = null;
if (isLoading) {
activityIndicator = (
);
}
const addButtonText = `Add (${inputLength})`;
addButton = (
);
}
let cancelButton;
if (!isLoading) {
cancelButton = (
);
} else {
cancelButton = ;
}
const threadMemberIDs = React.useMemo(
() => threadActualMembers(threadInfo.members),
[threadInfo.members],
);
const excludeUserIDs = React.useMemo(
() => userInfoInputIDs.concat(threadMemberIDs),
[userInfoInputIDs, threadMemberIDs],
);
const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers);
const { parentThreadID, community } = props.route.params.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const communityThreadInfo = useSelector(state =>
community ? threadInfoSelector(state)[community] : null,
);
const userSearchResults = usePotentialMemberItems({
text: usernameInputText,
userInfos: otherUserInfos,
excludeUserIDs,
inputParentThreadInfo: parentThreadInfo,
inputCommunityThreadInfo: communityThreadInfo,
threadType: threadInfo.type,
});
const onChangeTagInput = React.useCallback(
(newUserInfoInputArray: $ReadOnlyArray) => {
if (!isLoading) {
setUserInfoInputArray(newUserInfoInputArray);
}
},
[isLoading],
);
const onChangeTagInputText = React.useCallback(
(text: string) => {
if (!isLoading) {
setUsernameInputText(text);
}
},
[isLoading],
);
const onUserSelect = React.useCallback(
({ id }: AccountUserInfo) => {
if (isLoading) {
return;
}
if (userInfoInputIDs.some(existingUserID => id === existingUserID)) {
return;
}
setUserInfoInputArray(oldUserInfoInputArray => [
...oldUserInfoInputArray,
otherUserInfos[id],
]);
setUsernameInputText('');
},
[isLoading, userInfoInputIDs, otherUserInfos],
);
const inputProps = React.useMemo(
() => ({
...tagInputProps,
onSubmitEditing: onPressAdd,
}),
[onPressAdd],
);
const userSearchResultWithENSNames = useENSNames(userSearchResults);
const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray);
return (
{cancelButton}
{addButton}
);
}
const unboundStyles = {
activityIndicator: {
paddingRight: 6,
},
addButton: {
backgroundColor: 'vibrantGreenButton',
borderRadius: 3,
flexDirection: 'row',
paddingHorizontal: 10,
paddingVertical: 4,
},
addText: {
color: 'white',
fontSize: 18,
},
buttons: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
},
cancelButton: {
backgroundColor: 'modalButton',
borderRadius: 3,
paddingHorizontal: 10,
paddingVertical: 4,
},
cancelText: {
color: 'modalButtonLabel',
fontSize: 18,
},
};
const MemoizedAddUsersModal: React.ComponentType =
React.memo(AddUsersModal);
export default MemoizedAddUsersModal;
diff --git a/native/chat/settings/color-selector-modal.react.js b/native/chat/settings/color-selector-modal.react.js
index 3be36642a..191a01ad9 100644
--- a/native/chat/settings/color-selector-modal.react.js
+++ b/native/chat/settings/color-selector-modal.react.js
@@ -1,192 +1,193 @@
// @flow
import Icon from '@expo/vector-icons/FontAwesome.js';
import * as React from 'react';
import { TouchableHighlight } from 'react-native';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} from 'lib/actions/thread-actions.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import {
type ChangeThreadSettingsPayload,
type UpdateThreadRequest,
} from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import ColorSelector from '../../components/color-selector.react.js';
import Modal from '../../components/modal.react.js';
import type { RootNavigationProp } from '../../navigation/root-navigator.react.js';
import type { NavigationRoute } from '../../navigation/route-names.js';
import { useSelector } from '../../redux/redux-utils.js';
import { type Colors, useColors, useStyles } from '../../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
export type ColorSelectorModalParams = {
+presentedFrom: string,
+color: string,
+threadInfo: ThreadInfo,
+setColor: (color: string) => void,
};
const unboundStyles = {
closeButton: {
borderRadius: 3,
height: 18,
position: 'absolute',
right: 5,
top: 5,
width: 18,
},
closeButtonIcon: {
color: 'modalBackgroundSecondaryLabel',
left: 3,
position: 'absolute',
},
colorSelector: {
bottom: 10,
left: 10,
position: 'absolute',
right: 10,
top: 10,
},
colorSelectorContainer: {
backgroundColor: 'modalBackground',
borderRadius: 5,
flex: 0,
marginHorizontal: 15,
marginVertical: 20,
},
};
type BaseProps = {
+navigation: RootNavigationProp<'ColorSelectorModal'>,
+route: NavigationRoute<'ColorSelectorModal'>,
};
type Props = {
...BaseProps,
// Redux state
+colors: Colors,
+styles: $ReadOnly,
+windowWidth: number,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+changeThreadSettings: (
request: UpdateThreadRequest,
) => Promise,
};
function ColorSelectorModal(props: Props): React.Node {
const {
changeThreadSettings: updateThreadSettings,
dispatchActionPromise,
windowWidth,
} = props;
const { threadInfo, setColor } = props.route.params;
const close = props.navigation.goBackOnce;
const onErrorAcknowledged = React.useCallback(() => {
setColor(threadInfo.color);
}, [setColor, threadInfo.color]);
const editColor = React.useCallback(
async (newColor: string) => {
const threadID = threadInfo.id;
try {
return await updateThreadSettings({
threadID,
changes: { color: newColor },
});
} catch (e) {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: onErrorAcknowledged }],
{ cancelable: false },
);
throw e;
}
},
[onErrorAcknowledged, threadInfo.id, updateThreadSettings],
);
const onColorSelected = React.useCallback(
(color: string) => {
const colorEditValue = color.substr(1);
setColor(colorEditValue);
close();
const action = changeThreadSettingsActionTypes.started;
const threadID = props.route.params.threadInfo.id;
void dispatchActionPromise(
changeThreadSettingsActionTypes,
editColor(colorEditValue),
{
customKeyName: `${action}:${threadID}:color`,
},
);
},
[
setColor,
close,
dispatchActionPromise,
editColor,
props.route.params.threadInfo.id,
],
);
const { colorSelectorContainer, closeButton, closeButtonIcon } = props.styles;
// Based on the assumption we are always in portrait,
// and consequently width is the lowest dimensions
const modalStyle = React.useMemo(
() => [colorSelectorContainer, { height: 0.75 * windowWidth }],
[colorSelectorContainer, windowWidth],
);
const { modalIosHighlightUnderlay } = props.colors;
const { color } = props.route.params;
return (
);
}
const ConnectedColorSelectorModal: React.ComponentType =
React.memo(function ConnectedColorSelectorModal(props: BaseProps) {
const styles = useStyles(unboundStyles);
const colors = useColors();
const windowWidth = useSelector(state => state.dimensions.width);
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
return (
);
});
export default ConnectedColorSelectorModal;
diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js
index 9950d10e0..f01d3f6b1 100644
--- a/native/chat/settings/delete-thread.react.js
+++ b/native/chat/settings/delete-thread.react.js
@@ -1,293 +1,299 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
ActivityIndicator,
Text,
TextInput as BaseTextInput,
View,
} from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import type { DeleteThreadInput } from 'lib/actions/thread-actions.js';
import {
deleteThreadActionTypes,
useDeleteThread,
} from 'lib/actions/thread-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import {
containedThreadInfos,
threadInfoSelector,
} from 'lib/selectors/thread-selectors.js';
import {
getThreadsToDeleteText,
identifyInvalidatedThreads,
} from 'lib/shared/thread-utils.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type {
ResolvedThreadInfo,
ThreadInfo,
} from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { LeaveThreadPayload } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import Button from '../../components/button.react.js';
import { clearThreadsActionType } from '../../navigation/action-types.js';
import {
type NavAction,
NavContext,
} from '../../navigation/navigation-context.js';
import type { NavigationRoute } from '../../navigation/route-names.js';
import { useSelector } from '../../redux/redux-utils.js';
import { type Colors, useColors, useStyles } from '../../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
import type { ChatNavigationProp } from '../chat.react.js';
export type DeleteThreadParams = {
+threadInfo: ThreadInfo,
};
const unboundStyles = {
deleteButton: {
backgroundColor: 'vibrantRedButton',
borderRadius: 5,
flex: 1,
marginHorizontal: 24,
marginVertical: 12,
padding: 12,
},
deleteText: {
color: 'white',
fontSize: 18,
textAlign: 'center',
},
header: {
color: 'panelBackgroundLabel',
fontSize: 12,
fontWeight: '400',
paddingBottom: 3,
paddingHorizontal: 24,
},
input: {
color: 'panelForegroundLabel',
flex: 1,
fontFamily: 'Arial',
fontSize: 16,
paddingVertical: 0,
borderBottomColor: 'transparent',
},
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,
},
warningText: {
color: 'panelForegroundLabel',
fontSize: 16,
marginBottom: 24,
marginHorizontal: 24,
textAlign: 'center',
},
};
type BaseProps = {
+navigation: ChatNavigationProp<'DeleteThread'>,
+route: NavigationRoute<'DeleteThread'>,
};
type Props = {
...BaseProps,
// Redux state
+threadInfo: ResolvedThreadInfo,
+shouldUseDeleteConfirmationAlert: boolean,
+loadingStatus: LoadingStatus,
+colors: Colors,
+styles: $ReadOnly,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+deleteThread: (input: DeleteThreadInput) => Promise,
// withNavContext
+navDispatch: (action: NavAction) => void,
};
class DeleteThread extends React.PureComponent {
mounted = false;
passwordInput: ?React.ElementRef;
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
render(): React.Node {
const buttonContent =
this.props.loadingStatus === 'loading' ? (
) : (
Delete chat
);
const { threadInfo } = this.props;
return (
{`The chat "${threadInfo.uiName}" will be permanently deleted. `}
There is no way to reverse this.
);
}
passwordInputRef = (
passwordInput: ?React.ElementRef,
) => {
this.passwordInput = passwordInput;
};
focusPasswordInput = () => {
invariant(this.passwordInput, 'passwordInput should be set');
this.passwordInput.focus();
};
dispatchDeleteThreadAction = () => {
void this.props.dispatchActionPromise(
deleteThreadActionTypes,
this.deleteThread(),
);
};
submitDeletion = () => {
if (!this.props.shouldUseDeleteConfirmationAlert) {
this.dispatchDeleteThreadAction();
return;
}
Alert.alert(
'Warning',
`${getThreadsToDeleteText(
this.props.threadInfo,
)} will also be permanently deleted.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Continue', onPress: this.dispatchDeleteThreadAction },
],
{ cancelable: false },
);
};
async deleteThread(): Promise {
const { threadInfo, navDispatch } = this.props;
navDispatch({
type: clearThreadsActionType,
payload: { threadIDs: [threadInfo.id] },
});
try {
const result = await this.props.deleteThread({ threadID: threadInfo.id });
const invalidated = identifyInvalidatedThreads(
result.updatesResult.newUpdates,
);
navDispatch({
type: clearThreadsActionType,
payload: { threadIDs: [...invalidated] },
});
return result;
} catch (e) {
if (e.message === 'invalid_credentials') {
Alert.alert(
'Permission not granted',
'You do not have permission to delete this thread',
[{ text: 'OK' }],
{ cancelable: false },
);
} else {
- Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], {
- cancelable: false,
- });
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ [{ text: 'OK' }],
+ {
+ cancelable: false,
+ },
+ );
}
throw e;
}
}
}
const loadingStatusSelector = createLoadingStatusSelector(
deleteThreadActionTypes,
);
const ConnectedDeleteThread: React.ComponentType =
React.memo(function ConnectedDeleteThread(props: BaseProps) {
const threadID = props.route.params.threadInfo.id;
const reduxThreadInfo = useSelector(
state => threadInfoSelector(state)[threadID],
);
const reduxContainedThreadInfos = useSelector(
state => containedThreadInfos(state)[threadID],
);
const { setParams } = props.navigation;
React.useEffect(() => {
if (reduxThreadInfo) {
setParams({ threadInfo: reduxThreadInfo });
}
}, [reduxThreadInfo, setParams]);
const threadInfo = reduxThreadInfo ?? props.route.params.threadInfo;
const resolvedThreadInfo = useResolvedThreadInfo(threadInfo);
const loadingStatus = useSelector(loadingStatusSelector);
const colors = useColors();
const styles = useStyles(unboundStyles);
const dispatchActionPromise = useDispatchActionPromise();
const callDeleteThread = useDeleteThread();
const navContext = React.useContext(NavContext);
invariant(navContext, 'NavContext should be set in DeleteThread');
const navDispatch = navContext.dispatch;
const shouldUseDeleteConfirmationAlert =
reduxContainedThreadInfos && reduxContainedThreadInfos.length > 0;
return (
);
});
export default ConnectedDeleteThread;
diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js
index c5e692808..634b4fbb7 100644
--- a/native/chat/settings/thread-settings-description.react.js
+++ b/native/chat/settings/thread-settings-description.react.js
@@ -1,324 +1,325 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
ActivityIndicator,
Text,
TextInput as BaseTextInput,
View,
} from 'react-native';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} from 'lib/actions/thread-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadHasPermission } from 'lib/shared/thread-utils.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import {
type ChangeThreadSettingsPayload,
type UpdateThreadRequest,
} from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import SaveSettingButton from './save-setting-button.react.js';
import {
ThreadSettingsCategoryFooter,
ThreadSettingsCategoryHeader,
} from './thread-settings-category.react.js';
import Button from '../../components/button.react.js';
import EditSettingButton from '../../components/edit-setting-button.react.js';
import SWMansionIcon from '../../components/swmansion-icon.react.js';
import TextInput from '../../components/text-input.react.js';
import { useSelector } from '../../redux/redux-utils.js';
import { type Colors, useColors, useStyles } from '../../themes/colors.js';
import type {
ContentSizeChangeEvent,
LayoutEvent,
} from '../../types/react-native.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
const unboundStyles = {
addDescriptionButton: {
flexDirection: 'row',
paddingHorizontal: 24,
paddingVertical: 10,
},
addDescriptionText: {
color: 'panelForegroundTertiaryLabel',
flex: 1,
fontSize: 16,
},
editIcon: {
color: 'panelForegroundTertiaryLabel',
paddingLeft: 10,
textAlign: 'right',
},
outlineCategory: {
backgroundColor: 'panelForeground',
borderColor: 'panelForegroundBorder',
borderRadius: 1,
borderStyle: 'dashed',
borderWidth: 1,
marginLeft: -1,
marginRight: -1,
},
row: {
backgroundColor: 'panelForeground',
flexDirection: 'row',
paddingHorizontal: 24,
paddingVertical: 4,
},
text: {
color: 'panelForegroundSecondaryLabel',
flex: 1,
fontFamily: 'Arial',
fontSize: 16,
margin: 0,
padding: 0,
borderBottomColor: 'transparent',
},
};
type BaseProps = {
+threadInfo: ThreadInfo,
+descriptionEditValue: ?string,
+setDescriptionEditValue: (value: ?string, callback?: () => void) => void,
+descriptionTextHeight: ?number,
+setDescriptionTextHeight: (number: number) => void,
+canChangeSettings: boolean,
};
type Props = {
...BaseProps,
// Redux state
+loadingStatus: LoadingStatus,
+colors: Colors,
+styles: $ReadOnly,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+changeThreadSettings: (
update: UpdateThreadRequest,
) => Promise,
};
class ThreadSettingsDescription extends React.PureComponent {
textInput: ?React.ElementRef;
render(): React.Node {
if (
this.props.descriptionEditValue !== null &&
this.props.descriptionEditValue !== undefined
) {
const textInputStyle: { height?: number } = {};
if (
this.props.descriptionTextHeight !== undefined &&
this.props.descriptionTextHeight !== null
) {
textInputStyle.height = this.props.descriptionTextHeight;
}
return (
{this.renderButton()}
);
}
if (this.props.threadInfo.description) {
return (
{this.props.threadInfo.description}
{this.renderButton()}
);
}
const canEditThreadDescription = threadHasPermission(
this.props.threadInfo,
threadPermissions.EDIT_THREAD_DESCRIPTION,
);
const { panelIosHighlightUnderlay } = this.props.colors;
if (canEditThreadDescription) {
return (
);
}
return null;
}
renderButton(): React.Node {
if (this.props.loadingStatus === 'loading') {
return (
);
} else if (
this.props.descriptionEditValue === null ||
this.props.descriptionEditValue === undefined
) {
return (
);
}
return ;
}
textInputRef = (textInput: ?React.ElementRef) => {
this.textInput = textInput;
};
onLayoutText = (event: LayoutEvent) => {
this.props.setDescriptionTextHeight(event.nativeEvent.layout.height);
};
onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => {
this.props.setDescriptionTextHeight(event.nativeEvent.contentSize.height);
};
onPressEdit = () => {
this.props.setDescriptionEditValue(this.props.threadInfo.description);
};
onSubmit = () => {
invariant(
this.props.descriptionEditValue !== null &&
this.props.descriptionEditValue !== undefined,
'should be set',
);
const description = this.props.descriptionEditValue.trim();
if (description === this.props.threadInfo.description) {
this.props.setDescriptionEditValue(null);
return;
}
const editDescriptionPromise = this.editDescription(description);
const action = changeThreadSettingsActionTypes.started;
const threadID = this.props.threadInfo.id;
void this.props.dispatchActionPromise(
changeThreadSettingsActionTypes,
editDescriptionPromise,
{
customKeyName: `${action}:${threadID}:description`,
},
);
void editDescriptionPromise.then(() => {
this.props.setDescriptionEditValue(null);
});
};
async editDescription(
newDescription: string,
): Promise {
try {
return await this.props.changeThreadSettings({
threadID: this.props.threadInfo.id,
changes: { description: newDescription },
});
} catch (e) {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: this.onErrorAcknowledged }],
{ cancelable: false },
);
throw e;
}
}
onErrorAcknowledged = () => {
this.props.setDescriptionEditValue(
this.props.threadInfo.description,
() => {
invariant(this.textInput, 'textInput should be set');
this.textInput.focus();
},
);
};
}
const ConnectedThreadSettingsDescription: React.ComponentType =
React.memo(function ConnectedThreadSettingsDescription(
props: BaseProps,
) {
const threadID = props.threadInfo.id;
const loadingStatus = useSelector(
createLoadingStatusSelector(
changeThreadSettingsActionTypes,
`${changeThreadSettingsActionTypes.started}:${threadID}:description`,
),
);
const colors = useColors();
const styles = useStyles(unboundStyles);
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
return (
);
});
export default ConnectedThreadSettingsDescription;
diff --git a/native/chat/settings/thread-settings-edit-relationship.react.js b/native/chat/settings/thread-settings-edit-relationship.react.js
index d8bb27b01..b2ed31b64 100644
--- a/native/chat/settings/thread-settings-edit-relationship.react.js
+++ b/native/chat/settings/thread-settings-edit-relationship.react.js
@@ -1,129 +1,135 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { Text, View } from 'react-native';
import {
updateRelationships as serverUpdateRelationships,
updateRelationshipsActionTypes,
} from 'lib/actions/relationship-actions.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import {
getRelationshipActionText,
getRelationshipDispatchAction,
} from 'lib/shared/relationship-utils.js';
import { getSingleOtherUser } from 'lib/shared/thread-utils.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import {
type RelationshipAction,
type RelationshipButton,
} from 'lib/types/relationship-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import Button from '../../components/button.react.js';
import { useSelector } from '../../redux/redux-utils.js';
import { useColors, useStyles } from '../../themes/colors.js';
import type { ViewStyle } from '../../types/styles.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
type Props = {
+threadInfo: ThreadInfo,
+buttonStyle: ViewStyle,
+relationshipButton: RelationshipButton,
};
const ThreadSettingsEditRelationship: React.ComponentType =
React.memo(function ThreadSettingsEditRelationship(props: Props) {
const otherUserInfoFromRedux = useSelector(state => {
const currentUserID = state.currentUserInfo?.id;
const otherUserID = getSingleOtherUser(props.threadInfo, currentUserID);
invariant(otherUserID, 'Other user should be specified');
const { userInfos } = state.userStore;
return userInfos[otherUserID];
});
invariant(otherUserInfoFromRedux, 'Other user info should be specified');
const [otherUserInfo] = useENSNames([otherUserInfoFromRedux]);
const callUpdateRelationships = useLegacyAshoatKeyserverCall(
serverUpdateRelationships,
);
const updateRelationship = React.useCallback(
async (action: RelationshipAction) => {
try {
return await callUpdateRelationships({
action,
userIDs: [otherUserInfo.id],
});
} catch (e) {
- Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], {
- cancelable: true,
- });
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ [{ text: 'OK' }],
+ {
+ cancelable: true,
+ },
+ );
throw e;
}
},
[callUpdateRelationships, otherUserInfo],
);
const { relationshipButton } = props;
const relationshipAction = React.useMemo(
() => getRelationshipDispatchAction(relationshipButton),
[relationshipButton],
);
const dispatchActionPromise = useDispatchActionPromise();
const onButtonPress = React.useCallback(() => {
void dispatchActionPromise(
updateRelationshipsActionTypes,
updateRelationship(relationshipAction),
);
}, [dispatchActionPromise, relationshipAction, updateRelationship]);
const colors = useColors();
const { panelIosHighlightUnderlay } = colors;
const styles = useStyles(unboundStyles);
const otherUserInfoUsername = otherUserInfo.username;
invariant(otherUserInfoUsername, 'Other user username should be specified');
const relationshipButtonText = React.useMemo(
() =>
getRelationshipActionText(relationshipButton, otherUserInfoUsername),
[otherUserInfoUsername, relationshipButton],
);
return (
);
});
const unboundStyles = {
button: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 10,
},
container: {
backgroundColor: 'panelForeground',
paddingHorizontal: 12,
},
text: {
color: 'redText',
flex: 1,
fontSize: 16,
},
};
export default ThreadSettingsEditRelationship;
diff --git a/native/chat/settings/thread-settings-leave-thread.react.js b/native/chat/settings/thread-settings-leave-thread.react.js
index 456747a17..8de53dc9a 100644
--- a/native/chat/settings/thread-settings-leave-thread.react.js
+++ b/native/chat/settings/thread-settings-leave-thread.react.js
@@ -1,185 +1,191 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import type { LeaveThreadInput } from 'lib/actions/thread-actions.js';
import {
leaveThreadActionTypes,
useLeaveThread,
} from 'lib/actions/thread-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors.js';
import { identifyInvalidatedThreads } from 'lib/shared/thread-utils.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type { LeaveThreadPayload } from 'lib/types/thread-types.js';
import {
type DispatchActionPromise,
useDispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import Button from '../../components/button.react.js';
import { clearThreadsActionType } from '../../navigation/action-types.js';
import {
NavContext,
type NavContextType,
} from '../../navigation/navigation-context.js';
import { useSelector } from '../../redux/redux-utils.js';
import { type Colors, useColors, useStyles } from '../../themes/colors.js';
import type { ViewStyle } from '../../types/styles.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
const unboundStyles = {
button: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 10,
},
container: {
backgroundColor: 'panelForeground',
paddingHorizontal: 12,
},
text: {
color: 'redText',
flex: 1,
fontSize: 16,
},
};
type BaseProps = {
+threadInfo: ThreadInfo,
+buttonStyle: ViewStyle,
};
type Props = {
...BaseProps,
// Redux state
+loadingStatus: LoadingStatus,
+otherUsersButNoOtherAdmins: boolean,
+colors: Colors,
+styles: $ReadOnly,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+leaveThread: (input: LeaveThreadInput) => Promise,
// withNavContext
+navContext: ?NavContextType,
};
class ThreadSettingsLeaveThread extends React.PureComponent {
render(): React.Node {
const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel } =
this.props.colors;
const loadingIndicator =
this.props.loadingStatus === 'loading' ? (
) : null;
return (
);
}
onPress = () => {
if (this.props.otherUsersButNoOtherAdmins) {
Alert.alert(
'Need another admin',
'Make somebody else an admin before you leave!',
undefined,
{ cancelable: true },
);
return;
}
Alert.alert(
'Confirm action',
'Are you sure you want to leave this chat?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'OK', onPress: this.onConfirmLeaveThread },
],
{ cancelable: true },
);
};
onConfirmLeaveThread = () => {
const threadID = this.props.threadInfo.id;
void this.props.dispatchActionPromise(
leaveThreadActionTypes,
this.leaveThread(),
{
customKeyName: `${leaveThreadActionTypes.started}:${threadID}`,
},
);
};
async leaveThread(): Promise {
const threadID = this.props.threadInfo.id;
const { navContext } = this.props;
invariant(navContext, 'navContext should exist in leaveThread');
navContext.dispatch({
type: clearThreadsActionType,
payload: { threadIDs: [threadID] },
});
try {
const result = await this.props.leaveThread({ threadID });
const invalidated = identifyInvalidatedThreads(
result.updatesResult.newUpdates,
);
navContext.dispatch({
type: clearThreadsActionType,
payload: { threadIDs: [...invalidated] },
});
return result;
} catch (e) {
- Alert.alert('Unknown error', 'Uhh... try again?', undefined, {
- cancelable: true,
- });
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ undefined,
+ {
+ cancelable: true,
+ },
+ );
throw e;
}
}
}
const ConnectedThreadSettingsLeaveThread: React.ComponentType =
React.memo(function ConnectedThreadSettingsLeaveThread(
props: BaseProps,
) {
const threadID = props.threadInfo.id;
const loadingStatus = useSelector(
createLoadingStatusSelector(
leaveThreadActionTypes,
`${leaveThreadActionTypes.started}:${threadID}`,
),
);
const otherUsersButNoOtherAdminsValue = useSelector(
otherUsersButNoOtherAdmins(props.threadInfo.id),
);
const colors = useColors();
const styles = useStyles(unboundStyles);
const dispatchActionPromise = useDispatchActionPromise();
const callLeaveThread = useLeaveThread();
const navContext = React.useContext(NavContext);
return (
);
});
export default ConnectedThreadSettingsLeaveThread;
diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js
index fce52addf..81c395754 100644
--- a/native/chat/settings/thread-settings-name.react.js
+++ b/native/chat/settings/thread-settings-name.react.js
@@ -1,246 +1,247 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import {
Text,
ActivityIndicator,
TextInput as BaseTextInput,
View,
} from 'react-native';
import {
changeThreadSettingsActionTypes,
useChangeThreadSettings,
} from 'lib/actions/thread-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { ResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import type {
ChangeThreadSettingsPayload,
UpdateThreadRequest,
} from 'lib/types/thread-types.js';
import {
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import { firstLine } from 'lib/utils/string-utils.js';
import { chatNameMaxLength } from 'lib/utils/validation-utils.js';
import SaveSettingButton from './save-setting-button.react.js';
import EditSettingButton from '../../components/edit-setting-button.react.js';
import SingleLine from '../../components/single-line.react.js';
import TextInput from '../../components/text-input.react.js';
import { useSelector } from '../../redux/redux-utils.js';
import { type Colors, useStyles, useColors } from '../../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
const unboundStyles = {
currentValue: {
color: 'panelForegroundSecondaryLabel',
flex: 1,
fontFamily: 'Arial',
fontSize: 16,
margin: 0,
paddingLeft: 4,
paddingRight: 0,
paddingVertical: 0,
borderBottomColor: 'transparent',
},
label: {
color: 'panelForegroundTertiaryLabel',
fontSize: 16,
width: 96,
},
row: {
backgroundColor: 'panelForeground',
flexDirection: 'row',
paddingHorizontal: 24,
paddingVertical: 8,
},
};
type BaseProps = {
+threadInfo: ResolvedThreadInfo,
+nameEditValue: ?string,
+setNameEditValue: (value: ?string, callback?: () => void) => void,
+canChangeSettings: boolean,
};
type Props = {
...BaseProps,
// Redux state
+loadingStatus: LoadingStatus,
+colors: Colors,
+styles: $ReadOnly,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+changeThreadSettings: (
update: UpdateThreadRequest,
) => Promise,
};
class ThreadSettingsName extends React.PureComponent {
textInput: ?React.ElementRef;
render(): React.Node {
return (
Name
{this.renderContent()}
);
}
renderButton(): React.Node {
if (this.props.loadingStatus === 'loading') {
return (
);
} else if (
this.props.nameEditValue === null ||
this.props.nameEditValue === undefined
) {
return (
);
}
return ;
}
renderContent(): React.Node {
if (
this.props.nameEditValue === null ||
this.props.nameEditValue === undefined
) {
return (
{this.props.threadInfo.uiName}
{this.renderButton()}
);
}
return (
{this.renderButton()}
);
}
textInputRef = (textInput: ?React.ElementRef) => {
this.textInput = textInput;
};
threadEditName(): string {
return firstLine(
this.props.threadInfo.name ? this.props.threadInfo.name : '',
);
}
onPressEdit = () => {
this.props.setNameEditValue(this.threadEditName());
};
onSubmit = () => {
invariant(
this.props.nameEditValue !== null &&
this.props.nameEditValue !== undefined,
'should be set',
);
const name = firstLine(this.props.nameEditValue);
if (name === this.threadEditName()) {
this.props.setNameEditValue(null);
return;
}
const editNamePromise = this.editName(name);
const action = changeThreadSettingsActionTypes.started;
const threadID = this.props.threadInfo.id;
void this.props.dispatchActionPromise(
changeThreadSettingsActionTypes,
editNamePromise,
{
customKeyName: `${action}:${threadID}:name`,
},
);
void editNamePromise.then(() => {
this.props.setNameEditValue(null);
});
};
async editName(newName: string): Promise {
try {
return await this.props.changeThreadSettings({
threadID: this.props.threadInfo.id,
changes: { name: newName },
});
} catch (e) {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: this.onErrorAcknowledged }],
{ cancelable: false },
);
throw e;
}
}
onErrorAcknowledged = () => {
this.props.setNameEditValue(this.threadEditName(), () => {
invariant(this.textInput, 'textInput should be set');
this.textInput.focus();
});
};
}
const ConnectedThreadSettingsName: React.ComponentType =
React.memo(function ConnectedThreadSettingsName(props: BaseProps) {
const styles = useStyles(unboundStyles);
const colors = useColors();
const threadID = props.threadInfo.id;
const loadingStatus = useSelector(
createLoadingStatusSelector(
changeThreadSettingsActionTypes,
`${changeThreadSettingsActionTypes.started}:${threadID}:name`,
),
);
const dispatchActionPromise = useDispatchActionPromise();
const callChangeThreadSettings = useChangeThreadSettings();
return (
);
});
export default ConnectedThreadSettingsName;
diff --git a/native/chat/settings/thread-settings-promote-sidebar.react.js b/native/chat/settings/thread-settings-promote-sidebar.react.js
index e0cf7c647..dcd38d739 100644
--- a/native/chat/settings/thread-settings-promote-sidebar.react.js
+++ b/native/chat/settings/thread-settings-promote-sidebar.react.js
@@ -1,114 +1,120 @@
// @flow
import * as React from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import Button from '../../components/button.react.js';
import { type Colors, useColors, useStyles } from '../../themes/colors.js';
import type { ViewStyle } from '../../types/styles.js';
+import { UnknownErrorAlertDetails } from '../../utils/alert-messages.js';
import Alert from '../../utils/alert.js';
const unboundStyles = {
button: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 10,
},
container: {
backgroundColor: 'panelForeground',
paddingHorizontal: 12,
},
text: {
color: 'panelForegroundSecondaryLabel',
flex: 1,
fontSize: 16,
},
};
type BaseProps = {
+threadInfo: ThreadInfo,
+buttonStyle: ViewStyle,
};
type Props = {
...BaseProps,
+loadingStatus: LoadingStatus,
+colors: Colors,
+styles: $ReadOnly,
+promoteSidebar: () => mixed,
};
class ThreadSettingsPromoteSidebar extends React.PureComponent {
onClick = () => {
Alert.alert(
'Are you sure?',
'Promoting a thread to a channel cannot be undone.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Yes',
onPress: this.props.promoteSidebar,
},
],
);
};
render(): React.Node {
const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel } =
this.props.colors;
const loadingIndicator =
this.props.loadingStatus === 'loading' ? (
) : null;
return (
);
}
}
const onError = () => {
- Alert.alert('Unknown error', 'Uhh... try again?', undefined, {
- cancelable: true,
- });
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ undefined,
+ {
+ cancelable: true,
+ },
+ );
};
const ConnectedThreadSettingsPromoteSidebar: React.ComponentType =
React.memo(function ConnectedThreadSettingsPromoteSidebar(
props: BaseProps,
) {
const { threadInfo } = props;
const colors = useColors();
const styles = useStyles(unboundStyles);
const { onPromoteSidebar, loading } = usePromoteSidebar(
threadInfo,
onError,
);
return (
);
});
export default ConnectedThreadSettingsPromoteSidebar;
diff --git a/native/profile/default-notifications-preferences.react.js b/native/profile/default-notifications-preferences.react.js
index e8b5271a3..f5356e859 100644
--- a/native/profile/default-notifications-preferences.react.js
+++ b/native/profile/default-notifications-preferences.react.js
@@ -1,213 +1,214 @@
// @flow
import * as React from 'react';
import { View, Text, Platform } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import {
useSetUserSettings,
setUserSettingsActionTypes,
} from 'lib/actions/user-actions.js';
import { registerFetchKey } from 'lib/reducers/loading-reducer.js';
import {
type UpdateUserSettingsRequest,
type NotificationTypes,
type DefaultNotificationPayload,
notificationTypes,
userSettingsTypes,
} from 'lib/types/account-types.js';
import {
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import type { ProfileNavigationProp } from './profile.react.js';
import Action from '../components/action-row.react.js';
import SWMansionIcon from '../components/swmansion-icon.react.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { useStyles } from '../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
const CheckIcon = () => (
);
type ProfileRowProps = {
+content: string,
+onPress: () => void,
+danger?: boolean,
+selected?: boolean,
};
function NotificationRow(props: ProfileRowProps): React.Node {
const { content, onPress, danger, selected } = props;
return (
{selected ? : null}
);
}
const unboundStyles = {
scrollView: {
backgroundColor: 'panelBackground',
},
scrollViewContentContainer: {
paddingTop: 24,
},
section: {
backgroundColor: 'panelForeground',
borderBottomWidth: 1,
borderColor: 'panelForegroundBorder',
borderTopWidth: 1,
marginBottom: 24,
marginVertical: 2,
},
icon: {
lineHeight: Platform.OS === 'ios' ? 18 : 20,
},
header: {
color: 'panelBackgroundLabel',
fontSize: 12,
fontWeight: '400',
paddingBottom: 3,
paddingHorizontal: 24,
},
};
type BaseProps = {
+navigation: ProfileNavigationProp<'DefaultNotifications'>,
+route: NavigationRoute<'DefaultNotifications'>,
};
type Props = {
...BaseProps,
+styles: $ReadOnly,
+dispatchActionPromise: DispatchActionPromise,
+changeNotificationSettings: (
notificationSettingsRequest: UpdateUserSettingsRequest,
) => Promise,
+selectedDefaultNotification: NotificationTypes,
};
class DefaultNotificationsPreferences extends React.PureComponent {
async updatedDefaultNotifications(
data: NotificationTypes,
): Promise {
const { changeNotificationSettings } = this.props;
try {
await changeNotificationSettings({
name: userSettingsTypes.DEFAULT_NOTIFICATIONS,
data,
});
} catch (e) {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: () => {} }],
{ cancelable: false },
);
}
return {
[userSettingsTypes.DEFAULT_NOTIFICATIONS]: data,
};
}
selectNotificationSetting = (data: NotificationTypes) => {
const { dispatchActionPromise } = this.props;
void dispatchActionPromise(
setUserSettingsActionTypes,
this.updatedDefaultNotifications(data),
);
};
selectAllNotifications = () => {
this.selectNotificationSetting(notificationTypes.FOCUSED);
};
selectBackgroundNotifications = () => {
this.selectNotificationSetting(notificationTypes.BACKGROUND);
};
selectNoneNotifications = () => {
this.selectNotificationSetting(notificationTypes.BADGE_ONLY);
};
render(): React.Node {
const { styles, selectedDefaultNotification } = this.props;
return (
NOTIFICATIONS
);
}
}
registerFetchKey(setUserSettingsActionTypes);
const ConnectedDefaultNotificationPreferences: React.ComponentType =
React.memo(function ConnectedDefaultNotificationPreferences(
props: BaseProps,
) {
const styles = useStyles(unboundStyles);
const dispatchActionPromise = useDispatchActionPromise();
const changeNotificationSettings = useSetUserSettings();
const defaultNotification = userSettingsTypes.DEFAULT_NOTIFICATIONS;
const selectedDefaultNotification = useSelector(
({ currentUserInfo }) => {
if (
currentUserInfo?.settings &&
currentUserInfo?.settings[defaultNotification]
) {
return currentUserInfo?.settings[defaultNotification];
}
return notificationTypes.FOCUSED;
},
);
return (
);
});
export default ConnectedDefaultNotificationPreferences;
diff --git a/native/profile/edit-password.react.js b/native/profile/edit-password.react.js
index 1a027ac43..9a6369805 100644
--- a/native/profile/edit-password.react.js
+++ b/native/profile/edit-password.react.js
@@ -1,375 +1,376 @@
// @flow
import { CommonActions } from '@react-navigation/native';
import invariant from 'invariant';
import * as React from 'react';
import {
Text,
View,
TextInput as BaseTextInput,
ActivityIndicator,
} from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import {
changeKeyserverUserPasswordActionTypes,
changeKeyserverUserPassword,
} from 'lib/actions/user-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { PasswordUpdate } from 'lib/types/user-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import {
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import type { ProfileNavigationProp } from './profile.react.js';
import { setNativeCredentials } from '../account/native-credentials.js';
import Button from '../components/button.react.js';
import TextInput from '../components/text-input.react.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { type Colors, useColors, useStyles } from '../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
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: 'vibrantGreenButton',
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,
},
};
type BaseProps = {
+navigation: ProfileNavigationProp<'EditPassword'>,
+route: NavigationRoute<'EditPassword'>,
};
type Props = {
...BaseProps,
// Redux state
+loadingStatus: LoadingStatus,
+username: ?string,
+colors: Colors,
+styles: $ReadOnly,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+changeKeyserverUserPassword: (
passwordUpdate: PasswordUpdate,
) => 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(): React.Node {
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 {
void this.props.dispatchActionPromise(
changeKeyserverUserPasswordActionTypes,
this.savePassword(),
);
}
};
async savePassword() {
const { username } = this.props;
if (!username) {
return;
}
try {
await this.props.changeKeyserverUserPassword({
updatedFields: {
password: this.state.newPassword,
},
currentPassword: this.state.currentPassword,
});
await setNativeCredentials({
username,
password: this.state.newPassword,
});
this.goBackOnce();
} catch (e) {
if (e.message === 'invalid_credentials') {
Alert.alert(
'Incorrect password',
'The current password you entered is incorrect',
[{ text: 'OK', onPress: this.onCurrentPasswordAlertAcknowledged }],
{ cancelable: false },
);
} else {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ 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 loadingStatusSelector = createLoadingStatusSelector(
changeKeyserverUserPasswordActionTypes,
);
const ConnectedEditPassword: React.ComponentType =
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 colors = useColors();
const styles = useStyles(unboundStyles);
const dispatchActionPromise = useDispatchActionPromise();
const callChangeKeyserverUserPassword = useLegacyAshoatKeyserverCall(
changeKeyserverUserPassword,
);
return (
);
});
export default ConnectedEditPassword;
diff --git a/native/profile/relationship-list-item.react.js b/native/profile/relationship-list-item.react.js
index 1dd23b516..49e969f82 100644
--- a/native/profile/relationship-list-item.react.js
+++ b/native/profile/relationship-list-item.react.js
@@ -1,357 +1,363 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native';
import {
updateRelationshipsActionTypes,
updateRelationships,
} from 'lib/actions/relationship-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { ReactRef } from 'lib/types/react-types.js';
import {
type RelationshipRequest,
type RelationshipAction,
type RelationshipErrors,
userRelationshipStatus,
relationshipActions,
} from 'lib/types/relationship-types.js';
import type {
AccountUserInfo,
GlobalAccountUserInfo,
} from 'lib/types/user-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import {
useDispatchActionPromise,
type DispatchActionPromise,
} from 'lib/utils/redux-promise-utils.js';
import type { RelationshipListNavigate } from './relationship-list.react.js';
import UserAvatar from '../avatars/user-avatar.react.js';
import PencilIcon from '../components/pencil-icon.react.js';
import SingleLine from '../components/single-line.react.js';
import {
type KeyboardState,
KeyboardContext,
} from '../keyboard/keyboard-state.js';
import {
OverlayContext,
type OverlayContextType,
} from '../navigation/overlay-context.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import {
UserRelationshipTooltipModalRouteName,
FriendListRouteName,
BlockListRouteName,
} from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { type Colors, useColors, useStyles } from '../themes/colors.js';
import type { VerticalBounds } from '../types/layout-types.js';
import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
const unboundStyles = {
container: {
flex: 1,
flexDirection: 'row',
paddingHorizontal: 24,
paddingVertical: 10,
backgroundColor: 'panelForeground',
borderColor: 'panelForegroundBorder',
},
borderBottom: {
borderBottomWidth: 1,
},
buttonContainer: {
flexDirection: 'row',
},
editButtonWithMargin: {
marginLeft: 15,
},
username: {
color: 'panelForegroundSecondaryLabel',
flex: 1,
fontSize: 16,
lineHeight: 20,
marginLeft: 8,
},
editButton: {
paddingLeft: 10,
},
blueAction: {
color: 'link',
fontSize: 16,
paddingLeft: 6,
},
redAction: {
color: 'redText',
fontSize: 16,
paddingLeft: 6,
},
};
type BaseProps = {
+userInfo: AccountUserInfo,
+lastListItem: boolean,
+verticalBounds: ?VerticalBounds,
+relationshipListRoute: NavigationRoute<'FriendList' | 'BlockList'>,
+navigate: RelationshipListNavigate,
+onSelect: (selectedUser: GlobalAccountUserInfo) => void,
};
type Props = {
...BaseProps,
// Redux state
+removeUserLoadingStatus: LoadingStatus,
+colors: Colors,
+styles: $ReadOnly,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+updateRelationships: (
request: RelationshipRequest,
) => Promise,
// withOverlayContext
+overlayContext: ?OverlayContextType,
// withKeyboardState
+keyboardState: ?KeyboardState,
+navigateToUserProfileBottomSheet: (userID: string) => mixed,
};
class RelationshipListItem extends React.PureComponent {
editButton: ReactRef> = React.createRef();
render(): React.Node {
const {
lastListItem,
removeUserLoadingStatus,
userInfo,
relationshipListRoute,
} = this.props;
const relationshipsToEdit = {
[FriendListRouteName]: [userRelationshipStatus.FRIEND],
[BlockListRouteName]: [
userRelationshipStatus.BOTH_BLOCKED,
userRelationshipStatus.BLOCKED_BY_VIEWER,
],
}[relationshipListRoute.name];
const canEditFriendRequest = {
[FriendListRouteName]: true,
[BlockListRouteName]: false,
}[relationshipListRoute.name];
const borderBottom = lastListItem ? null : this.props.styles.borderBottom;
let editButton = null;
if (removeUserLoadingStatus === 'loading') {
editButton = (
);
} else if (relationshipsToEdit.includes(userInfo.relationshipStatus)) {
editButton = (
);
} else if (
userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED &&
canEditFriendRequest
) {
editButton = (
Accept
Reject
);
} else if (
userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT &&
canEditFriendRequest
) {
editButton = (
Cancel request
);
} else {
editButton = (
Add
);
}
return (
{this.props.userInfo.username}
{editButton}
);
}
onPressUser = () => {
this.props.navigateToUserProfileBottomSheet(this.props.userInfo.id);
};
onSelect = () => {
const { id, username } = this.props.userInfo;
this.props.onSelect({ id, username });
};
visibleEntryIDs(): [string] {
const { relationshipListRoute } = this.props;
const id = {
[FriendListRouteName]: 'unfriend',
[BlockListRouteName]: 'unblock',
}[relationshipListRoute.name];
return [id];
}
onPressEdit = () => {
if (this.props.keyboardState?.dismissKeyboardIfShowing()) {
return;
}
const {
editButton,
props: { verticalBounds },
} = this;
const { overlayContext, userInfo } = this.props;
invariant(
overlayContext,
'RelationshipListItem should have OverlayContext',
);
overlayContext.setScrollBlockingModalStatus('open');
if (!editButton.current || !verticalBounds) {
return;
}
const { relationshipStatus, ...restUserInfo } = userInfo;
const relativeUserInfo = {
...restUserInfo,
isViewer: false,
};
editButton.current.measure((x, y, width, height, pageX, pageY) => {
const coordinates = { x: pageX, y: pageY, width, height };
this.props.navigate<'UserRelationshipTooltipModal'>({
name: UserRelationshipTooltipModalRouteName,
params: {
presentedFrom: this.props.relationshipListRoute.key,
initialCoordinates: coordinates,
verticalBounds,
visibleEntryIDs: this.visibleEntryIDs(),
relativeUserInfo,
tooltipButtonIcon: 'pencil',
},
});
});
};
// We need to set onLayout in order to allow .measure() to be on the ref
onLayout = () => {};
onPressFriendUser = () => {
this.onPressUpdateFriendship(relationshipActions.FRIEND);
};
onPressUnfriendUser = () => {
this.onPressUpdateFriendship(relationshipActions.UNFRIEND);
};
onPressUpdateFriendship(action: RelationshipAction) {
const { id } = this.props.userInfo;
const customKeyName = `${updateRelationshipsActionTypes.started}:${id}`;
void this.props.dispatchActionPromise(
updateRelationshipsActionTypes,
this.updateFriendship(action),
{ customKeyName },
);
}
async updateFriendship(
action: RelationshipAction,
): Promise {
try {
return await this.props.updateRelationships({
action,
userIDs: [this.props.userInfo.id],
});
} catch (e) {
- Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], {
- cancelable: true,
- });
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ [{ text: 'OK' }],
+ {
+ cancelable: true,
+ },
+ );
throw e;
}
}
}
const ConnectedRelationshipListItem: React.ComponentType =
React.memo(function ConnectedRelationshipListItem(
props: BaseProps,
) {
const removeUserLoadingStatus = useSelector(state =>
createLoadingStatusSelector(
updateRelationshipsActionTypes,
`${updateRelationshipsActionTypes.started}:${props.userInfo.id}`,
)(state),
);
const colors = useColors();
const styles = useStyles(unboundStyles);
const dispatchActionPromise = useDispatchActionPromise();
const boundUpdateRelationships =
useLegacyAshoatKeyserverCall(updateRelationships);
const overlayContext = React.useContext(OverlayContext);
const keyboardState = React.useContext(KeyboardContext);
const navigateToUserProfileBottomSheet =
useNavigateToUserProfileBottomSheet();
return (
);
});
export default ConnectedRelationshipListItem;
diff --git a/native/profile/relationship-list.react.js b/native/profile/relationship-list.react.js
index efa292a62..20648ad96 100644
--- a/native/profile/relationship-list.react.js
+++ b/native/profile/relationship-list.react.js
@@ -1,488 +1,489 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { View, Text, Platform } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
import {
updateRelationshipsActionTypes,
updateRelationships,
} from 'lib/actions/relationship-actions.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { registerFetchKey } from 'lib/reducers/loading-reducer.js';
import { useUserSearchIndex } from 'lib/selectors/nav-selectors.js';
import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors.js';
import { useSearchUsers } from 'lib/shared/search-utils.js';
import {
userRelationshipStatus,
relationshipActions,
} from 'lib/types/relationship-types.js';
import type {
GlobalAccountUserInfo,
AccountUserInfo,
} from 'lib/types/user-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import { values } from 'lib/utils/objects.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import type { ProfileNavigationProp } from './profile.react.js';
import RelationshipListItem from './relationship-list-item.react.js';
import LinkButton from '../components/link-button.react.js';
import { createTagInput, BaseTagInput } from '../components/tag-input.react.js';
import { KeyboardContext } from '../keyboard/keyboard-state.js';
import { OverlayContext } from '../navigation/overlay-context.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import {
FriendListRouteName,
BlockListRouteName,
} from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { useStyles, useIndicatorStyle } from '../themes/colors.js';
import type { VerticalBounds } from '../types/layout-types.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
const TagInput = createTagInput();
export type RelationshipListNavigate = $PropertyType<
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,
};
// ESLint doesn't recognize that invariant always throws
// eslint-disable-next-line consistent-return
function 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');
}
const tagDataLabelExtractor = (userInfo: GlobalAccountUserInfo) =>
userInfo.username;
type Props = {
+navigation: ProfileNavigationProp<'FriendList' | 'BlockList'>,
+route: NavigationRoute<'FriendList' | 'BlockList'>,
};
function RelationshipList(props: Props): React.Node {
const { route } = props;
const routeName = route.name;
const excludeStatuses = React.useMemo(
() =>
({
[FriendListRouteName]: [
userRelationshipStatus.BLOCKED_VIEWER,
userRelationshipStatus.BOTH_BLOCKED,
],
[BlockListRouteName]: [],
})[routeName],
[routeName],
);
const userInfos = useSelector(state => state.userStore.userInfos);
const userInfosArray = React.useMemo(
() =>
values(userInfos).filter(userInfo => {
const relationship = userInfo.relationshipStatus;
return !excludeStatuses.includes(relationship);
}),
[userInfos, excludeStatuses],
);
const [searchInputText, setSearchInputText] = React.useState('');
const [userStoreSearchResults, setUserStoreSearchResults] = React.useState<
$ReadOnlySet,
>(new Set());
const serverSearchResults = useSearchUsers(searchInputText);
const filteredServerSearchResults = React.useMemo(
() =>
serverSearchResults.filter(searchUserInfo => {
const userInfo = userInfos[searchUserInfo.id];
return (
!userInfo || !excludeStatuses.includes(userInfo.relationshipStatus)
);
}),
[serverSearchResults, userInfos, excludeStatuses],
);
const userStoreSearchIndex = useUserSearchIndex(userInfosArray);
const onChangeSearchText = React.useCallback(
async (searchText: string) => {
setSearchInputText(searchText);
const results = userStoreSearchIndex.getSearchResults(searchText);
setUserStoreSearchResults(new Set(results));
},
[userStoreSearchIndex],
);
const overlayContext = React.useContext(OverlayContext);
invariant(overlayContext, 'RelationshipList should have OverlayContext');
const scrollEnabled = overlayContext.scrollBlockingModalStatus === 'closed';
const tagInputRef = React.useRef>();
const flatListContainerRef = React.useRef>();
const keyboardState = React.useContext(KeyboardContext);
const keyboardNotShowing = !!(
keyboardState && !keyboardState.keyboardShowing
);
const [verticalBounds, setVerticalBounds] =
React.useState(null);
const onFlatListContainerLayout = React.useCallback(() => {
if (!flatListContainerRef.current) {
return;
}
if (!keyboardNotShowing) {
return;
}
flatListContainerRef.current.measure(
(x, y, width, height, pageX, pageY) => {
if (
height === null ||
height === undefined ||
pageY === null ||
pageY === undefined
) {
return;
}
setVerticalBounds({ height, y: pageY });
},
);
}, [keyboardNotShowing]);
const [currentTags, setCurrentTags] = React.useState<
$ReadOnlyArray,
>([]);
const onSelect = React.useCallback(
(selectedUser: GlobalAccountUserInfo) => {
if (currentTags.find(o => o.id === selectedUser.id)) {
return;
}
setSearchInputText('');
setCurrentTags(prevCurrentTags => prevCurrentTags.concat(selectedUser));
},
[currentTags],
);
const onUnknownErrorAlertAcknowledged = React.useCallback(() => {
setCurrentTags([]);
setSearchInputText('');
tagInputRef.current?.focus();
}, []);
const callUpdateRelationships =
useLegacyAshoatKeyserverCall(updateRelationships);
const updateRelationshipsOnServer = React.useCallback(async () => {
const action = {
[FriendListRouteName]: relationshipActions.FRIEND,
[BlockListRouteName]: relationshipActions.BLOCK,
}[routeName];
const userIDs = currentTags.map(userInfo => userInfo.id);
try {
const result = await callUpdateRelationships({
action,
userIDs,
});
setCurrentTags([]);
setSearchInputText('');
return result;
} catch (e) {
Alert.alert(
- 'Unknown error',
- 'Uhh... try again?',
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
[{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }],
{ cancelable: true, onDismiss: onUnknownErrorAlertAcknowledged },
);
throw e;
}
}, [
routeName,
currentTags,
callUpdateRelationships,
onUnknownErrorAlertAcknowledged,
]);
const dispatchActionPromise = useDispatchActionPromise();
const noCurrentTags = currentTags.length === 0;
const onPressAdd = React.useCallback(() => {
if (noCurrentTags) {
return;
}
void dispatchActionPromise(
updateRelationshipsActionTypes,
updateRelationshipsOnServer(),
);
}, [noCurrentTags, dispatchActionPromise, updateRelationshipsOnServer]);
const inputProps = React.useMemo(
() => ({
...tagInputProps,
onSubmitEditing: onPressAdd,
}),
[onPressAdd],
);
const { navigation } = props;
const { navigate } = navigation;
const styles = useStyles(unboundStyles);
const renderItem = React.useCallback(
// ESLint doesn't recognize that invariant always throws
// eslint-disable-next-line consistent-return
({ item }: { item: ListItem, ... }) => {
if (item.type === 'empty') {
const action = {
[FriendListRouteName]: 'added',
[BlockListRouteName]: 'blocked',
}[routeName];
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}`);
}
},
[routeName, navigate, route, onSelect, styles.emptyText, styles.separator],
);
const { setOptions } = navigation;
const prevNoCurrentTags = React.useRef(noCurrentTags);
React.useEffect(() => {
let setSaveButtonDisabled;
if (!prevNoCurrentTags.current && noCurrentTags) {
setSaveButtonDisabled = true;
} else if (prevNoCurrentTags.current && !noCurrentTags) {
setSaveButtonDisabled = false;
}
prevNoCurrentTags.current = noCurrentTags;
if (setSaveButtonDisabled === undefined) {
return;
}
setOptions({
headerRight: () => (
),
});
}, [setOptions, noCurrentTags, onPressAdd]);
const relationships = useSelector(userRelationshipsSelector);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const usersWithoutENSNames = React.useMemo(() => {
if (searchInputText === '') {
return {
[FriendListRouteName]: relationships.friends,
[BlockListRouteName]: relationships.blocked,
}[routeName];
}
const mergedUserInfos: { [id: string]: AccountUserInfo } = {};
for (const userInfo of filteredServerSearchResults) {
mergedUserInfos[userInfo.id] = userInfo;
}
for (const id of userStoreSearchResults) {
const { username, relationshipStatus } = userInfos[id];
if (username) {
mergedUserInfos[id] = { id, username, relationshipStatus };
}
}
const excludeUserIDsArray = currentTags
.map(userInfo => userInfo.id)
.concat(viewerID || []);
const excludeUserIDs = new Set(excludeUserIDsArray);
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);
}
}
return userSearchResults.concat(sortToEnd);
}, [
searchInputText,
relationships,
routeName,
viewerID,
currentTags,
filteredServerSearchResults,
userStoreSearchResults,
userInfos,
]);
const displayUsers = useENSNames(usersWithoutENSNames);
const listData = React.useMemo(() => {
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' });
}, [displayUsers, verticalBounds, searchInputText]);
const indicatorStyle = useIndicatorStyle();
const currentTagsWithENSNames = useENSNames(currentTags);
return (
Search:
);
}
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(updateRelationshipsActionTypes);
const MemoizedRelationshipList: React.ComponentType =
React.memo(RelationshipList);
MemoizedRelationshipList.displayName = 'RelationshipList';
export default MemoizedRelationshipList;
diff --git a/native/profile/user-relationship-tooltip-modal.react.js b/native/profile/user-relationship-tooltip-modal.react.js
index 6913f4fe2..3424b88e0 100644
--- a/native/profile/user-relationship-tooltip-modal.react.js
+++ b/native/profile/user-relationship-tooltip-modal.react.js
@@ -1,166 +1,172 @@
// @flow
import * as React from 'react';
import { TouchableOpacity } from 'react-native';
import {
updateRelationshipsActionTypes,
updateRelationships,
} from 'lib/actions/relationship-actions.js';
import { stringForUser } from 'lib/shared/user-utils.js';
import type { RelativeUserInfo } from 'lib/types/user-types.js';
import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js';
import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js';
import PencilIcon from '../components/pencil-icon.react.js';
import SWMansionIcon from '../components/swmansion-icon.react.js';
import { useColors } from '../themes/colors.js';
import {
createTooltip,
type TooltipParams,
type BaseTooltipProps,
type TooltipMenuProps,
type TooltipRoute,
} from '../tooltip/tooltip.react.js';
import type { UserProfileBottomSheetNavigationProp } from '../user-profile/user-profile-bottom-sheet-navigator.react.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
type Action = 'unfriend' | 'block' | 'unblock';
type TooltipButtonIcon = 'pencil' | 'menu';
export type UserRelationshipTooltipModalParams = TooltipParams<{
+tooltipButtonIcon: TooltipButtonIcon,
+relativeUserInfo: RelativeUserInfo,
}>;
type OnRemoveUserProps = {
...UserRelationshipTooltipModalParams,
+action: Action,
};
function useRelationshipAction(input: OnRemoveUserProps) {
const boundRemoveRelationships =
useLegacyAshoatKeyserverCall(updateRelationships);
const dispatchActionPromise = useDispatchActionPromise();
const userText = stringForUser(input.relativeUserInfo);
return React.useCallback(() => {
const callRemoveRelationships = async () => {
try {
return await boundRemoveRelationships({
action: input.action,
userIDs: [input.relativeUserInfo.id],
});
} catch (e) {
- Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], {
- cancelable: true,
- });
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ [{ text: 'OK' }],
+ {
+ cancelable: true,
+ },
+ );
throw e;
}
};
const onConfirmRemoveUser = () => {
const customKeyName = `${updateRelationshipsActionTypes.started}:${input.relativeUserInfo.id}`;
void dispatchActionPromise(
updateRelationshipsActionTypes,
callRemoveRelationships(),
{ customKeyName },
);
};
const action = {
unfriend: 'removal',
block: 'block',
unblock: 'unblock',
}[input.action];
const message = {
unfriend: `remove ${userText} from friends?`,
block: `block ${userText}`,
unblock: `unblock ${userText}?`,
}[input.action];
Alert.alert(
`Confirm ${action}`,
`Are you sure you want to ${message}`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'OK', onPress: onConfirmRemoveUser },
],
{ cancelable: true },
);
}, [boundRemoveRelationships, dispatchActionPromise, userText, input]);
}
function TooltipMenu(
props: TooltipMenuProps<'UserRelationshipTooltipModal'>,
): React.Node {
const { route, tooltipItem: TooltipItem } = props;
const onRemoveUser = useRelationshipAction({
...route.params,
action: 'unfriend',
});
const onBlockUser = useRelationshipAction({
...route.params,
action: 'block',
});
const onUnblockUser = useRelationshipAction({
...route.params,
action: 'unblock',
});
return (
<>
>
);
}
type Props = {
+navigation: UserProfileBottomSheetNavigationProp<'UserRelationshipTooltipModal'>,
+route: TooltipRoute<'UserRelationshipTooltipModal'>,
...
};
function UserRelationshipTooltipButton(props: Props): React.Node {
const { navigation, route } = props;
const { goBackOnce } = navigation;
const { tooltipButtonIcon } = route.params;
const colors = useColors();
const icon = React.useMemo(() => {
if (tooltipButtonIcon === 'pencil') {
return ;
}
return (
);
}, [colors.modalBackgroundLabel, tooltipButtonIcon]);
return {icon};
}
const UserRelationshipTooltipModal: React.ComponentType<
BaseTooltipProps<'UserRelationshipTooltipModal'>,
> = createTooltip<'UserRelationshipTooltipModal'>(
UserRelationshipTooltipButton,
TooltipMenu,
);
export default UserRelationshipTooltipModal;
diff --git a/native/user-profile/user-profile-relationship-button.react.js b/native/user-profile/user-profile-relationship-button.react.js
index ae5fb5a03..135aaac77 100644
--- a/native/user-profile/user-profile-relationship-button.react.js
+++ b/native/user-profile/user-profile-relationship-button.react.js
@@ -1,153 +1,158 @@
// @flow
import * as React from 'react';
import { Text, View } from 'react-native';
import { useRelationshipPrompt } from 'lib/hooks/relationship-prompt.js';
import type { SetState } from 'lib/types/hook-types.js';
import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
import { userRelationshipStatus } from 'lib/types/relationship-types.js';
import type { UserInfo } from 'lib/types/user-types';
import { userProfileActionButtonHeight } from './user-profile-constants.js';
import RelationshipButton from '../components/relationship-button.react.js';
import { useStyles } from '../themes/colors.js';
+import { UnknownErrorAlertDetails } from '../utils/alert-messages.js';
import Alert from '../utils/alert.js';
const onErrorCallback = () => {
- Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]);
+ Alert.alert(
+ UnknownErrorAlertDetails.title,
+ UnknownErrorAlertDetails.message,
+ [{ text: 'OK' }],
+ );
};
type Props = {
+threadInfo: ThreadInfo,
+pendingPersonalThreadUserInfo?: UserInfo,
+setUserProfileRelationshipButtonHeight: SetState,
};
function UserProfileRelationshipButton(props: Props): React.Node {
const {
threadInfo,
pendingPersonalThreadUserInfo,
setUserProfileRelationshipButtonHeight,
} = props;
const {
otherUserInfo,
callbacks: { friendUser, unfriendUser },
} = useRelationshipPrompt(
threadInfo,
onErrorCallback,
pendingPersonalThreadUserInfo,
);
React.useLayoutEffect(() => {
if (
!otherUserInfo ||
otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND
) {
setUserProfileRelationshipButtonHeight(0);
} else if (
otherUserInfo?.relationshipStatus ===
userRelationshipStatus.REQUEST_RECEIVED
) {
const incomingFriendRequestButtonsContainerHeight = 88;
setUserProfileRelationshipButtonHeight(
incomingFriendRequestButtonsContainerHeight,
);
} else {
setUserProfileRelationshipButtonHeight(userProfileActionButtonHeight);
}
}, [
otherUserInfo,
otherUserInfo?.relationshipStatus,
setUserProfileRelationshipButtonHeight,
]);
const styles = useStyles(unboundStyles);
const userProfileRelationshipButton = React.useMemo(() => {
if (
!otherUserInfo ||
!otherUserInfo.username ||
otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND
) {
return null;
}
if (
otherUserInfo.relationshipStatus ===
userRelationshipStatus.REQUEST_RECEIVED
) {
return (
Incoming friend request
);
}
if (
otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT
) {
return (
);
}
return (
);
}, [
friendUser,
otherUserInfo,
styles.acceptFriendRequestButtonContainer,
styles.incomingFriendRequestButtonsContainer,
styles.incomingFriendRequestContainer,
styles.incomingFriendRequestLabel,
styles.rejectFriendRequestButtonContainer,
styles.singleButtonContainer,
unfriendUser,
]);
return userProfileRelationshipButton;
}
const unboundStyles = {
singleButtonContainer: {
marginTop: 16,
},
incomingFriendRequestContainer: {
marginTop: 24,
},
incomingFriendRequestLabel: {
color: 'modalForegroundLabel',
},
incomingFriendRequestButtonsContainer: {
flexDirection: 'row',
marginTop: 8,
},
acceptFriendRequestButtonContainer: {
flex: 1,
marginRight: 4,
},
rejectFriendRequestButtonContainer: {
flex: 1,
marginLeft: 4,
},
};
export default UserProfileRelationshipButton;
diff --git a/native/utils/alert-messages.js b/native/utils/alert-messages.js
index c78484557..e3c388774 100644
--- a/native/utils/alert-messages.js
+++ b/native/utils/alert-messages.js
@@ -1,32 +1,42 @@
// @flow
import { Platform } from 'react-native';
type AlertDetails = {
+title: string,
+message: string,
};
const platformStore: string = Platform.select({
ios: 'App Store',
android: 'Play Store',
});
export const AppOutOfDateAlertDetails: AlertDetails = {
title: 'App out of date',
message:
'Your app version is pretty old, and the server doesn’t know how ' +
`to speak to it anymore. Please use the ${platformStore} to update!`,
};
export const UsernameReservedAlertDetails: AlertDetails = {
title: 'Username reserved',
message:
'This username is currently reserved. Please contact support@' +
'comm.app if you would like to claim this account.',
};
export const UsernameTakenAlertDetails: AlertDetails = {
title: 'Username taken',
message: 'An account with that username already exists',
};
+
+export const UserNotFoundAlertDetails: AlertDetails = {
+ title: 'Incorrect username or password',
+ message: "Either that user doesn't exist, or the password is incorrect",
+};
+
+export const UnknownErrorAlertDetails: AlertDetails = {
+ title: 'Unknown error',
+ message: 'Uhh... try again?',
+};