diff --git a/web/chat/chat-message-list-container.react.js b/web/chat/chat-message-list-container.react.js index 4cadd1bdd..c29c683bf 100644 --- a/web/chat/chat-message-list-container.react.js +++ b/web/chat/chat-message-list-container.react.js @@ -1,193 +1,160 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; -import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { useDrop } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { useWatchThread, threadIsPending } from 'lib/shared/thread-utils.js'; import ChatInputBar from './chat-input-bar.react.js'; import css from './chat-message-list-container.css'; import ChatMessageList from './chat-message-list.react.js'; import ChatThreadComposer from './chat-thread-composer.react.js'; import ThreadTopBar from './thread-top-bar.react.js'; import { InputStateContext } from '../input/input-state.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useThreadInfoForPossiblyPendingThread, useInfosForPendingThread, } from '../utils/thread-utils.js'; type Props = { +activeChatThreadID: ?string, }; function ChatMessageListContainer(props: Props): React.Node { const { activeChatThreadID } = props; - const { - isChatCreation, - selectedUserIDs, - otherUserInfos, - userInfoInputArray, - } = useInfosForPendingThread(); + const { isChatCreation, selectedUserInfos, otherUserInfos } = + useInfosForPendingThread(); const threadInfo = useThreadInfoForPossiblyPendingThread(activeChatThreadID); invariant(threadInfo, 'ThreadInfo should be set'); const dispatch = useDispatch(); - // The effect removes members from list in navInfo - // if some of the user IDs don't exist in redux store - React.useEffect(() => { - if (!isChatCreation) { - return; - } - const existingSelectedUsersSet = new Set( - userInfoInputArray.map(userInfo => userInfo.id), - ); - if ( - selectedUserIDs?.length !== existingSelectedUsersSet.size || - !_isEqual(new Set(selectedUserIDs), existingSelectedUsersSet) - ) { - dispatch({ - type: updateNavInfoActionType, - payload: { - selectedUserList: Array.from(existingSelectedUsersSet), - }, - }); - } - }, [ - dispatch, - isChatCreation, - otherUserInfos, - selectedUserIDs, - userInfoInputArray, - ]); - React.useEffect(() => { if (isChatCreation && activeChatThreadID !== threadInfo?.id) { let payload = { activeChatThreadID: threadInfo?.id, }; if (threadIsPending(threadInfo?.id)) { payload = { ...payload, pendingThread: threadInfo, }; } dispatch({ type: updateNavInfoActionType, payload, }); } }, [activeChatThreadID, dispatch, isChatCreation, threadInfo]); const inputState = React.useContext(InputStateContext); invariant(inputState, 'InputState should be set'); const [{ isActive }, connectDropTarget] = useDrop({ accept: NativeTypes.FILE, drop: item => { const { files } = item; if (inputState && files.length > 0) { inputState.appendFiles(files); } }, collect: monitor => ({ isActive: monitor.isOver() && monitor.canDrop(), }), }); useWatchThread(threadInfo); const containerStyle = classNames({ [css.container]: true, [css.activeContainer]: isActive, }); const containerRef = React.useRef(); const onPaste = React.useCallback( (e: ClipboardEvent) => { if (!inputState) { return; } const { clipboardData } = e; if (!clipboardData) { return; } const { files } = clipboardData; if (files.length === 0) { return; } e.preventDefault(); inputState.appendFiles([...files]); }, [inputState], ); React.useEffect(() => { const currentContainerRef = containerRef.current; if (!currentContainerRef) { return undefined; } currentContainerRef.addEventListener('paste', onPaste); return () => { currentContainerRef.removeEventListener('paste', onPaste); }; }, [onPaste]); const content = React.useMemo(() => { const topBar = ; const messageListAndInput = ( <> ); if (!isChatCreation) { return ( <> {topBar} {messageListAndInput} ); } const chatUserSelection = ( ); - if (!userInfoInputArray.length) { + if (!selectedUserInfos.length) { return chatUserSelection; } return ( <> {topBar} {chatUserSelection} {messageListAndInput} ); }, [ inputState, isChatCreation, otherUserInfos, + selectedUserInfos, threadInfo, - userInfoInputArray, ]); return connectDropTarget(
{content}
, ); } export default ChatMessageListContainer; diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js index 42118df7a..575ecf3ec 100644 --- a/web/chat/chat-thread-composer.react.js +++ b/web/chat/chat-thread-composer.react.js @@ -1,191 +1,198 @@ // @flow + import classNames from 'classnames'; +import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import css from './chat-thread-composer.css'; import Button from '../components/button.react.js'; import Label from '../components/label.react.js'; import Search from '../components/search.react.js'; import UserAvatar from '../components/user-avatar.react.js'; import type { InputState } from '../input/input-state.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +userInfoInputArray: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, +threadID: string, +inputState: InputState, }; type ActiveThreadBehavior = | 'reset-active-thread-if-pending' | 'keep-active-thread'; function ChatThreadComposer(props: Props): React.Node { const { userInfoInputArray, otherUserInfos, threadID, inputState } = props; const [usernameInputText, setUsernameInputText] = React.useState(''); const dispatch = useDispatch(); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userInfoInputIDs = React.useMemo( () => userInfoInputArray.map(userInfo => userInfo.id), [userInfoInputArray], ); const userListItems = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, }), [usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs], ); const userListItemsWithENSNames = useENSNames(userListItems); const onSelectUserFromSearch = React.useCallback( - (id: string) => { - const selectedUserIDs = userInfoInputArray.map(user => user.id); + (user: AccountUserInfo) => { dispatch({ type: updateNavInfoActionType, payload: { - selectedUserList: [...selectedUserIDs, id], + selectedUserList: [...userInfoInputArray, user], }, }); setUsernameInputText(''); }, [dispatch, userInfoInputArray], ); const onRemoveUserFromSelected = React.useCallback( - (id: string) => { - const selectedUserIDs = userInfoInputArray.map(user => user.id); - if (!selectedUserIDs.includes(id)) { + (userID: string) => { + const newSelectedUserList = userInfoInputArray.filter( + ({ id }) => userID !== id, + ); + if (_isEqual(userInfoInputArray)(newSelectedUserList)) { return; } dispatch({ type: updateNavInfoActionType, payload: { - selectedUserList: selectedUserIDs.filter(userID => userID !== id), + selectedUserList: newSelectedUserList, }, }); }, [dispatch, userInfoInputArray], ); const userSearchResultList = React.useMemo(() => { if ( !userListItemsWithENSNames.length || (!usernameInputText && userInfoInputArray.length) ) { return null; } const userItems = userListItemsWithENSNames.map( - (userSearchResult: UserListItem) => ( -
  • - -
  • - ), + (userSearchResult: UserListItem) => { + const { alertTitle, alertText, notice, disabled, ...accountUserInfo } = + userSearchResult; + 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; diff --git a/web/types/nav-types.js b/web/types/nav-types.js index ba9e58c4d..92a4a4839 100644 --- a/web/types/nav-types.js +++ b/web/types/nav-types.js @@ -1,45 +1,49 @@ // @flow import t from 'tcomb'; import type { TInterface } from 'tcomb'; import { type BaseNavInfo } from 'lib/types/nav-types.js'; import { type ThreadInfo, threadInfoValidator, } from 'lib/types/thread-types.js'; +import { + type AccountUserInfo, + accountUserInfoValidator, +} from 'lib/types/user-types.js'; import { tID, tShape } from 'lib/utils/validation-utils.js'; export type NavigationTab = 'calendar' | 'chat' | 'settings'; const navigationTabValidator = t.enums.of(['calendar', 'chat', 'settings']); export type NavigationSettingsSection = 'account' | 'danger-zone'; const navigationSettingsSectionValidator = t.enums.of([ 'account', 'danger-zone', ]); export type NavigationChatMode = 'view' | 'create'; const navigationChatModeValidator = t.enums.of(['view', 'create']); export type NavInfo = { ...$Exact, +tab: NavigationTab, +activeChatThreadID: ?string, +pendingThread?: ThreadInfo, +settingsSection?: NavigationSettingsSection, - +selectedUserList?: $ReadOnlyArray, + +selectedUserList?: $ReadOnlyArray, +chatMode?: NavigationChatMode, +inviteSecret?: ?string, }; export const navInfoValidator: TInterface = tShape<$Exact>({ startDate: t.String, endDate: t.String, tab: navigationTabValidator, activeChatThreadID: t.maybe(tID), pendingThread: t.maybe(threadInfoValidator), settingsSection: t.maybe(navigationSettingsSectionValidator), - selectedUserList: t.maybe(t.list(t.String)), + selectedUserList: t.maybe(t.list(accountUserInfoValidator)), chatMode: t.maybe(navigationChatModeValidator), inviteSecret: t.maybe(t.String), }); diff --git a/web/url-utils.js b/web/url-utils.js index 1206d3f35..258603d60 100644 --- a/web/url-utils.js +++ b/web/url-utils.js @@ -1,143 +1,148 @@ // @flow import invariant from 'invariant'; +import _keyBy from 'lodash/fp/keyBy.js'; import { startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { yearExtractor, monthExtractor } from './selectors/nav-selectors.js'; import type { NavInfo } from './types/nav-types.js'; function canonicalURLFromReduxState( navInfo: NavInfo, currentURL: string, loggedIn: boolean, ): string { const urlInfo = infoFromURL(currentURL); const today = new Date(); let newURL = `/`; if (loggedIn) { newURL += `${navInfo.tab}/`; if (navInfo.tab === 'calendar') { const { startDate, endDate } = navInfo; const year = yearExtractor(startDate, endDate); if (urlInfo.year !== undefined) { invariant( year !== null && year !== undefined, `${startDate} and ${endDate} aren't in the same year`, ); newURL += `year/${year}/`; } else if ( year !== null && year !== undefined && year !== today.getFullYear() ) { newURL += `year/${year}/`; } const month = monthExtractor(startDate, endDate); if (urlInfo.month !== undefined) { invariant( month !== null && month !== undefined, `${startDate} and ${endDate} aren't in the same month`, ); newURL += `month/${month}/`; } else if ( month !== null && month !== undefined && month !== today.getMonth() + 1 ) { newURL += `month/${month}/`; } } else if (navInfo.tab === 'chat') { if (navInfo.chatMode === 'create') { - const users = navInfo.selectedUserList?.join('+') ?? ''; + const users = + navInfo.selectedUserList?.map(({ id }) => id)?.join('+') ?? ''; const potentiallyTrailingSlash = users.length > 0 ? '/' : ''; newURL += `thread/new/${users}${potentiallyTrailingSlash}`; } else { const activeChatThreadID = navInfo.activeChatThreadID; if (activeChatThreadID) { newURL += `thread/${activeChatThreadID}/`; } } } else if (navInfo.tab === 'settings' && navInfo.settingsSection) { newURL += `${navInfo.settingsSection}/`; } } return newURL; } // Given a URL, this function parses out a navInfo object, leaving values as // default if they are unspecified. function navInfoFromURL( url: string, backupInfo: { now?: Date, navInfo?: NavInfo }, ): NavInfo { const urlInfo = infoFromURL(url); const { navInfo } = backupInfo; const now = backupInfo.now ? backupInfo.now : new Date(); let year = urlInfo.year; if (!year && navInfo) { year = yearExtractor(navInfo.startDate, navInfo.endDate); } if (!year) { year = now.getFullYear(); } let month = urlInfo.month; if (!month && navInfo) { month = monthExtractor(navInfo.startDate, navInfo.endDate); } if (!month) { month = now.getMonth() + 1; } let activeChatThreadID = null; if (urlInfo.thread) { activeChatThreadID = urlInfo.thread.toString(); } else if (navInfo) { activeChatThreadID = navInfo.activeChatThreadID; } let tab = 'chat'; if (urlInfo.calendar) { tab = 'calendar'; } else if (urlInfo.settings) { tab = 'settings'; } const chatMode = urlInfo.threadCreation || navInfo?.chatMode === 'create' ? 'create' : 'view'; const newNavInfo: NavInfo = { tab, startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), activeChatThreadID, chatMode, }; if (urlInfo.selectedUserList) { - newNavInfo.selectedUserList = urlInfo.selectedUserList; + const selectedUsers = _keyBy('id')(navInfo?.selectedUserList ?? []); + newNavInfo.selectedUserList = urlInfo.selectedUserList + ?.map(id => selectedUsers[id]) + ?.filter(Boolean); } if (urlInfo.settings) { newNavInfo.settingsSection = urlInfo.settings; } if (urlInfo.inviteSecret) { newNavInfo.inviteSecret = urlInfo.inviteSecret; } return newNavInfo; } export { canonicalURLFromReduxState, navInfoFromURL }; diff --git a/web/utils/thread-utils.js b/web/utils/thread-utils.js index 57a8f7512..2d9cc3b95 100644 --- a/web/utils/thread-utils.js +++ b/web/utils/thread-utils.js @@ -1,114 +1,110 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { createPendingThread, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import type { AccountUserInfo } from 'lib/types/user-types.js'; import { useSelector } from '../redux/redux-utils.js'; type InfosForPendingThread = { +isChatCreation: boolean, - +selectedUserIDs: ?$ReadOnlyArray, + +selectedUserInfos: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, - +userInfoInputArray: $ReadOnlyArray, }; function useInfosForPendingThread(): InfosForPendingThread { const isChatCreation = useSelector( state => state.navInfo.chatMode === 'create', ); - const selectedUserIDs = useSelector(state => state.navInfo.selectedUserList); - const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); - const userInfoInputArray: $ReadOnlyArray = React.useMemo( - () => selectedUserIDs?.map(id => otherUserInfos[id]).filter(Boolean) ?? [], - [otherUserInfos, selectedUserIDs], + const selectedUserInfos = useSelector( + state => state.navInfo.selectedUserList ?? [], ); + const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); return { isChatCreation, - selectedUserIDs, + selectedUserInfos, otherUserInfos, - userInfoInputArray, }; } function useThreadInfoForPossiblyPendingThread( activeChatThreadID: ?string, ): ?ThreadInfo { - const { isChatCreation, userInfoInputArray } = useInfosForPendingThread(); + const { isChatCreation, selectedUserInfos } = useInfosForPendingThread(); const loggedInUserInfo = useLoggedInUserInfo(); invariant(loggedInUserInfo, 'loggedInUserInfo should be set'); const pendingPrivateThread = React.useRef( createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }), ); const newThreadID = 'pending/new_thread'; const pendingNewThread = React.useMemo( () => ({ ...createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], name: 'New thread', }), id: newThreadID, }), [loggedInUserInfo], ); const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder( pendingPrivateThread.current, ); const baseThreadInfo = useSelector(state => { if (activeChatThreadID) { const activeThreadInfo = threadInfoSelector(state)[activeChatThreadID]; if (activeThreadInfo) { return activeThreadInfo; } } return state.navInfo.pendingThread; }); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const threadInfo = React.useMemo(() => { if (isChatCreation) { - if (userInfoInputArray.length === 0) { + if (selectedUserInfos.length === 0) { return pendingNewThread; } return existingThreadInfoFinderForCreatingThread({ searching: true, - userInfoInputArray, + userInfoInputArray: selectedUserInfos, }); } return existingThreadInfoFinder({ searching: false, userInfoInputArray: [], }); }, [ existingThreadInfoFinder, existingThreadInfoFinderForCreatingThread, isChatCreation, - userInfoInputArray, pendingNewThread, + selectedUserInfos, ]); return threadInfo; } export { useThreadInfoForPossiblyPendingThread, useInfosForPendingThread };