diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 29f8f9952..acc355ebf 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,356 +1,360 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _orderBy from 'lodash/fp/orderBy'; import _memoize from 'lodash/memoize'; import PropTypes from 'prop-types'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { messageKey, robotextForMessageInfo, createMessageInfo, getMostRecentNonLocalMessageID, } from '../shared/message-utils'; import { threadIsTopLevel } from '../shared/thread-utils'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageInfoPropType, localMessageInfoPropType, messageTypes, isComposableMessageType, } from '../types/message-types'; import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, threadInfoPropType, type SidebarInfo, maxReadSidebars, maxUnreadSidebars, } from '../types/thread-types'; +import { userInfoPropType } from '../types/user-types'; +import type { UserInfo } from '../types/user-types'; import { threadInfoSelector, sidebarInfoSelector } from './thread-selectors'; type SidebarItem = | {| ...SidebarInfo, +type: 'sidebar', |} | {| +type: 'seeMore', +unread: boolean, |}; export type ChatThreadItem = {| +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, + +pendingPersonalThreadUserInfo?: UserInfo, |}; const chatThreadItemPropType = PropTypes.exact({ type: PropTypes.oneOf(['chatThreadItem']).isRequired, threadInfo: threadInfoPropType.isRequired, mostRecentMessageInfo: messageInfoPropType, mostRecentNonLocalMessage: PropTypes.string, lastUpdatedTime: PropTypes.number.isRequired, lastUpdatedTimeIncludingSidebars: PropTypes.number.isRequired, sidebars: PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.exact({ type: PropTypes.oneOf(['sidebar']).isRequired, threadInfo: threadInfoPropType.isRequired, lastUpdatedTime: PropTypes.number.isRequired, mostRecentNonLocalMessage: PropTypes.string, }), PropTypes.exact({ type: PropTypes.oneOf(['seeMore']).isRequired, unread: PropTypes.bool.isRequired, }), ]), ).isRequired, + pendingPersonalThreadUserInfo: userInfoPropType, }); const messageInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: MessageInfo } = createObjectSelector( (state: BaseAppState<*>) => state.messageStore.messages, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messages[messageID]; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map((sidebarInfo) => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( (sidebar) => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if (numReadSidebarsToShow > 0) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } if (sidebarItems.length < allSidebarItems.length) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: ( state: BaseAppState<*>, ) => ChatThreadItem[] = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, ): ChatThreadItem[] => _flow( _filter(threadIsTopLevel), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos), ); export type RobotextChatMessageInfoItem = {| itemType: 'message', messageInfo: RobotextMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, robotext: string, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| itemType: 'message', messageInfo: ComposableMessageInfo, localMessageInfo: ?LocalMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; const chatMessageItemPropType = PropTypes.oneOfType([ PropTypes.shape({ itemType: PropTypes.oneOf(['loader']).isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['message']).isRequired, messageInfo: messageInfoPropType.isRequired, localMessageInfo: localMessageInfoPropType, startsConversation: PropTypes.bool.isRequired, startsCluster: PropTypes.bool.isRequired, endsCluster: PropTypes.bool.isRequired, robotext: PropTypes.string, }), ]); const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; if (!thread) { return []; } const threadMessageInfos = thread.messageIDs .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const chatMessageItems = []; let lastMessageInfo = null; for (let i = threadMessageInfos.length - 1; i >= 0; i--) { const messageInfo = threadMessageInfos[i]; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > messageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(messageInfo.type) && lastMessageInfo.creator.id === messageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } if (isComposableMessageType(messageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(messageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, }); } else { invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( messageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo, startsConversation, startsCluster, endsCluster: false, robotext, }); } lastMessageInfo = messageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); if (thread.startReached) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] => createChatMessageItems(threadID, messageStore, messageInfos, threadInfos), ); const messageListData: ( threadID: string, ) => (state: BaseAppState<*>) => ChatMessageItem[] = _memoize( baseMessageListData, ); export { messageInfoSelector, createChatThreadItem, chatThreadItemPropType, chatListData, chatMessageItemPropType, createChatMessageItems, messageListData, }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index 28c6a265a..780ac4ccf 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,183 +1,213 @@ // @flow import _memoize from 'lodash/memoize'; import { createSelector } from 'reselect'; import SearchIndex from '../shared/search-index'; -import { memberHasAdminPowers } from '../shared/thread-utils'; +import { + getSingleOtherUser, + memberHasAdminPowers, +} from '../shared/thread-utils'; import type { BaseAppState } from '../types/redux-types'; import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type RelativeMemberInfo, + threadTypes, } from '../types/thread-types'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, } from '../types/user-types'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: string[], viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos = []; for (let userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; if (userID === viewerID) { relativeUserInfos.unshift({ id: userID, username, isViewer: true, }); } else { relativeUserInfos.push({ id: userID, username, isViewer: false, }); } } return relativeUserInfos; } const emptyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, ( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray => { const relativeMemberInfos = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { const isParentAdmin = memberHasAdminPowers(memberInfo); if (!memberInfo.role && !isParentAdmin) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: true, }); } else { relativeMemberInfos.push({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: false, }); } } return relativeMemberInfos; }, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<*>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: ( state: BaseAppState<*>, ) => { [id: string]: AccountUserInfo } = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); function searchIndexFromUserInfos(userInfos: { [id: string]: AccountUserInfo, }) { const searchIndex = new SearchIndex(); for (const id in userInfos) { searchIndex.addEntry(id, userInfos[id].username); } return searchIndex; } const userSearchIndexForPotentialMembers: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( userInfoSelectorForPotentialMembers, searchIndexFromUserInfos, ); const isLoggedIn = (state: BaseAppState<*>) => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const userStoreSearchIndex: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (userInfos: UserInfos) => { const searchIndex = new SearchIndex(); for (const id in userInfos) { const { username } = userInfos[id]; if (!username) { continue; } searchIndex.addEntry(id, username); } return searchIndex; }, ); +const usersWithPersonalThreadSelector: ( + state: BaseAppState<*>, +) => $ReadOnlySet = createSelector( + (state) => state.currentUserInfo && state.currentUserInfo.id, + (state) => state.threadStore.threadInfos, + (viewerID, threadInfos) => { + const personalThreadMembers = new Set(); + + for (const threadID in threadInfos) { + const thread = threadInfos[threadID]; + if ( + thread.type !== threadTypes.PERSONAL || + !thread.members.find((member) => member.id === viewerID) + ) { + continue; + } + const otherMemberID = getSingleOtherUser(thread, viewerID); + if (otherMemberID) { + personalThreadMembers.add(otherMemberID); + } + } + return personalThreadMembers; + }, +); + export { userIDsToRelativeUserInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, isLoggedIn, userStoreSearchIndex, + usersWithPersonalThreadSelector, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index bd5d8e8dd..f8b2d7631 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,567 +1,644 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find'; import tinycolor from 'tinycolor2'; import { permissionLookup, getAllThreadPermissions, + makePermissionsBlob, } from '../permissions/thread-permissions'; +import type { ChatThreadItem } from '../selectors/chat-selectors'; 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, threadTypes, threadPermissions, } from '../types/thread-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; -import type { UserInfos } from '../types/user-types'; +import type { GlobalAccountUserInfo, UserInfos } from '../types/user-types'; import { pluralize } from '../utils/text-utils'; function colorIsDark(color: string) { return tinycolor(`#${color}`).isDark(); } // Randomly distributed in RGB-space const hexNumerals = '0123456789abcdef'; function generateRandomColor() { let color = ''; for (let i = 0; i < 6; i++) { color += hexNumerals[Math.floor(Math.random() * 16)]; } return color; } function generatePendingThreadColor(userID: string, viewerID: string) { const ids = userID < viewerID ? `${userID}#${viewerID}` : `${viewerID}#${userID}`; let hash = 0; for (let i = 0; i < ids.length; i++) { hash = 1009 * hash + ids.charCodeAt(i) * 83; hash %= 1000000007; } const hashString = hash.toString(16); return hashString.substring(hashString.length - 6).padStart(6, '8'); } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { 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 || !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; } return threadInfo.members.some( (member) => member.id === userID && member.role !== null && member.role !== undefined, ); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter( (memberInfo) => memberInfo.role !== null && memberInfo.role !== undefined, ) .map((memberInfo) => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo) { return ( threadInfo.members.filter( (member) => member.role || member.permissions[threadPermissions.VOICED]?.value, ).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string) { return threadID?.startsWith('pending'); } function threadIsPersonalAndPending(threadInfo: ?(ThreadInfo | RawThreadInfo)) { return ( threadInfo?.type === threadTypes.PERSONAL && threadIsPending(threadInfo?.id) ); } function getPendingPersonalThreadOtherUser( threadInfo: ThreadInfo | RawThreadInfo, ) { invariant( threadIsPersonalAndPending(threadInfo), 'Thread should be personal and pending', ); const otherUserID = threadInfo.id.split('/')[1]; invariant( otherUserID, 'Pending thread should contain other member id in its id', ); return otherUserID; } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ) { if (!viewerID) { return undefined; } const otherMemberIDs = threadInfo.members .map((member) => member.id) .filter((id) => id !== viewerID); if (otherMemberIDs.length !== 1) { return undefined; } return otherMemberIDs[0]; } +function createPendingThreadItem( + viewerID: string, + user: GlobalAccountUserInfo, +): ChatThreadItem { + const now = Date.now(); + const threadID = `pending/${user.id}`; + + const permissions = { + [threadPermissions.KNOW_OF]: true, + [threadPermissions.VISIBLE]: true, + [threadPermissions.VOICED]: true, + }; + const membershipPermissions = getAllThreadPermissions( + makePermissionsBlob(permissions, null, threadID, threadTypes.PERSONAL), + threadID, + ); + const role = { + id: `${threadID}/role`, + name: 'Members', + permissions, + isDefault: true, + }; + + const rawThreadInfo = { + id: threadID, + type: threadTypes.PERSONAL, + name: null, + description: null, + color: generatePendingThreadColor(user.id, viewerID), + creationTime: now, + parentThreadID: null, + members: [ + { + id: viewerID, + role: role.id, + permissions: membershipPermissions, + }, + { + id: user.id, + role: role.id, + permissions: membershipPermissions, + }, + ], + roles: { + [role.id]: role, + }, + currentUser: { + role: role.id, + permissions: membershipPermissions, + subscription: { + pushNotifs: false, + home: false, + }, + unread: false, + }, + }; + + return { + type: 'chatThreadItem', + threadInfo: threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, { + [user.id]: user, + }), + mostRecentMessageInfo: null, + mostRecentNonLocalMessage: null, + lastUpdatedTime: now, + lastUpdatedTimeIncludingSidebars: now, + sidebars: [], + pendingPersonalThreadUserInfo: { + id: user.id, + username: user.username, + }, + }; +} + type RawThreadInfoOptions = {| +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, |}; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } members.push({ id: serverMember.id, role: serverMember.role, permissions: serverMember.permissions, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: serverMember.permissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = getAllThreadPermissions(null, serverThreadInfo.id); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rawThreadInfo = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, }; if (!includeVisibilityRules) { return rawThreadInfo; } return ({ ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }: any); } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames: string[] = threadInfo.members .filter( (threadMember) => threadMember.id !== viewerID && (threadMember.role || memberHasAdminPowers(threadMember)), ) .map( (threadMember) => userInfos[threadMember.id] && userInfos[threadMember.id].username, ) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { if (threadInfo.name) { return threadInfo.name; } return robotextName(threadInfo, viewerID, userInfos); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { return { 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, members: rawThreadInfo.members, roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), }; } function getCurrentUser( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(rawThreadInfo, viewerID, userInfos)) { return rawThreadInfo.currentUser; } return { ...rawThreadInfo.currentUser, permissions: { ...rawThreadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( 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 === userRelationshipStatus.BLOCKED_BY_VIEWER || otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || otherUserRelationshipStatus === userRelationshipStatus.BOTH_BLOCKED ); } 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 { return { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, members: threadInfo.members, roles: threadInfo.roles, currentUser: threadInfo.currentUser, }; } const threadTypeDescriptions = { [threadTypes.CHAT_NESTED_OPEN]: 'Anybody in the parent thread can see an open child thread.', [threadTypes.CHAT_SECRET]: 'Only visible to its members and admins of ancestor threads.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (let member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ) { 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) { return roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'; } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ) { 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, threadPermissions.CREATE_SUBTHREADS, 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 = `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, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (let member of threadInfo.members) { const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; export { colorIsDark, generateRandomColor, generatePendingThreadColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, getPendingPersonalThreadOtherUser, getSingleOtherUser, + createPendingThreadItem, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, }; diff --git a/native/chat/chat-thread-list-item.react.js b/native/chat/chat-thread-list-item.react.js index a64094ac2..801f65cd6 100644 --- a/native/chat/chat-thread-list-item.react.js +++ b/native/chat/chat-thread-list-item.react.js @@ -1,163 +1,167 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors'; import type { ThreadInfo } from 'lib/types/thread-types'; +import type { UserInfo } from 'lib/types/user-types'; import { shortAbsoluteDate } from 'lib/utils/date-utils'; import Button from '../components/button.react'; import ColorSplotch from '../components/color-splotch.react'; import { SingleLine } from '../components/single-line.react'; import { useColors, useStyles } from '../themes/colors'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react'; import MessagePreview from './message-preview.react'; import SwipeableThread from './swipeable-thread.react'; type Props = {| +data: ChatThreadItem, - +onPressItem: (threadInfo: ThreadInfo) => void, + +onPressItem: ( + data: ThreadInfo, + pendingPersonalThreadUserInfo?: UserInfo, + ) => void, +onPressSeeMoreSidebars: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, |}; function ChatThreadListItem({ data, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, currentlyOpenedSwipeableId, }: Props) { const styles = useStyles(unboundStyles); const colors = useColors(); const lastMessage = React.useMemo(() => { const mostRecentMessageInfo = data.mostRecentMessageInfo; if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [data.mostRecentMessageInfo, data.threadInfo, styles]); const sidebars = data.sidebars.map((sidebarItem) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( ); } else { return ( ); } }); const onPress = React.useCallback(() => { - onPressItem(data.threadInfo); - }, [onPressItem, data.threadInfo]); + onPressItem(data.threadInfo, data.pendingPersonalThreadUserInfo); + }, [onPressItem, data.threadInfo, data.pendingPersonalThreadUserInfo]); const lastActivity = shortAbsoluteDate(data.lastUpdatedTime); const unreadStyle = data.threadInfo.currentUser.unread ? styles.unread : null; return ( <> {sidebars} ); } const unboundStyles = { colorSplotch: { marginLeft: 10, marginTop: 2, }, container: { height: 60, paddingLeft: 10, paddingRight: 10, paddingTop: 5, backgroundColor: 'listBackground', }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 16, marginLeft: 10, }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 16, fontStyle: 'italic', paddingLeft: 10, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, threadName: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 20, paddingLeft: 10, }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, }; export default ChatThreadListItem; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index d2962c255..145e0bd0b 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,357 +1,400 @@ // @flow import invariant from 'invariant'; import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { View, FlatList, Platform, TextInput } from 'react-native'; import { FloatingAction } from 'react-native-floating-action'; import IonIcon from 'react-native-vector-icons/Ionicons'; import { createSelector } from 'reselect'; +import { searchUsers } from 'lib/actions/user-actions'; import { type ChatThreadItem, chatListData, } from 'lib/selectors/chat-selectors'; import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; +import { usersWithPersonalThreadSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; +import { createPendingThreadItem } from 'lib/shared/thread-utils'; +import type { UserSearchResult } from 'lib/types/search-types'; import type { ThreadInfo } from 'lib/types/thread-types'; +import type { GlobalAccountUserInfo, UserInfo } from 'lib/types/user-types'; +import { useServerCall } from 'lib/utils/action-utils'; import Search from '../components/search.react'; import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { ComposeThreadRouteName, MessageListRouteName, SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors'; import ChatThreadListItem from './chat-thread-list-item.react'; import type { ChatTopTabsNavigationProp, ChatNavigationProp, } from './chat.react'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; type Item = | ChatThreadItem - | {| type: 'search', searchText: string |} - | {| type: 'empty', emptyItem: React.ComponentType<{||}> |}; + | {| +type: 'search', +searchText: string |} + | {| +type: 'empty', +emptyItem: React.ComponentType<{||}> |}; type BaseProps = {| +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{||}>, |}; type Props = {| ...BaseProps, // Redux state +chatListData: $ReadOnlyArray, +viewerID: ?string, +threadSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, + +usersWithPersonalThread: $ReadOnlySet, + // async functions that hit server APIs + +searchUsers: (usernamePrefix: string) => Promise, |}; type State = {| +searchText: string, - +searchResults: Set, + +threadsSearchResults: Set, + +usersSearchResults: $ReadOnlyArray, +openedSwipeableId: string, |}; type PropsAndState = {| ...Props, ...State |}; class ChatThreadList extends React.PureComponent { state: State = { searchText: '', - searchResults: new Set(), + threadsSearchResults: new Set(), + usersSearchResults: [], openedSwipeableId: '', }; searchInput: ?React.ElementRef; flatList: ?FlatList; scrollPos = 0; componentDidMount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (!this.props.navigation.isFocused()) { return; } if (this.scrollPos > 0 && this.flatList) { this.flatList.scrollToOffset({ offset: 0, animated: true }); } else if (this.props.route.name === BackgroundChatThreadListRouteName) { this.props.navigation.navigate({ name: HomeChatThreadListRouteName }); } }; renderItem = (row: { item: Item }) => { const item = row.item; if (item.type === 'search') { return ( ); } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }; searchInputRef = (searchInput: ?React.ElementRef) => { this.searchInput = searchInput; }; static keyExtractor(item: Item) { - if (item.threadInfo) { + if (item.type === 'chatThreadItem') { return item.threadInfo.id; - } else if (item.emptyItem) { + } else if (item.type === 'empty') { return 'empty'; } else { return 'search'; } } static getItemLayout(data: ?$ReadOnlyArray, index: number) { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatThreadList.heightOfItems( data.filter((_, i) => i < index), ); const item = data[index]; const length = item ? ChatThreadList.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: Item): number { if (item.type === 'search') { return Platform.OS === 'ios' ? 54.5 : 55; } // itemHeight for emptyItem might be wrong because of line wrapping // but we don't care because we'll only ever be rendering this item by itself // and it should always be on-screen if (item.type === 'empty') { return 123; } return 60 + item.sidebars.length * 30; } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(ChatThreadList.itemHeight)); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.chatListData, (propsAndState: PropsAndState) => propsAndState.searchText, - (propsAndState: PropsAndState) => propsAndState.searchResults, + (propsAndState: PropsAndState) => propsAndState.threadsSearchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, + (propsAndState: PropsAndState) => propsAndState.usersSearchResults, ( reduxChatListData: $ReadOnlyArray, searchText: string, - searchResults: Set, + threadsSearchResults: Set, emptyItem?: React.ComponentType<{||}>, + usersSearchResults: $ReadOnlyArray, ): Item[] => { const chatItems = []; if (!searchText) { chatItems.push( ...reduxChatListData.filter((item) => this.props.filterThreads(item.threadInfo), ), ); } else { chatItems.push( ...reduxChatListData.filter((item) => - searchResults.has(item.threadInfo.id), + threadsSearchResults.has(item.threadInfo.id), + ), + ); + } + const { viewerID } = this.props; + if (viewerID) { + chatItems.push( + ...usersSearchResults.map((user) => + createPendingThreadItem(viewerID, user), ), ); } if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } return [{ type: 'search', searchText }, ...chatItems]; }, ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let floatingAction = null; if (Platform.OS === 'android') { floatingAction = ( ); } // this.props.viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem return ( {floatingAction} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.scrollPos = event.nativeEvent.contentOffset.y; }; - onChangeSearchText = (searchText: string) => { + async searchUsers(usernamePrefix: string) { + if (usernamePrefix.length === 0) { + return []; + } + + const { userInfos } = await this.props.searchUsers(usernamePrefix); + return userInfos.filter( + (info) => + !this.props.usersWithPersonalThread.has(info.id) && + info.id !== this.props.viewerID, + ); + } + + onChangeSearchText = async (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); - this.setState({ searchText, searchResults: new Set(results) }); + this.setState({ searchText, threadsSearchResults: new Set(results) }); + const usersSearchResults = await this.searchUsers(searchText); + this.setState({ usersSearchResults }); }; - onPressItem = (threadInfo: ThreadInfo) => { + onPressItem = ( + threadInfo: ThreadInfo, + pendingPersonalThreadUserInfo?: UserInfo, + ) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: MessageListRouteName, - params: { threadInfo }, + params: { threadInfo, pendingPersonalThreadUserInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState((state) => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { this.props.navigation.navigate({ name: ComposeThreadRouteName, params: {}, }); }; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, search: { marginBottom: 8, marginHorizontal: 12, marginTop: Platform.OS === 'android' ? 10 : 8, }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default React.memo(function ConnectedChatThreadList( props: BaseProps, ) { const boundChatListData = useSelector(chatListData); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useSelector(threadSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); + const callSearchUsers = useServerCall(searchUsers); + const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return ( ); }); diff --git a/native/components/search.react.js b/native/components/search.react.js index 4d5004bf3..ee290ef85 100644 --- a/native/components/search.react.js +++ b/native/components/search.react.js @@ -1,135 +1,135 @@ // @flow import PropTypes from 'prop-types'; import * as React from 'react'; import { View, ViewPropTypes, TouchableOpacity, TextInput } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { connect } from 'lib/utils/redux-utils'; import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; type Props = {| ...React.ElementConfig, searchText: string, - onChangeText: (searchText: string) => void, + onChangeText: (searchText: string) => mixed, containerStyle?: ViewStyle, textInputRef?: React.Ref, // Redux state colors: Colors, styles: typeof styles, loggedIn: boolean, |}; class Search extends React.PureComponent { static propTypes = { searchText: PropTypes.string.isRequired, onChangeText: PropTypes.func.isRequired, containerStyle: ViewPropTypes.style, textInputRef: PropTypes.func, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, loggedIn: PropTypes.bool.isRequired, }; componentDidUpdate(prevProps: Props) { if (!this.props.loggedIn && prevProps.loggedIn) { this.clearSearch(); } } render() { const { searchText, onChangeText, containerStyle, textInputRef, colors, styles, loggedIn, ...rest } = this.props; const { listSearchIcon: iconColor } = colors; let clearSearchInputIcon = null; if (searchText) { clearSearchInputIcon = ( ); } const textInputProps: React.ElementProps = { style: styles.searchInput, value: searchText, onChangeText: onChangeText, placeholderTextColor: iconColor, returnKeyType: 'go', }; return ( {clearSearchInputIcon} ); } clearSearch = () => { this.props.onChangeText(''); }; } const styles = { search: { alignItems: 'center', backgroundColor: 'listSearchBackground', borderRadius: 6, flexDirection: 'row', paddingLeft: 14, paddingRight: 12, paddingVertical: 6, }, searchInput: { color: 'listForegroundLabel', flex: 1, fontSize: 16, marginLeft: 8, marginVertical: 0, padding: 0, borderBottomColor: 'transparent', }, }; const stylesSelector = styleSelector(styles); const ConnectedSearch = connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), loggedIn: isLoggedIn(state), }))(Search); type ConnectedProps = $Diff< Props, {| colors: Colors, styles: typeof styles, loggedIn: boolean, |}, >; export default React.forwardRef( function ForwardedConnectedSearch( props: ConnectedProps, ref: React.Ref, ) { return ; }, );