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 ;
}, [
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 };