diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index e61c20079..2757e2249 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1279 +1,1327 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import * as React from 'react'; import stringHash from 'string-hash'; import tinycolor from 'tinycolor2'; import { fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from '../actions/message-actions'; import { newThreadActionTypes } from '../actions/thread-actions'; +import { searchUsers as searchUserCall } from '../actions/user-actions'; import ashoat from '../facts/ashoat'; import genesis from '../facts/genesis'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors'; +import { threadSearchIndex as threadSearchIndexSelector } from '../selectors/nav-selectors'; import { threadInfoSelector, locallyUniqueToRealizedThreadIDsSelector, } from '../selectors/thread-selectors'; -import { getRelativeMemberInfos } from '../selectors/user-selectors'; +import { + getRelativeMemberInfos, + usersWithPersonalThreadSelector, +} from '../selectors/user-selectors'; import type { CalendarQuery } from '../types/entry-types'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types'; import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, type ThreadType, type ClientNewThreadRequest, type NewThreadResult, threadTypes, threadPermissions, threadTypeIsCommunityRoot, } from '../types/thread-types'; import { type ClientUpdateInfo, updateTypes } from '../types/update-types'; import type { GlobalAccountUserInfo, UserInfos, UserInfo, AccountUserInfo, } from '../types/user-types'; import { useDispatchActionPromise, useServerCall } from '../utils/action-utils'; import type { DispatchActionPromise } from '../utils/action-utils'; import { useSelector } from '../utils/redux-utils'; import { firstLine } from '../utils/string-utils'; import { pluralize, trimText } from '../utils/text-utils'; import { type ParserRules } from './markdown'; import { getMessageTitle } from './message-utils'; import { relationshipBlockedInEitherDirection } from './relationship-utils'; import threadWatcher from './thread-watcher'; function colorIsDark(color: string): boolean { return tinycolor(`#${color}`).isDark(); } const selectedThreadColors = [ 'B8753D', 'C85000', 'AA4B4B', '4B87AA', '648CAA', '57697F', '6D49AB', '00A591', '008F83', '575757', ]; function generateRandomColor(): string { return selectedThreadColors[ Math.floor(Math.random() * selectedThreadColors.length) ]; } function generatePendingThreadColor(userIDs: $ReadOnlyArray): string { const ids = [...userIDs].sort().join('#'); const colorIdx = stringHash(ids) % selectedThreadColors.length; return selectedThreadColors[colorIdx]; } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInChatList(threadInfo) && threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis.id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo): boolean { return ( threadInfo.members.filter(member => { if ( member.id === ashoat.id && !member.role && threadInfo.community === genesis.id ) { return false; } return member.role || member.permissions[threadPermissions.VOICED]?.value; }).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function getThreadOtherUsers( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ): string[] { const otherUserIDs = []; for (const member of threadInfo.members) { if (!member.role || member.id === viewerID) { continue; } otherUserIDs.push(member.id); } return otherUserIDs; } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMemberIDs = getThreadOtherUsers(threadInfo, viewerID); if (otherMemberIDs.length !== 1) { return undefined; } return otherMemberIDs[0]; } function getLocallyUniqueThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members?: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); const nonViewerMembers = members ? members.filter(member => member.id !== viewerID) : []; const nonViewerMemberIDs = nonViewerMembers.map(member => member.id); const memberIDs = [...nonViewerMemberIDs, viewerID]; const threadID = getLocallyUniqueThreadID( threadType, memberIDs, sourceMessageID, ); const permissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role = { id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }; const rawThreadInfo = { id: threadID, type: threadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID(parentThreadInfo, threadType), community: getCommunity(parentThreadInfo), members: [ { id: viewerID, role: role.id, permissions: membershipPermissions, isSender: false, }, ...nonViewerMembers.map(member => ({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, })), ], roles: { [role.id]: role, }, currentUser: { role: role.id, permissions: membershipPermissions, subscription: { pushNotifs: false, home: false, }, unread: false, }, repliesCount: 0, sourceMessageID, }; const userInfos = {}; nonViewerMembers.forEach(member => (userInfos[member.id] = member)); return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } function createPendingThreadItem( viewerID: string, user: GlobalAccountUserInfo, ): ChatThreadItem { const threadInfo = createPendingThread({ viewerID, threadType: threadTypes.PERSONAL, members: [user], }); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo: { id: user.id, username: user.username, }, }; } function createPendingSidebar( sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, parentThreadInfo: ThreadInfo, viewerID: string, markdownRules: ParserRules, ): ThreadInfo { const { color, type: parentThreadType } = parentThreadInfo; const messageTitle = getMessageTitle( sourceMessageInfo, parentThreadInfo, markdownRules, 'global_viewer', ); const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername, } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo: GlobalAccountUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (parentThreadType === threadTypes.PERSONAL && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; invariant( singleOtherUsername, 'singleOtherUsername should be set in createPendingSidebar', ); const singleOtherUserInfo: GlobalAccountUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } function pendingThreadType(numberOfOtherMembers: number): 4 | 6 | 7 { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.SIDEBAR || threadType === threadTypes.PRIVATE ); } type CreateRealThreadParameters = { +threadInfo: ThreadInfo, +dispatchActionPromise: DispatchActionPromise, +createNewThread: ClientNewThreadRequest => Promise, +sourceMessageID: ?string, +viewerID: ?string, +handleError?: () => mixed, +calendarQuery: CalendarQuery, }; async function createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise, createNewThread, sourceMessageID, viewerID, calendarQuery, }: CreateRealThreadParameters): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } const otherMemberIDs = getThreadOtherUsers(threadInfo, viewerID); let resultPromise; if (threadInfo.type !== threadTypes.SIDEBAR) { invariant( otherMemberIDs.length > 0, 'otherMemberIDs should not be empty for threads', ); resultPromise = createNewThread({ type: pendingThreadType(otherMemberIDs.length), initialMemberIDs: otherMemberIDs, color: threadInfo.color, calendarQuery, }); } else { invariant( sourceMessageID, 'sourceMessageID should be set when creating a sidebar', ); resultPromise = createNewThread({ type: threadTypes.SIDEBAR, initialMemberIDs: otherMemberIDs, color: threadInfo.color, sourceMessageID, parentThreadID: threadInfo.parentThreadID, name: threadInfo.name, calendarQuery, }); } dispatchActionPromise(newThreadActionTypes, resultPromise); const { newThreadID } = await resultPromise; return newThreadID; } type RawThreadInfoOptions = { +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, +hideThreadStructure?: ?boolean, +shimThreadTypes?: ?{ +[inType: ThreadType]: ThreadType, }, +filterDetailedThreadEditPermissions?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const hideThreadStructure = options?.hideThreadStructure; const shimThreadTypes = options?.shimThreadTypes; const filterDetailedThreadEditPermissions = options?.filterDetailedThreadEditPermissions; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadEditDetailedPermissions( serverMember.permissions, filterDetailedThreadEditPermissions, ); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadEditDetailedPermissions( getAllThreadPermissions(null, serverThreadInfo.id), filterDetailedThreadEditPermissions, ); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } let { type } = serverThreadInfo; if ( shimThreadTypes && shimThreadTypes[type] !== null && shimThreadTypes[type] !== undefined ) { type = shimThreadTypes[type]; } let rawThreadInfo: any = { id: serverThreadInfo.id, type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, repliesCount: serverThreadInfo.repliesCount, }; if (!hideThreadStructure) { rawThreadInfo = { ...rawThreadInfo, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; } const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (includeVisibilityRules) { return { ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }; } return rawThreadInfo; } function filterThreadEditDetailedPermissions( permissions: ThreadPermissionsInfo, shouldFilter: ?boolean, ): ThreadPermissionsInfo { if (!shouldFilter) { return permissions; } const { edit_thread_color, edit_thread_description, ...newPermissions } = permissions; return newPermissions; } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames = getThreadOtherUsers(threadInfo, viewerID) .map(memberID => userInfos[memberID] && userInfos[memberID].username) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const uiName = threadInfo.name ? threadInfo.name : robotextName(threadInfo, viewerID, userInfos); return firstLine(uiName); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: threadUIName(rawThreadInfo, viewerID, userInfos), description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), repliesCount: rawThreadInfo.repliesCount, }; const { sourceMessageID } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } return threadInfo; } function getCurrentUser( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(threadInfo, viewerID, userInfos)) { return threadInfo.currentUser; } return { ...threadInfo.currentUser, permissions: { ...threadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadInfo.type !== threadTypes.PERSONAL && (threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo)) ) { return false; } const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { let rawThreadInfo: RawThreadInfo = { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, containingThreadID: threadInfo.containingThreadID, community: threadInfo.community, members: threadInfo.members.map(relativeMemberInfo => ({ id: relativeMemberInfo.id, role: relativeMemberInfo.role, permissions: relativeMemberInfo.permissions, isSender: relativeMemberInfo.isSender, })), roles: threadInfo.roles, currentUser: threadInfo.currentUser, repliesCount: threadInfo.repliesCount, }; const { sourceMessageID } = threadInfo; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } return rawThreadInfo; } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent thread can see an open child thread.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor threads.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (const member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ): boolean { return !!( memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]) ); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo): boolean { return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ): boolean { if (!threadInfo) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadInfo.members.filter(member => memberHasAdminPowers(member)).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD_NAME, threadPermissions.EDIT_THREAD_COLOR, threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissions.CREATE_SUBCHANNELS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText: string = `Background threads are just like normal threads, except they don't ` + `contribute to your unread count.\n\n` + `To move a thread over here, switch the “Background” option in its settings.`; const threadSearchText = ( threadInfo: RawThreadInfo | ThreadInfo, userInfos: UserInfos, viewerID: ?string, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; function threadNoun(threadType: ThreadType): string { return threadType === threadTypes.SIDEBAR ? 'sidebar' : 'thread'; } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { return 'Sidebar'; } else if (threadType === threadTypes.PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else { return 'Secret'; } } function useWatchThread(threadInfo: ?ThreadInfo) { const dispatchActionPromise = useDispatchActionPromise(); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, callFetchMostRecentMessages(threadID), ); } return () => { if (threadID && threadNotInChatList) { threadWatcher.removeID(threadID); } }; }, [ callFetchMostRecentMessages, dispatchActionPromise, threadNotInChatList, threadID, ]); } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const userInfos = useSelector(state => state.userStore.userInfos); const locallyUniqueToRealizedThreadIDs = useSelector(state => locallyUniqueToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!viewerID || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; const pendingThreadID = searching ? getLocallyUniqueThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getLocallyUniqueThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = locallyUniqueToRealizedThreadIDs.get( pendingThreadID, ); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: userInfoInputArray, }) : baseThreadInfo; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ baseThreadInfo, threadInfos, viewerID, locallyUniqueToRealizedThreadIDs, userInfos, ], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.PERSONAL || threadType === threadTypes.PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function threadMemberHasPermission( threadInfo: ServerThreadInfo | RawThreadInfo | ThreadInfo, memberID: string, permission: ThreadPermission, ): boolean { for (const member of threadInfo.members) { if (member.id !== memberID) { continue; } return permissionLookup(member.permissions, permission); } return false; } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const messageCreatorUserInfo = useSelector( state => state.userStore.userInfos[messageInfo.creator.id], ); if (!messageInfo.id || threadInfo.sourceMessageID === messageInfo.id) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; const creatorRelationshipHasBlock = messageCreatorRelationship && relationshipBlockedInEitherDirection(messageCreatorRelationship); const hasPermission = threadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); return hasPermission && !creatorRelationshipHasBlock; } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, messageItem.messageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; return !!defaultRole.permissions[threadPermissions.VOICED]; } function draftKeyFromThreadID(threadID: string): string { return `${threadID}/message_composer`; } function getContainingThreadID( parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: ?ServerThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { const chatItems = []; if (!searchText) { chatItems.push( ...chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ), ); } else { const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.PERSONAL) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } chatItems.push(...privateThreads, ...personalThreads, ...otherThreads); if (viewerID) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(viewerID, user), ), ); } } return chatItems; } +type ThreadListSearchResult = { + +threadSearchResults: $ReadOnlySet, + +usersSearchResults: $ReadOnlyArray, +}; +function useThreadListSearch( + chatListData: $ReadOnlyArray, + searchText: string, + viewerID: ?string, +): ThreadListSearchResult { + const callSearchUsers = useServerCall(searchUserCall); + const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); + const searchUsers = React.useCallback( + async (usernamePrefix: string) => { + if (usernamePrefix.length === 0) { + return []; + } + + const { userInfos } = await callSearchUsers(usernamePrefix); + return userInfos.filter( + info => !usersWithPersonalThread.has(info.id) && info.id !== viewerID, + ); + }, + [callSearchUsers, usersWithPersonalThread, viewerID], + ); + + const [threadSearchResults, setThreadSearchResults] = React.useState( + new Set(), + ); + const [usersSearchResults, setUsersSearchResults] = React.useState([]); + const threadSearchIndex = useSelector(threadSearchIndexSelector); + React.useEffect(() => { + (async () => { + const results = threadSearchIndex.getSearchResults(searchText); + setThreadSearchResults(new Set(results)); + const usersResults = await searchUsers(searchText); + setUsersSearchResults(usersResults); + })(); + }, [searchText, chatListData, threadSearchIndex, searchUsers]); + + return { threadSearchResults, usersSearchResults }; +} + export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, getSingleOtherUser, getLocallyUniqueThreadID, createPendingThread, createPendingThreadItem, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, filterThreadEditDetailedPermissions, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, + useThreadListSearch, }; diff --git a/web/chat/chat-tabs.css b/web/chat/chat-tabs.css index e484d9a85..c933f4a26 100644 --- a/web/chat/chat-tabs.css +++ b/web/chat/chat-tabs.css @@ -1,54 +1,47 @@ div.container { position: absolute; width: 400px; top: 0; bottom: 0; background-color: var(--bg); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; } div.tabs { display: flex; flex-direction: row; color: var(--fg); background: var(--bg); } div.tabItem { display: flex; justify-content: center; width: 50%; padding: 16px 0; text-align: center; cursor: pointer; } div.tabItem span { display: flex; } div.tabItem span svg { padding-right: 12px; } div.tabItemActive { border: outset var(--thread-selection); border-width: 0 0 3px 0; } div.tabItemInactive { background-color: var(--bg); color: var(--color-disabled); border: outset var(--border-color); border-width: 0 0 3px 0; } div.threadList { flex: 1; overflow-y: auto; } - -div.emptyItem { - padding: 10px; - font-size: 16px; - text-align: center; - white-space: pre-wrap; -} diff --git a/web/chat/chat-thread-list.css b/web/chat/chat-thread-list.css index 11a5585c2..e4fcf42e1 100644 --- a/web/chat/chat-thread-list.css +++ b/web/chat/chat-thread-list.css @@ -1,241 +1,288 @@ div.thread { display: flex; flex-direction: row; align-items: flex-start; padding-top: 4px; padding-bottom: 4px; padding-left: 16px; padding-right: 10px; } div.threadListSideBar { display: flex; flex-direction: row; align-items: flex-start; padding-bottom: 4px; padding-left: 16px; padding-right: 10px; position: relative; } div.threadListSideBar > svg { position: absolute; top: -13px; left: 30px; } div.thread:first-child { padding-top: 6px; } div.activeThread, div.thread:hover { background: var(--selected-thread-bg); } div.thread div.title { flex: 1; font-size: var(--m-font-16); font-weight: var(--semi-bold); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--thread-color-read); line-height: var(--line-height-text); } a.threadButton { flex: 1; cursor: pointer; overflow: hidden; padding-left: 8px; } .threadButtonSidebar { flex: 1; cursor: pointer; overflow: hidden; padding-left: 8px; } p.breadCrumbs { display: flex; font-size: var(--xs-font-12); font-weight: var(--normal); color: var(--breadcrumb-color); } p.breadCrumbs.unread { color: var(--breadcrumb-color-unread); } span.breadCrumb { display: flex; align-items: center; white-space: nowrap; text-overflow: ellipsis; } div.spacer, div.colorSplotch { height: 40px; width: 40px; border-radius: 1.68px; } div.lastActivity { font-size: var(--xxs-font-10); color: var(--fg); line-height: 1.5; font-weight: var(--semi-bold); white-space: nowrap; } div.lastMessage { font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } div.threadRow > .lastMessage { color: var(--thread-last-message-color-read); font-size: var(--s-font-14); } div.thread.activeThread a { color: red; } div.unread { color: var(--fg); font-weight: var(--semi-bold); } div.lastMessage.black { color: var(--fg); } div.dark { color: var(--thread-color-read); } .light { color: var(--thread-from-color-read); } div.italic { font-style: italic; } div.sidebarTitle { flex: 1; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--thread-color-read); align-self: flex-start; } div.sidebarLastActivity { white-space: nowrap; font-size: var(--xxs-font-10); line-height: var(--line-height-text); font-weight: var(--semi-bold); } svg.sidebarIcon { color: var(--thread-color-read); padding: 0 6px; font-size: 20px; } div.sidebar .menu > button svg { font-size: 16px; color: var(--thread-color-read); } div.sidebar .menu { opacity: 0; } div.sidebar:hover .menu { display: flex; align-self: flex-end; opacity: 1; } .menu { position: relative; display: flex; justify-content: flex-end; } .menu > button { background-color: transparent; color: var(--thread-color-read); border: none; cursor: pointer; display: flex; align-items: center; } .menu > button:focus { outline: none; } .menuContent { display: none; position: absolute; top: calc(100% + 1px); right: 0; z-index: 1; width: max-content; overflow: hidden; background-color: #eeeeee; border-radius: 5px; box-shadow: 1px 1px 5px 2px #00000022; } .menuContentVisible { display: block; } .menuContent ul { list-style: none; } .menuContent li:not(:last-child) { border-bottom: 1px solid #dddddd; } .menuContent button { border: none; cursor: pointer; padding: 10px; font-size: 16px; } .menuContent button:hover { background-color: #dddddd; } ul.list { margin: 5px 3px 10px 0px; overflow: auto; } div.search { display: flex; background-color: #dddddd; border-radius: 5px; padding: 3px 5px; align-items: center; } svg.searchVector { fill: #aaaaaa; height: 22px; width: 22px; padding: 0 3px; margin-left: 8px; } div.search > input { color: black; padding: 0; border: none; background-color: #dddddd; font-weight: 600; font-size: 15px; flex-grow: 1; margin-left: 3px; } div.search > input:focus { outline: none; } svg.clearQuery { font-size: 15px; padding-bottom: 1px; padding-right: 2px; color: #aaaaaa; } svg.clearQuery:hover { font-size: 15px; padding-bottom: 1px; padding-right: 2px; color: white; } div.spacer { height: 6px; } + +div.emptyItem { + padding: 10px; + font-size: 16px; + text-align: center; + white-space: pre-wrap; + color: var(--fg); +} + +div.threadListContainer { + display: flex; + flex-direction: column; +} + +div.searchContainer { + background-color: var(--text-input-bg); + display: flex; + align-items: center; + margin: 1rem; +} + +input.searchInput { + background-color: var(--text-input-bg); + font-size: var(--s-font-14); + padding: 1rem; + flex: 1; + border: none; + color: var(--text-input-color); + outline: none; +} + +input.searchInput::placeholder { + color: var(--text-input-placeholder); +} + +button.clearSearch { + color: var(--text-input-color); + transition: ease-in-out 0.15s; + border: none; + padding: 0 1rem; + font-size: var(--m-font-16); + background: none; +} + +button.clearSearchDisabled { + opacity: 0; +} diff --git a/web/chat/chat-thread-list.react.js b/web/chat/chat-thread-list.react.js index 80738194e..80fac3215 100644 --- a/web/chat/chat-thread-list.react.js +++ b/web/chat/chat-thread-list.react.js @@ -1,46 +1,58 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { emptyItemText } from 'lib/shared/thread-utils'; -import css from './chat-tabs.css'; import ChatThreadListItem from './chat-thread-list-item.react'; +import css from './chat-thread-list.css'; import { ThreadListContext } from './thread-list-provider'; +import ThreadListSearch from './thread-list-search.react'; type Props = { +setModal: (modal: ?React.Node) => void, }; + function ChatThreadList(props: Props): React.Node { const { setModal } = props; - const threadListContext = React.useContext(ThreadListContext); invariant( threadListContext, 'threadListContext should be set in ChatThreadList', ); - const { threadList, activeTab } = threadListContext; - const isBackground = activeTab === 'BACKGROUND'; - - const listData: React.Node[] = React.useMemo(() => { + const { + activeTab, + threadList, + setSearchText, + searchText, + } = threadListContext; + const isBackground = activeTab === 'Background'; + + const threadComponents: React.Node[] = React.useMemo(() => { const threads = threadList.map(item => ( )); if (threads.length === 0 && isBackground) { threads.push(); } return threads; }, [threadList, isBackground, setModal]); - return
{listData}
; + + return ( +
+ +
{threadComponents}
+
+ ); } function EmptyItem() { return
{emptyItemText}
; } export default ChatThreadList; diff --git a/web/chat/clear-search-button.react.js b/web/chat/clear-search-button.react.js new file mode 100644 index 000000000..55e1af6c4 --- /dev/null +++ b/web/chat/clear-search-button.react.js @@ -0,0 +1,27 @@ +// @flow + +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import * as React from 'react'; + +import css from './chat-thread-list.css'; + +type ClearSearchButtonProps = { + +active: boolean, + +onClick: () => void, +}; + +function ClearSearchButton(props: ClearSearchButtonProps): React.Node { + const { active, onClick } = props; + const searchClassNames = classNames(css.clearSearch, { + [css.clearSearchDisabled]: !active, + }); + return ( + + ); +} + +export default ClearSearchButton; diff --git a/web/chat/thread-list-provider.js b/web/chat/thread-list-provider.js index 9ed18c180..184235f8e 100644 --- a/web/chat/thread-list-provider.js +++ b/web/chat/thread-list-provider.js @@ -1,194 +1,214 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatThreadItem, - chatListData as chatListDataSelector, + useFlattenedChatListData, } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { threadInBackgroundChatList, threadInHomeChatList, threadInChatList, + getThreadListSearchResults, + useThreadListSearch, } from 'lib/shared/thread-utils'; import { threadTypes } from 'lib/types/thread-types'; import { useSelector } from '../redux/redux-utils'; import { useChatThreadItem, activeChatThreadItem as activeChatThreadItemSelector, } from '../selectors/chat-selectors'; type ChatTabType = 'Focus' | 'Background'; type ThreadListContextType = { +activeTab: ChatTabType, - +threadList: $ReadOnlyArray, +setActiveTab: (newActiveTab: ChatTabType) => void, + +threadList: $ReadOnlyArray, + +searchText: string, + +setSearchText: (searchText: string) => void, }; const ThreadListContext: React.Context = React.createContext(); type ThreadListProviderProps = { +children: React.Node, }; function ThreadListProvider(props: ThreadListProviderProps): React.Node { const [activeTab, setActiveTab] = React.useState('Focus'); const activeChatThreadItem = useSelector(activeChatThreadItemSelector); const activeThreadInfo = activeChatThreadItem?.threadInfo; const activeThreadID = activeThreadInfo?.id; - const activeSidebarParentThreadInfo = useSelector(state => { if (!activeThreadInfo || activeThreadInfo.type !== threadTypes.SIDEBAR) { return null; } const { parentThreadID } = activeThreadInfo; invariant(parentThreadID, 'sidebar must have parent thread'); return threadInfoSelector(state)[parentThreadID]; }); const activeTopLevelThreadInfo = activeThreadInfo?.type === threadTypes.SIDEBAR ? activeSidebarParentThreadInfo : activeThreadInfo; const activeTopLevelThreadIsFromHomeTab = activeTopLevelThreadInfo?.currentUser.subscription.home; const activeTopLevelThreadIsFromDifferentTab = (activeTab === 'Focus' && activeTopLevelThreadIsFromHomeTab) || (activeTab === 'Background' && !activeTopLevelThreadIsFromHomeTab); const activeTopLevelThreadIsInChatList = React.useMemo( () => threadInChatList(activeTopLevelThreadInfo), [activeTopLevelThreadInfo], ); const shouldChangeTab = activeTopLevelThreadIsInChatList && activeTopLevelThreadIsFromDifferentTab; const prevActiveThreadIDRef = React.useRef(); React.useEffect(() => { const prevActiveThreadID = prevActiveThreadIDRef.current; prevActiveThreadIDRef.current = activeThreadID; if (activeThreadID !== prevActiveThreadID && shouldChangeTab) { setActiveTab(activeTopLevelThreadIsFromHomeTab ? 'Focus' : 'Background'); } }, [activeThreadID, shouldChangeTab, activeTopLevelThreadIsFromHomeTab]); const activeThreadOriginalTab = React.useMemo(() => { if (activeTopLevelThreadIsInChatList) { return null; } return activeTab; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTopLevelThreadIsInChatList, activeThreadID]); const makeSureActiveSidebarIsIncluded = React.useCallback( (threadListData: $ReadOnlyArray) => { if ( !activeChatThreadItem || activeChatThreadItem.threadInfo.type !== threadTypes.SIDEBAR ) { return threadListData; } const sidebarParentIndex = threadListData.findIndex( thread => thread.threadInfo.id === activeChatThreadItem.threadInfo.parentThreadID, ); if (sidebarParentIndex === -1) { return threadListData; } const parentItem = threadListData[sidebarParentIndex]; for (const sidebarItem of parentItem.sidebars) { if (sidebarItem.type !== 'sidebar') { continue; } else if ( sidebarItem.threadInfo.id === activeChatThreadItem.threadInfo.id ) { return threadListData; } } let indexToInsert = parentItem.sidebars.findIndex( sidebar => sidebar.lastUpdatedTime === undefined || sidebar.lastUpdatedTime < activeChatThreadItem.lastUpdatedTime, ); if (indexToInsert === -1) { indexToInsert = parentItem.sidebars.length; } const activeSidebar = { type: 'sidebar', lastUpdatedTime: activeChatThreadItem.lastUpdatedTime, mostRecentNonLocalMessage: activeChatThreadItem.mostRecentNonLocalMessage, threadInfo: activeChatThreadItem.threadInfo, }; const newSidebarItems = [...parentItem.sidebars]; newSidebarItems.splice(indexToInsert, 0, activeSidebar); const newThreadListData = [...threadListData]; newThreadListData[sidebarParentIndex] = { ...parentItem, sidebars: newSidebarItems, }; return newThreadListData; }, [activeChatThreadItem], ); - const chatListData = useSelector(chatListDataSelector); + const chatListData = useFlattenedChatListData(); + const [searchText, setSearchText] = React.useState(''); + + const viewerID = useSelector( + state => state.currentUserInfo && state.currentUserInfo.id, + ); + + const { threadSearchResults, usersSearchResults } = useThreadListSearch( + chatListData, + searchText, + viewerID, + ); + const threadFilter = + activeTab === 'Background' + ? threadInBackgroundChatList + : threadInHomeChatList; + const chatListDataWithoutFilter = getThreadListSearchResults( + chatListData, + searchText, + threadFilter, + threadSearchResults, + usersSearchResults, + viewerID, + ); const activeTopLevelChatThreadItem = useChatThreadItem( activeTopLevelThreadInfo, ); - const { homeThreadList, backgroundThreadList } = React.useMemo(() => { - const home = chatListData.filter(item => - threadInHomeChatList(item.threadInfo), - ); - const background = chatListData.filter(item => - threadInBackgroundChatList(item.threadInfo), - ); - if (activeTopLevelChatThreadItem && !activeTopLevelThreadIsInChatList) { - if (activeThreadOriginalTab === 'Focus') { - home.unshift(activeTopLevelChatThreadItem); - } else { - background.unshift(activeTopLevelChatThreadItem); - } + const threadList = React.useMemo(() => { + let threadListWithTopLevelItem = chatListDataWithoutFilter; + + if ( + activeTopLevelChatThreadItem && + !activeTopLevelThreadIsInChatList && + activeThreadOriginalTab === activeTab + ) { + threadListWithTopLevelItem = [ + activeTopLevelChatThreadItem, + ...threadListWithTopLevelItem, + ]; } - return { - homeThreadList: makeSureActiveSidebarIsIncluded(home), - backgroundThreadList: makeSureActiveSidebarIsIncluded(background), - }; + return makeSureActiveSidebarIsIncluded(threadListWithTopLevelItem); }, [ + activeTab, activeThreadOriginalTab, activeTopLevelChatThreadItem, activeTopLevelThreadIsInChatList, - chatListData, + chatListDataWithoutFilter, makeSureActiveSidebarIsIncluded, ]); - - const currentThreadList = - activeTab === 'Focus' ? homeThreadList : backgroundThreadList; - const threadListContext = React.useMemo( () => ({ activeTab, - threadList: currentThreadList, + threadList, setActiveTab, + searchText, + setSearchText, }), - [activeTab, currentThreadList], + [activeTab, threadList, searchText], ); - return ( {props.children} ); } export { ThreadListProvider, ThreadListContext }; diff --git a/web/chat/thread-list-search.react.js b/web/chat/thread-list-search.react.js new file mode 100644 index 000000000..d7780db5e --- /dev/null +++ b/web/chat/thread-list-search.react.js @@ -0,0 +1,43 @@ +// @flow + +import * as React from 'react'; + +import css from './chat-thread-list.css'; +import ClearSearchButton from './clear-search-button.react'; + +type ThreadListSearchProps = { + +searchText: string, + +onChangeText: (searchText: string) => mixed, +}; + +function ThreadListSearch(props: ThreadListSearchProps): React.Node { + const { searchText, onChangeText } = props; + + const showClearButton = !!searchText; + + const onClear = React.useCallback(() => { + onChangeText(''); + }, [onChangeText]); + + const onChange = React.useCallback( + event => { + onChangeText(event.target.value); + }, + [onChangeText], + ); + + return ( +
+ + +
+ ); +} + +export default ThreadListSearch;