diff --git a/lib/actions/relationship-actions.js b/lib/actions/relationship-actions.js index a71e92839..c656cd3c4 100644 --- a/lib/actions/relationship-actions.js +++ b/lib/actions/relationship-actions.js @@ -1,39 +1,37 @@ // @flow import type { - TraditionalRelationshipRequest, RelationshipErrors, + RelationshipRequest, } from '../types/relationship-types.js'; import type { CallSingleKeyserverEndpoint } from '../utils/call-single-keyserver-endpoint.js'; import { ServerError } from '../utils/errors.js'; const updateRelationshipsActionTypes = Object.freeze({ started: 'UPDATE_RELATIONSHIPS_STARTED', success: 'UPDATE_RELATIONSHIPS_SUCCESS', failed: 'UPDATE_RELATIONSHIPS_FAILED', }); const updateRelationships = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, - ): (( - request: TraditionalRelationshipRequest, - ) => Promise) => + ): ((request: RelationshipRequest) => Promise) => async request => { const errors = await callSingleKeyserverEndpoint( 'update_relationships', request, ); const { invalid_user, already_friends, user_blocked } = errors; if (invalid_user) { throw new ServerError('invalid_user', errors); } else if (already_friends) { throw new ServerError('already_friends', errors); } else if (user_blocked) { throw new ServerError('user_blocked', errors); } return errors; }; export { updateRelationshipsActionTypes, updateRelationships }; diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index 624d34411..93a60c075 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,113 +1,117 @@ // @flow import type { TInterface, TRefinement } from 'tcomb'; import t from 'tcomb'; import type { AccountUserInfo } from './user-types.js'; import { values } from '../utils/objects.js'; import { tNumEnum, tShape, tString } from '../utils/validation-utils.js'; export const undirectedStatus = Object.freeze({ KNOW_OF: 0, FRIEND: 2, }); export type UndirectedStatus = $Values; export const directedStatus = Object.freeze({ PENDING_FRIEND: 1, BLOCKED: 3, }); export type DirectedStatus = $Values; export const userRelationshipStatus = Object.freeze({ REQUEST_SENT: 1, REQUEST_RECEIVED: 2, FRIEND: 3, BLOCKED_BY_VIEWER: 4, BLOCKED_VIEWER: 5, BOTH_BLOCKED: 6, }); export type UserRelationshipStatus = $Values; export const userRelationshipStatusValidator: TRefinement = tNumEnum( values(userRelationshipStatus), ); const traditionalRelationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', }); const farcasterRelationshipActions = Object.freeze({ FARCASTER_MUTUAL: 'farcaster', }); export const relationshipActions = Object.freeze({ ...traditionalRelationshipActions, ...farcasterRelationshipActions, }); export type RelationshipAction = $Values; export const relationshipActionsList: $ReadOnlyArray = values(relationshipActions); export type TraditionalRelationshipAction = $Values< typeof traditionalRelationshipActions, >; export const relationshipButtons = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', ACCEPT: 'accept', WITHDRAW: 'withdraw', REJECT: 'reject', }); export type RelationshipButton = $Values; export type TraditionalRelationshipRequest = { +action: RelationshipAction, +userIDs: $ReadOnlyArray, }; export type FarcasterRelationshipRequest = { +action: 'farcaster', +userIDsToFID: { +[userID: string]: string }, }; +export type RelationshipRequest = + | TraditionalRelationshipRequest + | FarcasterRelationshipRequest; + const exactlyTwoDictEntriesValidator = t.refinement( t.dict(t.String, t.String), dict => Object.keys(dict).length === 2, ); export const updateFarcasterRelationshipInputValidator: TInterface = tShape({ action: tString('farcaster'), userIDsToFID: exactlyTwoDictEntriesValidator, }); type SharedRelationshipRow = { user1: string, user2: string, }; export type DirectedRelationshipRow = { ...SharedRelationshipRow, status: DirectedStatus, }; export type UndirectedRelationshipRow = { ...SharedRelationshipRow, status: UndirectedStatus, }; export type RelationshipErrors = Partial<{ invalid_user: string[], already_friends: string[], user_blocked: string[], }>; export type UserRelationships = { +friends: $ReadOnlyArray, +blocked: $ReadOnlyArray, }; diff --git a/native/profile/relationship-list-item.react.js b/native/profile/relationship-list-item.react.js index 2d8fd5d63..d4f1c8ad2 100644 --- a/native/profile/relationship-list-item.react.js +++ b/native/profile/relationship-list-item.react.js @@ -1,363 +1,363 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ReactRef } from 'lib/types/react-types.js'; import { - type TraditionalRelationshipRequest, type RelationshipAction, type RelationshipErrors, userRelationshipStatus, relationshipActions, + type RelationshipRequest, } from 'lib/types/relationship-types.js'; import type { AccountUserInfo, GlobalAccountUserInfo, } from 'lib/types/user-types.js'; import { useLegacyAshoatKeyserverCall } from 'lib/utils/action-utils.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import type { RelationshipListNavigate } from './relationship-list.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import PencilIcon from '../components/pencil-icon.react.js'; import SingleLine from '../components/single-line.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { UserRelationshipTooltipModalRouteName, FriendListRouteName, BlockListRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { VerticalBounds } from '../types/layout-types.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; import { UnknownErrorAlertDetails } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; const unboundStyles = { container: { flex: 1, flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, backgroundColor: 'panelForeground', borderColor: 'panelForegroundBorder', }, borderBottom: { borderBottomWidth: 1, }, buttonContainer: { flexDirection: 'row', }, editButtonWithMargin: { marginLeft: 15, }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, editButton: { paddingLeft: 10, }, blueAction: { color: 'link', fontSize: 16, paddingLeft: 6, }, redAction: { color: 'redText', fontSize: 16, paddingLeft: 6, }, }; type BaseProps = { +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +relationshipListRoute: NavigationRoute<'FriendList' | 'BlockList'>, +navigate: RelationshipListNavigate, +onSelect: (selectedUser: GlobalAccountUserInfo) => void, }; type Props = { ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +colors: Colors, +styles: $ReadOnly, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateRelationships: ( - request: TraditionalRelationshipRequest, + request: RelationshipRequest, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +navigateToUserProfileBottomSheet: (userID: string) => mixed, }; class RelationshipListItem extends React.PureComponent { editButton: ReactRef> = React.createRef(); render(): React.Node { const { lastListItem, removeUserLoadingStatus, userInfo, relationshipListRoute, } = this.props; const relationshipsToEdit = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BOTH_BLOCKED, userRelationshipStatus.BLOCKED_BY_VIEWER, ], }[relationshipListRoute.name]; const canEditFriendRequest = { [FriendListRouteName]: true, [BlockListRouteName]: false, }[relationshipListRoute.name]; const borderBottom = lastListItem ? null : this.props.styles.borderBottom; let editButton = null; if (removeUserLoadingStatus === 'loading') { editButton = ( ); } else if (relationshipsToEdit.includes(userInfo.relationshipStatus)) { editButton = ( ); } else if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED && canEditFriendRequest ) { editButton = ( Accept Reject ); } else if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT && canEditFriendRequest ) { editButton = ( Cancel request ); } else { editButton = ( Add ); } return ( {this.props.userInfo.username} {editButton} ); } onPressUser = () => { this.props.navigateToUserProfileBottomSheet(this.props.userInfo.id); }; onSelect = () => { const { id, username } = this.props.userInfo; this.props.onSelect({ id, username }); }; visibleEntryIDs(): [string] { const { relationshipListRoute } = this.props; const id = { [FriendListRouteName]: 'unfriend', [BlockListRouteName]: 'unblock', }[relationshipListRoute.name]; return [id]; } onPressEdit = () => { if (this.props.keyboardState?.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; const { overlayContext, userInfo } = this.props; invariant( overlayContext, 'RelationshipListItem should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); if (!editButton.current || !verticalBounds) { return; } const { relationshipStatus, ...restUserInfo } = userInfo; const relativeUserInfo = { ...restUserInfo, isViewer: false, }; editButton.current.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'UserRelationshipTooltipModal'>({ name: UserRelationshipTooltipModalRouteName, params: { presentedFrom: this.props.relationshipListRoute.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), relativeUserInfo, tooltipButtonIcon: 'pencil', }, }); }); }; // We need to set onLayout in order to allow .measure() to be on the ref onLayout = () => {}; onPressFriendUser = () => { this.onPressUpdateFriendship(relationshipActions.FRIEND); }; onPressUnfriendUser = () => { this.onPressUpdateFriendship(relationshipActions.UNFRIEND); }; onPressUpdateFriendship(action: RelationshipAction) { const { id } = this.props.userInfo; const customKeyName = `${updateRelationshipsActionTypes.started}:${id}`; void this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateFriendship(action), { customKeyName }, ); } async updateFriendship( action: RelationshipAction, ): Promise { try { return await this.props.updateRelationships({ action, userIDs: [this.props.userInfo.id], }); } catch (e) { Alert.alert( UnknownErrorAlertDetails.title, UnknownErrorAlertDetails.message, [{ text: 'OK' }], { cancelable: true, }, ); throw e; } } } const ConnectedRelationshipListItem: React.ComponentType = React.memo(function ConnectedRelationshipListItem( props: BaseProps, ) { const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( updateRelationshipsActionTypes, `${updateRelationshipsActionTypes.started}:${props.userInfo.id}`, )(state), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const boundUpdateRelationships = useLegacyAshoatKeyserverCall(updateRelationships); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); return ( ); }); export default ConnectedRelationshipListItem;