diff --git a/native/chat/message-reactions-modal.react.js b/native/chat/message-reactions-modal.react.js index 611814b8e..096fecac5 100644 --- a/native/chat/message-reactions-modal.react.js +++ b/native/chat/message-reactions-modal.react.js @@ -1,142 +1,158 @@ // @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'; 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 renderItem = React.useCallback( ({ item }) => { + const avatarInfo = getAvatarForUser(item); + return ( - {item.username} + + + + {item.username} + + {item.reaction} ); }, [ 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 12a08c6a6..84970442b 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,290 +1,302 @@ // @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'; 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, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render() { const userText = stringForUser(this.props.memberInfo); - let userInfo = null; + const avatarInfo = getAvatarForUser(this.props.memberInfo); + let usernameInfo = null; if (this.props.memberInfo.username) { - userInfo = ( + usernameInfo = ( {userText} ); } else { - userInfo = ( + 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 ( - {userInfo} + + + {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); return ( ); }); export default ConnectedThreadSettingsMember; diff --git a/native/chat/typeahead-tooltip.react.js b/native/chat/typeahead-tooltip.react.js index ff50104ae..ea7669e77 100644 --- a/native/chat/typeahead-tooltip.react.js +++ b/native/chat/typeahead-tooltip.react.js @@ -1,148 +1,155 @@ // @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'; 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 { textBeforeAtSymbol, usernamePrefix } = matchedStrings; const styles = useStyles(unboundStyles); 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, textBeforeAtSymbol, usernamePrefix, ], ); // 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/user-list-user.react.js b/native/components/user-list-user.react.js index a4f7f9969..459779ba0 100644 --- a/native/components/user-list-user.react.js +++ b/native/components/user-list-user.react.js @@ -1,93 +1,99 @@ // @flow import * as React from 'react'; import { Text, Platform, Alert } from 'react-native'; +import { getAvatarForUser } from 'lib/shared/avatar-utils.js'; import type { UserListItem } from 'lib/types/user-types.js'; +import Avatar from './avatar.react.js'; import Button from './button.react.js'; import { SingleLine } from './single-line.react.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { TextStyle } from '../types/styles.js'; // eslint-disable-next-line no-unused-vars const getUserListItemHeight = (item: UserListItem): number => { // TODO consider parent thread notice return Platform.OS === 'ios' ? 31.5 : 33.5; }; type BaseProps = { +userInfo: UserListItem, +onSelect: (userID: string) => void, +textStyle?: TextStyle, }; type Props = { ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, }; class UserListUser extends React.PureComponent { render() { const { userInfo } = this.props; let notice = null; if (userInfo.notice) { notice = {userInfo.notice}; } const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; + + const avatarInfo = getAvatarForUser(this.props.userInfo); + return ( ); } onSelect = () => { const { userInfo } = this.props; if (!userInfo.alertText) { this.props.onSelect(userInfo.id); return; } Alert.alert(userInfo.alertTitle, userInfo.alertText, [{ text: 'OK' }], { cancelable: true, }); }; } const unboundStyles = { button: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, notice: { color: 'modalForegroundSecondaryLabel', fontStyle: 'italic', }, text: { color: 'modalForegroundLabel', flex: 1, fontSize: 16, paddingHorizontal: 12, paddingVertical: 6, }, }; const ConnectedUserListUser: React.ComponentType = React.memo(function ConnectedUserListUser(props: BaseProps) { const colors = useColors(); const styles = useStyles(unboundStyles); return ; }); export { ConnectedUserListUser as UserListUser, getUserListItemHeight }; diff --git a/native/profile/relationship-list-item.react.js b/native/profile/relationship-list-item.react.js index 4cf09fbef..067bf3807 100644 --- a/native/profile/relationship-list-item.react.js +++ b/native/profile/relationship-list-item.react.js @@ -1,345 +1,351 @@ // @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'; 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, }; 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 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); return ( ); }); export default ConnectedRelationshipListItem;