diff --git a/lib/shared/relationship-utils.js b/lib/shared/relationship-utils.js index 987b1db89..1e17368ec 100644 --- a/lib/shared/relationship-utils.js +++ b/lib/shared/relationship-utils.js @@ -1,9 +1,43 @@ // @flow +import { + type RelationshipButton, + userRelationshipStatus, + relationshipButtons, +} from '../types/relationship-types'; +import type { UserInfo } from '../types/user-types'; + function sortIDs(firstId: string, secondId: string): string[] { return [Number(firstId), Number(secondId)] .sort((a, b) => a - b) .map((num) => num.toString()); } -export { sortIDs }; +function getAvailableRelationshipButtons( + userInfo: UserInfo, +): RelationshipButton[] { + const relationship = userInfo.relationshipStatus; + + if (relationship === userRelationshipStatus.FRIEND) { + return [relationshipButtons.UNFRIEND, relationshipButtons.BLOCK]; + } else if (relationship === userRelationshipStatus.BLOCKED_VIEWER) { + return [relationshipButtons.BLOCK]; + } else if ( + relationship === userRelationshipStatus.BOTH_BLOCKED || + relationship === userRelationshipStatus.BLOCKED_BY_VIEWER + ) { + return [relationshipButtons.UNBLOCK]; + } else if (relationship === userRelationshipStatus.REQUEST_RECEIVED) { + return [ + relationshipButtons.ACCEPT, + relationshipButtons.REJECT, + relationshipButtons.BLOCK, + ]; + } else if (relationship === userRelationshipStatus.REQUEST_SENT) { + return [relationshipButtons.WITHDRAW, relationshipButtons.BLOCK]; + } else { + return [relationshipButtons.FRIEND, relationshipButtons.BLOCK]; + } +} + +export { sortIDs, getAvailableRelationshipButtons }; diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index 431964cd0..3898dbf1d 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,66 +1,77 @@ // @flow import { values } from '../utils/objects'; import type { AccountUserInfo } from './user-types'; 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 relationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', }); export type RelationshipAction = $Values; export const relationshipActionsList: $ReadOnlyArray = values( relationshipActions, ); +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 RelationshipRequest = {| action: RelationshipAction, userIDs: $ReadOnlyArray, |}; type SharedRelationshipRow = {| user1: string, user2: string, |}; export type DirectedRelationshipRow = {| ...SharedRelationshipRow, status: DirectedStatus, |}; export type UndirectedRelationshipRow = {| ...SharedRelationshipRow, status: UndirectedStatus, |}; export type RelationshipErrors = $Shape<{| invalid_user: string[], already_friends: string[], user_blocked: string[], |}>; export type UserRelationships = {| +friends: $ReadOnlyArray, +blocked: $ReadOnlyArray, |}; diff --git a/native/chat/settings/thread-settings-edit-relationship.react.js b/native/chat/settings/thread-settings-edit-relationship.react.js new file mode 100644 index 000000000..da8fc50b7 --- /dev/null +++ b/native/chat/settings/thread-settings-edit-relationship.react.js @@ -0,0 +1,148 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; +import { Alert, Text, View } from 'react-native'; + +import { + updateRelationships as serverUpdateRelationships, + updateRelationshipsActionTypes, +} from 'lib/actions/relationship-actions'; +import { getSingleOtherUser } from 'lib/shared/thread-utils'; +import { + type RelationshipAction, + type RelationshipButton, + relationshipButtons, + relationshipActions, +} from 'lib/types/relationship-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; +import { + useDispatchActionPromise, + useServerCall, +} from 'lib/utils/action-utils'; + +import Button from '../../components/button.react'; +import { useSelector } from '../../redux/redux-utils'; +import { useStyles, useColors } from '../../themes/colors'; +import type { ViewStyle } from '../../types/styles'; + +type Props = {| + +threadInfo: ThreadInfo, + +buttonStyle: ViewStyle, + +relationshipButton: RelationshipButton, +|}; + +export default React.memo(function ThreadSettingsEditRelationship( + props: Props, +) { + const otherUserInfo = useSelector((state) => { + const currentUserID = state.currentUserInfo?.id; + const otherUserID = getSingleOtherUser(props.threadInfo, currentUserID); + invariant(otherUserID, 'Other user should be specified'); + + const { userInfos } = state.userStore; + return userInfos[otherUserID]; + }); + invariant(otherUserInfo, 'Other user info should be specified'); + + const callUpdateRelationships = useServerCall(serverUpdateRelationships); + const updateRelationship = React.useCallback( + async (action: RelationshipAction) => { + try { + return await callUpdateRelationships({ + action, + userIDs: [otherUserInfo.id], + }); + } catch (e) { + Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { + cancelable: true, + }); + throw e; + } + }, + [callUpdateRelationships, otherUserInfo], + ); + + const { relationshipButton } = props; + const relationshipAction = React.useMemo(() => { + if (relationshipButton === relationshipButtons.BLOCK) { + return relationshipActions.BLOCK; + } else if ( + relationshipButton === relationshipButtons.FRIEND || + relationshipButton === relationshipButtons.ACCEPT + ) { + return relationshipActions.FRIEND; + } else if ( + relationshipButton === relationshipButtons.UNFRIEND || + relationshipButton === relationshipButtons.REJECT || + relationshipButton === relationshipButtons.WITHDRAW + ) { + return relationshipActions.UNFRIEND; + } else if (relationshipButton === relationshipButtons.UNBLOCK) { + return relationshipActions.UNBLOCK; + } + invariant(false, 'relationshipButton conditions should be exhaustive'); + }, [relationshipButton]); + + const dispatchActionPromise = useDispatchActionPromise(); + const onButtonPress = React.useCallback(() => { + dispatchActionPromise( + updateRelationshipsActionTypes, + updateRelationship(relationshipAction), + ); + }, [dispatchActionPromise, relationshipAction, updateRelationship]); + + const colors = useColors(); + const { panelIosHighlightUnderlay } = colors; + + const styles = useStyles(unboundStyles); + const otherUserInfoUsername = otherUserInfo.username; + invariant(otherUserInfoUsername, 'Other user username should be specified'); + + let relationshipButtonText; + if (relationshipButton === relationshipButtons.BLOCK) { + relationshipButtonText = `Block ${otherUserInfoUsername}`; + } else if (relationshipButton === relationshipButtons.FRIEND) { + relationshipButtonText = `Add ${otherUserInfoUsername} to friends`; + } else if (relationshipButton === relationshipButtons.UNFRIEND) { + relationshipButtonText = `Unfriend ${otherUserInfoUsername}`; + } else if (relationshipButton === relationshipButtons.UNBLOCK) { + relationshipButtonText = `Unblock ${otherUserInfoUsername}`; + } else if (relationshipButton === relationshipButtons.ACCEPT) { + relationshipButtonText = `Accept friend request from ${otherUserInfoUsername}`; + } else if (relationshipButton === relationshipButtons.REJECT) { + relationshipButtonText = `Reject friend request from ${otherUserInfoUsername}`; + } else if (relationshipButton === relationshipButtons.WITHDRAW) { + relationshipButtonText = `Withdraw request to friend ${otherUserInfoUsername}`; + } + + return ( + + + + ); +}); + +const unboundStyles = { + button: { + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 10, + }, + container: { + backgroundColor: 'panelForeground', + paddingHorizontal: 12, + }, + text: { + color: 'redText', + flex: 1, + fontSize: 16, + }, +}; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 3dfbf5113..31632cf18 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1073 +1,1137 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, FlatList, Platform } from 'react-native'; import { createSelector } from 'reselect'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; +import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils'; import { threadHasPermission, viewerIsMember, threadInChatList, + getSingleOtherUser, } from 'lib/shared/thread-utils'; import threadWatcher from 'lib/shared/thread-watcher'; +import type { RelationshipButton } from 'lib/types/relationship-types'; import { type ThreadInfo, type RelativeMemberInfo, threadPermissions, threadTypes, } from 'lib/types/thread-types'; +import type { UserInfos } from 'lib/types/user-types'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; import type { TabNavigationProp } from '../../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { AddUsersModalRouteName, ComposeSubthreadModalRouteName, } from '../../navigation/route-names'; import type { AppState } from '../../redux/redux-setup'; import { useSelector } from '../../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors'; import type { VerticalBounds } from '../../types/layout-types'; import type { ViewStyle } from '../../types/styles'; import type { ChatNavigationProp } from '../chat.react'; import type { CategoryType } from './thread-settings-category.react'; import { ThreadSettingsCategoryHeader, ThreadSettingsCategoryFooter, } from './thread-settings-category.react'; import ThreadSettingsChildThread from './thread-settings-child-thread.react'; import ThreadSettingsColor from './thread-settings-color.react'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react'; import ThreadSettingsDescription from './thread-settings-description.react'; +import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react'; import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react'; import { ThreadSettingsSeeMore, ThreadSettingsAddMember, ThreadSettingsAddSubthread, } from './thread-settings-list-action.react'; import ThreadSettingsMember from './thread-settings-member.react'; import ThreadSettingsName from './thread-settings-name.react'; import ThreadSettingsParent from './thread-settings-parent.react'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react'; import ThreadSettingsVisibility from './thread-settings-visibility.react'; const itemPageLength = 5; export type ThreadSettingsParams = {| threadInfo: ThreadInfo, |}; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | {| +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, |} | {| +itemType: 'footer', +key: string, +categoryType: CategoryType, |} | {| +itemType: 'name', +key: string, +threadInfo: ThreadInfo, +nameEditValue: ?string, +nameTextHeight: ?number, +canChangeSettings: boolean, |} | {| +itemType: 'color', +key: string, +threadInfo: ThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, |} | {| +itemType: 'description', +key: string, +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, |} | {| +itemType: 'parent', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, |} | {| +itemType: 'visibility', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'pushNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'homeNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'seeMore', +key: string, +onPress: () => void, |} | {| +itemType: 'childThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, |} | {| +itemType: 'addSubthread', +key: string, |} | {| +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, |} | {| +itemType: 'addMember', +key: string, |} | {| +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, + |} + | {| + +itemType: 'editRelationship', + +key: string, + +threadInfo: ThreadInfo, + +navigate: ThreadSettingsNavigate, + +buttonStyle: ViewStyle, + +relationshipButton: RelationshipButton, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, |}; type Props = {| ...BaseProps, // Redux state + +userInfos: UserInfos, + +viewerID: ?string, +threadInfo: ?ThreadInfo, +threadMembers: $ReadOnlyArray, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +numMembersShowing: number, +numSubthreadsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +nameTextHeight: ?number, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, |}; type PropsAndState = {| ...Props, ...State |}; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); const threadInfo = props.threadInfo; invariant(threadInfo, 'ThreadInfo should exist when ThreadSettings opened'); this.state = { numMembersShowing: itemPageLength, numSubthreadsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, nameTextHeight: null, descriptionTextHeight: null, colorEditValue: threadInfo.color, verticalBounds: null, }; } static getThreadInfo(props: { threadInfo: ?ThreadInfo, route: NavigationRoute<'ThreadSettings'>, ... }): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidMount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.watchID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.removeID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (this.props.navigation.isFocused() && !this.props.somethingIsSaving) { this.props.navigation.popToTop(); } }; componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } const oldNavThreadInfo = ThreadSettings.getThreadInfo(prevProps); const newNavThreadInfo = ThreadSettings.getThreadInfo(this.props); if (oldNavThreadInfo.id !== newNavThreadInfo.id) { if (!threadInChatList(oldNavThreadInfo)) { threadWatcher.removeID(oldNavThreadInfo.id); } if (!threadInChatList(newNavThreadInfo)) { threadWatcher.watchID(newNavThreadInfo.id); } } if ( newNavThreadInfo.color !== oldNavThreadInfo.color && this.state.colorEditValue === oldNavThreadInfo.color ) { this.setState({ colorEditValue: newNavThreadInfo.color }); } const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.nameTextHeight, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ThreadInfo, nameEditValue: ?string, nameTextHeight: ?number, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThread = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD, ); const canChangeSettings = canEditThread && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, nameTextHeight, canChangeSettings, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThread ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, navigate, }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subthreadsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubthreadsShowing, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubthreadsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subthreads = childThreads?.filter( (childThreadInfo) => childThreadInfo.type !== threadTypes.SIDEBAR, ) ?? []; const canCreateSubthreads = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBTHREADS, ); if (subthreads.length === 0 && !canCreateSubthreads) { return listData; } listData.push({ itemType: 'header', key: 'subthreadHeader', title: 'Subthreads', categoryType: 'unpadded', }); if (canCreateSubthreads) { listData.push({ itemType: 'addSubthread', key: 'addSubthread', }); } const numItems = Math.min(numSubthreadsShowing, subthreads.length); for (let i = 0; i < numItems; i++) { const subthreadInfo = subthreads[i]; listData.push({ itemType: 'childThread', key: `childThread${subthreadInfo.id}`, threadInfo: subthreadInfo, navigate, firstListItem: i === 0 && !canCreateSubthreads, lastListItem: i === numItems - 1 && numItems === subthreads.length, }); } if (numItems < subthreads.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubthreads', onPress: this.onPressSeeMoreSubthreads, }); } listData.push({ itemType: 'footer', key: 'subthreadFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( (childThreadInfo) => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Sidebars', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, navigate, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.threadMembers, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, threadMembers: $ReadOnlyArray, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadMembers.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadMembers.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadMembers[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadMembers.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadMembers.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, + (propsAndState: PropsAndState) => propsAndState.userInfos, + (propsAndState: PropsAndState) => propsAndState.viewerID, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, + userInfos: UserInfos, + viewerID: ?string, ) => { const buttons = []; const canChangeThreadType = threadHasPermission( threadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canPromoteSidebar = threadInfo.type === threadTypes.SIDEBAR && canChangeThreadType; if (canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } + const threadIsPersonal = threadInfo.type === threadTypes.PERSONAL; + if (threadIsPersonal && viewerID) { + const otherMemberID = getSingleOtherUser(threadInfo, viewerID); + invariant(otherMemberID, 'Other user should be specified'); + const otherUserInfo = userInfos[otherMemberID]; + const availableRelationshipActions = getAvailableRelationshipButtons( + otherUserInfo, + ); + + for (const action of availableRelationshipActions) { + buttons.push({ + itemType: 'editRelationship', + key: action, + threadInfo, + navigate, + relationshipButton: action, + }); + } + } + const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { - listData.push({ - ...buttons[i], - buttonStyle: [ - i === 0 ? null : styles.nonTopButton, - i === buttons.length - 1 ? styles.lastButton : null, - ], - }); + // Necessary for Flow... + if (buttons[i].itemType === 'editRelationship') { + listData.push({ + ...buttons[i], + buttonStyle: [ + i === 0 ? null : styles.nonTopButton, + i === buttons.length - 1 ? styles.lastButton : null, + ], + }); + } else { + listData.push({ + ...buttons[i], + buttonStyle: [ + i === 0 ? null : styles.nonTopButton, + i === buttons.length - 1 ? styles.lastButton : null, + ], + }); + } } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subthreadsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subthreadsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subthreadsListData, ...sidebarsListData, ...threadMembersListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; renderItem = (row: { item: ChatSettingsItem }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubthread') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); + } else if (item.itemType === 'editRelationship') { + return ( + + ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setNameTextHeight = (height: number) => { this.setState({ nameTextHeight: height }); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubthread = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(ComposeSubthreadModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressAddMember = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState((prevState) => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubthreads = () => { this.setState((prevState) => ({ numSubthreadsShowing: prevState.numSubthreadsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState((prevState) => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } for (let threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; export default React.memo(function ConnectedThreadSettings( props: BaseProps, ) { + const userInfos = useSelector((state) => state.userStore.userInfos); + const viewerID = useSelector( + (state) => state.currentUserInfo && state.currentUserInfo.id, + ); const threadID = props.route.params.threadInfo.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); const boundChildThreadInfos = useSelector( (state) => childThreadInfos(state)[threadID], ); const boundSomethingIsSaving = useSelector((state) => somethingIsSaving(state, threadMembers), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); return ( ); });