diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js index ee02d2611..6d4eab1b5 100644 --- a/native/chat/chat-list.react.js +++ b/native/chat/chat-list.react.js @@ -1,295 +1,299 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { - FlatList, Animated, Easing, StyleSheet, TouchableWithoutFeedback, View, + FlatList as ReactNativeFlatList, } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; import { localIDPrefix } from 'lib/shared/message-utils'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import type { TabNavigationProp } from '../navigation/tab-navigator.react'; import { useSelector } from '../redux/redux-utils'; import type { ChatMessageItemWithHeight } from '../types/chat-types'; import type { ScrollEvent } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import NewMessagesPill from './new-messages-pill.react'; import { chatMessageItemHeight, chatMessageItemKey } from './utils'; +type FlatListElementRef = React.ElementRef; +type FlatListProps = React.ElementConfig; + const animationSpec = { duration: 150, useNativeDriver: true, }; type BaseProps = { - ...React.ElementConfig, + ...FlatListProps, +navigation: ChatNavigationProp<'MessageList'>, +data: $ReadOnlyArray, ... }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, // withKeyboardState +keyboardState: ?KeyboardState, ... }; type State = { +newMessageCount: number, }; class ChatList extends React.PureComponent { state: State = { newMessageCount: 0, }; - flatList: ?React.ElementRef; + flatList: ?FlatListElementRef; scrollPos = 0; newMessagesPillProgress = new Animated.Value(0); newMessagesPillStyle: ViewStyle; constructor(props: Props) { super(props); const sendButtonTranslateY = this.newMessagesPillProgress.interpolate({ inputRange: [0, 1], outputRange: ([10, 0]: number[]), // Flow... }); this.newMessagesPillStyle = { opacity: this.newMessagesPillProgress, transform: [{ translateY: sendButtonTranslateY }], }; } componentDidMount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { const { flatList } = this; if (!this.props.navigation.isFocused() || !flatList) { return; } if (this.scrollPos > 0) { flatList.scrollToOffset({ offset: 0 }); } else { this.props.navigation.popToTop(); } }; get scrolledToBottom() { return this.scrollPos <= 0; } componentDidUpdate(prevProps: Props) { const { flatList } = this; if (!flatList || this.props.data === prevProps.data) { return; } if (this.props.data.length < prevProps.data.length) { // This should only happen due to MessageStorePruner, // which will only prune a thread when it is off-screen flatList.scrollToOffset({ offset: 0, animated: false }); return; } const { scrollPos } = this; let curDataIndex = 0, prevDataIndex = 0, heightSoFar = 0; let adjustScrollPos = 0, newLocalMessage = false, newRemoteMessageCount = 0; while (prevDataIndex < prevProps.data.length && heightSoFar <= scrollPos) { const prevItem = prevProps.data[prevDataIndex]; invariant(prevItem, 'prevItem should exist'); const prevItemKey = chatMessageItemKey(prevItem); const prevItemHeight = chatMessageItemHeight(prevItem); let curItem = this.props.data[curDataIndex]; while (curItem) { const curItemKey = chatMessageItemKey(curItem); if (curItemKey === prevItemKey) { break; } if (curItemKey.startsWith(localIDPrefix)) { newLocalMessage = true; } else if ( curItem.itemType === 'message' && curItem.messageInfo.creator.id !== this.props.viewerID ) { newRemoteMessageCount++; } adjustScrollPos += chatMessageItemHeight(curItem); curDataIndex++; curItem = this.props.data[curDataIndex]; } if (!curItem) { // Should never happen... console.log( `items not removed from ChatList, but ${prevItemKey} now missing`, ); return; } const curItemHeight = chatMessageItemHeight(curItem); adjustScrollPos += curItemHeight - prevItemHeight; heightSoFar += prevItemHeight; prevDataIndex++; curDataIndex++; } if (adjustScrollPos === 0) { return; } flatList.scrollToOffset({ offset: scrollPos + adjustScrollPos, animated: false, }); if (newLocalMessage || scrollPos <= 0) { flatList.scrollToOffset({ offset: 0 }); } else if (newRemoteMessageCount > 0) { this.setState(prevState => ({ newMessageCount: prevState.newMessageCount + newRemoteMessageCount, })); this.toggleNewMessagesPill(true); } } render() { const { navigation, viewerID, ...rest } = this.props; const { newMessageCount } = this.state; return ( 0 ? 'auto' : 'none'} containerStyle={styles.newMessagesPillContainer} style={this.newMessagesPillStyle} /> ); } - flatListRef = (flatList: ?React.ElementRef) => { + flatListRef = (flatList: any) => { this.flatList = flatList; }; static getItemLayout = ( data: ?$ReadOnlyArray, index: number, ) => { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatList.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? chatMessageItemHeight(item) : 0; return { length, offset, index }; }; static heightOfItems( data: $ReadOnlyArray, ): number { return _sum(data.map(chatMessageItemHeight)); } toggleNewMessagesPill(show: boolean) { Animated.timing(this.newMessagesPillProgress, { ...animationSpec, easing: show ? Easing.ease : Easing.out(Easing.ease), toValue: show ? 1 : 0, }).start(({ finished }) => { if (finished && !show) { this.setState({ newMessageCount: 0 }); } }); } onScroll = (event: ScrollEvent) => { this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos <= 0) { this.toggleNewMessagesPill(false); } this.props.onScroll && this.props.onScroll(event); }; onPressNewMessagesPill = () => { const { flatList } = this; if (!flatList) { return; } flatList.scrollToOffset({ offset: 0 }); this.toggleNewMessagesPill(false); }; onPressBackground = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const styles = StyleSheet.create({ container: { flex: 1, }, newMessagesPillContainer: { bottom: 30, position: 'absolute', right: 30, }, }); const ConnectedChatList: React.ComponentType = React.memo( function ConnectedChatList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); }, ); export default ConnectedChatList; diff --git a/native/chat/parent-thread-header.react.js b/native/chat/parent-thread-header.react.js index 852af8f82..128168840 100644 --- a/native/chat/parent-thread-header.react.js +++ b/native/chat/parent-thread-header.react.js @@ -1,70 +1,71 @@ // @flow import * as React from 'react'; -import { View, Text, ScrollView } from 'react-native'; +import { View, Text } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import type { ThreadInfo, ThreadType } from 'lib/types/thread-types'; import Button from '../components/button.react'; import CommunityPill from '../components/community-pill.react'; import ThreadVisibility from '../components/thread-visibility.react'; import { useColors, useStyles } from '../themes/colors'; import { useNavigateToThread } from './message-list-types'; type Props = { +parentThreadInfo: ThreadInfo, +childThreadType: ThreadType, }; function ParentThreadHeader(props: Props): React.Node { const colors = useColors(); const threadVisibilityColor = colors.modalForegroundLabel; const styles = useStyles(unboundStyles); const { parentThreadInfo, childThreadType } = props; const navigateToThread = useNavigateToThread(); const onPressParentThread = React.useCallback(() => { navigateToThread({ threadInfo: parentThreadInfo }); }, [parentThreadInfo, navigateToThread]); return ( within ); } const height = 48; const unboundStyles = { container: { height, backgroundColor: 'panelForeground', borderBottomWidth: 1, borderBottomColor: 'panelForegroundBorder', }, contentContainer: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 12, }, within: { color: 'modalSubtextLabel', fontSize: 16, paddingHorizontal: 6, }, }; export default ParentThreadHeader; diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index 6b79bdecf..374fd4c87 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,274 +1,274 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, - ScrollView, Alert, ActivityIndicator, } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { deleteThreadActionTypes, deleteThread, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../../components/button.react'; import { clearThreadsActionType } from '../../navigation/action-types'; import { NavContext, type NavAction, } from '../../navigation/navigation-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../../themes/colors'; import type { GlobalTheme } from '../../types/themes'; import type { ChatNavigationProp } from '../chat.react'; export type DeleteThreadParams = { +threadInfo: ThreadInfo, }; type BaseProps = { +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, }; type Props = { ...BaseProps, // Redux state +threadInfo: ?ThreadInfo, +loadingStatus: LoadingStatus, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: (threadID: string) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, }; class DeleteThread extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; static getThreadInfo(props: Props): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } guardedSetState(change, callback) { if (this.mounted) { this.setState(change, callback); } } componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete chat ); const threadInfo = DeleteThread.getThreadInfo(this.props); return ( {`The chat "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. ); } passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; async deleteThread() { const threadInfo = DeleteThread.getThreadInfo(this.props); const { navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread(threadInfo.id); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Permission not granted', 'You do not have permission to delete this thread', [{ text: 'OK' }], { cancelable: false }, ); } else { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } } } } const unboundStyles = { deleteButton: { backgroundColor: 'redButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const ConnectedDeleteThread: React.ComponentType = React.memo( function ConnectedDeleteThread(props: BaseProps) { const threadID = props.route.params.threadInfo.id; const threadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const loadingStatus = useSelector(loadingStatusSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useServerCall(deleteThread); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; return ( ); }, ); export default ConnectedDeleteThread; diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index 5cf58bbc3..02cf994de 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1124 +1,1125 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; -import { View, FlatList, Platform } from 'react-native'; +import { View, Platform } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; import { createSelector } from 'reselect'; import tinycolor from 'tinycolor2'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; import { usePromoteSidebar } from 'lib/hooks/promote-sidebar.react'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector, childThreadInfos, } from 'lib/selectors/thread-selectors'; import { getAvailableRelationshipButtons } from 'lib/shared/relationship-utils'; import { threadHasPermission, viewerIsMember, threadInChatList, getSingleOtherUser, threadIsChannel, } 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 ThreadAncestors from '../../components/thread-ancestors.react'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; import { defaultStackScreenOptions } from '../../navigation/options'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; import type { NavigationRoute } from '../../navigation/route-names'; import { AddUsersModalRouteName, ComposeSubchannelModalRouteName, } from '../../navigation/route-names'; import type { TabNavigationProp } from '../../navigation/tab-navigator.react'; import { useSelector } from '../../redux/redux-utils'; import type { AppState } from '../../redux/state-types'; 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, ThreadSettingsAddSubchannel, } 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, +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, +parentThreadInfo: ?ThreadInfo, } | { +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, +firstListItem: boolean, +lastListItem: boolean, } | { +itemType: 'addSubchannel', +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, +parentThreadInfo: ?ThreadInfo, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, +canPromoteSidebar: 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) { 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 = 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, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, nameEditValue: ?string, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThreadName = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_NAME, ); const canEditThreadDescription = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_DESCRIPTION, ); const canEditThreadColor = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD_COLOR, ); const canChangeName = canEditThreadName && canStartEditing; const canChangeDescription = canEditThreadDescription && canStartEditing; const canChangeColor = canEditThreadColor && canStartEditing; const listData: ChatSettingsItem[] = []; 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: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); if (threadInfo.type !== threadTypes.SIDEBAR) { 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: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, parentThreadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subchannelsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfo, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubchannelsShowing, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubchannelsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subchannels = childThreads?.filter(threadIsChannel) ?? []; const canCreateSubchannels = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBCHANNELS, ); 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 = 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: '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 = 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, ( threadInfo: ThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); 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; }, ); actionsListDataSelector = 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, ( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, userInfos: UserInfos, viewerID: ?string, ) => { const buttons = []; if (this.props.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); 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 = createSelector( this.threadBasicsListDataSelector, this.subchannelsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subchannelsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subchannelsListData, ...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 === 'addSubchannel') { 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); }; 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 = () => { 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, })); }; } 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 (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; React.useEffect(() => { if (threadInChatList(threadInfo)) { return undefined; } threadWatcher.watchID(threadInfo.id); return () => { threadWatcher.removeID(threadInfo.id); }; }, [threadInfo]); const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo: ?ThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const threadMembers = threadInfo.members; const boundChildThreadInfos = useSelector( state => childThreadInfos(state)[threadID], ); const boundSomethingIsSaving = useSelector(state => somethingIsSaving(state, threadMembers), ); const { navigation } = props; React.useEffect(() => { const tabNavigation: ?TabNavigationProp<'Chat'> = navigation.getParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); const onTabPress = () => { if (navigation.isFocused() && !boundSomethingIsSaving) { navigation.popToTop(); } }; tabNavigation.addListener('tabPress', onTabPress); return () => tabNavigation.removeListener('tabPress', onTabPress); }, [navigation, boundSomethingIsSaving]); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const { canPromoteSidebar } = usePromoteSidebar(threadInfo); return ( ); }, ); export default ConnectedThreadSettings; diff --git a/native/components/thread-ancestors.react.js b/native/components/thread-ancestors.react.js index 566f8b85e..7a3163aaf 100644 --- a/native/components/thread-ancestors.react.js +++ b/native/components/thread-ancestors.react.js @@ -1,111 +1,112 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5'; import * as React from 'react'; -import { View, ScrollView } from 'react-native'; +import { View } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { ancestorThreadInfos } from 'lib/selectors/thread-selectors'; import type { ThreadInfo } from 'lib/types/thread-types'; import { useNavigateToThread } from '../chat/message-list-types'; import { useSelector } from '../redux/redux-utils'; import { useColors, useStyles } from '../themes/colors'; import Button from './button.react'; import CommunityPill from './community-pill.react'; import ThreadPill from './thread-pill.react'; type Props = { +threadInfo: ThreadInfo, }; function ThreadAncestors(props: Props): React.Node { const { threadInfo } = props; const styles = useStyles(unboundStyles); const colors = useColors(); const ancestorThreads: $ReadOnlyArray = useSelector( ancestorThreadInfos(threadInfo.id), ); const rightArrow = React.useMemo( () => ( ), [colors.panelForegroundLabel], ); const navigateToThread = useNavigateToThread(); const pathElements = React.useMemo(() => { const elements = []; for (const [idx, ancestorThreadInfo] of ancestorThreads.entries()) { const isLastThread = idx === ancestorThreads.length - 1; const pill = idx === 0 ? ( ) : ( ); elements.push( {!isLastThread ? rightArrow : null} , ); } return {elements}; }, [ ancestorThreads, navigateToThread, rightArrow, styles.pathItem, styles.row, ]); return ( {pathElements} ); } const height = 48; const unboundStyles = { arrowIcon: { paddingHorizontal: 8, }, container: { height, backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', }, contentContainer: { paddingHorizontal: 12, }, pathItem: { alignItems: 'center', flexDirection: 'row', height, }, row: { flexDirection: 'row', }, }; export default ThreadAncestors; diff --git a/native/profile/appearance-preferences.react.js b/native/profile/appearance-preferences.react.js index 5e36a23fe..0d8a6725d 100644 --- a/native/profile/appearance-preferences.react.js +++ b/native/profile/appearance-preferences.react.js @@ -1,174 +1,175 @@ // @flow import * as React from 'react'; -import { View, Text, ScrollView, Platform } from 'react-native'; +import { View, Text, Platform } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { useDispatch } from 'react-redux'; import type { Dispatch } from 'lib/types/redux-types'; import Button from '../components/button.react'; import SWMansionIcon from '../components/swmansion-icon.react'; import { updateThemeInfoActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import { type GlobalThemePreference, type GlobalThemeInfo, osCanTheme, } from '../types/themes'; const CheckIcon = () => ( ); type OptionText = { themePreference: GlobalThemePreference, text: string, }; const optionTexts: OptionText[] = [ { themePreference: 'light', text: 'Light' }, { themePreference: 'dark', text: 'Dark' }, ]; if (osCanTheme) { optionTexts.push({ themePreference: 'system', text: 'Follow system preferences', }); } type Props = { +globalThemeInfo: GlobalThemeInfo, +styles: typeof unboundStyles, +colors: Colors, +dispatch: Dispatch, ... }; class AppearancePreferences extends React.PureComponent { render() { const { panelIosHighlightUnderlay: underlay } = this.props.colors; const options = []; for (let i = 0; i < optionTexts.length; i++) { const { themePreference, text } = optionTexts[i]; const icon = themePreference === this.props.globalThemeInfo.preference ? ( ) : null; options.push( , ); if (i + 1 < optionTexts.length) { options.push( , ); } } return ( APP THEME {options} ); } onSelectThemePreference = (themePreference: GlobalThemePreference) => { if (themePreference === this.props.globalThemeInfo.preference) { return; } const theme = themePreference === 'system' ? this.props.globalThemeInfo.systemTheme : themePreference; this.props.dispatch({ type: updateThemeInfoActionType, payload: { preference: themePreference, activeTheme: theme, }, }); }; } const unboundStyles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, option: { color: 'panelForegroundLabel', fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, }; const ConnectedAppearancePreferences: React.ComponentType<{ ... }> = React.memo<{ ... }>(function ConnectedAppearancePreferences(props: { ... }) { const globalThemeInfo = useSelector(state => state.globalThemeInfo); const styles = useStyles(unboundStyles); const colors = useColors(); const dispatch = useDispatch(); return ( ); }); export default ConnectedAppearancePreferences; diff --git a/native/profile/build-info.react.js b/native/profile/build-info.react.js index 88d31f6fc..5b27a0187 100644 --- a/native/profile/build-info.react.js +++ b/native/profile/build-info.react.js @@ -1,117 +1,118 @@ // @flow import * as React from 'react'; -import { View, Text, ScrollView } from 'react-native'; +import { View, Text } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { persistConfig, codeVersion } from '../redux/persist'; import { StaffContext } from '../staff/staff-context'; import { useStyles } from '../themes/colors'; import { isStaffRelease, useIsCurrentUserStaff, useStaffCanSee, } from '../utils/staff-utils'; // eslint-disable-next-line no-unused-vars function BuildInfo(props: { ... }): React.Node { const isCurrentUserStaff = useIsCurrentUserStaff(); const { staffUserHasBeenLoggedIn } = React.useContext(StaffContext); const styles = useStyles(unboundStyles); const staffCanSee = useStaffCanSee(); let staffCanSeeRows; if (staffCanSee || staffUserHasBeenLoggedIn) { staffCanSeeRows = ( <> __DEV__ {__DEV__ ? 'TRUE' : 'FALSE'} Staff Release {isStaffRelease ? 'TRUE' : 'FALSE'} isCurrentUserStaff {isCurrentUserStaff ? 'TRUE' : 'FALSE'} hasStaffUserLoggedIn {staffUserHasBeenLoggedIn ? 'TRUE' : 'FALSE'} ); } return ( Code version {codeVersion} State version {persistConfig.version} {staffCanSeeRows} Thank you for using Comm! ); } const unboundStyles = { label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingRight: 12, }, releaseText: { color: 'orange', fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingHorizontal: 24, paddingVertical: 6, }, text: { color: 'panelForegroundLabel', fontSize: 16, }, thanksText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, textAlign: 'center', }, }; export default BuildInfo; diff --git a/native/profile/default-notifications-preferences.react.js b/native/profile/default-notifications-preferences.react.js index aeff32cbb..b56e5bfaa 100644 --- a/native/profile/default-notifications-preferences.react.js +++ b/native/profile/default-notifications-preferences.react.js @@ -1,211 +1,212 @@ // @flow import * as React from 'react'; -import { View, Text, ScrollView, Platform, Alert } from 'react-native'; +import { View, Text, Platform, Alert } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { setUserSettings, setUserSettingsActionTypes, } from 'lib/actions/user-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type UpdateUserSettingsRequest, type NotificationTypes, type DefaultNotificationPayload, notificationTypes, userSettingsTypes, } from 'lib/types/account-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Action from '../components/action-row.react'; import SWMansionIcon from '../components/swmansion-icon.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; import type { ProfileNavigationProp } from './profile.react'; const CheckIcon = () => ( ); type ProfileRowProps = { +content: string, +onPress: () => void, +danger?: boolean, +selected?: boolean, }; function NotificationRow(props: ProfileRowProps): React.Node { const { content, onPress, danger, selected } = props; return ( {selected ? : null} ); } type BaseProps = { +navigation: ProfileNavigationProp<>, +route: NavigationRoute<'DefaultNotifications'>, }; type Props = { ...BaseProps, +styles: typeof unboundStyles, +dispatchActionPromise: DispatchActionPromise, +changeNotificationSettings: ( notificationSettingsRequest: UpdateUserSettingsRequest, ) => Promise, +selectedDefaultNotification: NotificationTypes, }; class DefaultNotificationsPreferences extends React.PureComponent { async updatedDefaultNotifications( data: NotificationTypes, ): Promise { const { changeNotificationSettings } = this.props; try { await changeNotificationSettings({ name: userSettingsTypes.DEFAULT_NOTIFICATIONS, data, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: () => {} }], { cancelable: false }, ); } return { [userSettingsTypes.DEFAULT_NOTIFICATIONS]: data, }; } selectNotificationSetting = (data: NotificationTypes) => { const { dispatchActionPromise } = this.props; dispatchActionPromise( setUserSettingsActionTypes, this.updatedDefaultNotifications(data), ); }; selectAllNotifications = () => { this.selectNotificationSetting(notificationTypes.FOCUSED); }; selectBackgroundNotifications = () => { this.selectNotificationSetting(notificationTypes.BACKGROUND); }; selectNoneNotifications = () => { this.selectNotificationSetting(notificationTypes.BADGE_ONLY); }; render() { const { styles, selectedDefaultNotification } = this.props; return ( NOTIFICATIONS ); } } const unboundStyles = { scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, marginVertical: 2, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, }; registerFetchKey(setUserSettingsActionTypes); const ConnectedDefaultNotificationPreferences: React.ComponentType = React.memo( function ConnectedDefaultNotificationPreferences(props: BaseProps) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const changeNotificationSettings = useServerCall(setUserSettings); const defaultNotification = userSettingsTypes.DEFAULT_NOTIFICATIONS; const selectedDefaultNotification = useSelector( ({ currentUserInfo }) => { if ( currentUserInfo?.settings && currentUserInfo?.settings[defaultNotification] ) { return currentUserInfo?.settings[defaultNotification]; } return notificationTypes.FOCUSED; }, ); return ( ); }, ); export default ConnectedDefaultNotificationPreferences; diff --git a/native/profile/delete-account.react.js b/native/profile/delete-account.react.js index ebe8ed299..53cca58cf 100644 --- a/native/profile/delete-account.react.js +++ b/native/profile/delete-account.react.js @@ -1,279 +1,279 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, - ScrollView, Alert, ActivityIndicator, } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { deleteAccountActionTypes, deleteAccount, } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { accountHasPassword } from 'lib/shared/account-utils'; import type { LogOutResult } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { PreRequestUserState } from 'lib/types/session-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { deleteNativeCredentialsFor } from '../account/native-credentials'; import Button from '../components/button.react'; import TextInput from '../components/text-input.react'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { GlobalTheme } from '../types/themes'; type Props = { // Redux state +isAccountWithPassword: boolean, +loadingStatus: LoadingStatus, +preRequestUserState: PreRequestUserState, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteAccount: ( password: ?string, preRequestUserState: PreRequestUserState, ) => Promise, }; type State = { +password: ?string, }; class DeleteAccount extends React.PureComponent { state: State = { password: null, }; mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete account ); const { panelForegroundTertiaryLabel } = this.props.colors; let inputPasswordPrompt; if (this.props.isAccountWithPassword) { inputPasswordPrompt = ( <> PASSWORD ); } return ( Your account will be permanently deleted. There is no way to reverse this. {inputPasswordPrompt} ); } onChangePasswordText = (newPassword: string) => { this.setState({ password: newPassword }); }; passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteAccountActionTypes, this.deleteAccount(), ); }; async deleteAccount() { try { await deleteNativeCredentialsFor(); const result = await this.props.deleteAccount( this.state.password, this.props.preRequestUserState, ); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } } } onErrorAlertAcknowledged = () => { this.setState({ password: '' }, this.focusPasswordInput); }; } const unboundStyles = { deleteButton: { backgroundColor: 'redButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, lastWarningText: { marginBottom: 24, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteAccountActionTypes, ); const ConnectedDeleteAccount: React.ComponentType<{ ... }> = React.memo<{ ... }>(function ConnectedDeleteAccount() { const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); const loadingStatus = useSelector(loadingStatusSelector); const preRequestUserState = useSelector(preRequestUserStateSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteAccount = useServerCall(deleteAccount); return ( ); }); export default ConnectedDeleteAccount; diff --git a/native/profile/dev-tools.react.js b/native/profile/dev-tools.react.js index 3b455094d..f505c2c13 100644 --- a/native/profile/dev-tools.react.js +++ b/native/profile/dev-tools.react.js @@ -1,255 +1,256 @@ // @flow import * as React from 'react'; -import { View, Text, ScrollView, Platform } from 'react-native'; +import { View, Text, Platform } from 'react-native'; import ExitApp from 'react-native-exit-app'; +import { ScrollView } from 'react-native-gesture-handler'; import { useDispatch } from 'react-redux'; import type { Dispatch } from 'lib/types/redux-types'; import { setURLPrefix } from 'lib/utils/url-utils'; import Button from '../components/button.react'; import SWMansionIcon from '../components/swmansion-icon.react'; import type { NavigationRoute } from '../navigation/route-names'; import { CustomServerModalRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useColors, useStyles, type Colors } from '../themes/colors'; import { wipeAndExit } from '../utils/crash-utils'; import { checkForMissingNatDevHostname } from '../utils/dev-hostname'; import { nodeServerOptions } from '../utils/url-utils'; import type { ProfileNavigationProp } from './profile.react'; const ServerIcon = () => ( ); type BaseProps = { +navigation: ProfileNavigationProp<'DevTools'>, +route: NavigationRoute<'DevTools'>, }; type Props = { ...BaseProps, +urlPrefix: string, +customServer: ?string, +colors: Colors, +styles: typeof unboundStyles, +dispatch: Dispatch, }; class DevTools extends React.PureComponent { render() { const { panelIosHighlightUnderlay: underlay } = this.props.colors; const serverButtons = []; for (const server of nodeServerOptions) { const icon = server === this.props.urlPrefix ? : null; serverButtons.push( , ); serverButtons.push( , ); } const customServerLabel = this.props.customServer ? ( {'custom: '} {this.props.customServer} ) : ( custom ); const customServerIcon = this.props.customServer === this.props.urlPrefix ? : null; serverButtons.push( , ); return ( SERVER {serverButtons} ); } onPressCrash = () => { throw new Error('User triggered crash through dev menu!'); }; onPressKill = () => { ExitApp.exitApp(); }; onPressWipe = async () => { await wipeAndExit(); }; onSelectServer = (server: string) => { if (server !== this.props.urlPrefix) { this.props.dispatch({ type: setURLPrefix, payload: server, }); } }; onSelectCustomServer = () => { checkForMissingNatDevHostname(); this.props.navigation.navigate(CustomServerModalRouteName, { presentedFrom: this.props.route.key, }); }; } const unboundStyles = { container: { flex: 1, }, customServerLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, redText: { color: 'redText', flex: 1, fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, serverContainer: { flex: 1, }, serverText: { color: 'panelForegroundLabel', fontSize: 16, }, slightlyPaddedSection: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, }; const ConnectedDevTools: React.ComponentType = React.memo( function ConnectedDevTools(props: BaseProps) { const urlPrefix = useSelector(state => state.urlPrefix); const customServer = useSelector(state => state.customServer); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); return ( ); }, ); export default ConnectedDevTools; diff --git a/native/profile/edit-password.react.js b/native/profile/edit-password.react.js index d21a32e17..1c3cf849b 100644 --- a/native/profile/edit-password.react.js +++ b/native/profile/edit-password.react.js @@ -1,376 +1,376 @@ // @flow import { CommonActions } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, - ScrollView, Alert, ActivityIndicator, } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { changeUserPasswordActionTypes, changeUserPassword, } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { PasswordUpdate } from 'lib/types/user-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { setNativeCredentials } from '../account/native-credentials'; import Button from '../components/button.react'; import TextInput from '../components/text-input.react'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { GlobalTheme } from '../types/themes'; import type { ProfileNavigationProp } from './profile.react'; type BaseProps = { +navigation: ProfileNavigationProp<'EditPassword'>, +route: NavigationRoute<'EditPassword'>, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +username: ?string, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeUserPassword: (passwordUpdate: PasswordUpdate) => Promise, }; type State = { +currentPassword: string, +newPassword: string, +confirmPassword: string, }; class EditPassword extends React.PureComponent { state: State = { currentPassword: '', newPassword: '', confirmPassword: '', }; mounted = false; currentPasswordInput: ?React.ElementRef; newPasswordInput: ?React.ElementRef; confirmPasswordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Save ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( CURRENT PASSWORD NEW PASSWORD ); } onChangeCurrentPassword = (currentPassword: string) => { this.setState({ currentPassword }); }; currentPasswordRef = ( currentPasswordInput: ?React.ElementRef, ) => { this.currentPasswordInput = currentPasswordInput; }; focusCurrentPassword = () => { invariant(this.currentPasswordInput, 'currentPasswordInput should be set'); this.currentPasswordInput.focus(); }; onChangeNewPassword = (newPassword: string) => { this.setState({ newPassword }); }; newPasswordRef = ( newPasswordInput: ?React.ElementRef, ) => { this.newPasswordInput = newPasswordInput; }; focusNewPassword = () => { invariant(this.newPasswordInput, 'newPasswordInput should be set'); this.newPasswordInput.focus(); }; onChangeConfirmPassword = (confirmPassword: string) => { this.setState({ confirmPassword }); }; confirmPasswordRef = ( confirmPasswordInput: ?React.ElementRef, ) => { this.confirmPasswordInput = confirmPasswordInput; }; focusConfirmPassword = () => { invariant(this.confirmPasswordInput, 'confirmPasswordInput should be set'); this.confirmPasswordInput.focus(); }; goBackOnce() { this.props.navigation.dispatch(state => ({ ...CommonActions.goBack(), target: state.key, })); } submitPassword = () => { if (this.state.newPassword === '') { Alert.alert( 'Empty password', 'New password cannot be empty', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword !== this.state.confirmPassword) { Alert.alert( 'Passwords don’t match', 'New password fields must contain the same password', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword === this.state.currentPassword) { this.goBackOnce(); } else { this.props.dispatchActionPromise( changeUserPasswordActionTypes, this.savePassword(), ); } }; async savePassword() { const { username } = this.props; if (!username) { return; } try { await this.props.changeUserPassword({ updatedFields: { password: this.state.newPassword, }, currentPassword: this.state.currentPassword, }); await setNativeCredentials({ username, password: this.state.newPassword, }); this.goBackOnce(); } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The current password you entered is incorrect', [{ text: 'OK', onPress: this.onCurrentPasswordAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } } } onNewPasswordAlertAcknowledged = () => { this.setState( { newPassword: '', confirmPassword: '' }, this.focusNewPassword, ); }; onCurrentPasswordAlertAcknowledged = () => { this.setState({ currentPassword: '' }, this.focusCurrentPassword); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentPassword: '', newPassword: '', confirmPassword: '' }, this.focusCurrentPassword, ); }; } const unboundStyles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 9, }, saveButton: { backgroundColor: 'greenButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 3, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeUserPasswordActionTypes, ); const ConnectedEditPassword: React.ComponentType = React.memo( function ConnectedEditPassword(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const username = useSelector(state => { if (state.currentUserInfo && !state.currentUserInfo.anonymous) { return state.currentUserInfo.username; } return undefined; }); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeUserPassword = useServerCall(changeUserPassword); return ( ); }, ); export default ConnectedEditPassword; diff --git a/native/profile/privacy-preferences.react.js b/native/profile/privacy-preferences.react.js index 682f7b153..bbf51d4e5 100644 --- a/native/profile/privacy-preferences.react.js +++ b/native/profile/privacy-preferences.react.js @@ -1,74 +1,75 @@ // @flow import * as React from 'react'; -import { View, Text, ScrollView } from 'react-native'; +import { View, Text } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { useStyles } from '../themes/colors'; import ToggleReport from './toggle-report.react'; // eslint-disable-next-line no-unused-vars function PrivacyPreferences(props: { ... }): React.Node { const styles = useStyles(unboundStyles); return ( REPORTS Toggle crash reports Toggle media reports Toggle inconsistency reports ); } const unboundStyles = { scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, marginVertical: 2, }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, submenuButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, alignItems: 'center', }, submenuText: { color: 'panelForegroundLabel', flex: 1, fontSize: 16, }, }; export default PrivacyPreferences; diff --git a/native/profile/relationship-list.react.js b/native/profile/relationship-list.react.js index 92ee4df4c..5eb531e59 100644 --- a/native/profile/relationship-list.react.js +++ b/native/profile/relationship-list.react.js @@ -1,574 +1,575 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; -import { View, Text, FlatList, Alert, Platform } from 'react-native'; +import { View, Text, Alert, Platform } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; import { createSelector } from 'reselect'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions'; import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { type UserRelationships, type RelationshipRequest, type RelationshipErrors, userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types'; import type { UserSearchResult } from 'lib/types/search-types'; import type { UserInfos, GlobalAccountUserInfo, AccountUserInfo, } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import LinkButton from '../components/link-button.react'; import { createTagInput, BaseTagInput } from '../components/tag-input.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { FriendListRouteName, BlockListRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; import type { ProfileNavigationProp } from './profile.react'; import RelationshipListItem from './relationship-list-item.react'; const TagInput = createTagInput(); export type RelationshipListNavigate = $PropertyType< ProfileNavigationProp<'FriendList' | 'BlockList'>, 'navigate', >; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; type ListItem = | { +type: 'empty', +because: 'no-relationships' | 'no-results' } | { +type: 'header' } | { +type: 'footer' } | { +type: 'user', +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, }; type BaseProps = { +navigation: ProfileNavigationProp<>, +route: NavigationRoute<'FriendList' | 'BlockList'>, }; type Props = { ...BaseProps, // Redux state +relationships: UserRelationships, +userInfos: UserInfos, +viewerID: ?string, +userStoreSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, +updateRelationships: ( request: RelationshipRequest, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, }; type State = { +verticalBounds: ?VerticalBounds, +searchInputText: string, +serverSearchResults: $ReadOnlyArray, +currentTags: $ReadOnlyArray, +userStoreSearchResults: Set, }; type PropsAndState = { ...Props, ...State }; class RelationshipList extends React.PureComponent { flatListContainerRef = React.createRef(); tagInput: ?BaseTagInput = null; state: State = { verticalBounds: null, searchInputText: '', serverSearchResults: [], userStoreSearchResults: new Set(), currentTags: [], }; componentDidMount() { this.setSaveButton(false); } componentDidUpdate(prevProps: Props, prevState: State) { const prevTags = prevState.currentTags.length; const currentTags = this.state.currentTags.length; if (prevTags !== 0 && currentTags === 0) { this.setSaveButton(false); } else if (prevTags === 0 && currentTags !== 0) { this.setSaveButton(true); } } setSaveButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } static keyExtractor = (item: ListItem) => { if (item.userInfo) { return item.userInfo.id; } else if (item.type === 'empty') { return 'empty'; } else if (item.type === 'header') { return 'header'; } else if (item.type === 'footer') { return 'footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'RelationshipList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = RelationshipList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } render() { const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( Search: ); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.relationships, (propsAndState: PropsAndState) => propsAndState.route.name, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (propsAndState: PropsAndState) => propsAndState.searchInputText, (propsAndState: PropsAndState) => propsAndState.serverSearchResults, (propsAndState: PropsAndState) => propsAndState.userStoreSearchResults, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.currentTags, ( relationships: UserRelationships, routeName: 'FriendList' | 'BlockList', verticalBounds: ?VerticalBounds, searchInputText: string, serverSearchResults: $ReadOnlyArray, userStoreSearchResults: Set, userInfos: UserInfos, viewerID: ?string, currentTags: $ReadOnlyArray, ) => { const defaultUsers = { [FriendListRouteName]: relationships.friends, [BlockListRouteName]: relationships.blocked, }[routeName]; const excludeUserIDsArray = currentTags .map(userInfo => userInfo.id) .concat(viewerID || []); const excludeUserIDs = new Set(excludeUserIDsArray); let displayUsers = defaultUsers; if (searchInputText !== '') { const mergedUserInfos: { [id: string]: AccountUserInfo } = {}; for (const userInfo of serverSearchResults) { mergedUserInfos[userInfo.id] = userInfo; } for (const id of userStoreSearchResults) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedUserInfos[id] = { id, username, relationshipStatus }; } } const sortToEnd = []; const userSearchResults = []; const sortRelationshipTypesToEnd = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], }[routeName]; for (const userID in mergedUserInfos) { if (excludeUserIDs.has(userID)) { continue; } const userInfo = mergedUserInfos[userID]; if ( sortRelationshipTypesToEnd.includes(userInfo.relationshipStatus) ) { sortToEnd.push(userInfo); } else { userSearchResults.push(userInfo); } } displayUsers = userSearchResults.concat(sortToEnd); } let emptyItem; if (displayUsers.length === 0 && searchInputText === '') { emptyItem = { type: 'empty', because: 'no-relationships' }; } else if (displayUsers.length === 0) { emptyItem = { type: 'empty', because: 'no-results' }; } const mappedUsers = displayUsers.map((userInfo, index) => ({ type: 'user', userInfo, lastListItem: displayUsers.length - 1 === index, verticalBounds, })); return [] .concat(emptyItem ? emptyItem : []) .concat(emptyItem ? [] : { type: 'header' }) .concat(mappedUsers) .concat(emptyItem ? [] : { type: 'footer' }); }, ); tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; tagDataLabelExtractor = (userInfo: GlobalAccountUserInfo) => userInfo.username; onChangeTagInput = (currentTags: $ReadOnlyArray) => { this.setState({ currentTags }); }; onChangeSearchText = async (searchText: string) => { const excludeStatuses = { [FriendListRouteName]: [ userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], [BlockListRouteName]: [], }[this.props.route.name]; const results = this.props.userStoreSearchIndex .getSearchResults(searchText) .filter(userID => { const relationship = this.props.userInfos[userID].relationshipStatus; return !excludeStatuses.includes(relationship); }); this.setState({ searchInputText: searchText, userStoreSearchResults: new Set(results), }); const serverSearchResults = await this.searchUsers(searchText); const filteredServerSearchResults = serverSearchResults.filter( searchUserInfo => { const userInfo = this.props.userInfos[searchUserInfo.id]; return ( !userInfo || !excludeStatuses.includes(userInfo.relationshipStatus) ); }, ); this.setState({ serverSearchResults: filteredServerSearchResults }); }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos; } onFlatListContainerLayout = () => { const { flatListContainerRef } = this; if (!flatListContainerRef.current) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainerRef.current.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }, ); }; onSelect = (selectedUser: GlobalAccountUserInfo) => { this.setState(state => { if (state.currentTags.find(o => o.id === selectedUser.id)) { return null; } return { searchInputText: '', currentTags: state.currentTags.concat(selectedUser), }; }); }; onPressAdd = () => { if (this.state.currentTags.length === 0) { return; } this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateRelationships(), ); }; async updateRelationships() { const routeName = this.props.route.name; const action = { [FriendListRouteName]: relationshipActions.FRIEND, [BlockListRouteName]: relationshipActions.BLOCK, }[routeName]; const userIDs = this.state.currentTags.map(userInfo => userInfo.id); try { const result = await this.props.updateRelationships({ action, userIDs, }); this.setState({ currentTags: [], searchInputText: '', }); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: true, onDismiss: this.onUnknownErrorAlertAcknowledged }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentTags: [], searchInputText: '', }, this.onErrorAcknowledged, ); }; renderItem = ({ item }: { item: ListItem, ... }) => { if (item.type === 'empty') { const action = { [FriendListRouteName]: 'added', [BlockListRouteName]: 'blocked', }[this.props.route.name]; const emptyMessage = item.because === 'no-relationships' ? `You haven't ${action} any users yet` : 'No results'; return {emptyMessage}; } else if (item.type === 'header' || item.type === 'footer') { return ; } else if (item.type === 'user') { return ( ); } else { invariant(false, `unexpected RelationshipList item type ${item.type}`); } }; } const unboundStyles = { container: { flex: 1, backgroundColor: 'panelBackground', }, contentContainer: { paddingTop: 12, paddingBottom: 24, }, separator: { backgroundColor: 'panelForegroundBorder', height: Platform.OS === 'android' ? 1.5 : 1, }, emptyText: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, textAlign: 'center', paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 12, }, tagInput: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingLeft: 12, }, tagInputContainer: { alignItems: 'center', backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; registerFetchKey(searchUsersActionTypes); registerFetchKey(updateRelationshipsActionTypes); const ConnectedRelationshipList: React.ComponentType = React.memo( function ConnectedRelationshipList(props: BaseProps) { const relationships = useSelector(userRelationshipsSelector); const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const dispatchActionPromise = useDispatchActionPromise(); const callSearchUsers = useServerCall(searchUsers); const callUpdateRelationships = useServerCall(updateRelationships); return ( ); }, ); export default ConnectedRelationshipList;