diff --git a/native/apps/apps-directory.react.js b/native/apps/apps-directory.react.js index ccce31f02..fb9190641 100644 --- a/native/apps/apps-directory.react.js +++ b/native/apps/apps-directory.react.js @@ -1,72 +1,78 @@ // @flow import * as React from 'react'; import { Text, FlatList, View } from 'react-native'; import { useSelector } from 'react-redux'; import AppListing from './app-listing.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; +import type { TabNavigationProp } from '../navigation/tab-navigator.react.js'; import { useStyles } from '../themes/colors.js'; const APP_DIRECTORY_DATA = [ { id: 'chat', alwaysEnabled: true, appName: 'Chat', appIcon: 'message-square', appCopy: 'Keep in touch with your community', }, { id: 'calendar', alwaysEnabled: false, appName: 'Calendar', appIcon: 'calendar', appCopy: 'Shared calendar for your community', }, ]; +type Props = { + +navigation: TabNavigationProp<'Apps'>, + +route: NavigationRoute<'Apps'>, +}; // eslint-disable-next-line no-unused-vars -function AppsDirectory(props: { ... }): React.Node { +function AppsDirectory(props: Props): React.Node { const styles = useStyles(unboundStyles); const enabledApps = useSelector(state => state.enabledApps); const renderAppCell = React.useCallback( ({ item }) => ( ), [enabledApps], ); const getItemID = React.useCallback(item => item.id, []); return ( Choose Apps ); } const unboundStyles = { view: { flex: 1, backgroundColor: 'panelBackground', padding: 18, }, title: { color: 'modalForegroundLabel', fontSize: 28, paddingVertical: 12, }, }; export default AppsDirectory; diff --git a/native/profile/add-keyserver.react.js b/native/profile/add-keyserver.react.js index 6e346aeab..e15fad37c 100644 --- a/native/profile/add-keyserver.react.js +++ b/native/profile/add-keyserver.react.js @@ -1,119 +1,125 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { View, Text } from 'react-native'; import { useDispatch } from 'react-redux'; import { addKeyserverActionType } from 'lib/actions/keyserver-actions.js'; import type { KeyserverInfo } from 'lib/types/keyserver-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; +import type { ProfileNavigationProp } from './profile.react.js'; import TextInput from '../components/text-input.react.js'; import HeaderRightTextButton from '../navigation/header-right-text-button.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useColors } from '../themes/colors.js'; +type Props = { + +navigation: ProfileNavigationProp<'AddKeyserver'>, + +route: NavigationRoute<'AddKeyserver'>, +}; // eslint-disable-next-line no-unused-vars -function AddKeyserver(props: { ... }): React.Node { +function AddKeyserver(props: Props): React.Node { const { goBack, setOptions } = useNavigation(); const dispatch = useDispatch(); const currentUserID = useSelector(state => state.currentUserInfo?.id); const { panelForegroundTertiaryLabel } = useColors(); const styles = useStyles(unboundStyles); const [urlInput, setUrlInput] = React.useState(''); const onPressSave = React.useCallback(() => { if (!currentUserID || !urlInput) { return; } const newKeyserverInfo: KeyserverInfo = { cookie: null, updatesCurrentAsOf: 0, urlPrefix: urlInput, connection: defaultConnectionInfo, lastCommunicatedPlatformDetails: null, deviceToken: null, }; dispatch({ type: addKeyserverActionType, payload: { keyserverAdminUserID: currentUserID, newKeyserverInfo, }, }); goBack(); }, [currentUserID, dispatch, goBack, urlInput]); React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => ( ), }); }, [onPressSave, setOptions, styles.header]); const onChangeText = React.useCallback( (text: string) => setUrlInput(text), [], ); return ( KEYSERVER URL ); } const unboundStyles = { container: { paddingTop: 8, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, inputContainer: { backgroundColor: 'panelForeground', flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 12, borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, }; export default AddKeyserver; diff --git a/native/profile/appearance-preferences.react.js b/native/profile/appearance-preferences.react.js index 70fd7f7e0..5651468c0 100644 --- a/native/profile/appearance-preferences.react.js +++ b/native/profile/appearance-preferences.react.js @@ -1,154 +1,163 @@ // @flow import * as React from 'react'; import { View, Text, Platform } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useUpdateThemePreference } from 'lib/hooks/theme.js'; import type { GlobalThemeInfo, GlobalThemePreference, } from 'lib/types/theme-types.js'; +import type { ProfileNavigationProp } from './profile.react.js'; import Button from '../components/button.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 { type Colors, useColors, useStyles } from '../themes/colors.js'; import { osCanTheme } from '../themes/theme-utils.js'; const CheckIcon = () => ( ); type OptionText = { themePreference: GlobalThemePreference, text: string, }; const optionTexts: OptionText[] = [ { themePreference: 'light', text: 'Light' }, { themePreference: 'dark', text: 'Dark' }, ]; if (osCanTheme) { optionTexts.push({ themePreference: 'system', text: 'Follow system preferences', }); } type Props = { + +navigation: ProfileNavigationProp<'AppearancePreferences'>, + +route: NavigationRoute<'AppearancePreferences'>, +globalThemeInfo: GlobalThemeInfo, +updateThemePreference: (themePreference: GlobalThemePreference) => mixed, +styles: typeof unboundStyles, +colors: Colors, - ... }; class AppearancePreferences extends React.PureComponent { render() { const { panelIosHighlightUnderlay: underlay } = this.props.colors; const options = []; for (let i = 0; i < optionTexts.length; i++) { const { themePreference, text } = optionTexts[i]; const icon = themePreference === this.props.globalThemeInfo.preference ? ( ) : null; options.push( , ); if (i + 1 < optionTexts.length) { options.push( , ); } } return ( APP THEME {options} ); } } const unboundStyles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, option: { color: 'panelForegroundLabel', fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, }; -const ConnectedAppearancePreferences: React.ComponentType<{ ... }> = - React.memo<{ ... }>(function ConnectedAppearancePreferences(props: { ... }) { +type BaseProps = { + +navigation: ProfileNavigationProp<'AppearancePreferences'>, + +route: NavigationRoute<'AppearancePreferences'>, +}; +const ConnectedAppearancePreferences: React.ComponentType = + React.memo(function ConnectedAppearancePreferences( + props: BaseProps, + ) { const globalThemeInfo = useSelector(state => state.globalThemeInfo); const updateThemePreference = useUpdateThemePreference(); const styles = useStyles(unboundStyles); const colors = useColors(); return ( ); }); export default ConnectedAppearancePreferences; diff --git a/native/profile/backup-menu.react.js b/native/profile/backup-menu.react.js index 0fe986cdc..316962aae 100644 --- a/native/profile/backup-menu.react.js +++ b/native/profile/backup-menu.react.js @@ -1,124 +1,130 @@ // @flow import * as React from 'react'; import { Alert, Switch, Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useDispatch } from 'react-redux'; import { getMessageForException } from 'lib/utils/errors.js'; import { entries } from 'lib/utils/objects.js'; +import type { ProfileNavigationProp } from './profile.react.js'; import { useClientBackup } from '../backup/use-client-backup.js'; import Button from '../components/button.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { setLocalSettingsActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, useStyles } from '../themes/colors.js'; +type Props = { + +navigation: ProfileNavigationProp<'BackupMenu'>, + +route: NavigationRoute<'BackupMenu'>, +}; // eslint-disable-next-line no-unused-vars -function BackupMenu(props: { ... }): React.Node { +function BackupMenu(props: Props): React.Node { const styles = useStyles(unboundStyles); const dispatch = useDispatch(); const colors = useColors(); const userStore = useSelector(state => state.userStore); const isBackupEnabled = useSelector( state => state.localSettings.isBackupEnabled, ); const { restoreBackupProtocol } = useClientBackup(); const testRestore = React.useCallback(async () => { let message; try { const result = await restoreBackupProtocol({ userStore }); message = entries(result) .map(([key, value]) => `${key}: ${String(value)}`) .join('\n'); } catch (e) { console.error(`Backup uploading error: ${e}`); message = `Backup restore error: ${String(getMessageForException(e))}`; } Alert.alert('Restore protocol result', message); }, [restoreBackupProtocol, userStore]); const onBackupToggled = React.useCallback( value => { dispatch({ type: setLocalSettingsActionType, payload: { isBackupEnabled: value }, }); }, [dispatch], ); return ( SETTINGS Toggle automatic backup ACTIONS ); } const unboundStyles = { scrollViewContentContainer: { paddingTop: 24, }, scrollView: { backgroundColor: 'panelBackground', }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, marginVertical: 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, submenuButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, alignItems: 'center', }, submenuText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 14, }, }; export default BackupMenu; diff --git a/native/profile/build-info.react.js b/native/profile/build-info.react.js index c7b4f8303..9ca6ea9b6 100644 --- a/native/profile/build-info.react.js +++ b/native/profile/build-info.react.js @@ -1,116 +1,122 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useIsCurrentUserStaff } from 'lib/shared/staff-utils.js'; +import type { ProfileNavigationProp } from './profile.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { persistConfig, codeVersion } from '../redux/persist.js'; import { StaffContext } from '../staff/staff-context.js'; import { useStyles } from '../themes/colors.js'; import { isStaffRelease, useStaffCanSee } from '../utils/staff-utils.js'; +type Props = { + +navigation: ProfileNavigationProp<'BuildInfo'>, + +route: NavigationRoute<'BuildInfo'>, +}; // eslint-disable-next-line no-unused-vars -function BuildInfo(props: { ... }): React.Node { +function BuildInfo(props: Props): React.Node { const isCurrentUserStaff = useIsCurrentUserStaff(); const { staffUserHasBeenLoggedIn } = React.useContext(StaffContext); const styles = useStyles(unboundStyles); const staffCanSee = useStaffCanSee(); let staffCanSeeRows; if (staffCanSee || staffUserHasBeenLoggedIn) { staffCanSeeRows = ( <> __DEV__ {__DEV__ ? 'TRUE' : 'FALSE'} Staff Release {isStaffRelease ? 'TRUE' : 'FALSE'} isCurrentUserStaff {isCurrentUserStaff ? 'TRUE' : 'FALSE'} hasStaffUserLoggedIn {staffUserHasBeenLoggedIn ? 'TRUE' : 'FALSE'} ); } return ( Code version {codeVersion} State version {persistConfig.version} {staffCanSeeRows} Thank you for using Comm! ); } const unboundStyles = { label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingRight: 12, }, releaseText: { color: 'orange', fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingHorizontal: 24, paddingVertical: 6, }, text: { color: 'panelForegroundLabel', fontSize: 16, }, thanksText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, textAlign: 'center', }, }; export default BuildInfo; diff --git a/native/profile/default-notifications-preferences.react.js b/native/profile/default-notifications-preferences.react.js index 482f3d4b1..75fe1c68a 100644 --- a/native/profile/default-notifications-preferences.react.js +++ b/native/profile/default-notifications-preferences.react.js @@ -1,213 +1,213 @@ // @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 { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/action-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 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} ); } type BaseProps = { - +navigation: ProfileNavigationProp<>, + +navigation: ProfileNavigationProp<'DefaultNotifications'>, +route: NavigationRoute<'DefaultNotifications'>, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, +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?', [{ text: 'OK', onPress: () => {} }], { cancelable: false }, ); } return { [userSettingsTypes.DEFAULT_NOTIFICATIONS]: data, }; } selectNotificationSetting = (data: NotificationTypes) => { const { dispatchActionPromise } = this.props; dispatchActionPromise( setUserSettingsActionTypes, this.updatedDefaultNotifications(data), ); }; selectAllNotifications = () => { this.selectNotificationSetting(notificationTypes.FOCUSED); }; selectBackgroundNotifications = () => { this.selectNotificationSetting(notificationTypes.BACKGROUND); }; selectNoneNotifications = () => { this.selectNotificationSetting(notificationTypes.BADGE_ONLY); }; render() { const { styles, selectedDefaultNotification } = this.props; return ( NOTIFICATIONS ); } } 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, }, }; 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/delete-account.react.js b/native/profile/delete-account.react.js index 340268014..b5c3ff020 100644 --- a/native/profile/delete-account.react.js +++ b/native/profile/delete-account.react.js @@ -1,116 +1,122 @@ // @flow import * as React from 'react'; import { Text, View, ActivityIndicator } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { deleteAccountActionTypes, useDeleteAccount, } from 'lib/actions/user-actions.js'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; +import type { ProfileNavigationProp } from './profile.react.js'; import { deleteNativeCredentialsFor } from '../account/native-credentials.js'; import Button from '../components/button.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const loadingStatusSelector = createLoadingStatusSelector( deleteAccountActionTypes, ); -const DeleteAccount: React.ComponentType<{ ... }> = React.memo<{ ... }>( +type Props = { + +navigation: ProfileNavigationProp<'DeleteAccount'>, + +route: NavigationRoute<'DeleteAccount'>, +}; +const DeleteAccount: React.ComponentType = React.memo( function DeleteAccount() { const loadingStatus = useSelector(loadingStatusSelector); const preRequestUserState = useSelector(preRequestUserStateSelector); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteAccount = useDeleteAccount(); const buttonContent = loadingStatus === 'loading' ? ( ) : ( Delete account ); const noWayToReverseThisStyles = React.useMemo( () => [styles.warningText, styles.lastWarningText], [styles.warningText, styles.lastWarningText], ); const deleteAction = React.useCallback(async () => { try { await deleteNativeCredentialsFor(); return await callDeleteAccount(preRequestUserState); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); throw e; } }, [callDeleteAccount, preRequestUserState]); const onDelete = React.useCallback(() => { dispatchActionPromise(deleteAccountActionTypes, deleteAction()); }, [dispatchActionPromise, deleteAction]); return ( Your account will be permanently deleted. There is no way to reverse this. ); }, ); const unboundStyles = { deleteButton: { backgroundColor: 'vibrantRedButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, lastWarningText: { marginBottom: 24, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginHorizontal: 24, textAlign: 'center', }, }; export default DeleteAccount; diff --git a/native/profile/emoji-user-avatar-creation.react.js b/native/profile/emoji-user-avatar-creation.react.js index 0a7fe427e..fe1d13c59 100644 --- a/native/profile/emoji-user-avatar-creation.react.js +++ b/native/profile/emoji-user-avatar-creation.react.js @@ -1,45 +1,51 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; import { savedEmojiAvatarSelectorForCurrentUser } from 'lib/selectors/user-selectors.js'; +import type { ProfileNavigationProp } from './profile.react.js'; import { useNativeSetUserAvatar } from '../avatars/avatar-hooks.js'; import EmojiAvatarCreation from '../avatars/emoji-avatar-creation.react.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; +type Props = { + +navigation: ProfileNavigationProp<'EmojiUserAvatarCreation'>, + +route: NavigationRoute<'EmojiUserAvatarCreation'>, +}; // eslint-disable-next-line no-unused-vars -function EmojiUserAvatarCreation(props: { ... }): React.Node { +function EmojiUserAvatarCreation(props: Props): React.Node { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext should be set'); const { userAvatarSaveInProgress } = editUserAvatarContext; const nativeSetUserAvatar = useNativeSetUserAvatar(); const setAvatar = React.useCallback( async avatarRequest => { const result = await nativeSetUserAvatar(avatarRequest); displayActionResultModal('Avatar updated!'); return result; }, [nativeSetUserAvatar], ); const savedEmojiAvatarFunc = useSelector( savedEmojiAvatarSelectorForCurrentUser, ); return ( ); } export default EmojiUserAvatarCreation; diff --git a/native/profile/keyserver-selection-list.react.js b/native/profile/keyserver-selection-list.react.js index 8ea6e35bc..b27bf253f 100644 --- a/native/profile/keyserver-selection-list.react.js +++ b/native/profile/keyserver-selection-list.react.js @@ -1,121 +1,127 @@ // @flow import * as React from 'react'; import { Text, View, FlatList } from 'react-native'; import { selectedKeyserversSelector } from 'lib/selectors/keyserver-selectors.js'; import type { SelectedKeyserverInfo } from 'lib/types/keyserver-types.js'; import KeyserverSelectionListItem from './keyserver-selection-list-item.react.js'; +import type { ProfileNavigationProp } from './profile.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; function keyExtractor(item: SelectedKeyserverInfo) { return `${item.keyserverAdminUserInfo.id}${item.keyserverInfo.urlPrefix}`; } function renderKeyserverListItem({ item }) { return ; } +type Props = { + +navigation: ProfileNavigationProp<'KeyserverSelectionList'>, + +route: NavigationRoute<'KeyserverSelectionList'>, +}; // eslint-disable-next-line no-unused-vars -function KeyserverSelectionList(props: { ... }): React.Node { +function KeyserverSelectionList(props: Props): React.Node { const styles = useStyles(unboundStyles); const selectedKeyserverInfos: $ReadOnlyArray = useSelector(selectedKeyserversSelector); const keyserverListSeparatorComponent = React.useCallback( () => , [styles.separator], ); const keyserverSelectionList = React.useMemo( () => ( CONNECTED KEYSERVERS ), [ keyserverListSeparatorComponent, selectedKeyserverInfos, styles.container, styles.header, styles.keyserverListContentContainer, ], ); return keyserverSelectionList; } const unboundStyles = { container: { flex: 1, backgroundColor: 'panelBackground', paddingTop: 24, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, keyserverListContentContainer: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, keyserverListItemContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 24, paddingVertical: 10, }, separator: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 16, }, onlineIndicatorOuter: { justifyContent: 'center', alignItems: 'center', backgroundColor: 'greenIndicatorOuter', width: 18, height: 18, borderRadius: 9, }, onlineIndicatorInner: { backgroundColor: 'greenIndicatorInner', width: 9, height: 9, borderRadius: 4.5, }, offlineIndicatorOuter: { justifyContent: 'center', alignItems: 'center', backgroundColor: 'redIndicatorOuter', width: 18, height: 18, borderRadius: 9, }, offlineIndicatorInner: { backgroundColor: 'redIndicatorInner', width: 9, height: 9, borderRadius: 4.5, }, }; export default KeyserverSelectionList; diff --git a/native/profile/linked-devices.react.js b/native/profile/linked-devices.react.js index 4613330e8..4e43464da 100644 --- a/native/profile/linked-devices.react.js +++ b/native/profile/linked-devices.react.js @@ -1,9 +1,17 @@ // @flow + import * as React from 'react'; +import type { ProfileNavigationProp } from './profile.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; + +type Props = { + +navigation: ProfileNavigationProp<'LinkedDevices'>, + +route: NavigationRoute<'LinkedDevices'>, +}; // eslint-disable-next-line no-unused-vars -function LinkedDevices(props: { ... }): React.Node { +function LinkedDevices(props: Props): React.Node { return null; } export default LinkedDevices; diff --git a/native/profile/privacy-preferences.react.js b/native/profile/privacy-preferences.react.js index a1def1a81..854ace1e4 100644 --- a/native/profile/privacy-preferences.react.js +++ b/native/profile/privacy-preferences.react.js @@ -1,75 +1,81 @@ // @flow import * as React from 'react'; import { View, Text } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; +import type { ProfileNavigationProp } from './profile.react.js'; import ToggleReport from './toggle-report.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; +type Props = { + +navigation: ProfileNavigationProp<'PrivacyPreferences'>, + +route: NavigationRoute<'PrivacyPreferences'>, +}; // eslint-disable-next-line no-unused-vars -function PrivacyPreferences(props: { ... }): React.Node { +function PrivacyPreferences(props: Props): React.Node { const styles = useStyles(unboundStyles); return ( REPORTS Toggle crash reports Toggle media reports Toggle inconsistency reports ); } const unboundStyles = { scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, marginVertical: 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, submenuButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, alignItems: 'center', }, submenuText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, }, }; export default PrivacyPreferences; diff --git a/native/profile/relationship-list.react.js b/native/profile/relationship-list.react.js index 917731753..cc8ebffbf 100644 --- a/native/profile/relationship-list.react.js +++ b/native/profile/relationship-list.react.js @@ -1,499 +1,499 @@ // @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 { searchUsersActionTypes, searchUsers, } from 'lib/actions/user-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors.js'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors.js'; import { userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types.js'; import type { GlobalAccountUserInfo, AccountUserInfo, } from 'lib/types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-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 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<>, + +navigation: ProfileNavigationProp<'FriendList' | 'BlockList'>, +route: NavigationRoute<'FriendList' | 'BlockList'>, }; function RelationshipList(props: Props): React.Node { const callSearchUsers = useServerCall(searchUsers); const userInfos = useSelector(state => state.userStore.userInfos); const searchUsersOnServer = React.useCallback( async (usernamePrefix: string) => { if (usernamePrefix.length === 0) { return []; } const userInfosResult = await callSearchUsers(usernamePrefix); return userInfosResult.userInfos; }, [callSearchUsers], ); const [searchInputText, setSearchInputText] = React.useState(''); const [userStoreSearchResults, setUserStoreSearchResults] = React.useState< $ReadOnlySet, >(new Set()); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const { route } = props; const routeName = route.name; const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); const onChangeSearchText = React.useCallback( async (searchText: string) => { setSearchInputText(searchText); const excludeStatuses = { [FriendListRouteName]: [ userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], [BlockListRouteName]: [], }[routeName]; const results = userStoreSearchIndex .getSearchResults(searchText) .filter(userID => { const relationship = userInfos[userID].relationshipStatus; return !excludeStatuses.includes(relationship); }); setUserStoreSearchResults(new Set(results)); const searchResultsFromServer = await searchUsersOnServer(searchText); const filteredServerSearchResults = searchResultsFromServer.filter( searchUserInfo => { const userInfo = userInfos[searchUserInfo.id]; return ( !userInfo || !excludeStatuses.includes(userInfo.relationshipStatus) ); }, ); setServerSearchResults(filteredServerSearchResults); }, [routeName, userStoreSearchIndex, userInfos, searchUsersOnServer], ); 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 = useServerCall(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?', [{ 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; } 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({ // eslint-disable-next-line react/display-name 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 serverSearchResults) { 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, serverSearchResults, 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(searchUsersActionTypes); registerFetchKey(updateRelationshipsActionTypes); const MemoizedRelationshipList: React.ComponentType = React.memo(RelationshipList); MemoizedRelationshipList.displayName = 'RelationshipList'; export default MemoizedRelationshipList; diff --git a/native/profile/secondary-device-qr-code-scanner.react.js b/native/profile/secondary-device-qr-code-scanner.react.js index bee7a8b14..bdb9b68e3 100644 --- a/native/profile/secondary-device-qr-code-scanner.react.js +++ b/native/profile/secondary-device-qr-code-scanner.react.js @@ -1,126 +1,132 @@ // @flow import { useNavigation } from '@react-navigation/native'; import { BarCodeScanner, type BarCodeEvent } from 'expo-barcode-scanner'; import * as React from 'react'; import { View } from 'react-native'; import { parseDataFromDeepLink } from 'lib/facts/links.js'; +import type { ProfileNavigationProp } from './profile.react.js'; +import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const barCodeTypes = [BarCodeScanner.Constants.BarCodeType.qr]; +type Props = { + +navigation: ProfileNavigationProp<'SecondaryDeviceQRCodeScanner'>, + +route: NavigationRoute<'SecondaryDeviceQRCodeScanner'>, +}; // eslint-disable-next-line no-unused-vars -function SecondaryDeviceQRCodeScanner(props: { ... }): React.Node { +function SecondaryDeviceQRCodeScanner(props: Props): React.Node { const [hasPermission, setHasPermission] = React.useState(null); const [scanned, setScanned] = React.useState(false); const styles = useStyles(unboundStyles); const navigation = useNavigation(); React.useEffect(() => { (async () => { const { status } = await BarCodeScanner.requestPermissionsAsync(); setHasPermission(status === 'granted'); if (status !== 'granted') { Alert.alert( 'No access to camera', 'Please allow Comm to access your camera in order to scan the QR code.', [{ text: 'OK' }], ); navigation.goBack(); } })(); }, [navigation]); const onConnect = React.useCallback((barCodeEvent: BarCodeEvent) => { const { data } = barCodeEvent; const parsedData = parseDataFromDeepLink(data); const keysMatch = parsedData?.data?.keys; if (!parsedData || !keysMatch) { Alert.alert( 'Scan failed', 'QR code does not contain a valid pair of keys.', [{ text: 'OK' }], ); return; } const keys = JSON.parse(decodeURIComponent(keysMatch)); Alert.alert( 'Scan successful', `QR code contains the following keys: ${JSON.stringify(keys)}`, [{ text: 'OK' }], ); }, []); const onCancelScan = React.useCallback(() => setScanned(false), []); const handleBarCodeScanned = React.useCallback( (barCodeEvent: BarCodeEvent) => { setScanned(true); Alert.alert( 'Connect with this device?', 'Are you sure you want to allow this device to log in to your account?', [ { text: 'Cancel', style: 'cancel', onPress: onCancelScan, }, { text: 'Connect', onPress: () => onConnect(barCodeEvent), }, ], { cancelable: false }, ); }, [onCancelScan, onConnect], ); if (hasPermission === null) { return ; } // Note: According to the BarCodeScanner Expo docs, we should adhere to two // guidances when using the BarCodeScanner: // 1. We should specify the potential barCodeTypes we want to scan for to // minimize battery usage. // 2. We should set the onBarCodeScanned callback to undefined if it scanned // in order to 'pause' the scanner from continuing to scan while we // process the data from the scan. // See: https://docs.expo.io/versions/latest/sdk/bar-code-scanner return ( ); } const unboundStyles = { container: { flex: 1, flexDirection: 'column', justifyContent: 'center', }, scanner: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, }; export default SecondaryDeviceQRCodeScanner; diff --git a/native/profile/tunnelbroker-menu.react.js b/native/profile/tunnelbroker-menu.react.js index 0bc731545..8fbb83648 100644 --- a/native/profile/tunnelbroker-menu.react.js +++ b/native/profile/tunnelbroker-menu.react.js @@ -1,150 +1,156 @@ // @flow import * as React from 'react'; import { useState } from 'react'; import { Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useTunnelbroker } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { TunnelbrokerMessage } from 'lib/types/tunnelbroker/messages.js'; +import type { ProfileNavigationProp } from './profile.react.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 { useColors, useStyles } from '../themes/colors.js'; +type Props = { + +navigation: ProfileNavigationProp<'TunnelbrokerMenu'>, + +route: NavigationRoute<'TunnelbrokerMenu'>, +}; // eslint-disable-next-line no-unused-vars -function TunnelbrokerMenu(props: { ... }): React.Node { +function TunnelbrokerMenu(props: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const { connected, addListener, sendMessage, removeListener } = useTunnelbroker(); const [messages, setMessages] = useState([]); const [recipient, setRecipient] = useState(''); const [message, setMessage] = useState(''); const listener = React.useCallback((msg: TunnelbrokerMessage) => { setMessages(prev => [...prev, msg]); }, []); React.useEffect(() => { addListener(listener); return () => removeListener(listener); }, [addListener, listener, removeListener]); const onSubmit = React.useCallback(async () => { try { await sendMessage({ deviceID: recipient, payload: message }); } catch (e) { console.error(e.message); } }, [message, recipient, sendMessage]); return ( INFO Connected {connected.toString()} SEND MESSAGE Recipient Message MESSAGES {messages.map(msg => ( {JSON.stringify(msg)} ))} ); } const unboundStyles = { scrollViewContentContainer: { paddingTop: 24, }, scrollView: { backgroundColor: 'panelBackground', }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, marginVertical: 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, submenuButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, alignItems: 'center', }, submenuText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, }, text: { color: 'panelForegroundLabel', fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 14, }, textInput: { color: 'modalBackgroundLabel', flex: 1, fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; export default TunnelbrokerMenu;