diff --git a/native/chat/chat-thread-list-item.react.js b/native/chat/chat-thread-list-item.react.js index 7eeeda098..0bbe08130 100644 --- a/native/chat/chat-thread-list-item.react.js +++ b/native/chat/chat-thread-list-item.react.js @@ -1,297 +1,297 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js'; import MessagePreview from './message-preview.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import ThreadAvatar from '../avatars/thread-avatar.react.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import ThreadAncestorsLabel from '../components/thread-ancestors-label.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { +data: ChatThreadItem, +onPressItem: ( - threadInfo: LegacyThreadInfo, + threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, ) => void, - +onPressSeeMoreSidebars: (threadInfo: LegacyThreadInfo) => void, - +onSwipeableWillOpen: (threadInfo: LegacyThreadInfo) => void, + +onPressSeeMoreSidebars: (threadInfo: ThreadInfo) => void, + +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, }; function ChatThreadListItem({ data, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, currentlyOpenedSwipeableId, }: Props): React.Node { const styles = useStyles(unboundStyles); const colors = useColors(); const lastMessage = React.useMemo(() => { const mostRecentMessageInfo = data.mostRecentMessageInfo; if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [data.mostRecentMessageInfo, data.threadInfo, styles]); const numOfSidebarsWithExtendedArrow = data.sidebars.filter(sidebarItem => sidebarItem.type === 'sidebar').length - 1; const sidebars = React.useMemo( () => data.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return ; } }), [ currentlyOpenedSwipeableId, data.sidebars, data.threadInfo, numOfSidebarsWithExtendedArrow, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, styles.spacer, ], ); const onPress = React.useCallback(() => { onPressItem(data.threadInfo, data.pendingPersonalThreadUserInfo); }, [onPressItem, data.threadInfo, data.pendingPersonalThreadUserInfo]); const threadNameStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.threadName; } return [styles.threadName, styles.unreadThreadName]; }, [ data.threadInfo.currentUser.unread, styles.threadName, styles.unreadThreadName, ]); const lastActivity = shortAbsoluteDate(data.lastUpdatedTime); const lastActivityStyle = React.useMemo(() => { if (!data.threadInfo.currentUser.unread) { return styles.lastActivity; } return [styles.lastActivity, styles.unreadLastActivity]; }, [ data.threadInfo.currentUser.unread, styles.lastActivity, styles.unreadLastActivity, ]); const resolvedThreadInfo = useResolvedThreadInfo(data.threadInfo); const unreadDot = React.useMemo( () => ( ), [data.threadInfo.currentUser.unread, styles.avatarContainer], ); const threadAvatar = React.useMemo( () => ( ), [data.threadInfo, styles.avatarContainer], ); const threadDetails = React.useMemo( () => ( {resolvedThreadInfo.uiName} {lastMessage} {lastActivity} ), [ data.threadInfo, lastActivity, lastActivityStyle, lastMessage, resolvedThreadInfo.uiName, styles.row, styles.threadDetails, threadNameStyle, ], ); const swipeableThreadContent = React.useMemo( () => ( ), [ colors.listIosHighlightUnderlay, onPress, styles.container, styles.content, threadAvatar, threadDetails, unreadDot, ], ); const swipeableThread = React.useMemo( () => ( {swipeableThreadContent} ), [ currentlyOpenedSwipeableId, data.mostRecentNonLocalMessage, data.threadInfo, onSwipeableWillOpen, swipeableThreadContent, ], ); const chatThreadListItem = React.useMemo( () => ( <> {swipeableThread} {sidebars} ), [sidebars, swipeableThread], ); return chatThreadListItem; } const chatThreadListItemHeight = 70; const spacerHeight = 6; const unboundStyles = { container: { height: chatThreadListItemHeight, justifyContent: 'center', backgroundColor: 'listBackground', }, content: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, avatarContainer: { marginLeft: 6, marginBottom: 12, }, threadDetails: { paddingLeft: 12, paddingRight: 18, justifyContent: 'center', flex: 1, marginTop: 5, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, unreadLastActivity: { color: 'listForegroundLabel', fontWeight: 'bold', }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 14, fontStyle: 'italic', }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, threadName: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 21, }, unreadThreadName: { color: 'listForegroundLabel', fontWeight: '500', }, spacer: { height: spacerHeight, }, }; export { ChatThreadListItem, chatThreadListItemHeight, spacerHeight }; diff --git a/native/chat/chat-thread-list-see-more-sidebars.react.js b/native/chat/chat-thread-list-see-more-sidebars.react.js index 4b6d45e29..111a3df31 100644 --- a/native/chat/chat-thread-list-see-more-sidebars.react.js +++ b/native/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,70 +1,70 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import * as React from 'react'; import { Text } from 'react-native'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import { sidebarHeight } from './sidebar-item.react.js'; import Button from '../components/button.react.js'; import { useColors, useStyles } from '../themes/colors.js'; type Props = { - +threadInfo: LegacyThreadInfo, + +threadInfo: ThreadInfo, +unread: boolean, - +onPress: (threadInfo: LegacyThreadInfo) => void, + +onPress: (threadInfo: ThreadInfo) => void, }; function ChatThreadListSeeMoreSidebars(props: Props): React.Node { const { onPress, threadInfo, unread } = props; const onPressButton = React.useCallback( () => onPress(threadInfo), [onPress, threadInfo], ); const colors = useColors(); const styles = useStyles(unboundStyles); const unreadStyle = unread ? styles.unread : null; return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, button: { height: sidebarHeight, flexDirection: 'row', display: 'flex', paddingLeft: 28, paddingRight: 18, alignItems: 'center', backgroundColor: 'listBackground', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, text: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 3, paddingBottom: 2, }, }; export default ChatThreadListSeeMoreSidebars; diff --git a/native/chat/chat-thread-list-sidebar.react.js b/native/chat/chat-thread-list-sidebar.react.js index 6e22a8542..aae0fc121 100644 --- a/native/chat/chat-thread-list-sidebar.react.js +++ b/native/chat/chat-thread-list-sidebar.react.js @@ -1,158 +1,158 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; -import type { LegacyThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; +import type { SidebarInfo, ThreadInfo } from 'lib/types/thread-types.js'; import { SidebarItem, sidebarHeight } from './sidebar-item.react.js'; import SwipeableThread from './swipeable-thread.react.js'; import Button from '../components/button.react.js'; import UnreadDot from '../components/unread-dot.react.js'; import { useColors, useStyles } from '../themes/colors.js'; import ExtendedArrow from '../vectors/arrow-extended.react.js'; import Arrow from '../vectors/arrow.react.js'; type Props = { +sidebarInfo: SidebarInfo, - +onPressItem: (threadInfo: LegacyThreadInfo) => void, - +onSwipeableWillOpen: (threadInfo: LegacyThreadInfo) => void, + +onPressItem: (threadInfo: ThreadInfo) => void, + +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, +extendArrow: boolean, }; function ChatThreadListSidebar(props: Props): React.Node { const colors = useColors(); const styles = useStyles(unboundStyles); const { sidebarInfo, onSwipeableWillOpen, currentlyOpenedSwipeableId, onPressItem, extendArrow = false, } = props; const { threadInfo } = sidebarInfo; const onPress = React.useCallback( () => onPressItem(threadInfo), [threadInfo, onPressItem], ); const arrow = React.useMemo(() => { if (extendArrow) { return ( ); } return ( ); }, [extendArrow, styles.arrow, styles.extendedArrow]); const unreadIndicator = React.useMemo( () => ( ), [ sidebarInfo.threadInfo.currentUser.unread, styles.unreadIndicatorContainer, ], ); const sidebarItem = React.useMemo( () => , [sidebarInfo], ); const swipeableThread = React.useMemo( () => ( {sidebarItem} ), [ currentlyOpenedSwipeableId, onSwipeableWillOpen, sidebarInfo.mostRecentNonLocalMessage, sidebarInfo.threadInfo, sidebarItem, styles.swipeableThreadContainer, ], ); const chatThreadListSidebar = React.useMemo( () => ( ), [ arrow, colors.listIosHighlightUnderlay, onPress, styles.sidebar, swipeableThread, unreadIndicator, ], ); return chatThreadListSidebar; } const unboundStyles = { arrow: { left: 28, position: 'absolute', top: -12, }, extendedArrow: { left: 28, position: 'absolute', top: -6, }, sidebar: { alignItems: 'center', flexDirection: 'row', width: '100%', height: sidebarHeight, paddingLeft: 6, paddingRight: 18, backgroundColor: 'listBackground', }, swipeableThreadContainer: { flex: 1, height: '100%', }, unreadIndicatorContainer: { alignItems: 'center', flexDirection: 'row', justifyContent: 'flex-start', paddingLeft: 6, width: 56, }, }; export default ChatThreadListSidebar; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index ee9001ca6..9ea756624 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,494 +1,494 @@ // @flow import IonIcon from '@expo/vector-icons/Ionicons.js'; import type { TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, StackNavigationState, StackOptions, StackNavigationEventMap, } from '@react-navigation/core'; import invariant from 'invariant'; import * as React from 'react'; import { View, FlatList, Platform, TouchableWithoutFeedback, BackHandler, TextInput, } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { type ChatThreadItem, useFlattenedChatListData, } from 'lib/selectors/chat-selectors.js'; import { createPendingThread, getThreadListSearchResults, useThreadListSearch, } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; -import type { LegacyThreadInfo, ThreadInfo } from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { UserInfo } from 'lib/types/user-types.js'; import { ChatThreadListItem } from './chat-thread-list-item.react.js'; import ChatThreadListSearch from './chat-thread-list-search.react.js'; import { getItemLayout, keyExtractor } from './chat-thread-list-utils.js'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import { SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, 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 { indicatorStyleSelector, useStyles } from '../themes/colors.js'; import type { ScrollEvent } from '../types/react-native.js'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; export type Item = | ChatThreadItem | { +type: 'search', +searchText: string } | { +type: 'empty', +emptyItem: React.ComponentType<{}> }; type BaseProps = { +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{}>, }; export type SearchStatus = 'inactive' | 'activating' | 'active'; function ChatThreadList(props: BaseProps): React.Node { const boundChatListData = useFlattenedChatListData(); const loggedInUserInfo = useLoggedInUserInfo(); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); const navigateToThread = useNavigateToThread(); const { navigation, route, filterThreads, emptyItem } = props; const [searchText, setSearchText] = React.useState(''); const [searchStatus, setSearchStatus] = React.useState('inactive'); const { threadSearchResults, usersSearchResults } = useThreadListSearch( searchText, loggedInUserInfo?.id, ); const [openedSwipeableID, setOpenedSwipeableID] = React.useState(''); const [numItemsToDisplay, setNumItemsToDisplay] = React.useState(25); const onChangeSearchText = React.useCallback((updatedSearchText: string) => { setSearchText(updatedSearchText); setNumItemsToDisplay(25); }, []); const scrollPos = React.useRef(0); const flatListRef = React.useRef>(); const onScroll = React.useCallback( (event: ScrollEvent) => { const oldScrollPos = scrollPos.current; scrollPos.current = event.nativeEvent.contentOffset.y; if (scrollPos.current !== 0 || oldScrollPos === 0) { return; } if (searchStatus === 'activating') { setSearchStatus('active'); } }, [searchStatus], ); const onSwipeableWillOpen = React.useCallback( (threadInfo: ThreadInfo) => setOpenedSwipeableID(threadInfo.id), [], ); const composeThread = React.useCallback(() => { if (!loggedInUserInfo) { return; } const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }); navigateToThread({ threadInfo, searching: true }); }, [loggedInUserInfo, navigateToThread]); const onSearchFocus = React.useCallback(() => { if (searchStatus !== 'inactive') { return; } if (scrollPos.current === 0) { setSearchStatus('active'); } else { setSearchStatus('activating'); } }, [searchStatus]); const clearSearch = React.useCallback(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } setSearchStatus('inactive'); }, []); const onSearchBlur = React.useCallback(() => { if (searchStatus !== 'active') { return; } clearSearch(); }, [clearSearch, searchStatus]); const onSearchCancel = React.useCallback(() => { onChangeSearchText(''); clearSearch(); }, [clearSearch, onChangeSearchText]); const searchInputRef = React.useRef>(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigateToThread({ threadInfo, pendingPersonalThreadUserInfo }); }, [navigateToThread, onChangeSearchText], ); const onPressSeeMoreSidebars = React.useCallback( - (threadInfo: LegacyThreadInfo) => { + (threadInfo: ThreadInfo) => { onChangeSearchText(''); if (searchInputRef.current) { searchInputRef.current.blur(); } navigation.navigate<'SidebarListModal'>({ name: SidebarListModalRouteName, params: { threadInfo }, }); }, [navigation, onChangeSearchText], ); const hardwareBack = React.useCallback(() => { if (!navigation.isFocused()) { return false; } const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; if (!isActiveOrActivating) { return false; } onSearchCancel(); return true; }, [navigation, onSearchCancel, searchStatus]); const searchItem = React.useMemo( () => ( ), [ onChangeSearchText, onSearchBlur, onSearchCancel, onSearchFocus, searchStatus, searchText, styles.searchContainer, ], ); const renderItem = React.useCallback( (row: { item: Item, ... }) => { const item = row.item; if (item.type === 'search') { return searchItem; } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }, [ onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, openedSwipeableID, searchItem, ], ); const listData: $ReadOnlyArray = React.useMemo(() => { const chatThreadItems = getThreadListSearchResults( boundChatListData, searchText, filterThreads, threadSearchResults, usersSearchResults, loggedInUserInfo, ); const chatItems: Item[] = [...chatThreadItems]; if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } if (searchStatus === 'inactive' || searchStatus === 'activating') { chatItems.unshift({ type: 'search', searchText }); } return chatItems; }, [ boundChatListData, emptyItem, filterThreads, loggedInUserInfo, searchStatus, searchText, threadSearchResults, usersSearchResults, ]); const partialListData: $ReadOnlyArray = React.useMemo( () => listData.slice(0, numItemsToDisplay), [listData, numItemsToDisplay], ); const onEndReached = React.useCallback(() => { if (partialListData.length === listData.length) { return; } setNumItemsToDisplay(prevNumItems => prevNumItems + 25); }, [listData.length, partialListData.length]); const floatingAction = React.useMemo(() => { if (Platform.OS !== 'android') { return null; } return ( ); }, [composeThread]); const fixedSearch = React.useMemo(() => { if (searchStatus !== 'active') { return null; } return ( ); }, [ onChangeSearchText, onSearchBlur, onSearchCancel, searchStatus, searchText, styles.searchContainer, ]); const scrollEnabled = searchStatus === 'inactive' || searchStatus === 'active'; // viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem const viewerID = loggedInUserInfo?.id; const extraData = `${viewerID || ''} ${openedSwipeableID}`; const chatThreadList = React.useMemo( () => ( {fixedSearch} {floatingAction} ), [ extraData, fixedSearch, floatingAction, indicatorStyle, onEndReached, onScroll, partialListData, renderItem, scrollEnabled, styles.container, styles.flatList, ], ); const onTabPress = React.useCallback(() => { if (!navigation.isFocused()) { return; } if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } else if (route.name === BackgroundChatThreadListRouteName) { navigation.navigate({ name: HomeChatThreadListRouteName }); } }, [navigation, route.name]); React.useEffect(() => { const clearNavigationBlurListener = navigation.addListener('blur', () => { setNumItemsToDisplay(25); }); return () => { // `.addListener` returns function that can be called to unsubscribe. // https://reactnavigation.org/docs/navigation-events/#navigationaddlistener clearNavigationBlurListener(); }; }, [navigation]); React.useEffect(() => { const chatNavigation = navigation.getParent< ScreenParamList, 'ChatThreadList', StackNavigationState, StackOptions, StackNavigationEventMap, ChatNavigationProp<'ChatThreadList'>, >(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation = chatNavigation.getParent< ScreenParamList, 'Chat', TabNavigationState, BottomTabOptions, BottomTabNavigationEventMap, TabNavigationProp<'Chat'>, >(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', onTabPress); return () => { tabNavigation.removeListener('tabPress', onTabPress); }; }, [navigation, onTabPress]); React.useEffect(() => { BackHandler.addEventListener('hardwareBackPress', hardwareBack); return () => { BackHandler.removeEventListener('hardwareBackPress', hardwareBack); }; }, [hardwareBack]); React.useEffect(() => { if (scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: false }); } }, [searchText]); const isSearchActivating = searchStatus === 'activating'; React.useEffect(() => { if (isSearchActivating && scrollPos.current > 0 && flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: true }); } }, [isSearchActivating]); return chatThreadList; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, searchContainer: { backgroundColor: 'listBackground', display: 'flex', justifyContent: 'center', flexDirection: 'row', }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default ChatThreadList; diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js index 1ed512ab7..be49ed120 100644 --- a/native/chat/compose-subchannel.react.js +++ b/native/chat/compose-subchannel.react.js @@ -1,387 +1,386 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { View, Text } from 'react-native'; import { newThreadActionTypes, useNewThread, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils.js'; import { type ThreadType, threadTypes } from 'lib/types/thread-types-enum.js'; -import type { ThreadInfo, LegacyThreadInfo } from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import LinkButton from '../components/link-button.react.js'; import { createTagInput, type BaseTagInput, } from '../components/tag-input.react.js'; import ThreadList from '../components/thread-list.react.js'; import UserList from '../components/user-list.react.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type ComposeSubchannelParams = { +threadType: ThreadType, +parentThreadInfo: ThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'ComposeSubchannel'>, +route: NavigationRoute<'ComposeSubchannel'>, }; function ComposeSubchannel(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const [createButtonEnabled, setCreateButtonEnabled] = React.useState(true); const tagInputRef = React.useRef>(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); tagInputRef.current?.focus(); }, []); const waitingOnThreadIDRef = React.useRef(); const { threadType, parentThreadInfo } = props.route.params; const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const callNewThread = useNewThread(); const calendarQuery = useCalendarQuery(); const newChatThreadAction = React.useCallback(async () => { try { const assumedThreadType = threadType ?? threadTypes.COMMUNITY_SECRET_SUBTHREAD; const query = calendarQuery(); invariant( assumedThreadType === 3 || assumedThreadType === 4 || assumedThreadType === 6 || assumedThreadType === 7, "Sidebars and communities can't be created from the thread composer", ); const result = await callNewThread({ type: assumedThreadType, parentThreadID: parentThreadInfo.id, initialMemberIDs: userInfoInputIDs, color: parentThreadInfo.color, calendarQuery: query, }); waitingOnThreadIDRef.current = result.newThreadID; return result; } catch (e) { setCreateButtonEnabled(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ threadType, userInfoInputIDs, calendarQuery, parentThreadInfo, callNewThread, onUnknownErrorAlertAcknowledged, ]); const dispatchActionPromise = useDispatchActionPromise(); const dispatchNewChatThreadAction = React.useCallback(() => { setCreateButtonEnabled(false); dispatchActionPromise(newThreadActionTypes, newChatThreadAction()); }, [dispatchActionPromise, newChatThreadAction]); const userInfoInputArrayEmpty = userInfoInputArray.length === 0; const onPressCreateThread = React.useCallback(() => { if (!createButtonEnabled) { return; } if (userInfoInputArrayEmpty) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a channel containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { dispatchNewChatThreadAction(); } }, [ createButtonEnabled, userInfoInputArrayEmpty, dispatchNewChatThreadAction, ]); const { navigation } = props; const { setOptions } = navigation; React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => ( ), }); }, [setOptions, onPressCreateThread, createButtonEnabled]); const { setParams } = navigation; const parentThreadInfoID = parentThreadInfo.id; const reduxParentThreadInfo = useSelector( state => threadInfoSelector(state)[parentThreadInfoID], ); React.useEffect(() => { if (reduxParentThreadInfo) { setParams({ parentThreadInfo: reduxParentThreadInfo }); } }, [reduxParentThreadInfo, setParams]); const threadInfos = useSelector(threadInfoSelector); const newlyCreatedThreadInfo = waitingOnThreadIDRef.current ? threadInfos[waitingOnThreadIDRef.current] : null; const { pushNewThread } = navigation; React.useEffect(() => { if (!newlyCreatedThreadInfo) { return; } const waitingOnThreadID = waitingOnThreadIDRef.current; if (waitingOnThreadID === null || waitingOnThreadID === undefined) { return; } waitingOnThreadIDRef.current = undefined; pushNewThread(newlyCreatedThreadInfo); }, [newlyCreatedThreadInfo, pushNewThread]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { community } = parentThreadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType, }), [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, parentThreadInfo, communityThreadInfo, threadType, ], ); - const existingThreads: $ReadOnlyArray = - React.useMemo(() => { - if (userInfoInputIDs.length === 0) { - return []; - } - return _flow( - _filter( - (threadInfo: ThreadInfo) => - threadInFilterList(threadInfo) && - threadInfo.parentThreadID === parentThreadInfo.id && - userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)), - ), - _sortBy( - ([ - 'members.length', - (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), - ]: $ReadOnlyArray mixed)>), - ), - )(threadInfos); - }, [userInfoInputIDs, threadInfos, parentThreadInfo]); + const existingThreads: $ReadOnlyArray = React.useMemo(() => { + if (userInfoInputIDs.length === 0) { + return []; + } + return _flow( + _filter( + (threadInfo: ThreadInfo) => + threadInFilterList(threadInfo) && + threadInfo.parentThreadID === parentThreadInfo.id && + userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)), + ), + _sortBy( + ([ + 'members.length', + (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), + ]: $ReadOnlyArray mixed)>), + ), + )(threadInfos); + }, [userInfoInputIDs, threadInfos, parentThreadInfo]); const navigateToThread = useNavigateToThread(); const onSelectExistingThread = React.useCallback( (threadID: string) => { const threadInfo = threadInfos[threadID]; navigateToThread({ threadInfo }); }, [threadInfos, navigateToThread], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [userInfoInputIDs, otherUserInfos], ); const styles = useStyles(unboundStyles); let existingThreadsSection = null; if (existingThreads.length > 0) { existingThreadsSection = ( Existing channels ); } const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressCreateThread, }), [onPressCreateThread], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( To: {existingThreadsSection} ); } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; const MemoizedComposeSubchannel: React.ComponentType = React.memo(ComposeSubchannel); export default MemoizedComposeSubchannel; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index 4b8c3bb6b..9972e0d53 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,141 +1,141 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useSearchSidebars } from 'lib/hooks/search-threads.js'; -import type { LegacyThreadInfo, SidebarInfo } from 'lib/types/thread-types.js'; +import type { SidebarInfo, ThreadInfo } from 'lib/types/thread-types.js'; import { SidebarItem } from './sidebar-item.react.js'; import ThreadListModal from './thread-list-modal.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; import ExtendedArrow from '../vectors/arrow-extended.react.js'; import Arrow from '../vectors/arrow.react.js'; export type SidebarListModalParams = { - +threadInfo: LegacyThreadInfo, + +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SidebarListModal'>, +route: NavigationRoute<'SidebarListModal'>, }; function SidebarListModal(props: Props): React.Node { const { listData, searchState, setSearchState, onChangeSearchInputText } = useSearchSidebars(props.route.params.threadInfo); const numOfSidebarsWithExtendedArrow = listData.length - 1; const createRenderItem = React.useCallback( - (onPressItem: (threadInfo: LegacyThreadInfo) => void) => + (onPressItem: (threadInfo: ThreadInfo) => void) => // eslint-disable-next-line react/display-name (row: { +item: SidebarInfo, +index: number, ... }) => { let extendArrow: boolean = false; if (row.index < numOfSidebarsWithExtendedArrow) { extendArrow = true; } return ( ); }, [numOfSidebarsWithExtendedArrow], ); return ( ); } function Item(props: { item: SidebarInfo, - onPressItem: (threadInfo: LegacyThreadInfo) => void, + onPressItem: (threadInfo: ThreadInfo) => void, extendArrow: boolean, }): React.Node { const { item, onPressItem, extendArrow } = props; const { threadInfo } = item; const onPressButton = React.useCallback( () => onPressItem(threadInfo), [onPressItem, threadInfo], ); const colors = useColors(); const styles = useStyles(unboundStyles); let arrow; if (extendArrow) { arrow = ( ); } else { arrow = ( ); } return ( ); } const unboundStyles = { arrow: { position: 'absolute', top: -12, }, extendedArrow: { position: 'absolute', top: -6, }, sidebar: { paddingLeft: 0, paddingRight: 5, height: 38, }, sidebarItemContainer: { flex: 1, }, sidebarRowContainer: { flex: 1, flexDirection: 'row', }, spacer: { width: 30, }, }; export default SidebarListModal; diff --git a/native/chat/subchannels-list-modal.react.js b/native/chat/subchannels-list-modal.react.js index eeb68bb5f..4626fb8b9 100644 --- a/native/chat/subchannels-list-modal.react.js +++ b/native/chat/subchannels-list-modal.react.js @@ -1,96 +1,96 @@ // @flow import * as React from 'react'; import { View } from 'react-native'; import { useSearchSubchannels } from 'lib/hooks/search-threads.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; -import { type LegacyThreadInfo } from 'lib/types/thread-types.js'; +import { type ThreadInfo } from 'lib/types/thread-types.js'; import SubchannelItem from './subchannel-item.react.js'; import ThreadListModal from './thread-list-modal.react.js'; import Button from '../components/button.react.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useColors, useStyles } from '../themes/colors.js'; export type SubchannelListModalParams = { - +threadInfo: LegacyThreadInfo, + +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'SubchannelsListModal'>, +route: NavigationRoute<'SubchannelsListModal'>, }; function SubchannelListModal(props: Props): React.Node { const { listData, searchState, setSearchState, onChangeSearchInputText } = useSearchSubchannels(props.route.params.threadInfo); return ( ); } const createRenderItem = - (onPressItem: (threadInfo: LegacyThreadInfo) => void) => + (onPressItem: (threadInfo: ThreadInfo) => void) => // eslint-disable-next-line react/display-name (row: { +item: ChatThreadItem, +index: number, ... }) => { return ; }; function Item(props: { - onPressItem: (threadInfo: LegacyThreadInfo) => void, + onPressItem: (threadInfo: ThreadInfo) => void, subchannelInfo: ChatThreadItem, }): React.Node { const { onPressItem, subchannelInfo } = props; const { threadInfo } = subchannelInfo; const onPressButton = React.useCallback( () => onPressItem(threadInfo), [onPressItem, threadInfo], ); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { subchannel: { paddingLeft: 0, paddingRight: 5, }, subchannelItemContainer: { flex: 1, }, subchannelRowContainer: { flex: 1, flexDirection: 'row', }, }; export default SubchannelListModal; diff --git a/native/chat/swipeable-thread.react.js b/native/chat/swipeable-thread.react.js index d3d162891..914a29f29 100644 --- a/native/chat/swipeable-thread.react.js +++ b/native/chat/swipeable-thread.react.js @@ -1,100 +1,100 @@ // @flow import MaterialIcon from '@expo/vector-icons/MaterialCommunityIcons.js'; import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; // eslint-disable-next-line import/extensions import SwipeableComponent from 'react-native-gesture-handler/Swipeable'; import useToggleUnreadStatus from 'lib/hooks/toggle-unread-status.js'; -import type { LegacyThreadInfo } from 'lib/types/thread-types.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; import Swipeable from '../components/swipeable.js'; import { useColors } from '../themes/colors.js'; type Props = { - +threadInfo: LegacyThreadInfo, + +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, - +onSwipeableWillOpen: (threadInfo: LegacyThreadInfo) => void, + +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId?: string, +iconSize: number, +children: React.Node, }; function SwipeableThread(props: Props): React.Node { const swipeable = React.useRef(); const navigation = useNavigation(); React.useEffect(() => { return navigation.addListener('blur', () => { if (swipeable.current) { swipeable.current.close(); } }); }, [navigation, swipeable]); const { threadInfo, currentlyOpenedSwipeableId } = props; React.useEffect(() => { if (swipeable.current && threadInfo.id !== currentlyOpenedSwipeableId) { swipeable.current.close(); } }, [currentlyOpenedSwipeableId, swipeable, threadInfo.id]); const { onSwipeableWillOpen } = props; const onSwipeableRightWillOpen = React.useCallback(() => { onSwipeableWillOpen(threadInfo); }, [onSwipeableWillOpen, threadInfo]); const colors = useColors(); const { mostRecentNonLocalMessage, iconSize } = props; const swipeableClose = React.useCallback(() => { if (swipeable.current) { swipeable.current.close(); } }, []); const toggleUnreadStatus = useToggleUnreadStatus( threadInfo, mostRecentNonLocalMessage, swipeableClose, ); const swipeableActions = React.useMemo(() => { const isUnread = threadInfo.currentUser.unread; return [ { key: 'action1', onPress: toggleUnreadStatus, color: isUnread ? colors.vibrantRedButton : colors.vibrantGreenButton, content: ( ), }, ]; }, [ threadInfo.currentUser.unread, toggleUnreadStatus, colors.vibrantRedButton, colors.vibrantGreenButton, iconSize, ]); const swipeableThread = React.useMemo( () => ( {props.children} ), [onSwipeableRightWillOpen, props.children, swipeableActions], ); return swipeableThread; } export default SwipeableThread; diff --git a/native/chat/thread-list-modal.react.js b/native/chat/thread-list-modal.react.js index c8c46cac0..6911eac40 100644 --- a/native/chat/thread-list-modal.react.js +++ b/native/chat/thread-list-modal.react.js @@ -1,204 +1,198 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as React from 'react'; import { Text, TextInput, FlatList, View, TouchableOpacity, } from 'react-native'; import type { ThreadSearchState } from 'lib/hooks/search-threads.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import type { SetState } from 'lib/types/hook-types.js'; -import type { - LegacyThreadInfo, - SidebarInfo, - ThreadInfo, -} from 'lib/types/thread-types.js'; +import type { SidebarInfo, ThreadInfo } from 'lib/types/thread-types.js'; import { useNavigateToThread } from './message-list-types.js'; import Modal from '../components/modal.react.js'; import Search from '../components/search.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import ThreadPill from '../components/thread-pill.react.js'; import { useIndicatorStyle, useStyles } from '../themes/colors.js'; import { waitForModalInputFocus } from '../utils/timers.js'; function keyExtractor(sidebarInfo: SidebarInfo | ChatThreadItem) { return sidebarInfo.threadInfo.id; } function getItemLayout( data: ?$ReadOnlyArray, index: number, ) { return { length: 24, offset: 24 * index, index }; } type Props = { +threadInfo: ThreadInfo, - +createRenderItem: ( - onPressItem: (threadInfo: LegacyThreadInfo) => void, - ) => (row: { + +createRenderItem: (onPressItem: (threadInfo: ThreadInfo) => void) => (row: { +item: U, +index: number, ... }) => React.Node, +listData: $ReadOnlyArray, +searchState: ThreadSearchState, +setSearchState: SetState, +onChangeSearchInputText: (text: string) => mixed, +searchPlaceholder?: string, +modalTitle: string, }; function ThreadListModal( props: Props, ): React.Node { const { threadInfo: parentThreadInfo, searchState, setSearchState, onChangeSearchInputText, listData, createRenderItem, searchPlaceholder, modalTitle, } = props; const searchTextInputRef = React.useRef>(); const setSearchTextInputRef = React.useCallback( async (textInput: ?React.ElementRef) => { searchTextInputRef.current = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (searchTextInputRef.current) { searchTextInputRef.current.focus(); } }, [], ); const navigateToThread = useNavigateToThread(); const onPressItem = React.useCallback( (threadInfo: ThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigateToThread({ threadInfo }); }, [navigateToThread, setSearchState], ); const renderItem = React.useMemo( () => createRenderItem(onPressItem), [createRenderItem, onPressItem], ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const navigation = useNavigation(); return ( {modalTitle} ); } const unboundStyles = { parentNameWrapper: { alignItems: 'flex-start', }, body: { paddingHorizontal: 16, flex: 1, }, headerTopRow: { flexDirection: 'row', justifyContent: 'space-between', height: 32, alignItems: 'center', }, header: { borderBottomColor: 'subthreadsModalSearch', borderBottomWidth: 1, height: 94, padding: 16, justifyContent: 'space-between', }, modal: { borderRadius: 8, paddingHorizontal: 0, backgroundColor: 'subthreadsModalBackground', paddingTop: 0, justifyContent: 'flex-start', }, search: { height: 40, marginVertical: 16, backgroundColor: 'subthreadsModalSearch', }, title: { color: 'listForegroundLabel', fontSize: 20, fontWeight: '500', lineHeight: 26, alignSelf: 'center', marginLeft: 2, }, closeIcon: { color: 'subthreadsModalClose', }, closeButton: { marginRight: 2, height: 40, alignItems: 'center', justifyContent: 'center', }, }; export default ThreadListModal;