diff --git a/native/chat/message-reactions-modal.react.js b/native/chat/message-reactions-modal.react.js index 096fecac5..8d3bfa9ae 100644 --- a/native/chat/message-reactions-modal.react.js +++ b/native/chat/message-reactions-modal.react.js @@ -1,158 +1,167 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { View, Text, FlatList, TouchableHighlight } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { getAvatarForUser } from 'lib/shared/avatar-utils.js'; import { useMessageReactionsList } from 'lib/shared/reaction-utils.js'; import Avatar from '../components/avatar.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 { useColors, useStyles } from '../themes/colors.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; export type MessageReactionsModalParams = { +reactions: ReactionInfo, }; type Props = { +navigation: RootNavigationProp<'MessageReactionsModal'>, +route: NavigationRoute<'MessageReactionsModal'>, }; function MessageReactionsModal(props: Props): React.Node { const { reactions } = props.route.params; const styles = useStyles(unboundStyles); const colors = useColors(); const navigation = useNavigation(); const modalSafeAreaEdges = React.useMemo(() => ['top'], []); const modalContainerSafeAreaEdges = React.useMemo(() => ['bottom'], []); const close = React.useCallback(() => navigation.goBack(), [navigation]); const reactionsListData = useMessageReactionsList(reactions); + const shouldRenderAvatars = useShouldRenderAvatars(); + const marginLeftStyle = React.useMemo( + () => ({ + marginLeft: shouldRenderAvatars ? 8 : 0, + }), + [shouldRenderAvatars], + ); + const renderItem = React.useCallback( ({ item }) => { const avatarInfo = getAvatarForUser(item); return ( - + {item.username} {item.reaction} ); }, [ + marginLeftStyle, styles.reactionsListReactionText, styles.reactionsListRowContainer, styles.reactionsListUserInfoContainer, styles.reactionsListUsernameText, ], ); const itemSeperator = React.useCallback(() => { return ; }, [styles.reactionsListItemSeperator]); return ( Reactions ); } const unboundStyles = { modalStyle: { // we need to set each margin property explicitly to override marginLeft: 0, marginRight: 0, marginBottom: 0, marginTop: 0, justifyContent: 'flex-end', flex: 0, borderWidth: 0, borderTopLeftRadius: 10, borderTopRightRadius: 10, }, modalContainerStyle: { justifyContent: 'flex-end', }, modalContentContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, marginTop: 8, }, reactionsListContentContainer: { paddingBottom: 16, }, reactionsListTitleText: { color: 'modalForegroundLabel', fontSize: 18, }, reactionsListRowContainer: { flexDirection: 'row', justifyContent: 'space-between', }, reactionsListUserInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, reactionsListUsernameText: { color: 'modalForegroundLabel', fontSize: 18, - marginLeft: 8, }, reactionsListReactionText: { fontSize: 18, }, reactionsListItemSeperator: { height: 16, }, closeButton: { borderRadius: 4, width: 18, height: 18, alignItems: 'center', }, closeIcon: { color: 'modalBackgroundSecondaryLabel', }, }; export default MessageReactionsModal; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index 84970442b..37d7bf8eb 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,302 +1,317 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform, ActivityIndicator, TouchableOpacity, } from 'react-native'; import { removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { getAvatarForUser } from 'lib/shared/avatar-utils.js'; import { memberIsAdmin, memberHasAdminPowers, getAvailableThreadMemberActions, } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import Avatar from '../../components/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 { ThreadSettingsMemberTooltipModalRouteName } 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 { useShouldRenderAvatars } from '../../utils/avatar-utils.js'; type BaseProps = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, + +shouldRenderAvatars: boolean, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render() { const userText = stringForUser(this.props.memberInfo); const avatarInfo = getAvatarForUser(this.props.memberInfo); + + const marginLeftStyle = { + marginLeft: this.props.shouldRenderAvatars ? 8 : 0, + }; + let usernameInfo = null; if (this.props.memberInfo.username) { usernameInfo = ( - {userText} + + {userText} + ); } else { usernameInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if ( getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ).length !== 0 ) { editButton = ( ); } let roleInfo = null; if (memberIsAdmin(this.props.memberInfo, this.props.threadInfo)) { roleInfo = ( admin ); } else if (memberHasAdminPowers(this.props.memberInfo)) { roleInfo = ( parent admin ); } const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastInnerContainer : null; return ( {usernameInfo} {editButton} {roleInfo} ); } editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'ThreadSettingsMemberTooltipModal'>({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const unboundStyles = { anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, innerContainer: { flex: 1, paddingHorizontal: 12, paddingVertical: 8, }, lastInnerContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, userInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, - marginLeft: 8, }, }; const ConnectedThreadSettingsMember: React.ComponentType = React.memo(function ConnectedThreadSettingsMember( props: BaseProps, ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector(state => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const [memberInfo] = useENSNames([props.memberInfo]); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); + const shouldRenderAvatars = useShouldRenderAvatars(); + return ( ); }); export default ConnectedThreadSettingsMember; diff --git a/native/chat/typeahead-tooltip.react.js b/native/chat/typeahead-tooltip.react.js index ea7669e77..14d2b876a 100644 --- a/native/chat/typeahead-tooltip.react.js +++ b/native/chat/typeahead-tooltip.react.js @@ -1,155 +1,165 @@ // @flow import * as React from 'react'; import { Platform, Text } from 'react-native'; import { PanGestureHandler, FlatList } from 'react-native-gesture-handler'; import { getAvatarForUser } from 'lib/shared/avatar-utils.js'; import { type TypeaheadMatchedStrings, type Selection, getNewTextAndSelection, } from 'lib/shared/mention-utils.js'; import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; import Avatar from '../components/avatar.react.js'; import Button from '../components/button.react.js'; import { useStyles } from '../themes/colors.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; export type TypeaheadTooltipProps = { +text: string, +matchedStrings: TypeaheadMatchedStrings, +suggestedUsers: $ReadOnlyArray, +focusAndUpdateTextAndSelection: (text: string, selection: Selection) => void, }; function TypeaheadTooltip(props: TypeaheadTooltipProps): React.Node { const { text, matchedStrings, suggestedUsers, focusAndUpdateTextAndSelection, } = props; + const shouldRenderAvatars = useShouldRenderAvatars(); + const { textBeforeAtSymbol, usernamePrefix } = matchedStrings; const styles = useStyles(unboundStyles); + const marginLeftStyle = React.useMemo( + () => ({ + marginLeft: shouldRenderAvatars ? 8 : 0, + }), + [shouldRenderAvatars], + ); + const renderTypeaheadButton = React.useCallback( ({ item }: { item: RelativeMemberInfo, ... }) => { const onPress = () => { const { newText, newSelectionStart } = getNewTextAndSelection( textBeforeAtSymbol, text, usernamePrefix, item, ); focusAndUpdateTextAndSelection(newText, { start: newSelectionStart, end: newSelectionStart, }); }; const avatarInfo = getAvatarForUser(item); return ( ); }, [ - focusAndUpdateTextAndSelection, styles.button, styles.buttonLabel, - text, + marginLeftStyle, textBeforeAtSymbol, + text, usernamePrefix, + focusAndUpdateTextAndSelection, ], ); // This is a hack that was introduced due to a buggy behavior of a // absolutely positioned FlatList on Android. // There was a bug that was present when there were too few items in a // FlatList and it wasn't scrollable. It was only present on Android as // iOS has a default "bounce" animation, even if the list is too short. // The bug manifested itself when we tried to scroll the FlatList. // Because it was unscrollable we were really scrolling FlatList // below it (in the ChatList) as FlatList here has "position: absolute" // and is positioned over the other FlatList. // The hack here solves it by using a PanGestureHandler. This way Pan events // on TypeaheadTooltip FlatList are always caught by handler. // When the FlatList is scrollable it scrolls normally, because handler // passes those events down to it. // If it's not scrollable, the PanGestureHandler "swallows" them. // Normally it would trigger onGestureEvent callback, but we don't need to // handle those events. We just want them to be ignored // and that's what's actually happening. const flatList = React.useMemo( () => ( ), [ renderTypeaheadButton, styles.container, styles.contentContainer, suggestedUsers, ], ); const listWithConditionalHandler = React.useMemo(() => { if (Platform.OS === 'android') { return {flatList}; } return flatList; }, [flatList]); return listWithConditionalHandler; } const unboundStyles = { container: { position: 'absolute', maxHeight: 200, left: 0, right: 0, bottom: '100%', backgroundColor: 'typeaheadTooltipBackground', borderBottomWidth: 1, borderTopWidth: 1, borderColor: 'typeaheadTooltipBorder', borderStyle: 'solid', }, contentContainer: { padding: 8, }, button: { alignItems: 'center', flexDirection: 'row', innerHeight: 24, padding: 8, color: 'typeaheadTooltipText', }, buttonLabel: { color: 'white', fontSize: 16, fontWeight: '400', - marginLeft: 8, }, }; export default TypeaheadTooltip; diff --git a/native/components/avatar.react.js b/native/components/avatar.react.js index 6b50aa755..6dc448db1 100644 --- a/native/components/avatar.react.js +++ b/native/components/avatar.react.js @@ -1,98 +1,106 @@ // @flow import * as React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import type { ClientAvatar } from 'lib/types/avatar-types.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.js'; + type Props = { +avatarInfo: ClientAvatar, +size?: 'large' | 'small' | 'profile' | 'micro', }; function Avatar(props: Props): React.Node { const { avatarInfo, size } = props; + const shouldRenderAvatars = useShouldRenderAvatars(); + const containerSizeStyle = React.useMemo(() => { if (size === 'profile') { return styles.profile; } else if (size === 'small') { return styles.small; } else if (size === 'micro') { return styles.micro; } return styles.large; }, [size]); const emojiContainerStyle = React.useMemo(() => { const containerStyles = [styles.emojiContainer, containerSizeStyle]; if (avatarInfo.type === 'emoji') { const backgroundColor = { backgroundColor: `#${avatarInfo.color}` }; containerStyles.push(backgroundColor); } return containerStyles; }, [avatarInfo, containerSizeStyle]); const emojiSizeStyle = React.useMemo(() => { if (size === 'profile') { return styles.emojiProfile; } else if (size === 'small') { return styles.emojiSmall; } else if (size === 'micro') { return styles.emojiMicro; } return styles.emojiLarge; }, [size]); + if (!shouldRenderAvatars) { + return null; + } + return ( {avatarInfo.emoji} ); } const styles = StyleSheet.create({ emojiContainer: { alignItems: 'center', justifyContent: 'center', }, emojiLarge: { fontSize: 28, textAlign: 'center', }, emojiMicro: { fontSize: 9, textAlign: 'center', }, emojiProfile: { fontSize: 80, textAlign: 'center', }, emojiSmall: { fontSize: 14, textAlign: 'center', }, large: { borderRadius: 20, height: 40, width: 40, }, micro: { borderRadius: 8, height: 16, width: 16, }, profile: { borderRadius: 56, height: 112, width: 112, }, small: { borderRadius: 12, height: 24, width: 24, }, }); export default Avatar; diff --git a/native/profile/profile-screen.react.js b/native/profile/profile-screen.react.js index decc9d754..a409bddbd 100644 --- a/native/profile/profile-screen.react.js +++ b/native/profile/profile-screen.react.js @@ -1,416 +1,429 @@ // @flow import * as React from 'react'; import { View, Text, Alert, Platform, ScrollView } from 'react-native'; import { logOutActionTypes, logOut } from 'lib/actions/user-actions.js'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import { getAvatarForUser } from 'lib/shared/avatar-utils.js'; import type { LogOutResult } from 'lib/types/account-types.js'; import { type PreRequestUserState } from 'lib/types/session-types.js'; import { type CurrentUserInfo } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import { deleteNativeCredentialsFor } from '../account/native-credentials.js'; import Action from '../components/action-row.react.js'; import Avatar from '../components/avatar.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, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; +import { useShouldRenderAvatars } from '../utils/avatar-utils.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 ( ); } type BaseProps = { +navigation: ProfileNavigationProp<'ProfileScreen'>, +route: NavigationRoute<'ProfileScreen'>, }; type Props = { ...BaseProps, +currentUserInfo: ?CurrentUserInfo, +preRequestUserState: PreRequestUserState, +logOutLoading: boolean, +colors: Colors, +styles: typeof unboundStyles, +dispatchActionPromise: DispatchActionPromise, +logOut: (preRequestUserState: PreRequestUserState) => Promise, +staffCanSee: boolean, +stringForUser: ?string, +isAccountWithPassword: boolean, + +shouldRenderAvatars: boolean, }; class ProfileScreen extends React.PureComponent { get loggedOutOrLoggingOut() { return ( !this.props.currentUserInfo || this.props.currentUserInfo.anonymous || this.props.logOutLoading ); } render() { let developerTools, defaultNotifications; const { staffCanSee } = this.props; if (staffCanSee) { developerTools = ( ); defaultNotifications = ( ); } let passwordEditionUI; if (accountHasPassword(this.props.currentUserInfo)) { passwordEditionUI = ( Password •••••••••••••••• ); } const avatarInfo = getAvatarForUser(this.props.currentUserInfo); const avatar = ( ); + let avatarSection; + if (this.props.shouldRenderAvatars) { + avatarSection = ( + <> + USER AVATAR + {avatar} + + ); + } + return ( - USER AVATAR - {avatar} + {avatarSection} ACCOUNT Logged in as {this.props.stringForUser} {passwordEditionUI} PREFERENCES {defaultNotifications} {developerTools} ); } 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 }, ); }; logOutWithoutDeletingNativeCredentialsWrapper = () => { if (this.loggedOutOrLoggingOut) { return; } this.logOut(); }; logOutAndDeleteNativeCredentialsWrapper = async () => { if (this.loggedOutOrLoggingOut) { return; } await this.deleteNativeCredentials(); this.logOut(); }; logOut() { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } async deleteNativeCredentials() { await deleteNativeCredentialsFor(); } navigateIfActive(name) { this.props.navigation.navigate({ name }); } onPressEditPassword = () => { this.navigateIfActive(EditPasswordRouteName); }; onPressDeleteAccount = () => { this.navigateIfActive(DeleteAccountRouteName); }; onPressBuildInfo = () => { this.navigateIfActive(BuildInfoRouteName); }; onPressDevTools = () => { this.navigateIfActive(DevToolsRouteName); }; onPressAppearance = () => { this.navigateIfActive(AppearancePreferencesRouteName); }; onPressPrivacy = () => { this.navigateIfActive(PrivacyPreferencesRouteName); }; onPressDefaultNotifications = () => { this.navigateIfActive(DefaultNotificationsPreferencesRouteName); }; onPressFriendList = () => { this.navigateIfActive(FriendListRouteName); }; onPressBlockList = () => { this.navigateIfActive(BlockListRouteName); }; } 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', }, }; const logOutLoadingStatusSelector = createLoadingStatusSelector(logOutActionTypes); const ConnectedProfileScreen: React.ComponentType = React.memo(function ConnectedProfileScreen(props: BaseProps) { const currentUserInfo = useSelector(state => state.currentUserInfo); const preRequestUserState = useSelector(preRequestUserStateSelector); const logOutLoading = useSelector(logOutLoadingStatusSelector) === 'loading'; const colors = useColors(); const styles = useStyles(unboundStyles); const callLogOut = useServerCall(logOut); const dispatchActionPromise = useDispatchActionPromise(); const staffCanSee = useStaffCanSee(); const stringForUser = useStringForUser(currentUserInfo); const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); + const shouldRenderAvatars = useShouldRenderAvatars(); return ( ); }); export default ConnectedProfileScreen; diff --git a/native/profile/relationship-list-item.react.js b/native/profile/relationship-list-item.react.js index 067bf3807..7c3d4026b 100644 --- a/native/profile/relationship-list-item.react.js +++ b/native/profile/relationship-list-item.react.js @@ -1,351 +1,360 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Alert, 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 { getAvatarForUser } from 'lib/shared/avatar-utils.js'; import type { LoadingStatus } from 'lib/types/loading-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 { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { RelationshipListNavigate } from './relationship-list.react.js'; import Avatar from '../components/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 { RelationshipListItemTooltipModalRouteName, 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 { useShouldRenderAvatars } from '../utils/avatar-utils.js'; 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: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateRelationships: ( request: RelationshipRequest, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, + +shouldRenderAvatars: boolean, }; class RelationshipListItem extends React.PureComponent { editButton = React.createRef>(); render() { 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 ); } + const marginLeftStyle = { + marginLeft: this.props.shouldRenderAvatars ? 8 : 0, + }; + const avatarInfo = getAvatarForUser(this.props.userInfo); return ( - + {this.props.userInfo.username} {editButton} ); } onSelect = () => { const { id, username } = this.props.userInfo; this.props.onSelect({ id, username }); }; visibleEntryIDs() { 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<'RelationshipListItemTooltipModal'>({ name: RelationshipListItemTooltipModalRouteName, params: { presentedFrom: this.props.relationshipListRoute.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), relativeUserInfo, }, }); }); }; // 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}`; this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateFriendship(action), { customKeyName }, ); } async updateFriendship(action: RelationshipAction) { 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, }); throw e; } } } const unboundStyles = { editButton: { paddingLeft: 10, }, container: { flex: 1, paddingHorizontal: 12, backgroundColor: 'panelForeground', }, innerContainer: { paddingVertical: 10, paddingHorizontal: 12, borderColor: 'panelForegroundBorder', flexDirection: 'row', }, borderBottom: { borderBottomWidth: 1, }, buttonContainer: { flexDirection: 'row', }, editButtonWithMargin: { marginLeft: 15, }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, blueAction: { color: 'link', fontSize: 16, paddingLeft: 6, }, redAction: { color: 'redText', fontSize: 16, paddingLeft: 6, }, }; 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 = useServerCall(updateRelationships); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); + const shouldRenderAvatars = useShouldRenderAvatars(); + return ( ); }); export default ConnectedRelationshipListItem; diff --git a/native/utils/avatar-utils.js b/native/utils/avatar-utils.js new file mode 100644 index 000000000..e8a4651c6 --- /dev/null +++ b/native/utils/avatar-utils.js @@ -0,0 +1,14 @@ +// @flow + +import * as React from 'react'; + +import { FeatureFlagsContext } from '../components/feature-flags-provider.react.js'; + +function useShouldRenderAvatars(): boolean { + const { configuration: featureFlagConfig } = + React.useContext(FeatureFlagsContext); + + return !!featureFlagConfig['AVATARS_DISPLAY']; +} + +export { useShouldRenderAvatars };