diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index 60302d1b3..2d957d62d 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,293 +1,303 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import bots from '../facts/bots.js'; import { getAvatarForUser, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { getSingleOtherUser } from '../shared/thread-utils.js'; import { type AuxUserInfos, type AuxUserInfo, } from '../types/aux-user-types.js'; import type { ClientEmojiAvatar } from '../types/avatar-types'; import type { RelativeMemberInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { RawThreadInfos } from '../types/thread-types.js'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, CurrentUserInfo, } from '../types/user-types.js'; import { entries, values } from '../utils/objects.js'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: $ReadOnlyArray, viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos: RelativeUserInfo[] = []; for (const userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; const relativeUserInfo = { id: userID, username, isViewer: userID === viewerID, }; if (userID === viewerID) { relativeUserInfos.unshift(relativeUserInfo); } else { relativeUserInfos.push(relativeUserInfo); } } return relativeUserInfos; } function getRelativeMemberInfos( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray { const relativeMemberInfos: RelativeMemberInfo[] = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { if (!memberInfo.role) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; const { id, role, isSender, minimallyEncoded } = memberInfo; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ id, role, isSender, minimallyEncoded, username, isViewer: true, }); } else { relativeMemberInfos.push({ id, role, isSender, minimallyEncoded, username, isViewer: false, }); } } return relativeMemberInfos; } const emptyArray: $ReadOnlyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, getRelativeMemberInfos, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: (state: BaseAppState<>) => { [id: string]: AccountUserInfo, } = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); const isLoggedIn = (state: BaseAppState<>): boolean => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const isLoggedInToKeyserver: ( keyserverID: ?string, ) => (state: BaseAppState<>) => boolean = _memoize( (keyserverID: ?string) => (state: BaseAppState<>) => { if (!keyserverID) { return false; } const cookie = state.keyserverStore.keyserverInfos[keyserverID]?.cookie; return !!cookie && cookie.startsWith('user='); }, ); const usersWithPersonalThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlySet = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.threadStore.threadInfos, (viewerID: ?string, threadInfos: RawThreadInfos) => { const personalThreadMembers = new Set(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; if ( thread.type !== threadTypes.GENESIS_PERSONAL || !thread.members.find(member => member.id === viewerID) ) { continue; } const otherMemberID = getSingleOtherUser(thread, viewerID); if (otherMemberID) { personalThreadMembers.add(otherMemberID); } } return personalThreadMembers; }, ); const savedEmojiAvatarSelectorForCurrentUser: ( state: BaseAppState<>, ) => () => ClientEmojiAvatar = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo, (currentUser: ?CurrentUserInfo) => { return () => { let userAvatar = getAvatarForUser(currentUser); if (userAvatar.type !== 'emoji') { userAvatar = getRandomDefaultEmojiAvatar(); } return userAvatar; }; }, ); const getRelativeUserIDs: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (userInfos: UserInfos): $ReadOnlyArray => Object.keys(userInfos), ); const usersWithMissingDeviceListSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( getRelativeUserIDs, (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, ( userIDs: $ReadOnlyArray, auxUserInfos: AuxUserInfos, ): $ReadOnlyArray => userIDs.filter( userID => (!auxUserInfos[userID] || !auxUserInfos[userID].deviceList) && userID !== bots.commbot.userID, ), ); // Foreign Peer Devices are all devices of users we are aware of, // but not our own devices. const getForeignPeerDevices: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( auxUserInfos: AuxUserInfos, currentUserID: ?string, ): $ReadOnlyArray => entries(auxUserInfos) .map(([userID, auxUserInfo]: [string, AuxUserInfo]) => userID !== currentUserID && auxUserInfo.deviceList?.devices ? auxUserInfo.deviceList.devices : [], ) .flat(), ); const getAllPeerDevices: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (auxUserInfos: AuxUserInfos): $ReadOnlyArray => values(auxUserInfos) .map( (auxUserInfo: AuxUserInfo) => auxUserInfo.deviceList?.devices ?? [], ) .flat(), ); const getAllPeerUserIDAndDeviceIDs: ( state: BaseAppState<>, ) => $ReadOnlyArray<{ +userID: string, +deviceID: string }> = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, ( auxUserInfos: AuxUserInfos, ): $ReadOnlyArray<{ +userID: string, +deviceID: string }> => entries(auxUserInfos).flatMap( ([userID, { deviceList }]: [string, AuxUserInfo]) => deviceList?.devices.map(deviceID => ({ userID, deviceID, })) ?? [], ), ); +const getOwnPrimaryDeviceID: (state: BaseAppState<>) => ?string = + createSelector( + (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, + (state: BaseAppState<>) => + state.currentUserInfo && state.currentUserInfo.id, + (auxUserInfos: AuxUserInfos, currentUserID: ?string): ?string => + currentUserID && auxUserInfos[currentUserID]?.deviceList?.devices[0], + ); + export { userIDsToRelativeUserInfos, getRelativeMemberInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, isLoggedIn, isLoggedInToKeyserver, usersWithPersonalThreadSelector, savedEmojiAvatarSelectorForCurrentUser, getRelativeUserIDs, usersWithMissingDeviceListSelector, getForeignPeerDevices, getAllPeerDevices, getAllPeerUserIDAndDeviceIDs, + getOwnPrimaryDeviceID, }; diff --git a/native/profile/profile-screen.react.js b/native/profile/profile-screen.react.js index e0f527404..69c9934a3 100644 --- a/native/profile/profile-screen.react.js +++ b/native/profile/profile-screen.react.js @@ -1,566 +1,564 @@ // @flow import * as React from 'react'; import { View, Text, Platform, ScrollView } from 'react-native'; import { logOutActionTypes, useLogOut, usePrimaryDeviceLogOut, useSecondaryDeviceLogOut, } from 'lib/actions/user-actions.js'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; +import { getOwnPrimaryDeviceID } from 'lib/selectors/user-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import { type CurrentUserInfo } from 'lib/types/user-types.js'; +import { getContentSigningKey } from 'lib/utils/crypto-utils.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import { deleteNativeCredentialsFor } from '../account/native-credentials.js'; import EditUserAvatar from '../avatars/edit-user-avatar.react.js'; import Action from '../components/action-row.react.js'; import Button from '../components/button.react.js'; import EditSettingButton from '../components/edit-setting-button.react.js'; import SingleLine from '../components/single-line.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { EditPasswordRouteName, DeleteAccountRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, FriendListRouteName, BlockListRouteName, PrivacyPreferencesRouteName, DefaultNotificationsPreferencesRouteName, LinkedDevicesRouteName, BackupMenuRouteName, KeyserverSelectionListRouteName, TunnelbrokerMenuRouteName, FarcasterAccountSettingsRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type ProfileRowProps = { +content: string, +onPress: () => void, +danger?: boolean, }; function ProfileRow(props: ProfileRowProps): React.Node { const { content, onPress, danger } = props; return ( ); } const unboundStyles = { avatarSection: { alignItems: 'center', paddingVertical: 16, }, container: { flex: 1, }, content: { flex: 1, }, deleteAccountButton: { paddingHorizontal: 24, paddingVertical: 12, }, editPasswordButton: { paddingTop: Platform.OS === 'android' ? 3 : 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingRight: 12, }, loggedInLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, logOutText: { color: 'link', fontSize: 16, paddingLeft: 6, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, paddedRow: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 1, }, unpaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, }, username: { color: 'panelForegroundLabel', flex: 1, }, value: { color: 'panelForegroundLabel', fontSize: 16, textAlign: 'right', }, }; type BaseProps = { +navigation: ProfileNavigationProp<'ProfileScreen'>, +route: NavigationRoute<'ProfileScreen'>, }; type Props = { ...BaseProps, +currentUserInfo: ?CurrentUserInfo, + +primaryDeviceID: ?string, +logOutLoading: boolean, +colors: Colors, +styles: $ReadOnly, +dispatchActionPromise: DispatchActionPromise, +logOut: () => Promise, +logOutPrimaryDevice: () => Promise, +logOutSecondaryDevice: () => Promise, +staffCanSee: boolean, +stringForUser: ?string, +isAccountWithPassword: boolean, }; class ProfileScreen extends React.PureComponent { get loggedOutOrLoggingOut(): boolean { return ( !this.props.currentUserInfo || this.props.currentUserInfo.anonymous || this.props.logOutLoading ); } render(): React.Node { let developerTools, defaultNotifications, keyserverSelection, tunnelbrokerMenu; const { staffCanSee } = this.props; if (staffCanSee) { developerTools = ( ); defaultNotifications = ( ); keyserverSelection = ( ); tunnelbrokerMenu = ( ); } let backupMenu; if (staffCanSee) { backupMenu = ( ); } let passwordEditionUI; if (accountHasPassword(this.props.currentUserInfo)) { passwordEditionUI = ( Password •••••••••••••••• ); } let linkedDevices; if (__DEV__) { linkedDevices = ( ); } let farcasterAccountSettings; if (usingCommServicesAccessToken || __DEV__) { farcasterAccountSettings = ( ); } let experimentalLogoutActions; if (__DEV__) { experimentalLogoutActions = ( <> - ); } return ( USER AVATAR ACCOUNT Logged in as {this.props.stringForUser} {passwordEditionUI} PREFERENCES {defaultNotifications} {backupMenu} {tunnelbrokerMenu} {farcasterAccountSettings} {linkedDevices} {keyserverSelection} {developerTools} {experimentalLogoutActions} ); } onPressLogOut = () => { if (this.loggedOutOrLoggingOut) { return; } if (!this.props.isAccountWithPassword) { Alert.alert( 'Log out', 'Are you sure you want to log out?', [ { text: 'No', style: 'cancel' }, { text: 'Yes', onPress: this.logOutWithoutDeletingNativeCredentialsWrapper, style: 'destructive', }, ], { cancelable: true }, ); return; } const alertTitle = Platform.OS === 'ios' ? 'Keep Login Info in Keychain' : 'Keep Login Info'; const alertDescription = 'We will automatically fill out log-in forms with your credentials ' + 'in the app.'; Alert.alert( alertTitle, alertDescription, [ { text: 'Cancel', style: 'cancel' }, { text: 'Keep', onPress: this.logOutWithoutDeletingNativeCredentialsWrapper, }, { text: 'Remove', onPress: this.logOutAndDeleteNativeCredentialsWrapper, style: 'destructive', }, ], { cancelable: true }, ); }; - onPressPrimaryDeviceLogout = () => { - if (this.loggedOutOrLoggingOut) { - return; - } - // TODO: Add check for primary device - Alert.alert( - 'Log out primary device', - 'Are you sure you want to log out all devices?', - [ - { text: 'No', style: 'cancel' }, - { - text: 'Yes', - onPress: this.logOutPrimaryDevice, - style: 'destructive', - }, - ], - { cancelable: true }, - ); - }; + onPressNewLogout = () => { + void (async () => { + if (this.loggedOutOrLoggingOut) { + return; + } + const { primaryDeviceID } = this.props; + const currentDeviceID = await getContentSigningKey(); + const isPrimaryDevice = currentDeviceID === primaryDeviceID; + + let alertTitle, alertMessage, onPressAction; + if (isPrimaryDevice) { + alertTitle = 'Log out all devices?'; + alertMessage = + 'This device is your primary device, ' + + 'so logging out will cause all of your other devices to log out too.'; + onPressAction = this.logOutPrimaryDevice; + } else { + alertTitle = 'Log out?'; + alertMessage = 'Are you sure you want to log out of this device?'; + onPressAction = this.logOutSecondaryDevice; + } - onPressSecondaryDeviceLogout = () => { - if (this.loggedOutOrLoggingOut) { - return; - } - // TODO: Add check for secondary device - Alert.alert( - 'Log out secondary device', - 'Are you sure you want to log out this device?', - [ - { text: 'No', style: 'cancel' }, - { - text: 'Yes', - onPress: this.logOutSecondaryDevice, - style: 'destructive', - }, - ], - { cancelable: true }, - ); + Alert.alert( + alertTitle, + alertMessage, + [ + { text: 'No', style: 'cancel' }, + { + text: 'Yes', + onPress: onPressAction, + style: 'destructive', + }, + ], + { cancelable: true }, + ); + })(); }; logOutWithoutDeletingNativeCredentialsWrapper = () => { if (this.loggedOutOrLoggingOut) { return; } this.logOut(); }; logOutAndDeleteNativeCredentialsWrapper = async () => { if (this.loggedOutOrLoggingOut) { return; } await this.deleteNativeCredentials(); this.logOut(); }; logOut() { void this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(), ); } logOutPrimaryDevice = async () => { if (this.loggedOutOrLoggingOut) { return; } void this.props.dispatchActionPromise( logOutActionTypes, this.props.logOutPrimaryDevice(), ); }; logOutSecondaryDevice = async () => { if (this.loggedOutOrLoggingOut) { return; } void this.props.dispatchActionPromise( logOutActionTypes, this.props.logOutSecondaryDevice(), ); }; async deleteNativeCredentials() { await deleteNativeCredentialsFor(); } onPressEditPassword = () => { this.props.navigation.navigate({ name: EditPasswordRouteName }); }; onPressDeleteAccount = () => { this.props.navigation.navigate({ name: DeleteAccountRouteName }); }; onPressFaracsterAccount = () => { this.props.navigation.navigate({ name: FarcasterAccountSettingsRouteName }); }; onPressDevices = () => { this.props.navigation.navigate({ name: LinkedDevicesRouteName }); }; onPressBuildInfo = () => { this.props.navigation.navigate({ name: BuildInfoRouteName }); }; onPressDevTools = () => { this.props.navigation.navigate({ name: DevToolsRouteName }); }; onPressAppearance = () => { this.props.navigation.navigate({ name: AppearancePreferencesRouteName }); }; onPressPrivacy = () => { this.props.navigation.navigate({ name: PrivacyPreferencesRouteName }); }; onPressDefaultNotifications = () => { this.props.navigation.navigate({ name: DefaultNotificationsPreferencesRouteName, }); }; onPressFriendList = () => { this.props.navigation.navigate({ name: FriendListRouteName }); }; onPressBlockList = () => { this.props.navigation.navigate({ name: BlockListRouteName }); }; onPressBackupMenu = () => { this.props.navigation.navigate({ name: BackupMenuRouteName }); }; onPressTunnelbrokerMenu = () => { this.props.navigation.navigate({ name: TunnelbrokerMenuRouteName }); }; onPressKeyserverSelection = () => { this.props.navigation.navigate({ name: KeyserverSelectionListRouteName }); }; } const logOutLoadingStatusSelector = createLoadingStatusSelector(logOutActionTypes); const ConnectedProfileScreen: React.ComponentType = React.memo(function ConnectedProfileScreen(props: BaseProps) { const currentUserInfo = useSelector(state => state.currentUserInfo); + const primaryDeviceID = useSelector(getOwnPrimaryDeviceID); const logOutLoading = useSelector(logOutLoadingStatusSelector) === 'loading'; const colors = useColors(); const styles = useStyles(unboundStyles); const callLogOut = useLogOut(); const callPrimaryDeviceLogOut = usePrimaryDeviceLogOut(); const callSecondaryDeviceLogOut = useSecondaryDeviceLogOut(); const dispatchActionPromise = useDispatchActionPromise(); const staffCanSee = useStaffCanSee(); const stringForUser = useStringForUser(currentUserInfo); const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); return ( ); }); export default ConnectedProfileScreen;