diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index 64bfcbea9..1186cf4df 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,486 +1,489 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import { useNavigationState } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import genesis from 'lib/facts/genesis.js'; import { useUsersSupportThickThreads } from 'lib/hooks/user-identities-hooks.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems, useSearchUsers, } from 'lib/shared/search-utils.js'; import { pendingThreadType, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { pinnedMessageCountText } from 'lib/utils/message-pinning-utils.js'; import { type MessagesMeasurer, useHeightMeasurer } from './chat-context.js'; import { ChatInputBar } from './chat-input-bar.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import { type NativeChatMessageItem, useNativeMessageListData, } from './message-data.react.js'; import MessageListThreadSearch from './message-list-thread-search.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import MessageList from './message-list.react.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import ContentLoading from '../components/content-loading.react.js'; import { InputStateContext } from '../input/input-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { PinnedMessagesScreenRouteName, ThreadSettingsRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; const unboundStyles = { pinnedCountBanner: { backgroundColor: 'panelForeground', height: 30, flexDirection: 'row', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }, pinnedCountText: { color: 'panelBackgroundLabel', marginRight: 5, }, container: { backgroundColor: 'listBackground', flex: 1, }, threadContent: { flex: 1, }, hiddenThreadContent: { height: 0, opacity: 0, }, }; type BaseProps = { +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +resolveToUser: (user: AccountUserInfo) => Promise, +userSearchResults: $ReadOnlyArray, +threadInfo: ThreadInfo, +genesisThreadInfo: ?ThreadInfo, +messageListData: ?$ReadOnlyArray, +colors: Colors, +styles: $ReadOnly, // withOverlayContext +overlayContext: ?OverlayContextType, +measureMessages: MessagesMeasurer, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, }; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; get frozen(): boolean { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } setListData = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; componentDidMount() { this.props.measureMessages( this.props.messageListData, this.props.threadInfo, this.setListData, ); } componentDidUpdate(prevProps: Props) { const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if ( oldListData !== newListData || prevProps.threadInfo !== this.props.threadInfo || prevProps.measureMessages !== this.props.measureMessages ) { this.props.measureMessages( newListData, this.props.threadInfo, this.allHeightsMeasured, ); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render(): React.Node { const { threadInfo, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { const { userInfoInputArray, genesisThreadInfo } = this.props; - // It's technically possible for the client to be missing the Genesis - // ThreadInfo when it first opens up (before the server delivers it) let parentThreadHeader; if (threadTypeIsThick(threadInfo.type)) { parentThreadHeader = ( ); } else if (genesisThreadInfo) { + // It's technically possible for the client to be missing the Genesis + // ThreadInfo when it first opens up (before the server delivers it) parentThreadHeader = ( ); } searchComponent = ( <> {parentThreadHeader} ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let messageList; if (showMessageList && listDataWithHeights) { messageList = ( ); } else if (showMessageList) { messageList = ( ); } const threadContentStyles = showMessageList ? [styles.threadContent] : [styles.hiddenThreadContent]; const pointerEvents = showMessageList ? 'auto' : 'none'; const threadContent = ( {messageList} ); return ( {searchComponent} {threadContent} ); } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const ConnectedMessageListContainer: React.ComponentType = React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const serverSearchResults = useSearchUsers(usernameInputText); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const viewerID = useSelector(state => state.currentUserInfo?.id); const excludeUserIDs = React.useMemo( () => [ ...userInfoInputArray.map(userInfo => userInfo.id), ...(viewerID && userInfoInputArray.length > 0 ? [viewerID] : []), ], [userInfoInputArray, viewerID], ); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, auxUserInfos, excludeUserIDs, includeServerSearchUsers: serverSearchResults, }); const [baseThreadInfo, setBaseThreadInfo] = React.useState( props.route.params.threadInfo, ); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const checkUsersThickThreadSupport = useUsersSupportThickThreads(); const [allUsersSupportThickThreads, setAllUsersSupportThickThreads] = React.useState(false); React.useEffect(() => { void (async () => { const usersSupportingThickThreads = await checkUsersThickThreadSupport( userInfoInputArray.map(user => user.id), ); setAllUsersSupportThickThreads( userInfoInputArray.every(userInfo => usersSupportingThickThreads.has(userInfo.id), ), ); })(); }, [checkUsersThickThreadSupport, userInfoInputArray]); const isSearching = !!props.route.params.searching; const threadInfo = React.useMemo( () => existingThreadInfoFinder({ searching: isSearching, userInfoInputArray, allUsersSupportThickThreads, }), [ allUsersSupportThickThreads, existingThreadInfoFinder, isSearching, userInfoInputArray, ], ); invariant( threadInfo, 'threadInfo must be specified in messageListContainer', ); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState should be set in MessageListContainer'); const isFocused = props.navigation.isFocused(); const { setPendingThreadUpdateHandler } = inputState; React.useEffect(() => { if (!isFocused) { return undefined; } setPendingThreadUpdateHandler(threadInfo.id, setBaseThreadInfo); return () => { setPendingThreadUpdateHandler(threadInfo.id, undefined); }; }, [setPendingThreadUpdateHandler, isFocused, threadInfo.id]); const { setParams } = props.navigation; const navigationStack = useNavigationState(state => state.routes); React.useEffect(() => { const topRoute = navigationStack[navigationStack.length - 1]; if (topRoute?.name !== ThreadSettingsRouteName) { return; } setBaseThreadInfo(threadInfo); if (isSearching) { setParams({ searching: false }); } }, [isSearching, navigationStack, setParams, threadInfo]); const hideSearch = React.useCallback(() => { setBaseThreadInfo(threadInfo); setParams({ searching: false }); }, [setParams, threadInfo]); React.useEffect(() => { if (!isSearching) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, isSearching]); React.useEffect(() => { setParams({ threadInfo }); }, [setParams, threadInfo]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const { editInputMessage } = inputState; const resolveToUser = React.useCallback( async (user: AccountUserInfo) => { - const usersSupportingThickThreads = await checkUsersThickThreadSupport([ - user.id, - ]); + const newUserInfoInputArray = user.id === viewerID ? [] : [user]; + const usersSupportingThickThreads = await checkUsersThickThreadSupport( + newUserInfoInputArray.map(userInfo => userInfo.id), + ); const resolvedThreadInfo = existingThreadInfoFinder({ searching: true, - userInfoInputArray: [user], - allUsersSupportThickThreads: usersSupportingThickThreads.has(user.id), + userInfoInputArray: newUserInfoInputArray, + allUsersSupportThickThreads: + user.id === viewerID || usersSupportingThickThreads.has(user.id), }); invariant( resolvedThreadInfo, 'resolvedThreadInfo must be specified in messageListContainer', ); editInputMessage({ message: '', mode: 'prepend' }); setBaseThreadInfo(resolvedThreadInfo); setParams({ searching: false, threadInfo: resolvedThreadInfo }); }, [ checkUsersThickThreadSupport, + viewerID, editInputMessage, existingThreadInfoFinder, setParams, ], ); const messageListData = useNativeMessageListData({ searching: isSearching, userInfoInputArray, threadInfo, }); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const measureMessages = useHeightMeasurer(); const genesisThreadInfo = useSelector( state => threadInfoSelector(state)[genesis().id], ); const bannerText = !!threadInfo.pinnedCount && pinnedMessageCountText(threadInfo.pinnedCount); const navigateToMessageResults = React.useCallback(() => { props.navigation.navigate<'PinnedMessagesScreen'>({ name: PinnedMessagesScreenRouteName, params: { threadInfo, }, key: `PinnedMessagesScreen${threadInfo.id}`, }); }, [props.navigation, threadInfo]); const pinnedCountBanner = React.useMemo(() => { if (!bannerText) { return null; } return ( {bannerText} ); }, [ navigateToMessageResults, bannerText, styles.pinnedCountBanner, styles.pinnedCountText, colors.panelBackgroundLabel, ]); return ( {pinnedCountBanner} ); }); export default ConnectedMessageListContainer; diff --git a/native/chat/message-list-thread-search.react.js b/native/chat/message-list-thread-search.react.js index e46ad8474..e8d1af521 100644 --- a/native/chat/message-list-thread-search.react.js +++ b/native/chat/message-list-thread-search.react.js @@ -1,172 +1,175 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { notFriendNotice } from 'lib/shared/search-utils.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { createTagInput } from '../components/tag-input.react.js'; import UserList from '../components/user-list.react.js'; +import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; const TagInput = createTagInput(); type Props = { +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +resolveToUser: (user: AccountUserInfo) => Promise, +userSearchResults: $ReadOnlyArray, }; const inputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const MessageListThreadSearch: React.ComponentType = React.memo( function MessageListThreadSearch({ usernameInputText, updateUsernameInput, userInfoInputArray, updateTagInput, resolveToUser, userSearchResults, }) { const styles = useStyles(unboundStyles); const [userListItems, nonFriends] = React.useMemo(() => { const nonFriendsSet = new Set(); if (userInfoInputArray.length > 0) { return [userSearchResults, nonFriendsSet]; } const userListItemsArr = []; for (const searchResult of userSearchResults) { if (searchResult.notice !== notFriendNotice) { userListItemsArr.push(searchResult); continue; } nonFriendsSet.add(searchResult.id); const { alert, ...rest } = searchResult; userListItemsArr.push(rest); } return [userListItemsArr, nonFriendsSet]; }, [userSearchResults, userInfoInputArray]); + const viewerID = useSelector(state => state.currentUserInfo?.id); const onUserSelect = React.useCallback( async (userInfo: AccountUserInfo) => { for (const existingUserInfo of userInfoInputArray) { if (userInfo.id === existingUserInfo.id) { return; } } - if (nonFriends.has(userInfo.id)) { + if (nonFriends.has(userInfo.id) || userInfo.id === viewerID) { await resolveToUser(userInfo); return; } const newUserInfoInputArray = [...userInfoInputArray, userInfo]; updateUsernameInput(''); updateTagInput(newUserInfoInputArray); }, [ userInfoInputArray, nonFriends, updateTagInput, resolveToUser, updateUsernameInput, + viewerID, ], ); const tagDataLabelExtractor = React.useCallback( (userInfo: AccountUserInfo) => userInfo.username, [], ); const isSearchResultVisible = (userInfoInputArray.length === 0 || usernameInputText.length > 0) && userSearchResults.length > 0; let separator = null; let userList = null; let userSelectionAdditionalStyles = styles.userSelectionLimitedHeight; const userListItemsWithENSNames = useENSNames(userListItems); if (isSearchResultVisible) { userList = ( ); separator = ; userSelectionAdditionalStyles = null; } const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( <> To: {userList} {separator} ); }, ); const unboundStyles = { userSelection: { backgroundColor: 'panelBackground', flex: 1, }, userSelectionLimitedHeight: { flex: 0, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, tagInputContainer: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, tagInput: { flex: 1, }, userList: { backgroundColor: 'modalBackground', paddingLeft: 35, paddingRight: 12, flex: 1, }, separator: { height: 1, backgroundColor: 'modalForegroundBorder', }, }; export default MessageListThreadSearch; diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js index 3f031b7d1..3b04c4e20 100644 --- a/web/chat/chat-thread-composer.react.js +++ b/web/chat/chat-thread-composer.react.js @@ -1,265 +1,272 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { useUsersSupportThickThreads } from 'lib/hooks/user-identities-hooks.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems, useSearchUsers, notFriendNotice, } from 'lib/shared/search-utils.js'; import { createPendingThread, threadIsPending, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import css from './chat-thread-composer.css'; import UserAvatar from '../avatars/user-avatar.react.js'; import Button from '../components/button.react.js'; import Label from '../components/label.react.js'; import Search from '../components/search.react.js'; import type { InputState } from '../input/input-state.js'; import Alert from '../modals/alert.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +userInfoInputArray: $ReadOnlyArray, +threadID: string, +inputState: InputState, }; type ActiveThreadBehavior = | 'reset-active-thread-if-pending' | 'keep-active-thread'; function ChatThreadComposer(props: Props): React.Node { const { userInfoInputArray, threadID, inputState } = props; const [usernameInputText, setUsernameInputText] = React.useState(''); const dispatch = useDispatch(); const loggedInUserInfo = useLoggedInUserInfo(); invariant(loggedInUserInfo, 'loggedInUserInfo should be set'); const viewerID = loggedInUserInfo.id; const excludeUserIDs = React.useMemo( () => [ ...userInfoInputArray.map(userInfo => userInfo.id), ...(viewerID && userInfoInputArray.length > 0 ? [viewerID] : []), ], [userInfoInputArray, viewerID], ); const searchResults = useSearchUsers(usernameInputText); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userListItems = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, auxUserInfos, excludeUserIDs, includeServerSearchUsers: searchResults, }); const userListItemsWithENSNames = useENSNames(userListItems); const { pushModal } = useModalContext(); const pendingPrivateThread = React.useRef( createPendingThread({ viewerID, threadType: threadTypes.GENESIS_PRIVATE, members: [loggedInUserInfo], }), ); const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder( pendingPrivateThread.current, ); const checkUsersThickThreadSupport = useUsersSupportThickThreads(); const onSelectUserFromSearch = React.useCallback( async (userListItem: UserListItem) => { const { alert, notice, disabled, ...user } = userListItem; setUsernameInputText(''); - if (notice === notFriendNotice && userInfoInputArray.length === 0) { - const newUserInfoInputArray = [ - { id: userListItem.id, username: userListItem.username }, - ]; + if ( + (notice === notFriendNotice || user.id === viewerID) && + userInfoInputArray.length === 0 + ) { + const newUserInfo = { + id: userListItem.id, + username: userListItem.username, + }; + const newUserInfoInputArray = user.id === viewerID ? [] : [newUserInfo]; const usersSupportingThickThreads = await checkUsersThickThreadSupport( newUserInfoInputArray.map(userInfo => userInfo.id), ); const threadInfo = existingThreadInfoFinderForCreatingThread({ searching: true, userInfoInputArray: newUserInfoInputArray, - allUsersSupportThickThreads: newUserInfoInputArray.every(userInfo => - usersSupportingThickThreads.has(userInfo.id), - ), + allUsersSupportThickThreads: + user.id === viewerID + ? true + : usersSupportingThickThreads.has(user.id), }); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadInfo?.id, pendingThread: threadInfo, }, }); } else if (!alert) { dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: [...userInfoInputArray, user], }, }); } else { pushModal({alert.text}); } }, [ checkUsersThickThreadSupport, dispatch, + viewerID, existingThreadInfoFinderForCreatingThread, pushModal, userInfoInputArray, ], ); const onRemoveUserFromSelected = React.useCallback( (userID: string) => { const newSelectedUserList = userInfoInputArray.filter( ({ id }) => userID !== id, ); if (_isEqual(userInfoInputArray)(newSelectedUserList)) { return; } dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: newSelectedUserList, }, }); }, [dispatch, userInfoInputArray], ); const userSearchResultList = React.useMemo(() => { if ( !userListItemsWithENSNames.length || (!usernameInputText && userInfoInputArray.length) ) { return null; } const userItems = userListItemsWithENSNames.map( (userSearchResult: UserListItem) => { return (
  • ); }, ); return
      {userItems}
    ; }, [ onSelectUserFromSearch, userInfoInputArray.length, userListItemsWithENSNames, usernameInputText, ]); const hideSearch = React.useCallback( (threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadBehavior === 'keep-active-thread' || !threadIsPending(threadID) ? threadID : null, }, }); }, [dispatch, threadID], ); const onCloseSearch = React.useCallback(() => { hideSearch('reset-active-thread-if-pending'); }, [hideSearch]); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); const tagsList = React.useMemo(() => { if (!userInfoInputArrayWithENSNames?.length) { return null; } const labels = userInfoInputArrayWithENSNames.map(user => { return ( ); }); return
    {labels}
    ; }, [userInfoInputArrayWithENSNames, onRemoveUserFromSelected]); React.useEffect(() => { if (!inputState) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState]); const threadSearchContainerStyles = classNames(css.threadSearchContainer, { [css.fullHeight]: !userInfoInputArray.length, }); return (
    {tagsList} {userSearchResultList}
    ); } export default ChatThreadComposer;