diff --git a/lib/types/thread-types-enum.js b/lib/types/thread-types-enum.js index f50206fc0..c1e9cd97e 100644 --- a/lib/types/thread-types-enum.js +++ b/lib/types/thread-types-enum.js @@ -1,176 +1,185 @@ // @flow import invariant from 'invariant'; import type { TRefinement } from 'tcomb'; import { values } from '../utils/objects.js'; import { tNumEnum } from '../utils/validation-utils.js'; // Should be in sync with native/cpp/CommonCpp/NativeModules/\ // PersistentStorageUtilities/ThreadOperationsUtilities/ThreadTypeEnum.h export const thinThreadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) // has parent, not top-level (appears under parent in inbox), and visible to // all members of parent SIDEBAR: 5, // canonical thread for each pair of users. represents the friendship // created under GENESIS. being deprecated in favor of PERSONAL GENESIS_PERSONAL: 6, // canonical thread for each single user // created under GENESIS. being deprecated in favor of PRIVATE GENESIS_PRIVATE: 7, // aka "org". no parent, top-level, has admin COMMUNITY_ROOT: 8, // like COMMUNITY_ROOT, but members aren't voiced COMMUNITY_ANNOUNCEMENT_ROOT: 9, // an open subthread. has parent, top-level (not sidebar), and visible to all // members of parent. root ancestor is a COMMUNITY_ROOT COMMUNITY_OPEN_SUBTHREAD: 3, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD: 10, // a secret subthread. optional parent, top-level (not sidebar), visible only // to its members. root ancestor is a COMMUNITY_ROOT COMMUNITY_SECRET_SUBTHREAD: 4, // like COMMUNITY_SECRET_SUBTHREAD, but members aren't voiced COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD: 11, // like COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, but you can't leave GENESIS: 12, }); export type ThinThreadType = $Values; export const nonSidebarThickThreadTypes = Object.freeze({ // local "thick" thread (outside of community). no parent, can only have // sidebar children LOCAL: 13, // canonical thread for each pair of users. represents the friendship PERSONAL: 14, // canonical thread for each single user PRIVATE: 15, }); export type NonSidebarThickThreadType = $Values< typeof nonSidebarThickThreadTypes, >; export const sidebarThickThreadTypes = Object.freeze({ // has parent, not top-level (appears under parent in inbox), and visible to // all members of parent THICK_SIDEBAR: 16, }); export type SidebarThickThreadType = $Values; export const thickThreadTypes = Object.freeze({ ...nonSidebarThickThreadTypes, ...sidebarThickThreadTypes, }); export type ThickThreadType = | NonSidebarThickThreadType | SidebarThickThreadType; export type ThreadType = ThinThreadType | ThickThreadType; export const threadTypes = Object.freeze({ ...thinThreadTypes, ...thickThreadTypes, }); const thickThreadTypesSet = new Set(Object.values(thickThreadTypes)); export function threadTypeIsThick(threadType: ThreadType): boolean { return thickThreadTypesSet.has(threadType); } export function assertThinThreadType(threadType: number): ThinThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6 || threadType === 7 || threadType === 8 || threadType === 9 || threadType === 10 || threadType === 11 || threadType === 12, 'number is not ThinThreadType enum', ); return threadType; } export function assertThickThreadType(threadType: number): ThickThreadType { invariant( threadType === 13 || threadType === 14 || threadType === 15 || threadType === 16, 'number is not ThickThreadType enum', ); return threadType; } export const thickThreadTypeValidator: TRefinement = tNumEnum( values(thickThreadTypes), ); export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6 || threadType === 7 || threadType === 8 || threadType === 9 || threadType === 10 || threadType === 11 || threadType === 12 || threadType === 13 || threadType === 14 || threadType === 15 || threadType === 16, 'number is not ThreadType enum', ); return threadType; } export const threadTypeValidator: TRefinement = tNumEnum( values(threadTypes), ); export const communityThreadTypes: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_ROOT, threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, threadTypes.GENESIS, ]); export const announcementThreadTypes: $ReadOnlyArray = Object.freeze([ threadTypes.GENESIS, threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, ]); export const communitySubthreads: $ReadOnlyArray = Object.freeze([ threadTypes.COMMUNITY_OPEN_SUBTHREAD, threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD, threadTypes.COMMUNITY_SECRET_SUBTHREAD, threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD, ]); export const sidebarThreadTypes: $ReadOnlyArray = Object.freeze([ threadTypes.SIDEBAR, threadTypes.THICK_SIDEBAR, ]); +export const personalThreadTypes: $ReadOnlyArray = Object.freeze([ + threadTypes.PERSONAL, + threadTypes.GENESIS_PERSONAL, +]); + export function threadTypeIsCommunityRoot(threadType: ThreadType): boolean { return communityThreadTypes.includes(threadType); } export function threadTypeIsAnnouncementThread( threadType: ThreadType, ): boolean { return announcementThreadTypes.includes(threadType); } export function threadTypeIsSidebar(threadType: ThreadType): boolean { return sidebarThreadTypes.includes(threadType); } + +export function threadTypeIsPersonal(threadType: ThreadType): boolean { + return personalThreadTypes.includes(threadType); +} diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 87cf0354a..05ea16b7b 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1352 +1,1350 @@ // @flow import type { BottomTabNavigationEventMap, BottomTabOptions, TabNavigationState, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { fetchPrimaryInviteLinkActionTypes, useFetchPrimaryInviteLinks, } from 'lib/actions/link-actions.js'; import { changeThreadMemberRolesActionTypes, changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react.js'; import { useAddUsersPermissions } from 'lib/permissions/add-users-permissions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { childThreadInfos, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils.js'; import { getSingleOtherUser, threadIsChannel, useThreadHasPermission, useIsThreadInChatList, viewerIsMember, } from 'lib/shared/thread-utils.js'; import threadWatcher from 'lib/shared/thread-watcher.js'; import type { RelativeMemberInfo, ResolvedThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RelationshipButton } from 'lib/types/relationship-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { - threadTypes, threadTypeIsSidebar, threadTypeIsThick, + threadTypeIsPersonal, } from 'lib/types/thread-types-enum.js'; import type { UserInfos } from 'lib/types/user-types.js'; import { useResolvedOptionalThreadInfo, useResolvedOptionalThreadInfos, useResolvedThreadInfo, } from 'lib/utils/entity-helpers.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import ThreadSettingsAvatar from './thread-settings-avatar.react.js'; import type { CategoryType } from './thread-settings-category.react.js'; import { ThreadSettingsCategoryActionHeader, ThreadSettingsCategoryFooter, ThreadSettingsCategoryHeader, } from './thread-settings-category.react.js'; import ThreadSettingsChildThread from './thread-settings-child-thread.react.js'; import ThreadSettingsColor from './thread-settings-color.react.js'; import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react.js'; import ThreadSettingsDescription from './thread-settings-description.react.js'; import ThreadSettingsEditRelationship from './thread-settings-edit-relationship.react.js'; import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react.js'; import { ThreadSettingsAddMember, ThreadSettingsAddSubchannel, ThreadSettingsSeeMore, } from './thread-settings-list-action.react.js'; import ThreadSettingsMediaGallery from './thread-settings-media-gallery.react.js'; import ThreadSettingsMember from './thread-settings-member.react.js'; import ThreadSettingsName from './thread-settings-name.react.js'; import ThreadSettingsParent from './thread-settings-parent.react.js'; import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react.js'; import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react.js'; import ThreadSettingsVisibility from './thread-settings-visibility.react.js'; import ThreadAncestors from '../../components/thread-ancestors.react.js'; import { KeyboardContext, type KeyboardState, } from '../../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { InviteLinkNavigatorRouteName, ViewInviteLinksRouteName, ManagePublicLinkRouteName, AddUsersModalRouteName, } from '../../navigation/route-names.js'; import { ComposeSubchannelModalRouteName, FullScreenThreadMediaGalleryRouteName, type NavigationRoute, type ScreenParamList, } from '../../navigation/route-names.js'; import type { TabNavigationProp } from '../../navigation/tab-navigator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import type { AppState } from '../../redux/state-types.js'; import { type IndicatorStyle, useIndicatorStyle, useStyles, } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import type { ViewStyle } from '../../types/styles.js'; import type { ChatNavigationProp } from '../chat.react.js'; 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: 'actionHeader', +key: string, +title: string, +actionText: string, +onPress: () => void, } | { +itemType: 'footer', +key: string, +categoryType: CategoryType, } | { +itemType: 'avatar', +key: string, +threadInfo: ResolvedThreadInfo, +canChangeSettings: boolean, } | { +itemType: 'name', +key: string, +threadInfo: ResolvedThreadInfo, +nameEditValue: ?string, +canChangeSettings: boolean, } | { +itemType: 'color', +key: string, +threadInfo: ResolvedThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, } | { +itemType: 'description', +key: string, +threadInfo: ResolvedThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, } | { +itemType: 'parent', +key: string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, } | { +itemType: 'visibility', +key: string, +threadInfo: ResolvedThreadInfo, } | { +itemType: 'pushNotifs', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, } | { +itemType: 'seeMore', +key: string, +onPress: () => void, } | { +itemType: 'childThread', +key: string, +threadInfo: ResolvedThreadInfo, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +key: string, } | { +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ResolvedThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, } | { +itemType: 'addMember', +key: string, } | { +itemType: 'mediaGallery', +key: string, +threadInfo: ThreadInfo, +limit: number, +verticalBounds: ?VerticalBounds, } | { +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, } | { +itemType: 'editRelationship', +key: string, +threadInfo: ResolvedThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, +relationshipButton: RelationshipButton, }; const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; type BaseProps = { +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, }; type Props = { ...BaseProps, // Redux state +userInfos: UserInfos, +viewerID: ?string, +threadInfo: ResolvedThreadInfo, +parentThreadInfo: ?ResolvedThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: $ReadOnly, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: boolean, +canEditThreadAvatar: boolean, +canEditThreadName: boolean, +canEditThreadDescription: boolean, +canEditThreadColor: boolean, +canCreateSubchannels: boolean, +canAddMembers: boolean, +canLeaveThread: boolean, +canDeleteThread: boolean, +canManageInviteLinks: boolean, +inviteLinkExists: boolean, }; type State = { +numMembersShowing: number, +numSubchannelsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, }; type PropsAndState = { ...Props, ...State }; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); this.state = { numMembersShowing: itemPageLength, numSubchannelsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, descriptionTextHeight: null, colorEditValue: props.threadInfo.color, verticalBounds: null, }; } static scrollDisabled(props: Props): boolean { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const prevThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if ( !tinycolor.equals(newThreadInfo.color, prevThreadInfo.color) && tinycolor.equals(this.state.colorEditValue, prevThreadInfo.color) ) { this.setState({ colorEditValue: newThreadInfo.color }); } if (defaultStackScreenOptions.gestureEnabled) { 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: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.nameEditValue, (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, (propsAndState: PropsAndState) => propsAndState.canEditThreadAvatar, (propsAndState: PropsAndState) => propsAndState.canEditThreadName, (propsAndState: PropsAndState) => propsAndState.canEditThreadDescription, (propsAndState: PropsAndState) => propsAndState.canEditThreadColor, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, canEditThreadAvatar: boolean, canEditThreadName: boolean, canEditThreadDescription: boolean, canEditThreadColor: boolean, ) => { const canChangeAvatar = canEditThreadAvatar && canStartEditing; const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'avatarHeader', title: 'Channel Avatar', categoryType: 'unpadded', }); listData.push({ itemType: 'avatar', key: 'avatar', threadInfo, canChangeSettings: canChangeAvatar, }); listData.push({ itemType: 'footer', key: 'avatarFooter', categoryType: 'outline', }); listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, canChangeSettings: canChangeName, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings: canChangeColor, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThreadDescription ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings: canChangeDescription, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'notificationsHeader', title: 'Notifications', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, navigate, }); listData.push({ itemType: 'footer', key: 'notificationsFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, (propsAndState: PropsAndState) => propsAndState.canCreateSubchannels, ( threadInfo: ResolvedThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubchannelsShowing: number, canCreateSubchannels: boolean, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; if (subchannels.length === 0 && !canCreateSubchannels) { return listData; } listData.push({ itemType: 'header', key: 'subchannelHeader', title: 'Subchannels', categoryType: 'unpadded', }); if (canCreateSubchannels) { listData.push({ itemType: 'addSubchannel', key: 'addSubchannel', }); } const numItems = Math.min(numSubchannelsShowing, subchannels.length); for (let i = 0; i < numItems; i++) { const subchannelInfo = subchannels[i]; listData.push({ itemType: 'childThread', key: `childThread${subchannelInfo.id}`, threadInfo: subchannelInfo, firstListItem: i === 0 && !canCreateSubchannels, lastListItem: i === numItems - 1 && numItems === subchannels.length, }); } if (numItems < subchannels.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubchannels', onPress: this.onPressSeeMoreSubchannels, }); } listData.push({ itemType: 'footer', key: 'subchannelFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector: PropsAndState => $ReadOnlyArray = 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 => threadTypeIsSidebar(childThreadInfo.type), ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Threads', 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, 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: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (propsAndState: PropsAndState) => propsAndState.canAddMembers, ( threadInfo: ResolvedThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, canAddMembers: boolean, ) => { const listData: ChatSettingsItem[] = []; if (threadInfo.members.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, threadInfo.members.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadInfo.members[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadInfo.members.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadInfo.members.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); mediaGalleryListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (threadInfo: ThreadInfo, verticalBounds: ?VerticalBounds) => { const listData: ChatSettingsItem[] = []; const limit = 6; if (threadTypeIsThick(threadInfo.type)) { return listData; } listData.push({ itemType: 'mediaGallery', key: 'mediaGallery', threadInfo, limit, verticalBounds, }); return listData; }, ); actionsListDataSelector: PropsAndState => $ReadOnlyArray = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.canLeaveThread, (propsAndState: PropsAndState) => propsAndState.canDeleteThread, ( threadInfo: ResolvedThreadInfo, parentThreadInfo: ?ResolvedThreadInfo, navigate: ThreadSettingsNavigate, styles: $ReadOnly, userInfos: UserInfos, viewerID: ?string, canLeaveThread: boolean, canDeleteThread: boolean, ) => { const buttons = []; if (this.props.canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } - const threadIsPersonal = - threadInfo.type === threadTypes.GENESIS_PERSONAL; - if (threadIsPersonal && viewerID) { + if (threadInfo && threadTypeIsPersonal(threadInfo.type) && viewerID) { const otherMemberID = getSingleOtherUser(threadInfo, viewerID); if (otherMemberID) { 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++) { // 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: PropsAndState => $ReadOnlyArray = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.mediaGalleryListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: $ReadOnlyArray, subchannelsListData: $ReadOnlyArray, sidebarsListData: $ReadOnlyArray, threadMembersListData: $ReadOnlyArray, mediaGalleryListData: $ReadOnlyArray, actionsListData: $ReadOnlyArray, ) => [ ...threadBasicsListData, ...subchannelsListData, ...sidebarsListData, ...threadMembersListData, ...mediaGalleryListData, ...actionsListData, ], ); get listData(): $ReadOnlyArray { return this.listDataSelector({ ...this.props, ...this.state }); } render(): React.Node { 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 } }); }); }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return renderItem = (row: { +item: ChatSettingsItem, ... }): React.Node => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'actionHeader') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'avatar') { 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 === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubchannel') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'mediaGallery') { 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); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubchannel = () => { this.props.navigation.navigate(ComposeSubchannelModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); }; onPressAddMember = () => { if (this.props.inviteLinkExists) { this.props.navigation.navigate(InviteLinkNavigatorRouteName, { screen: ViewInviteLinksRouteName, params: { community: this.props.threadInfo, }, }); } else if (this.props.canManageInviteLinks) { this.props.navigation.navigate(InviteLinkNavigatorRouteName, { screen: ManagePublicLinkRouteName, params: { community: this.props.threadInfo, }, }); } else { this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo: this.props.threadInfo, }); } }; onPressSeeMoreMembers = () => { this.setState(prevState => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubchannels = () => { this.setState(prevState => ({ numSubchannelsShowing: prevState.numSubchannelsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState(prevState => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; onPressSeeMoreMediaGallery = () => { this.props.navigation.navigate(FullScreenThreadMediaGalleryRouteName, { threadInfo: this.props.threadInfo, }); }; } const threadMembersChangeIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { for (const 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; }; const ConnectedThreadSettings: React.ComponentType = 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 reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); React.useEffect(() => { invariant( reduxThreadInfo, 'ReduxThreadInfo should exist when ThreadSettings is opened', ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo: ThreadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const isThreadInChatList = useIsThreadInChatList(threadInfo); React.useEffect(() => { if (isThreadInChatList) { return undefined; } threadWatcher.watchID(threadInfo.id); return () => { threadWatcher.removeID(threadInfo.id); }; }, [isThreadInChatList, threadInfo.id]); const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const resolvedParentThreadInfo = useResolvedOptionalThreadInfo(parentThreadInfo); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const resolvedChildThreadInfos = useResolvedOptionalThreadInfos( boundChildThreadInfos, ); const somethingIsSaving = useSelector(state => { const editNameLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:name`, )(state); const editColorLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:color`, )(state); const editDescriptionLoadingStatus = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:${threadID}:description`, )(state); const leaveThreadLoadingStatus = createLoadingStatusSelector( leaveThreadActionTypes, `${leaveThreadActionTypes.started}:${threadID}`, )(state); const boundThreadMembersChangeIsSaving = threadMembersChangeIsSaving( state, threadMembers, ); return ( boundThreadMembersChangeIsSaving || editNameLoadingStatus === 'loading' || editColorLoadingStatus === 'loading' || editDescriptionLoadingStatus === 'loading' || leaveThreadLoadingStatus === 'loading' ); }); const { navigation } = props; React.useEffect(() => { const tabNavigation = navigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !somethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, somethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); const canEditThreadAvatar = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_AVATAR, ); const canEditThreadName = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canCreateSubchannels = useThreadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); const canLeaveThread = useThreadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); const canDeleteThread = useThreadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); const { inviteLink, canManageLinks, canAddMembers, isCommunityRoot } = useAddUsersPermissions(threadInfo); const callFetchPrimaryLinks = useFetchPrimaryInviteLinks(); const dispatchActionPromise = useDispatchActionPromise(); // Because we don't support updates and persistance for invite links, // we have to fetch them whenever we want to display them. // Here we need invite links for the "Add users" button in ThreadSettings React.useEffect(() => { if (!isCommunityRoot) { return; } void dispatchActionPromise( fetchPrimaryInviteLinkActionTypes, callFetchPrimaryLinks(), ); }, [callFetchPrimaryLinks, dispatchActionPromise, isCommunityRoot]); return ( ); }); export default ConnectedThreadSettings; diff --git a/web/modals/threads/settings/thread-settings-modal.react.js b/web/modals/threads/settings/thread-settings-modal.react.js index 9acd0e907..6a5289721 100644 --- a/web/modals/threads/settings/thread-settings-modal.react.js +++ b/web/modals/threads/settings/thread-settings-modal.react.js @@ -1,310 +1,317 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils.js'; import { getSingleOtherUser, threadUIName, useThreadHasPermission, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { RelationshipButton } from 'lib/types/relationship-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; -import { threadTypes } from 'lib/types/thread-types-enum.js'; +import { + threadTypeIsPersonal, + threadTypes, +} from 'lib/types/thread-types-enum.js'; import { type ThreadChanges } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import ThreadSettingsDeleteButton from './thread-settings-delete-button.react.js'; import ThreadSettingsDeleteTab from './thread-settings-delete-tab.react.js'; import ThreadSettingsGeneralTab from './thread-settings-general-tab.react.js'; import css from './thread-settings-modal.css'; import ThreadSettingsPrivacyTab from './thread-settings-privacy-tab.react.js'; import ThreadSettingsRelationshipTab from './thread-settings-relationship-tab.react.js'; import ThreadSettingsSaveButton from './thread-settings-save-button.react.js'; import Tabs, { type TabData } from '../../../components/tabs.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; import Modal from '../../modal.react.js'; type TabType = 'general' | 'privacy' | 'delete' | 'relationship'; type BaseProps = { +threadID: string, }; const deleteThreadLoadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); const ConnectedThreadSettingsModal: React.ComponentType = React.memo(function ConnectedThreadSettingsModal(props) { const changeInProgress = useSelector( state => deleteThreadLoadingStatusSelector(state) === 'loading' || changeThreadSettingsLoadingStatusSelector(state) === 'loading', ); const threadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[props.threadID], ); const modalContext = useModalContext(); const [errorMessage, setErrorMessage] = React.useState(''); const [currentTabType, setCurrentTabType] = React.useState('general'); const [queuedChanges, setQueuedChanges] = React.useState( Object.freeze({}), ); const threadInfoWithNoName = React.useMemo(() => { invariant(threadInfo, 'threadInfo should exist in threadInfoWithNoName'); if (threadInfo.name === null || threadInfo.name === undefined) { return threadInfo; } const withNoName = { ...threadInfo, name: undefined }; return { ...withNoName, uiName: threadUIName(withNoName), }; }, [threadInfo]); const resolvedThreadInfo = useResolvedThreadInfo(threadInfoWithNoName); const namePlaceholder = resolvedThreadInfo.uiName; const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const userInfos = useSelector(state => state.userStore.userInfos); const otherMemberID = React.useMemo(() => { if (!threadInfo) { return null; } return getSingleOtherUser(threadInfo, viewerID); }, [threadInfo, viewerID]); const otherUserInfo = otherMemberID ? userInfos[otherMemberID] : null; const availableRelationshipActions = React.useMemo(() => { - if (!otherUserInfo) { + if ( + !otherUserInfo || + !threadInfo?.type || + !threadTypeIsPersonal(threadInfo.type) + ) { return ([]: RelationshipButton[]); } return getAvailableRelationshipButtons(otherUserInfo); - }, [otherUserInfo]); + }, [otherUserInfo, threadInfo?.type]); const canEditThreadName = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadColor = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canEditThreadDescription = useThreadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadPermissions = useThreadHasPermission( threadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canDeleteThread = useThreadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); const hasPermissionForTab = React.useCallback( // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return (tab: TabType) => { if (tab === 'general') { return ( canEditThreadName || canEditThreadColor || canEditThreadDescription ); } else if (tab === 'privacy') { return canEditThreadPermissions; } else if (tab === 'delete') { return canDeleteThread; } else if (tab === 'relationship') { return true; } invariant(false, `invalid tab: ${tab}`); }, [ canEditThreadName, canEditThreadColor, canEditThreadDescription, canEditThreadPermissions, canDeleteThread, ], ); React.useEffect(() => { if ( currentTabType !== 'general' && !hasPermissionForTab(currentTabType) ) { setCurrentTabType('general'); } }, [currentTabType, hasPermissionForTab]); React.useEffect(() => () => setErrorMessage(''), [currentTabType]); const tabsData: $ReadOnlyArray> = React.useMemo(() => { if (!threadInfo) { return []; } const result = [{ id: 'general', header: 'General' }]; // This UI needs to be updated to handle sidebars but we haven't gotten // there yet. We'll probably end up ripping it out anyways, so for now we // are just hiding the privacy tab for any thread that was created as a // sidebar const canSeePrivacyTab = (queuedChanges['parentThreadID'] ?? threadInfo['parentThreadID']) && !threadInfo.sourceMessageID && (threadInfo.type === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadInfo.type === threadTypes.COMMUNITY_SECRET_SUBTHREAD); if (canSeePrivacyTab) { result.push({ id: 'privacy', header: 'Privacy' }); } if (availableRelationshipActions.length > 0 && otherUserInfo) { result.push({ id: 'relationship', header: 'Relationship' }); } if (hasPermissionForTab('delete')) { result.push({ id: 'delete', header: 'Delete' }); } return result; }, [ availableRelationshipActions.length, hasPermissionForTab, otherUserInfo, queuedChanges, threadInfo, ]); const tabs = React.useMemo( () => ( ), [currentTabType, tabsData], ); const tabContent = React.useMemo(() => { if (!threadInfo) { return null; } if (currentTabType === 'general') { return ( ); } if (currentTabType === 'privacy') { return ( ); } if (currentTabType === 'relationship') { invariant(otherUserInfo, 'otherUserInfo should be set'); return ( ); } return ; }, [ availableRelationshipActions, changeInProgress, currentTabType, namePlaceholder, otherUserInfo, queuedChanges, threadInfo, ]); const primaryButton = React.useMemo(() => { if (!threadInfo) { return null; } if (currentTabType === 'general' || currentTabType === 'privacy') { return ( ); } if (currentTabType === 'relationship') { return
; } return ( ); }, [changeInProgress, currentTabType, queuedChanges, threadInfo]); if (!threadInfo) { return (

You no longer have permission to view this chat

); } return (
{tabContent}
{errorMessage}
); }); export default ConnectedThreadSettingsModal;