Changeset View
Standalone View
web/chat/chat-thread-composer.react.js
// @flow | // @flow | ||||
import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
import _cloneDeep from 'lodash/fp/cloneDeep'; | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { useDispatch } from 'react-redux'; | import { useDispatch } from 'react-redux'; | ||||
import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors'; | import { searchUsers } from 'lib/actions/user-actions'; | ||||
import { | |||||
filterPotentialMembers, | |||||
userSearchIndexForPotentialMembers, | |||||
} from 'lib/selectors/user-selectors'; | |||||
import { getPotentialMemberItems } from 'lib/shared/search-utils'; | import { getPotentialMemberItems } from 'lib/shared/search-utils'; | ||||
import { threadIsPending } from 'lib/shared/thread-utils'; | import { threadIsPending } from 'lib/shared/thread-utils'; | ||||
import type { AccountUserInfo, UserListItem } from 'lib/types/user-types'; | import type { SetState } from 'lib/types/hook-types'; | ||||
import type { | |||||
AccountUserInfo, | |||||
UserListItem, | |||||
GlobalAccountUserInfo, | |||||
} from 'lib/types/user-types'; | |||||
import { useServerCall } from 'lib/utils/action-utils'; | |||||
import Button from '../components/button.react'; | import Button from '../components/button.react'; | ||||
import Label from '../components/label.react'; | import Label from '../components/label.react'; | ||||
import Search from '../components/search.react'; | import Search from '../components/search.react'; | ||||
import type { InputState } from '../input/input-state'; | import type { InputState } from '../input/input-state'; | ||||
import { updateNavInfoActionType } from '../redux/action-types'; | import { updateNavInfoActionType } from '../redux/action-types'; | ||||
import { useSelector } from '../redux/redux-utils'; | import { useSelector } from '../redux/redux-utils'; | ||||
import SWMansionIcon from '../SWMansionIcon.react'; | import SWMansionIcon from '../SWMansionIcon.react'; | ||||
import css from './chat-thread-composer.css'; | import css from './chat-thread-composer.css'; | ||||
type Props = { | type Props = { | ||||
+userInfoInputArray: $ReadOnlyArray<AccountUserInfo>, | +userInfoInputArray: $ReadOnlyArray<AccountUserInfo>, | ||||
+setUserInfoInputArray: SetState<$ReadOnlyArray<AccountUserInfo>>, | |||||
+otherUserInfos: { [id: string]: AccountUserInfo }, | +otherUserInfos: { [id: string]: AccountUserInfo }, | ||||
+threadID: string, | +threadID: string, | ||||
+inputState: InputState, | +inputState: InputState, | ||||
}; | }; | ||||
type ActiveThreadBehavior = | type ActiveThreadBehavior = | ||||
| 'reset-active-thread-if-pending' | | 'reset-active-thread-if-pending' | ||||
| 'keep-active-thread'; | | 'keep-active-thread'; | ||||
function ChatThreadComposer(props: Props): React.Node { | function ChatThreadComposer(props: Props): React.Node { | ||||
const { userInfoInputArray, otherUserInfos, threadID, inputState } = props; | const { | ||||
userInfoInputArray, | |||||
setUserInfoInputArray, | |||||
otherUserInfos, | |||||
threadID, | |||||
inputState, | |||||
} = props; | |||||
const [usernameInputText, setUsernameInputText] = React.useState(''); | const [usernameInputText, setUsernameInputText] = React.useState(''); | ||||
const dispatch = useDispatch(); | const userInfos = useSelector(state => state.userStore.userInfos); | ||||
const viewerID = useSelector(state => state.currentUserInfo?.id); | |||||
const [serverSearchUserInfos, setServerSearchUserInfos] = React.useState< | |||||
$ReadOnlyArray<GlobalAccountUserInfo>, | |||||
>([]); | |||||
const callSearchUsers = useServerCall(searchUsers); | |||||
React.useEffect(() => { | |||||
(async () => { | |||||
if (usernameInputText.length === 0) { | |||||
setServerSearchUserInfos([]); | |||||
} else { | |||||
const { userInfos: serverUserInfos } = await callSearchUsers( | |||||
usernameInputText, | |||||
); | |||||
tomek: It seems like this approach is the same as in other places where we call this endpoint, but it… | |||||
setServerSearchUserInfos(serverUserInfos); | |||||
} | |||||
})(); | |||||
}, [callSearchUsers, usernameInputText]); | |||||
const filteredServerUserInfos = React.useMemo(() => { | |||||
const result = {}; | |||||
for (const user of serverSearchUserInfos) { | |||||
if (!(user.id in userInfos)) { | |||||
result[user.id] = user; | |||||
} | |||||
} | |||||
return filterPotentialMembers(result, viewerID); | |||||
}, [serverSearchUserInfos, userInfos, viewerID]); | |||||
michalUnsubmitted Done Inline ActionsLogic for getting userInfos from the keyserver. callSearchUsers doesn't return relationships, but we can assume that if we don't have someone in the userStore we don't have a relationship with them. Because of that we need to remove users that we already have in the userStore. michal: Logic for getting `userInfos` from the keyserver. `callSearchUsers` doesn't return… | |||||
const mergedUserInfos = React.useMemo( | |||||
() => ({ ...filteredServerUserInfos, ...otherUserInfos }), | |||||
tomekUnsubmitted Not Done Inline ActionsAre you sure this is correct? We're filtering out the users that are in userInfos but then merge with otherUserInfos - it may be correct, but could you explain why is that? tomek: Are you sure this is correct? We're filtering out the users that are in `userInfos` but then… | |||||
michalUnsubmitted Done Inline ActionsWe want to remove users from serverSearchUserInfos that blocked the viewer but the useServerCall(searchUsers) doesn't return the relationship status. We can assume that we have a relationship with someone only if we have them in the userStore. So we remove users that are in userInfos and that leaves us with only the users that we don't have a relationship with. Users that were removed but didn't blocks us are included in the otherUserInfos. So when we merge these two values we should get a list of users that didn't block us from both userStore and keyserver. michal: We want to remove users from `serverSearchUserInfos` that blocked the viewer but the… | |||||
tomekUnsubmitted Not Done Inline Actionsok, that makes sense tomek: ok, that makes sense | |||||
[filteredServerUserInfos, otherUserInfos], | |||||
); | |||||
const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); | const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); | ||||
const mergedUserSearchIndex = React.useMemo(() => { | |||||
const searchIndex = _cloneDeep(userSearchIndex); | |||||
tomekUnsubmitted Done Inline ActionsWhy do we need to clone the index? What is the approach used in other similar places - do we do something similar there? tomek: Why do we need to clone the index? What is the approach used in other similar places - do we do… | |||||
michalUnsubmitted Done Inline ActionsModifying searchIndex mutates it, so to keep it pure I had to clone it. I'm doing this because getPotentialMemberItems requires SearchIndex with all the potential users. I've just looked at how this is solved in other places and there we just use the results from the useServerCall(searchUsers) directly. We can use a similar approach here, we would just have to modify getPotentialMemberItems to take an additional array of users that skip the SearchIndex. michal: Modifying `searchIndex` mutates it, so to keep it pure I had to clone it. I'm doing this… | |||||
tomekUnsubmitted Not Done Inline ActionsCloning the index on every change seems really wasteful - usually we would need to only add or remove a single entry. But regardless, taking a similar approach that we have in other places is a really good idea, unless there are good reasons not to do so. tomek: Cloning the index on every change seems really wasteful - usually we would need to only add or… | |||||
for (const id in filteredServerUserInfos) { | |||||
searchIndex.addEntry(id, filteredServerUserInfos[id].username); | |||||
} | |||||
return searchIndex; | |||||
}, [filteredServerUserInfos, userSearchIndex]); | |||||
const userInfoInputIDs = React.useMemo( | const userInfoInputIDs = React.useMemo( | ||||
() => userInfoInputArray.map(userInfo => userInfo.id), | () => userInfoInputArray.map(userInfo => userInfo.id), | ||||
[userInfoInputArray], | [userInfoInputArray], | ||||
); | ); | ||||
const userListItems = React.useMemo( | const userListItems = React.useMemo( | ||||
() => | () => | ||||
getPotentialMemberItems( | getPotentialMemberItems( | ||||
usernameInputText, | usernameInputText, | ||||
otherUserInfos, | mergedUserInfos, | ||||
userSearchIndex, | mergedUserSearchIndex, | ||||
userInfoInputIDs, | userInfoInputIDs, | ||||
), | ), | ||||
[usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs], | [ | ||||
usernameInputText, | |||||
mergedUserInfos, | |||||
mergedUserSearchIndex, | |||||
userInfoInputIDs, | |||||
], | |||||
); | ); | ||||
const onSelectUserFromSearch = React.useCallback( | const onSelectUserFromSearch = React.useCallback( | ||||
(id: string) => { | (id: string, username: string) => { | ||||
const selectedUserIDs = userInfoInputArray.map(user => user.id); | setUserInfoInputArray(previousUserInfoInputArray => [ | ||||
dispatch({ | ...previousUserInfoInputArray, | ||||
type: updateNavInfoActionType, | { id, username }, | ||||
payload: { | ]); | ||||
selectedUserList: [...selectedUserIDs, id], | |||||
}, | |||||
}); | |||||
setUsernameInputText(''); | setUsernameInputText(''); | ||||
}, | }, | ||||
[dispatch, userInfoInputArray], | [setUserInfoInputArray], | ||||
); | ); | ||||
michalUnsubmitted Done Inline ActionsInstead of updating the navInfo we can just use setUserInfoInputArray. These changes will be dispatched in the useEffect in chat-message-list-container.react.js. michal: Instead of updating the `navInfo` we can just use `setUserInfoInputArray`. These changes will… | |||||
const onRemoveUserFromSelected = React.useCallback( | const onRemoveUserFromSelected = React.useCallback( | ||||
(id: string) => { | (id: string) => { | ||||
const selectedUserIDs = userInfoInputArray.map(user => user.id); | setUserInfoInputArray(previousUserInfoInputArray => | ||||
if (!selectedUserIDs.includes(id)) { | previousUserInfoInputArray.filter(user => user.id !== id), | ||||
return; | ); | ||||
} | |||||
dispatch({ | |||||
type: updateNavInfoActionType, | |||||
payload: { | |||||
selectedUserList: selectedUserIDs.filter(userID => userID !== id), | |||||
}, | |||||
}); | |||||
}, | }, | ||||
[dispatch, userInfoInputArray], | [setUserInfoInputArray], | ||||
); | ); | ||||
const userSearchResultList = React.useMemo(() => { | const userSearchResultList = React.useMemo(() => { | ||||
if ( | if ( | ||||
!userListItems.length || | !userListItems.length || | ||||
(!usernameInputText && userInfoInputArray.length) | (!usernameInputText && userInfoInputArray.length) | ||||
) { | ) { | ||||
return null; | return null; | ||||
} | } | ||||
return ( | return ( | ||||
<ul className={css.searchResultsContainer}> | <ul className={css.searchResultsContainer}> | ||||
{userListItems.map((userSearchResult: UserListItem) => ( | {userListItems.map((userSearchResult: UserListItem) => ( | ||||
<li key={userSearchResult.id} className={css.searchResultsItem}> | <li key={userSearchResult.id} className={css.searchResultsItem}> | ||||
<Button | <Button | ||||
variant="text" | variant="text" | ||||
onClick={() => onSelectUserFromSearch(userSearchResult.id)} | onClick={() => | ||||
onSelectUserFromSearch( | |||||
userSearchResult.id, | |||||
userSearchResult.username, | |||||
) | |||||
} | |||||
className={css.searchResultsButton} | className={css.searchResultsButton} | ||||
> | > | ||||
<div className={css.userName}>{userSearchResult.username}</div> | <div className={css.userName}>{userSearchResult.username}</div> | ||||
<div className={css.userInfo}>{userSearchResult.alertTitle}</div> | <div className={css.userInfo}>{userSearchResult.alertTitle}</div> | ||||
</Button> | </Button> | ||||
</li> | </li> | ||||
))} | ))} | ||||
</ul> | </ul> | ||||
); | ); | ||||
}, [ | }, [ | ||||
onSelectUserFromSearch, | onSelectUserFromSearch, | ||||
userInfoInputArray.length, | userInfoInputArray.length, | ||||
userListItems, | userListItems, | ||||
usernameInputText, | usernameInputText, | ||||
]); | ]); | ||||
const dispatch = useDispatch(); | |||||
const hideSearch = React.useCallback( | const hideSearch = React.useCallback( | ||||
(threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => { | (threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => { | ||||
dispatch({ | dispatch({ | ||||
type: updateNavInfoActionType, | type: updateNavInfoActionType, | ||||
payload: { | payload: { | ||||
chatMode: 'view', | chatMode: 'view', | ||||
activeChatThreadID: | activeChatThreadID: | ||||
threadBehavior === 'keep-active-thread' || | threadBehavior === 'keep-active-thread' || | ||||
▲ Show 20 Lines • Show All 64 Lines • Show Last 20 Lines |
It seems like this approach is the same as in other places where we call this endpoint, but it is not what we should do. Instead we should use dispatchActionPromise so that appropriate actions are dispatched.