diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index 19db56c0a..e6c94efe2 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,329 +1,483 @@ // @flow import invariant from 'invariant'; import type { FetchMessageInfosPayload, SendMessageResult, SendEditMessageResult, SendReactionMessageRequest, SimpleMessagesPayload, SendEditMessageRequest, FetchPinnedMessagesRequest, FetchPinnedMessagesResult, SearchMessagesRequest, SearchMessagesResponse, } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; -import type { - CallServerEndpoint, - CallServerEndpointResultInfo, -} from '../utils/call-server-endpoint.js'; +import { + extractKeyserverIDFromID, + sortThreadIDsPerKeyserver, +} from '../utils/action-utils.js'; +import type { CallServerEndpointResultInfo } from '../utils/call-server-endpoint.js'; +import { useKeyserverCall } from '../utils/keyserver-call.js'; +import type { CallKeyserverEndpoint } from '../utils/keyserver-call.js'; const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); +export type FetchMessagesBeforeCursorInput = { + +threadID: string, + +beforeMessageID: string, +}; const fetchMessagesBeforeCursor = ( - callServerEndpoint: CallServerEndpoint, + callKeyserverEndpoint: CallKeyserverEndpoint, ): (( - threadID: string, - beforeMessageID: string, + input: FetchMessagesBeforeCursorInput, ) => Promise) => - async (threadID, beforeMessageID) => { - const response = await callServerEndpoint('fetch_messages', { - cursors: { - [threadID]: beforeMessageID, + async input => { + const { threadID, beforeMessageID } = input; + + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { + [keyserverID]: { + cursors: { + [threadID]: beforeMessageID, + }, }, - }); + }; + + const responses = await callKeyserverEndpoint('fetch_messages', requests); return { threadID, - rawMessageInfos: response.rawMessageInfos, - truncationStatus: response.truncationStatuses[threadID], + rawMessageInfos: responses[keyserverID].rawMessageInfos, + truncationStatus: responses[keyserverID].truncationStatuses[threadID], }; }; +function useFetchMessagesBeforeCursor(): ( + input: FetchMessagesBeforeCursorInput, +) => Promise { + return useKeyserverCall(fetchMessagesBeforeCursor); +} + +export type FetchMostRecentMessagesInput = { + +threadID: string, +}; + const fetchMostRecentMessagesActionTypes = Object.freeze({ started: 'FETCH_MOST_RECENT_MESSAGES_STARTED', success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED', }); const fetchMostRecentMessages = ( - callServerEndpoint: CallServerEndpoint, - ): ((threadID: string) => Promise) => - async threadID => { - const response = await callServerEndpoint('fetch_messages', { - cursors: { - [threadID]: null, + callKeyserverEndpoint: CallKeyserverEndpoint, + ): (( + input: FetchMostRecentMessagesInput, + ) => Promise) => + async input => { + const { threadID } = input; + + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { + [keyserverID]: { + cursors: { + [threadID]: null, + }, }, - }); + }; + + const responses = await callKeyserverEndpoint('fetch_messages', requests); return { threadID, - rawMessageInfos: response.rawMessageInfos, - truncationStatus: response.truncationStatuses[threadID], + rawMessageInfos: responses[keyserverID].rawMessageInfos, + truncationStatus: responses[keyserverID].truncationStatuses[threadID], }; }; +function useFetchMostRecentMessages(): ( + input: FetchMostRecentMessagesInput, +) => Promise { + return useKeyserverCall(fetchMostRecentMessages); +} + const fetchSingleMostRecentMessagesFromThreadsActionTypes = Object.freeze({ started: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_STARTED', success: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_SUCCESS', failed: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_FAILED', }); const fetchSingleMostRecentMessagesFromThreads = ( - callServerEndpoint: CallServerEndpoint, + callKeyserverEndpoint: CallKeyserverEndpoint, ): ((threadIDs: $ReadOnlyArray) => Promise) => async threadIDs => { - const cursors = Object.fromEntries( - threadIDs.map(threadID => [threadID, null]), - ); - const response = await callServerEndpoint('fetch_messages', { - cursors, - numberPerThread: 1, - }); + const sortedThreadIDs = sortThreadIDsPerKeyserver(threadIDs); + + const requests = {}; + for (const keyserverID in sortedThreadIDs) { + const cursors = Object.fromEntries( + sortedThreadIDs[keyserverID].map(threadID => [threadID, null]), + ); + requests[keyserverID] = { + cursors, + numberPerThread: 1, + }; + } + + const responses = await callKeyserverEndpoint('fetch_messages', requests); + let rawMessageInfos = []; + let truncationStatuses = {}; + for (const keyserverID in responses) { + rawMessageInfos = rawMessageInfos.concat( + responses[keyserverID].rawMessageInfos, + ); + truncationStatuses = { + ...truncationStatuses, + ...responses[keyserverID].truncationStatuses, + }; + } + return { - rawMessageInfos: response.rawMessageInfos, - truncationStatuses: response.truncationStatuses, + rawMessageInfos, + truncationStatuses, }; }; +function useFetchSingleMostRecentMessagesFromThreads(): ( + threadIDs: $ReadOnlyArray, +) => Promise { + return useKeyserverCall(fetchSingleMostRecentMessagesFromThreads); +} + +export type SendTextMessageInput = { + +threadID: string, + +localID: string, + +text: string, + +sidebarCreation?: boolean, +}; + const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); const sendTextMessage = ( - callServerEndpoint: CallServerEndpoint, - ): (( - threadID: string, - localID: string, - text: string, - sidebarCreation?: boolean, - ) => Promise) => - async (threadID, localID, text, sidebarCreation) => { + callKeyserverEndpoint: CallKeyserverEndpoint, + ): ((input: SendTextMessageInput) => Promise) => + async input => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; + const { threadID, localID, text, sidebarCreation } = input; let payload = { threadID, localID, text }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } - const response = await callServerEndpoint('create_text_message', payload, { - getResultInfo, - }); + + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { [keyserverID]: payload }; + + const responses = await callKeyserverEndpoint( + 'create_text_message', + requests, + { + getResultInfo, + }, + ); const resultInterface = resultInfo?.interface; invariant( resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', + 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, + id: responses[keyserverID].newMessageInfo.id, + time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; +function useSendTextMessage(): ( + input: SendTextMessageInput, +) => Promise { + return useKeyserverCall(sendTextMessage); +} + const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE'; +export type SendMultimediaMessageInput = { + +threadID: string, + +localID: string, + +mediaMessageContents: $ReadOnlyArray, + +sidebarCreation?: boolean, +}; + const sendMultimediaMessageActionTypes = Object.freeze({ started: 'SEND_MULTIMEDIA_MESSAGE_STARTED', success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED', }); const sendMultimediaMessage = ( - callServerEndpoint: CallServerEndpoint, - ): (( - threadID: string, - localID: string, - mediaMessageContents: $ReadOnlyArray, - sidebarCreation?: boolean, - ) => Promise) => - async (threadID, localID, mediaMessageContents, sidebarCreation) => { + callKeyserverEndpoint: CallKeyserverEndpoint, + ): ((input: SendMultimediaMessageInput) => Promise) => + async input => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; + const { threadID, localID, mediaMessageContents, sidebarCreation } = input; let payload = { threadID, localID, mediaMessageContents }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } - const response = await callServerEndpoint( + + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { [keyserverID]: payload }; + + const responses = await callKeyserverEndpoint( 'create_multimedia_message', - payload, + requests, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', + 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, + id: responses[keyserverID].newMessageInfo.id, + time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; +function useSendMultimediaMessage(): ( + input: SendMultimediaMessageInput, +) => Promise { + return useKeyserverCall(sendMultimediaMessage); +} + +export type LegacySendMultimediaMessageInput = { + +threadID: string, + +localID: string, + +mediaIDs: $ReadOnlyArray, + +sidebarCreation?: boolean, +}; + const legacySendMultimediaMessage = ( - callServerEndpoint: CallServerEndpoint, + callKeyserverEndpoint: CallKeyserverEndpoint, ): (( - threadID: string, - localID: string, - mediaIDs: $ReadOnlyArray, - sidebarCreation?: boolean, + input: LegacySendMultimediaMessageInput, ) => Promise) => - async (threadID, localID, mediaIDs, sidebarCreation) => { + async input => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; + const { threadID, localID, mediaIDs, sidebarCreation } = input; let payload = { threadID, localID, mediaIDs }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } - const response = await callServerEndpoint( + + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { [keyserverID]: payload }; + + const responses = await callKeyserverEndpoint( 'create_multimedia_message', - payload, + requests, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', + 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, + id: responses[keyserverID].newMessageInfo.id, + time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; +function useLegacySendMultimediaMessage(): ( + input: LegacySendMultimediaMessageInput, +) => Promise { + return useKeyserverCall(legacySendMultimediaMessage); +} + const sendReactionMessageActionTypes = Object.freeze({ started: 'SEND_REACTION_MESSAGE_STARTED', success: 'SEND_REACTION_MESSAGE_SUCCESS', failed: 'SEND_REACTION_MESSAGE_FAILED', }); const sendReactionMessage = ( - callServerEndpoint: CallServerEndpoint, - ): ((request: SendReactionMessageRequest) => Promise) => - async request => { + callKeyserverEndpoint: CallKeyserverEndpoint, + ): ((input: SendReactionMessageRequest) => Promise) => + async input => { let resultInfo; const getResultInfo = (passedResultInfo: CallServerEndpointResultInfo) => { resultInfo = passedResultInfo; }; - const response = await callServerEndpoint( - 'create_reaction_message', - { - threadID: request.threadID, - localID: request.localID, - targetMessageID: request.targetMessageID, - reaction: request.reaction, - action: request.action, + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { + [keyserverID]: { + threadID: input.threadID, + localID: input.localID, + targetMessageID: input.targetMessageID, + reaction: input.reaction, + action: input.action, }, + }; + + const responses = await callKeyserverEndpoint( + 'create_reaction_message', + requests, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, - 'getResultInfo not called before callServerEndpoint resolves', + 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { - id: response.newMessageInfo.id, - time: response.newMessageInfo.time, + id: responses[keyserverID].newMessageInfo.id, + time: responses[keyserverID].newMessageInfo.time, interface: resultInterface, }; }; +function useSendReactionMessage(): ( + input: SendReactionMessageRequest, +) => Promise { + return useKeyserverCall(sendReactionMessage); +} + const sendEditMessageActionTypes = Object.freeze({ started: 'SEND_EDIT_MESSAGE_STARTED', success: 'SEND_EDIT_MESSAGE_SUCCESS', failed: 'SEND_EDIT_MESSAGE_FAILED', }); const sendEditMessage = ( - callServerEndpoint: CallServerEndpoint, - ): ((request: SendEditMessageRequest) => Promise) => - async request => { - const response = await callServerEndpoint('edit_message', { - targetMessageID: request.targetMessageID, - text: request.text, - }); + callKeyserverEndpoint: CallKeyserverEndpoint, + ): ((input: SendEditMessageRequest) => Promise) => + async input => { + const keyserverID = extractKeyserverIDFromID(input.targetMessageID); + const requests = { + [keyserverID]: { + targetMessageID: input.targetMessageID, + text: input.text, + }, + }; + + const responses = await callKeyserverEndpoint('edit_message', requests); + return { - newMessageInfos: response.newMessageInfos, + newMessageInfos: responses[keyserverID].newMessageInfos, }; }; +function useSendEditMessage(): ( + input: SendEditMessageRequest, +) => Promise { + return useKeyserverCall(sendEditMessage); +} + const saveMessagesActionType = 'SAVE_MESSAGES'; const processMessagesActionType = 'PROCESS_MESSAGES'; const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE'; const fetchPinnedMessageActionTypes = Object.freeze({ started: 'FETCH_PINNED_MESSAGES_STARTED', success: 'FETCH_PINNED_MESSAGES_SUCCESS', failed: 'FETCH_PINNED_MESSAGES_FAILED', }); const fetchPinnedMessages = ( - callServerEndpoint: CallServerEndpoint, + callKeyserverEndpoint: CallKeyserverEndpoint, ): (( - request: FetchPinnedMessagesRequest, + input: FetchPinnedMessagesRequest, ) => Promise) => - async request => { - const response = await callServerEndpoint('fetch_pinned_messages', request); - return { pinnedMessages: response.pinnedMessages }; + async input => { + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { [keyserverID]: input }; + + const responses = await callKeyserverEndpoint( + 'fetch_pinned_messages', + requests, + ); + + return { pinnedMessages: responses[keyserverID].pinnedMessages }; }; +function useFetchPinnedMessages(): ( + input: FetchPinnedMessagesRequest, +) => Promise { + return useKeyserverCall(fetchPinnedMessages); +} + const searchMessagesActionTypes = Object.freeze({ started: 'SEARCH_MESSAGES_STARTED', success: 'SEARCH_MESSAGES_SUCCESS', failed: 'SEARCH_MESSAGES_FAILED', }); const searchMessages = ( - callServerEndpoint: CallServerEndpoint, - ): ((request: SearchMessagesRequest) => Promise) => - async request => { - const response = await callServerEndpoint('search_messages', request); + callKeyserverEndpoint: CallKeyserverEndpoint, + ): ((input: SearchMessagesRequest) => Promise) => + async input => { + const keyserverID = extractKeyserverIDFromID(input.threadID); + const requests = { [keyserverID]: input }; + + const responses = await callKeyserverEndpoint('search_messages', requests); + return { - messages: response.messages, - endReached: response.endReached, + messages: responses[keyserverID].messages, + endReached: responses[keyserverID].endReached, }; }; +function useSearchMessages(): ( + input: SearchMessagesRequest, +) => Promise { + return useKeyserverCall(searchMessages); +} + export { fetchMessagesBeforeCursorActionTypes, - fetchMessagesBeforeCursor, + useFetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, - fetchMostRecentMessages, + useFetchMostRecentMessages, fetchSingleMostRecentMessagesFromThreadsActionTypes, - fetchSingleMostRecentMessagesFromThreads, + useFetchSingleMostRecentMessagesFromThreads, sendTextMessageActionTypes, - sendTextMessage, + useSendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, - sendMultimediaMessage, - legacySendMultimediaMessage, + useSendMultimediaMessage, + useLegacySendMultimediaMessage, searchMessagesActionTypes, - searchMessages, + useSearchMessages, sendReactionMessageActionTypes, - sendReactionMessage, + useSendReactionMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, sendEditMessageActionTypes, - sendEditMessage, - fetchPinnedMessages, + useSendEditMessage, + useFetchPinnedMessages, fetchPinnedMessageActionTypes, }; diff --git a/lib/hooks/child-threads.js b/lib/hooks/child-threads.js index d87370090..7bbcbbfda 100644 --- a/lib/hooks/child-threads.js +++ b/lib/hooks/child-threads.js @@ -1,120 +1,116 @@ // @flow import * as React from 'react'; import { - fetchSingleMostRecentMessagesFromThreads, + useFetchSingleMostRecentMessagesFromThreads, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { useFilteredChatListData, type ChatThreadItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { childThreadInfos } from '../selectors/thread-selectors.js'; import { threadInChatList } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { ThreadInfo } from '../types/thread-types.js'; -import { - useDispatchActionPromise, - useServerCall, -} from '../utils/action-utils.js'; +import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type ThreadFilter = { +predicate?: (thread: ThreadInfo) => boolean, +searchText?: string, }; function useFilteredChildThreads( threadID: string, filter?: ThreadFilter, ): $ReadOnlyArray { const defaultPredicate = React.useCallback(() => true, []); const { predicate = defaultPredicate, searchText = '' } = filter ?? {}; const childThreads = useSelector(state => childThreadInfos(state)[threadID]); const subchannelIDs = React.useMemo(() => { if (!childThreads) { return new Set(); } return new Set( childThreads.filter(predicate).map(threadInfo => threadInfo.id), ); }, [childThreads, predicate]); const filterSubchannels = React.useCallback( thread => subchannelIDs.has(thread?.id), [subchannelIDs], ); const allSubchannelsList = useFilteredChatListData(filterSubchannels); const searchIndex = useGlobalThreadSearchIndex(); const searchResultIDs = React.useMemo( () => searchIndex.getSearchResults(searchText), [searchIndex, searchText], ); const searchTextExists = !!searchText.length; const subchannelIDsNotInChatList = React.useMemo( () => new Set( allSubchannelsList .filter(item => !threadInChatList(item.threadInfo)) .map(item => item.threadInfo.id), ), [allSubchannelsList], ); React.useEffect(() => { if (!subchannelIDsNotInChatList.size) { return undefined; } subchannelIDsNotInChatList.forEach(tID => threadWatcher.watchID(tID)); return () => subchannelIDsNotInChatList.forEach(tID => threadWatcher.removeID(tID)); }, [subchannelIDsNotInChatList]); const filteredSubchannelsChatList = React.useMemo(() => { if (!searchTextExists) { return allSubchannelsList; } return allSubchannelsList.filter(item => searchResultIDs.includes(item.threadInfo.id), ); }, [allSubchannelsList, searchResultIDs, searchTextExists]); const threadIDsWithNoMessages = React.useMemo( () => new Set( filteredSubchannelsChatList .filter(item => !item.mostRecentMessageInfo) .map(item => item.threadInfo.id), ), [filteredSubchannelsChatList], ); const dispatchActionPromise = useDispatchActionPromise(); - const fetchSingleMostRecentMessages = useServerCall( - fetchSingleMostRecentMessagesFromThreads, - ); + const fetchSingleMostRecentMessages = + useFetchSingleMostRecentMessagesFromThreads(); React.useEffect(() => { if (!threadIDsWithNoMessages.size) { return; } dispatchActionPromise( fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessages(Array.from(threadIDsWithNoMessages)), ); }, [ threadIDsWithNoMessages, fetchSingleMostRecentMessages, dispatchActionPromise, ]); return filteredSubchannelsChatList; } export { useFilteredChildThreads }; diff --git a/lib/shared/edit-messages-utils.js b/lib/shared/edit-messages-utils.js index dddfbd373..e6a04e421 100644 --- a/lib/shared/edit-messages-utils.js +++ b/lib/shared/edit-messages-utils.js @@ -1,88 +1,85 @@ // @flow import * as React from 'react'; import { threadIsPending, threadHasPermission } from './thread-utils.js'; import { sendEditMessageActionTypes, - sendEditMessage, + useSendEditMessage, } from '../actions/message-actions.js'; import type { SendEditMessageResult, RobotextMessageInfo, ComposableMessageInfo, RawMessageInfo, } from '../types/message-types'; import { messageTypes } from '../types/message-types-enum.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type ThreadInfo } from '../types/thread-types.js'; -import { - useDispatchActionPromise, - useServerCall, -} from '../utils/action-utils.js'; +import { useDispatchActionPromise } from '../utils/action-utils.js'; import { useSelector } from '../utils/redux-utils.js'; function useEditMessage(): ( messageID: string, newText: string, ) => Promise { - const callEditMessage = useServerCall(sendEditMessage); + const callEditMessage = useSendEditMessage(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (messageID, newText) => { const editMessagePromise = (async () => { const result = await callEditMessage({ targetMessageID: messageID, text: newText, }); return ({ newMessageInfos: result.newMessageInfos, }: { +newMessageInfos: $ReadOnlyArray }); })(); dispatchActionPromise(sendEditMessageActionTypes, editMessagePromise); return editMessagePromise; }, [dispatchActionPromise, callEditMessage], ); } function useCanEditMessage( threadInfo: ThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const currentUserInfo = useSelector(state => state.currentUserInfo); if (targetMessageInfo.type !== messageTypes.TEXT) { return false; } if (!currentUserInfo || !currentUserInfo.id) { return false; } const currentUserId = currentUserInfo.id; const targetMessageCreatorId = targetMessageInfo.creator.id; if (currentUserId !== targetMessageCreatorId) { return false; } const hasPermission = threadHasPermission( threadInfo, threadPermissions.EDIT_MESSAGE, ); return hasPermission; } function getMessageLabel(hasBeenEdited: ?boolean, threadID: string): ?string { const isPending = threadIsPending(threadID); if (hasBeenEdited && !isPending) { return 'Edited'; } return null; } export { useCanEditMessage, useEditMessage, getMessageLabel }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index 6e927fb62..c32c8f4b7 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,346 +1,346 @@ // @flow import * as React from 'react'; import { useSelector } from 'react-redux'; import SearchIndex from './search-index.js'; import { userIsMember, threadMemberHasPermission, getContainingThreadID, } from './thread-utils.js'; import { - searchMessages, + useSearchMessages as useSearchMessagesAction, searchMessagesActionTypes, } from '../actions/message-actions.js'; import { searchUsers, searchUsersActionTypes, } from '../actions/user-actions.js'; import genesis from '../facts/genesis.js'; import type { ChatMessageInfoItem, MessageListData, } from '../selectors/chat-selectors'; import type { MessageInfo, RawMessageInfo } from '../types/message-types'; import { isComposableMessageType } from '../types/message-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; import { type ThreadInfo } from '../types/thread-types.js'; import type { AccountUserInfo, UserListItem, GlobalAccountUserInfo, } from '../types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from '../utils/action-utils.js'; import { values } from '../utils/objects.js'; const notFriendNotice = 'not friend'; function getPotentialMemberItems({ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, +searchIndex: SearchIndex, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, +inputParentThreadInfo?: ?ThreadInfo, +inputCommunityThreadInfo?: ?ThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { const communityThreadInfo = inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null; const parentThreadInfo = inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null; const containgThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; let containingThreadInfo = null; if (containgThreadID === parentThreadInfo?.id) { containingThreadInfo = parentThreadInfo; } else if (containgThreadID === communityThreadInfo?.id) { containingThreadInfo = communityThreadInfo; } const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; const appendUserInfo = ( userInfo: AccountUserInfo | GlobalAccountUserInfo, ) => { const { id } = userInfo; if (excludeUserIDs.includes(id) || id in results) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results[id] = { ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), isMemberOfContainingThread: userIsMember(containingThreadInfo, id), }; }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo(userInfo); } } const blockedRelationshipsStatuses = new Set([ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ]); let userResults = values(results); if (text === '') { userResults = userResults.filter(userInfo => containingThreadInfo ? userInfo.isMemberOfContainingThread && !blockedRelationshipsStatuses.has(userInfo.relationshipStatus) : userInfo?.relationshipStatus === userRelationshipStatus.FRIEND, ); } const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of userResults) { const relationshipStatus = userResult.relationshipStatus; if (blockedRelationshipsStatuses.has(relationshipStatus)) { blockedUsers.push(userResult); } else if (userResult.isMemberOfParentThread) { parentThreadMembers.push(userResult); } else if (userResult.isMemberOfContainingThread) { containingThreadMembers.push(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friends.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = parentThreadMembers .concat(containingThreadMembers) .concat(friends) .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfContainingThread, isMemberOfParentThread, relationshipStatus, ...result }) => { let notice, alert; const username = result.username; if (blockedRelationshipsStatuses.has(relationshipStatus)) { notice = 'user is blocked'; alert = { title: 'User is blocked', text: `Before you add ${username} to this chat, ` + 'you’ll need to unblock them. You can do this from the Block List ' + 'in the Profile tab.', }; } else if (!isMemberOfContainingThread && containingThreadInfo) { if (threadType !== threadTypes.SIDEBAR) { notice = 'not in community'; alert = { title: 'Not in community', text: 'You can only add members of the community to this chat', }; } else { notice = 'not in parent chat'; alert = { title: 'Not in parent chat', text: 'You can only add members of the parent chat to a thread', }; } } else if ( !containingThreadInfo && relationshipStatus !== userRelationshipStatus.FRIEND ) { notice = notFriendNotice; alert = { title: 'Not a friend', text: `Before you add ${username} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.', }; } else if (parentThreadInfo && !isMemberOfParentThread) { notice = 'not in parent chat'; } if (notice) { result = { ...result, notice }; } if (alert) { result = { ...result, alert }; } return result; }, ); } function useSearchMessages(): ( query: string, threadID: string, onResultsReceived: ( messages: $ReadOnlyArray, endReached: boolean, queryID: number, threadID: string, ) => mixed, queryID: number, cursor?: string, ) => void { - const callSearchMessages = useServerCall(searchMessages); + const callSearchMessages = useSearchMessagesAction(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (query, threadID, onResultsReceived, queryID, cursor) => { const searchMessagesPromise = (async () => { if (query === '') { onResultsReceived([], true, queryID, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, cursor, }); onResultsReceived(messages, endReached, queryID, threadID); })(); dispatchActionPromise(searchMessagesActionTypes, searchMessagesPromise); }, [callSearchMessages, dispatchActionPromise], ); } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useServerCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { const searchUsersPromise = (async () => { if (usernameInputText.length === 0) { setServerSearchResults([]); } else { try { const { userInfos } = await callSearchUsers(usernameInputText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } } })(); dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ callSearchUsers, currentUserID, dispatchActionPromise, usernameInputText, ]); return serverSearchResults; } function filterChatMessageInfosForSearch( chatMessageInfos: MessageListData, translatedSearchResults: $ReadOnlyArray, ): ?(ChatMessageInfoItem[]) { if (!chatMessageInfos) { return null; } const idSet = new Set(translatedSearchResults.map(item => item.id)); const chatMessageInfoItems = chatMessageInfos.filter( item => item.messageInfo && idSet.has(item.messageInfo.id) && isComposableMessageType(item.messageInfo.type), ); const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); const sortedChatMessageInfoItems = []; for (let i = 0; i < translatedSearchResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(translatedSearchResults[i].id), ); } return sortedChatMessageInfoItems.filter(Boolean); } export { getPotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, filterChatMessageInfosForSearch, }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 26d46ef9f..0ab14b9db 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1761 +1,1761 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { generatePendingThreadColor } from './color-utils.js'; import { type ParserRules } from './markdown.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import threadWatcher from './thread-watcher.js'; import { fetchMostRecentMessagesActionTypes, - fetchMostRecentMessages, + useFetchMostRecentMessages, } from '../actions/message-actions.js'; import { changeThreadMemberRolesActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { searchUsers as searchUserCall } from '../actions/user-actions.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, ChatMessageInfoItem, } from '../selectors/chat-selectors.js'; import { useGlobalThreadSearchIndex } from '../selectors/nav-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import { getUserAvatarForThread } from '../shared/avatar-utils.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RobotextMessageInfo, type ComposableMessageInfo, } from '../types/message-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, configurableCommunityPermissions, type ThreadPermission, type ThreadPermissionsInfo, type UserSurfacedPermission, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, } from '../types/thread-types-enum.js'; import { type RawThreadInfo, type ThreadInfo, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ClientNewThreadRequest, type NewThreadResult, type ChangeThreadSettingsPayload, type UserProfileThreadInfo, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from '../utils/action-utils.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { ET, entityTextToRawString, getEntityTextAsString, type ThreadEntity, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { trimText } from '../utils/text-utils.js'; import { pendingThreadIDRegex } from '../utils/validation-utils.js'; 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) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return 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 threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAshoat( threadInfo: T, ): $PropertyType { if (threadInfo.community !== genesis.id) { return threadInfo.members; } return threadInfo.members.filter( member => member.id !== ashoat.id || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAshoat(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return !!threadID?.startsWith('pending/sidebar/'); } function getSingleOtherUser( threadInfo: ThreadInfo | RawThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( 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 PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: string, ... }; 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(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(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: members.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, pinnedCount: 0, }; const userInfos = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; const threadName = trimText(messageTitle, 30); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; invariant( sourceAuthorUsername, 'sourceAuthorUsername should be set in createPendingSidebar', ); const initialMemberUserInfo = { 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 = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, getENSNames, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } 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 = threadOtherMembers(threadInfo.members, viewerID).map( member => member.id, ); 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 = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterThreadPermissions = _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)), ); const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis.id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); 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 = filterThreadPermissions( 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 rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithFilteredThreadPermissions, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { return { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } return rawThreadInfo; } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers = threadInfo.members.filter( memberInfo => memberInfo.role, ); const memberEntities = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadInfo.type === threadTypes.PRIVATE ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', 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, }; threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( rawThreadInfo.type === threadTypes.PERSONAL || rawThreadInfo.type === threadTypes.PRIVATE ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } 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 ( 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); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; 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 ( threadMembersWithoutAddedAshoat(threadInfo).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 chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Background” option in its settings.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis.id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } 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 'Thread'; } 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 callFetchMostRecentMessages = useFetchMostRecentMessages(); const threadID = threadInfo?.id; const threadNotInChatList = !threadInChatList(threadInfo); React.useEffect(() => { if (threadID && threadNotInChatList) { threadWatcher.watchID(threadID); dispatchActionPromise( fetchMostRecentMessagesActionTypes, - callFetchMostRecentMessages(threadID), + 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 loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; 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 ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; return { ...updatedThread, currentUser: getCurrentUser(updatedThread, viewerID, userInfos), }; }, [ baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs, 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 || isInvalidSidebarSource(messageInfo) ) { 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]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } 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, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } 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: [] }); } } const chatItems = [...privateThreads, ...personalThreads, ...otherThreads]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem(loggedInUserInfo, user), ), ); } return chatItems; } type ThreadListSearchResult = { +threadSearchResults: $ReadOnlySet, +usersSearchResults: $ReadOnlyArray, }; function useThreadListSearch( 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 = useGlobalThreadSearchIndex(); React.useEffect(() => { (async () => { const results = threadSearchIndex.getSearchResults(searchText); setThreadSearchResults(new Set(results)); const usersResults = await searchUsers(searchText); setUsersSearchResults(usersResults); })(); }, [searchText, threadSearchIndex, searchUsers]); return { threadSearchResults, usersSearchResults }; } function removeMemberFromThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, dispatchActionPromise: DispatchActionPromise, removeUserFromThreadServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, ) => Promise, ) { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( removeUsersFromThreadActionTypes, removeUserFromThreadServerCall(threadInfo.id, [memberInfo.id]), { customKeyName }, ); } function switchMemberAdminRoleInThread( threadInfo: ThreadInfo, memberInfo: RelativeMemberInfo, isCurrentlyAdmin: boolean, dispatchActionPromise: DispatchActionPromise, changeUserRoleServerCall: ( threadID: string, memberIDs: $ReadOnlyArray, newRole: string, ) => Promise, ) { let newRole = null; for (const roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchActionPromise( changeThreadMemberRolesActionTypes, changeUserRoleServerCall(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); } function getAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const role = memberInfo.role; if (!canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if (canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo)) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || threadInfo.roles[role]?.isDefault) ) { result.push('remove_user'); } return result; } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => (username ? { id, username } : null)) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( threadInfo: RawThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: ThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = Object.keys(threadInfo.roles[roleID].permissions); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo]); } function communityOrThreadNoun(threadInfo: RawThreadInfo | ThreadInfo): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: RawThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } export { threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, createUnresolvedPendingSidebar, extractNewMentionedParentMembers, createPendingSidebar, pendingThreadType, createRealThreadFromPendingThread, getCurrentUser, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useWatchThread, useExistingThreadInfoFinder, getThreadTypeParentRequirement, threadMemberHasPermission, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, useThreadListSearch, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, threadMembersWithoutAddedAshoat, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useUserProfileThreadInfo, }; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 6db45aaf2..f74ddb58a 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,470 +1,486 @@ // @flow import invariant from 'invariant'; import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import callServerEndpoint from './call-server-endpoint.js'; import type { CallServerEndpoint, CallServerEndpointOptions, } from './call-server-endpoint.js'; import { getConfig } from './config.js'; import { serverCallStateSelector } from '../selectors/server-calls.js'; import { logInActionSources, type LogInActionSource, type LogInStartingPayload, type LogInResult, } from '../types/account-types.js'; import type { PlatformDetails } from '../types/device-types.js'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints.js'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types.js'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types.js'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types.js'; import type { ConnectionStatus } from '../types/socket-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; function extractKeyserverIDFromID(id: string): string { return id.split('|')[0]; } let nextPromiseIndex = 0; export type ActionTypes< STARTED_ACTION_TYPE: string, SUCCESS_ACTION_TYPE: string, FAILED_ACTION_TYPE: string, > = { started: STARTED_ACTION_TYPE, success: SUCCESS_ACTION_TYPE, failed: FAILED_ACTION_TYPE, }; function wrapActionPromise< STARTED_ACTION_TYPE: string, STARTED_PAYLOAD: ActionPayload, SUCCESS_ACTION_TYPE: string, SUCCESS_PAYLOAD: ActionPayload, FAILED_ACTION_TYPE: string, >( actionTypes: ActionTypes< STARTED_ACTION_TYPE, SUCCESS_ACTION_TYPE, FAILED_ACTION_TYPE, >, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?STARTED_PAYLOAD, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!loadingOptions?.trackMultipleRequests, customKeyName: loadingOptions?.customKeyName || null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, payload: (startingPayload: STARTED_PAYLOAD), } : { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: SUCCESS_ACTION_TYPE), payload: (result: SUCCESS_PAYLOAD), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: FAILED_ACTION_TYPE), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPromise = < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise(): DispatchActionPromise { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = { +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, }; let currentlyWaitingForNewCookie = false; let serverEndpointCallsWaitingForNewCookie: (( callServerEndpoint: ?CallServerEndpoint, ) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, logInActionSource: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, payload: { sessionChange, preRequestUserState, error, logInActionSource }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, logInActionSource: LogInActionSource, getInitialNotificationsEncryptedMessage?: () => Promise, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let callServerEndpointCallback = null; const boundCallServerEndpoint = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?CallServerEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession(dispatch, sessionChange, null, error, logInActionSource); }; try { const result = await callServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, 'disconnected', null, null, endpoint, data, dispatch, options, false, ); if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } return result; } catch (e) { if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, logInActionSource }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise(r => (callServerEndpointCallback = r)); }; await resolveInvalidatedCookie( boundCallServerEndpoint, dispatchRecoveryAttempt, logInActionSource, getInitialNotificationsEncryptedMessage, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoCallServerEndpoint( params: BindServerCallsParams, ): CallServerEndpoint { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, lastCommunicatedPlatformDetails, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before callServerEndpoint sends a request, // to make sure that we're not in the middle of trying to recover // an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, logInActionSources.cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = serverEndpointCallsWaitingForNewCookie; serverEndpointCallsWaitingForNewCookie = []; const newCallServerEndpoint = newSessionChange ? bindCookieAndUtilsIntoCallServerEndpoint({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newCallServerEndpoint); } return newCallServerEndpoint; }; // If this function is called, callServerEndpoint got a response invalidating // its cookie, and is wondering if it should just like... give up? // Or if there's a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return ( endpoint: Endpoint, data: Object, options?: ?CallServerEndpointOptions, ) => callServerEndpoint( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, lastCommunicatedPlatformDetails, socketAPIHandler, endpoint, data, dispatch, options, loggedIn, ); } export type ActionFunc = (callServerEndpoint: CallServerEndpoint) => F; export type BindServerCall = (serverCall: ActionFunc) => F; export type BindServerCallsParams = { +dispatch: Dispatch, +cookie: ?string, +urlPrefix: string, +sessionID: ?string, +currentUserInfo: ?CurrentUserInfo, +connectionStatus: ConnectionStatus, +lastCommunicatedPlatformDetails: ?PlatformDetails, }; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where callServerEndpoint is called. We don't want to bother // propagating the cookie (and any future config info that callServerEndpoint // needs) through to the server calls so they can pass it to callServerEndpoint. // Instead, we "curry" the cookie onto callServerEndpoint within react-redux's // connect's mapStateToProps function, and then pass that "bound" // callServerEndpoint that no longer needs the cookie as a parameter on to // the server call. const baseCreateBoundServerCallsSelector = ( actionFunc: ActionFunc, ): (BindServerCallsParams => F) => createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, (state: BindServerCallsParams) => state.lastCommunicatedPlatformDetails, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, lastCommunicatedPlatformDetails: ?PlatformDetails, ) => { const boundCallServerEndpoint = bindCookieAndUtilsIntoCallServerEndpoint({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, lastCommunicatedPlatformDetails, }); return actionFunc(boundCallServerEndpoint); }, ); type CreateBoundServerCallsSelectorType = ( ActionFunc, ) => BindServerCallsParams => F; const createBoundServerCallsSelector: CreateBoundServerCallsSelectorType = (_memoize(baseCreateBoundServerCallsSelector): any); function useServerCall( serverCall: ActionFunc, paramOverride?: ?$Shape, ): F { const dispatch = useDispatch(); const serverCallState = useSelector(serverCallStateSelector); return React.useMemo(() => { const { urlPrefix, connectionStatus } = serverCallState; invariant( !!urlPrefix && !!connectionStatus, 'keyserver missing from keyserverStore', ); return createBoundServerCallsSelector(serverCall)({ ...serverCallState, urlPrefix, connectionStatus, dispatch, ...paramOverride, }); }, [serverCall, serverCallState, dispatch, paramOverride]); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } +function sortThreadIDsPerKeyserver(threadIDs: $ReadOnlyArray): { + +[keyserverID: string]: $ReadOnlyArray, +} { + const results = {}; + for (const threadID of threadIDs) { + const keyserverID = extractKeyserverIDFromID(threadID); + invariant(keyserverID, 'keyserver data missing from thread id'); + if (results[keyserverID] === undefined) { + results[keyserverID] = []; + } + results[keyserverID].push(threadID); + } + return results; +} + export { useDispatchActionPromise, setNewSessionActionType, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, registerActiveSocket, useServerCall, bindCookieAndUtilsIntoCallServerEndpoint, extractKeyserverIDFromID, + sortThreadIDsPerKeyserver, }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index 723e028ec..1de1bd88e 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,372 +1,370 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import * as React from 'react'; import { View, TouchableWithoutFeedback } from 'react-native'; import { createSelector } from 'reselect'; import { fetchMessagesBeforeCursorActionTypes, - fetchMessagesBeforeCursor, + useFetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, - fetchMostRecentMessages, + useFetchMostRecentMessages, + type FetchMostRecentMessagesInput, + type FetchMessagesBeforeCursorInput, } from 'lib/actions/message-actions.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { useWatchThread } from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, - useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import ChatList from './chat-list.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt.react.js'; import ListLoadingIndicator from '../components/list-loading-indicator.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { defaultStackScreenOptions } from '../navigation/options.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight, ChatMessageItemWithHeight, } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import type { ViewableItemsChange } from '../types/react-native.js'; type BaseProps = { +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, +startReached: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( - threadID: string, - beforeMessageID: string, + input: FetchMessagesBeforeCursorInput, ) => Promise, +fetchMostRecentMessages: ( - threadID: string, + input: FetchMostRecentMessagesInput, ) => Promise, +overlayContext: ?OverlayContextType, +keyboardState: ?KeyboardState, +oldestMessageServerID: ?string, }; type State = { +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, }; type PropsAndState = { ...Props, ...State, }; type FlatListExtraData = { messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, }; class MessageList extends React.PureComponent { state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } if (defaultStackScreenOptions.gestureEnabled) { const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight, ... }) => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = () => ; render() { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; if (this.props.threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: ViewableItemsChange) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (const token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } this.setState({ loadingFromScroll: true }); const { oldestMessageServerID } = this.props; const threadID = this.props.threadInfo.id; (async () => { try { if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, - this.props.fetchMessagesBeforeCursor( + this.props.fetchMessagesBeforeCursor({ threadID, - oldestMessageServerID, - ), + beforeMessageID: oldestMessageServerID, + }), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, - this.props.fetchMostRecentMessages(threadID), + this.props.fetchMostRecentMessages({ threadID }), ); } } finally { this.setState({ loadingFromScroll: false }); } })(); }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedMessageList: React.ComponentType = React.memo(function ConnectedMessageList(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( state => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); - const callFetchMessagesBeforeCursor = useServerCall( - fetchMessagesBeforeCursor, - ); - const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); + const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); + const callFetchMostRecentMessages = useFetchMostRecentMessages(); const oldestMessageServerID = useOldestMessageServerID(threadID); useWatchThread(props.threadInfo); return ( ); }); export default ConnectedMessageList; diff --git a/native/chat/message-results-screen.react.js b/native/chat/message-results-screen.react.js index b97f932c4..e6c2e2ed8 100644 --- a/native/chat/message-results-screen.react.js +++ b/native/chat/message-results-screen.react.js @@ -1,181 +1,180 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; -import { fetchPinnedMessages } from 'lib/actions/message-actions.js'; +import { useFetchPinnedMessages } from 'lib/actions/message-actions.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createMessageInfo, isInvalidPinSourceForThread, } from 'lib/shared/message-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; -import { useServerCall } from 'lib/utils/action-utils.js'; import { useHeightMeasurer } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react'; import MessageResult from './message-result.react.js'; import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; export type MessageResultsScreenParams = { +threadInfo: ThreadInfo, }; type MessageResultsScreenProps = { +navigation: ChatNavigationProp<'MessageResultsScreen'>, +route: NavigationRoute<'MessageResultsScreen'>, }; function MessageResultsScreen(props: MessageResultsScreenProps): React.Node { const { navigation, route } = props; const { threadInfo } = route.params; const styles = useStyles(unboundStyles); const { id: threadID } = threadInfo; const [rawMessageResults, setRawMessageResults] = React.useState([]); const measureMessages = useHeightMeasurer(); const [measuredMessages, setMeasuredMessages] = React.useState([]); const [messageVerticalBounds, setMessageVerticalBounds] = React.useState(); const scrollViewContainerRef = React.useRef(); - const callFetchPinnedMessages = useServerCall(fetchPinnedMessages); + const callFetchPinnedMessages = useFetchPinnedMessages(); const userInfos = useSelector(state => state.userStore.userInfos); React.useEffect(() => { (async () => { const result = await callFetchPinnedMessages({ threadID }); setRawMessageResults(result.pinnedMessages); })(); }, [callFetchPinnedMessages, threadID]); const translatedMessageResults = React.useMemo(() => { const threadInfos = { [threadID]: threadInfo }; return rawMessageResults .map(messageInfo => createMessageInfo(messageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [rawMessageResults, userInfos, threadID, threadInfo]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedMessageResults), ); const sortedUniqueChatMessageInfoItems = React.useMemo(() => { if (!chatMessageInfos) { return []; } const chatMessageInfoItems = chatMessageInfos.filter( item => item.itemType === 'message' && item.isPinned && !isInvalidPinSourceForThread(item.messageInfo, threadInfo), ); // By the nature of using messageListData and passing in // the desired translatedMessageResults as additional // messages, we will have duplicate ChatMessageInfoItems. const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); // Push the items in the order they appear in the rawMessageResults // since the messages fetched from the server are already sorted // in the order of pin_time (newest first). const sortedChatMessageInfoItems = []; for (let i = 0; i < rawMessageResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(rawMessageResults[i].id), ); } return sortedChatMessageInfoItems.filter(Boolean); }, [chatMessageInfos, rawMessageResults, threadInfo]); const measureCallback = React.useCallback( (listDataWithHeights: $ReadOnlyArray) => { setMeasuredMessages(listDataWithHeights); }, [], ); React.useEffect(() => { measureMessages( sortedUniqueChatMessageInfoItems, threadInfo, measureCallback, ); }, [ measureCallback, measureMessages, sortedUniqueChatMessageInfoItems, threadInfo, ]); const onLayout = React.useCallback(() => { scrollViewContainerRef.current?.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setMessageVerticalBounds({ height, y: pageY }); }, ); }, []); const messageResultsToDisplay = React.useMemo( () => measuredMessages.map(item => { invariant(item.itemType !== 'loader', 'should not be loader'); return ( ); }), [measuredMessages, threadInfo, navigation, route, messageVerticalBounds], ); return ( {messageResultsToDisplay} ); } const unboundStyles = { scrollViewContainer: { flex: 1, }, }; export default MessageResultsScreen; diff --git a/native/chat/reaction-message-utils.js b/native/chat/reaction-message-utils.js index abb8b22db..b11cfc028 100644 --- a/native/chat/reaction-message-utils.js +++ b/native/chat/reaction-message-utils.js @@ -1,207 +1,204 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { - sendReactionMessage, + useSendReactionMessage, sendReactionMessageActionTypes, } from 'lib/actions/message-actions.js'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawReactionMessageInfo } from 'lib/types/messages/reaction.js'; -import { - useDispatchActionPromise, - useServerCall, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { cloneError } from 'lib/utils/errors.js'; import { useSelector } from '../redux/redux-utils.js'; import type { LayoutCoordinates, VerticalBounds, } from '../types/layout-types.js'; import Alert from '../utils/alert.js'; function useSendReaction( messageID: ?string, localID: string, threadID: string, reactions: ReactionInfo, ): (reaction: string) => mixed { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); - const callSendReactionMessage = useServerCall(sendReactionMessage); + const callSendReactionMessage = useSendReactionMessage(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( reaction => { if (!messageID) { return; } invariant(viewerID, 'viewerID should be set'); const viewerReacted = reactions[reaction] ? reactions[reaction].viewerReacted : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; const reactionMessagePromise = (async () => { try { const result = await callSendReactionMessage({ threadID, localID, targetMessageID: messageID, reaction, action, }); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { Alert.alert( 'Couldn’t send the reaction', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } })(); const startingPayload: RawReactionMessageInfo = { type: messageTypes.REACTION, threadID, localID, creatorID: viewerID, time: Date.now(), targetMessageID: messageID, reaction, action, }; dispatchActionPromise( sendReactionMessageActionTypes, reactionMessagePromise, undefined, startingPayload, ); }, [ messageID, viewerID, reactions, threadID, localID, dispatchActionPromise, callSendReactionMessage, ], ); } type ReactionSelectionPopoverPositionArgs = { +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +margin: ?number, }; type ReactionSelectionPopoverPosition = { +containerStyle: { +position: 'absolute', +left?: number, +right?: number, +bottom?: number, +top?: number, ... }, +popoverLocation: 'above' | 'below', }; function useReactionSelectionPopoverPosition({ initialCoordinates, verticalBounds, margin, }: ReactionSelectionPopoverPositionArgs): ReactionSelectionPopoverPosition { const calculatedMargin = getCalculatedMargin(margin); const windowWidth = useSelector(state => state.dimensions.width); const popoverLocation: 'above' | 'below' = (() => { const { y, height } = initialCoordinates; const contentTop = y; const contentBottom = y + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const fullHeight = reactionSelectionPopoverDimensions.height + calculatedMargin; if ( contentBottom + fullHeight > boundsBottom && contentTop - fullHeight > boundsTop ) { return 'above'; } return 'below'; })(); const containerStyle = React.useMemo(() => { const { x, width, height } = initialCoordinates; const style = {}; style.position = 'absolute'; const extraLeftSpace = x; const extraRightSpace = windowWidth - width - x; if (extraLeftSpace < extraRightSpace) { style.left = 0; } else { style.right = 0; } if (popoverLocation === 'above') { style.bottom = height + calculatedMargin / 2; } else { style.top = height + calculatedMargin / 2; } return style; }, [calculatedMargin, initialCoordinates, popoverLocation, windowWidth]); return React.useMemo( () => ({ popoverLocation, containerStyle, }), [popoverLocation, containerStyle], ); } function getCalculatedMargin(margin: ?number): number { return margin ?? 16; } const reactionSelectionPopoverDimensions = { height: 56, width: 316, }; export { useSendReaction, useReactionSelectionPopoverPosition, getCalculatedMargin, reactionSelectionPopoverDimensions, }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 3f6f24566..9c917da4a 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1739 +1,1732 @@ // @flow import * as FileSystem from 'expo-file-system'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, - sendMultimediaMessage, + useSendMultimediaMessage, sendTextMessageActionTypes, - sendTextMessage, + useSendTextMessage, +} from 'lib/actions/message-actions.js'; +import type { + SendMultimediaMessageInput, + SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { newThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, uploadMediaMetadata, updateMultimediaMessageMediaActionType, blobServiceUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, type BlobServiceUploadAction, } from 'lib/actions/upload-actions.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.js'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js'; import { isLocalUploadID, getNextLocalUploadID, } from 'lib/media/media-utils.js'; import { videoDurationLimit } from 'lib/media/video-utils.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { createMediaMessageInfo, useNextLocalID, useMessageCreationSideEffectsFunc, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread, threadIsPending, threadIsPendingSidebar, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, UploadMediaMetadataRequest, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; -import type { - MediaMessageServerDBContent, - RawMediaMessageInfo, -} from 'lib/types/messages/media.js'; +import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ClientNewThreadRequest, type NewThreadResult, type ThreadInfo, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { CallServerEndpointOptions, CallServerEndpointResponse, } from 'lib/utils/call-server-endpoint.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, cloneError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { type EditInputBarMessageParameters, InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, } from './input-state.js'; import { encryptMedia } from '../media/encryption-utils.js'; import { disposeTempFile } from '../media/file-utils.js'; import { processMedia } from '../media/media-utils.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import { useSelector } from '../redux/redux-utils.js'; import blobServiceUploadHandler from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type MediaIDs = | { +type: 'photo', +localMediaID: string } | { +type: 'video', +localMediaID: string, +localThumbnailID: string }; type UploadFileInput = { +selection: NativeMediaSelection, +ids: MediaIDs, }; type CompletedUploads = { +[localMessageID: string]: ?Set }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?string, +nextLocalID: string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +mediaReportsEnabled: boolean, +calendarQuery: () => CalendarQuery, +dispatch: Dispatch, +staffCanSee: boolean, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +uploadMediaMetadata: ( input: UploadMediaMetadataRequest, ) => Promise, +blobServiceUpload: BlobServiceUploadAction, +sendMultimediaMessage: ( - threadID: string, - localID: string, - mediaMessageContents: $ReadOnlyArray, - sidebarCreation?: boolean, - ) => Promise, - +sendTextMessage: ( - threadID: string, - localID: string, - text: string, - sidebarCreation?: boolean, + input: SendMultimediaMessageInput, ) => Promise, + +sendTextMessage: (input: SendTextMessageInput) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); editInputBarCallbacks: Array< (params: EditInputBarMessageParameters) => void, > = []; scrollToMessageCallbacks: Array<(messageID: string) => void> = []; pendingThreadCreations = new Map>(); pendingThreadUpdateHandlers = new Map mixed>(); // TODO: flip the switch // Note that this enables Blob service for encrypted media only useBlobServiceUploads = false; // When the user sends a multimedia message that triggers the creation of a // sidebar, the sidebar gets created right away, but the message needs to wait // for the uploads to complete before sending. We use this Set to track the // message localIDs that need sidebarCreation: true. pendingSidebarCreationMessageLocalIDs = new Set(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (const localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); let allUploadsComplete = true; const completedUploadIDs = new Set(Object.keys(messagePendingUploads)); for (const singleMedia of rawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { allUploadsComplete = false; completedUploadIDs.delete(singleMedia.id); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completedUploadIDs.size > 0) { completedUploads[localMessageID] = completedUploadIDs; } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (const localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (const localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (const localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } async dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { if (!threadIsPending(messageInfo.threadID)) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } newThreadID = await threadCreationPromise; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const mediaMessageContents = getMediaMessageServerDBContentsFromMedia( messageInfo.media, ); try { - const result = await this.props.sendMultimediaMessage( + const result = await this.props.sendMultimediaMessage({ threadID, localID, mediaMessageContents, sidebarCreation, - ); + }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, editInputMessage: this.editInputMessage, addEditInputMessageListener: this.addEditInputMessageListener, removeEditInputMessageListener: this.removeEditInputMessageListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMessage: this.retryMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, setPendingThreadUpdateHandler: this.setPendingThreadUpdateHandler, scrollToMessage: this.scrollToMessage, addScrollToMessageListener: this.addScrollToMessageListener, removeScrollToMessageListener: this.removeScrollToMessageListener, }), ); scrollToMessage = (messageID: string) => { this.scrollToMessageCallbacks.forEach(callback => callback(messageID)); }; addScrollToMessageListener = (callback: (messageID: string) => void) => { this.scrollToMessageCallbacks.push(callback); }; removeScrollToMessageListener = ( callbackScrollToMessage: (messageID: string) => void, ) => { this.scrollToMessageCallbacks = this.scrollToMessageCallbacks.filter( candidate => candidate !== callbackScrollToMessage, ); }; uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } const { pendingUploads } = this.state; return values(pendingUploads).some(messagePendingUploads => values(messagePendingUploads).some(upload => !upload.failed), ); }; sendTextMessage = async ( messageInfo: RawTextMessageInfo, inputThreadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); if (threadIsPendingSidebar(inputThreadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localID); } if (!threadIsPending(inputThreadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { const pendingThreadUpdateHandler = this.pendingThreadUpdateHandlers.get( threadInfo.id, ); pendingThreadUpdateHandler?.(threadInfo); } } let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; const newThreadInfo = { ...threadInfo, id: newThreadID, }; this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); }; startThreadCreation(threadInfo: ThreadInfo): Promise { if (!threadIsPending(threadInfo.id)) { return Promise.resolve(threadInfo.id); } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } async sendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): Promise { try { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); - const result = await this.props.sendTextMessage( - messageInfo.threadID, + const result = await this.props.sendTextMessage({ + threadID: messageInfo.threadID, localID, - messageInfo.text, + text: messageInfo.text, sidebarCreation, - ); + }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } shouldEncryptMedia(threadInfo: ThreadInfo): boolean { return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id); } sendMultimediaMessage = async ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = this.props.nextLocalID; this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const uploadFileInputs = [], media = []; for (const selection of selections) { const localMediaID = getNextLocalUploadID(); let ids; if ( selection.step === 'photo_library' || selection.step === 'photo_capture' || selection.step === 'photo_paste' ) { media.push({ id: localMediaID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, thumbHash: null, }); ids = { type: 'photo', localMediaID }; } const localThumbnailID = getNextLocalUploadID(); if (selection.step === 'video_library') { media.push({ id: localMediaID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, thumbnailID: localThumbnailID, thumbnailURI: selection.uri, thumbnailThumbHash: null, }); ids = { type: 'video', localMediaID, localThumbnailID }; } invariant(ids, `unexpected MediaSelection ${selection.step}`); uploadFileInputs.push({ selection, ids }); } const pendingUploads = {}; for (const uploadFileInput of uploadFileInputs) { const { localMediaID } = uploadFileInput.ids; pendingUploads[localMediaID] = { failed: false, progressPercent: 0, processingStep: null, }; if (uploadFileInput.ids.type === 'video') { const { localThumbnailID } = uploadFileInput.ids; pendingUploads[localThumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState( prevState => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const messageInfo = createMediaMessageInfo( { localID: localMessageID, threadID: threadInfo.id, creatorID, media, }, { forceMultimediaMessageType: this.shouldEncryptMedia(threadInfo) }, ); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; async uploadFiles( localMessageID: string, uploadFileInputs: $ReadOnlyArray, threadInfo: ThreadInfo, ) { const results = await Promise.all( uploadFileInputs.map(uploadFileInput => this.uploadFile(localMessageID, uploadFileInput, threadInfo), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, uploadFileInput: UploadFileInput, threadInfo: ThreadInfo, ): Promise { const { ids, selection } = uploadFileInput; const { localMediaID } = ids; const start = selection.sendTime; const steps = [selection]; let encryptionSteps = []; let serverID; let userTime; let errorMessage; let reportPromise; const filesToDispose = []; const onUploadFinished = async (result: MediaMissionResult) => { if (!this.props.mediaReportsEnabled) { return errorMessage; } if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); steps.push(...encryptionSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID: localMediaID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const onUploadFailed = (mediaID: string, message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, mediaID); userTime = Date.now() - start; }; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localMediaID, 'transcoding', percent); }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia(selection, { hasWiFi: this.props.hasWiFi, finalFileHeaderCheck: this.props.staffCanSee, onTranscodingProgress, }); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; onUploadFailed(localMediaID, message); return await onUploadFinished(processResult); } if (processResult.shouldDisposePath) { filesToDispose.push(processResult.shouldDisposePath); } processedMedia = processResult; } catch (e) { onUploadFailed(localMediaID, 'processing failed'); return await onUploadFinished({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } if (this.shouldEncryptMedia(threadInfo)) { const encryptionStart = Date.now(); try { const { result: encryptionResult, ...encryptionReturn } = await encryptMedia(processedMedia); encryptionSteps = encryptionReturn.steps; if (!encryptionResult.success) { onUploadFailed(localMediaID, encryptionResult.reason); return await onUploadFinished(encryptionResult); } if (encryptionResult.shouldDisposePath) { filesToDispose.push(encryptionResult.shouldDisposePath); } processedMedia = encryptionResult; } catch (e) { onUploadFailed(localMediaID, 'encryption failed'); return await onUploadFinished({ success: false, reason: 'encryption_exception', time: Date.now() - encryptionStart, exceptionMessage: getMessageForException(e), }); } } const { uploadURI, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, uploadThumbnailResult, mediaMissionResult; try { const uploadPromises = []; if ( this.useBlobServiceUploads && (processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video') ) { uploadPromises.push( this.props.blobServiceUpload({ input: { blobInput: { type: 'uri', uri: uploadURI, filename: filename, mimeType: mime, }, blobHash: processedMedia.blobHash, encryptionKey: processedMedia.encryptionKey, dimensions: processedMedia.dimensions, thumbHash: processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, callbacks: { blobServiceUploadHandler, onProgress: (percent: number) => { this.setProgress( localMessageID, localMediaID, 'uploading', percent, ); }, }, }), ); if (processedMedia.mediaType === 'encrypted_video') { uploadPromises.push( this.props.blobServiceUpload({ input: { blobInput: { type: 'uri', uri: processedMedia.uploadThumbnailURI, filename: replaceExtension(`thumb${filename}`, 'jpg'), mimeType: 'image/jpeg', }, blobHash: processedMedia.thumbnailBlobHash, encryptionKey: processedMedia.thumbnailEncryptionKey, loop: false, dimensions: processedMedia.dimensions, thumbHash: processedMedia.thumbHash, }, callbacks: { blobServiceUploadHandler, }, }), ); } [uploadResult, uploadThumbnailResult] = await Promise.all( uploadPromises, ); } else { uploadPromises.push( this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ? processedMedia.loop : undefined, encryptionKey: processedMedia.encryptionKey, thumbHash: processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo' ? processedMedia.thumbHash : null, }, { onProgress: (percent: number) => this.setProgress( localMessageID, localMediaID, 'uploading', percent, ), uploadBlob: this.uploadBlob, }, ), ); if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { uploadPromises.push( this.props.uploadMultimedia( { uri: processedMedia.uploadThumbnailURI, name: replaceExtension(`thumb${filename}`, 'jpg'), type: 'image/jpeg', }, { ...processedMedia.dimensions, loop: false, encryptionKey: processedMedia.thumbnailEncryptionKey, thumbHash: processedMedia.thumbHash, }, { uploadBlob: this.uploadBlob, }, ), ); } [uploadResult, uploadThumbnailResult] = await Promise.all( uploadPromises, ); } mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed(localMediaID, 'upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if ( ((processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo') && uploadResult) || ((processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video') && uploadResult && uploadThumbnailResult) ) { const { encryptionKey } = processedMedia; const { id, uri, dimensions, loop } = uploadResult; serverID = id; const mediaSourcePayload = processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video' ? { type: processedMedia.mediaType, blobURI: uri, encryptionKey, } : { type: uploadResult.mediaType, uri, }; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, ...mediaSourcePayload, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult; const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } = processedMedia; if (processedMedia.mediaType === 'encrypted_video') { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }, }; } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailURI, thumbnailThumbHash, }, }; } } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbHash: processedMedia.thumbHash, }, }; } // When we dispatch this action, it updates Redux and triggers the // componentDidUpdate in this class. componentDidUpdate will handle // calling dispatchMultimediaMessageAction once all the uploads are // complete, and does not wait until this function concludes. this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: updateMediaPayload, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push(...encryptionSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const cleanupPromises = []; if (filesToDispose.length > 0) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete filesToDispose.forEach(shouldDisposePath => { cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); }); } // if there's a thumbnail we'll temporarily unlink it here // instead of in media-utils, will be changed in later diffs if (processedMedia.mediaType === 'video') { const { uploadThumbnailURI } = processedMedia; cleanupPromises.push( (async () => { const { steps: clearSteps, result: thumbnailPath } = await this.waitForCaptureURIUnload(uploadThumbnailURI); steps.push(...clearSteps); if (!thumbnailPath) { return; } const disposeStep = await disposeTempFile(thumbnailPath); steps.push(disposeStep); })(), ); } if (selection.captureTime || selection.step === 'photo_paste') { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose. Check out the // Multimedia component to see how the URIs get switched out. const captureURI = selection.uri; cleanupPromises.push( (async () => { const { steps: clearSteps, result: capturePath } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(cleanupPromises); return await onUploadFinished(mediaMissionResult); } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, progressPercent: number, ) { this.setState(prevState => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, processingStep, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?CallServerEndpointOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (const key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadTask = FileSystem.createUploadTask( url, path, { uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'multimedia', headers: { Accept: 'application/json', }, parameters, }, uploadProgress => { if (options && options.onProgress) { const { totalByteSent, totalBytesExpectedToSend } = uploadProgress; options.onProgress(totalByteSent / totalBytesExpectedToSend); } }, ); if (options && options.abortHandler) { options.abortHandler(() => uploadTask.cancelAsync()); } try { const response = await uploadTask.uploadAsync(); return JSON.parse(response.body); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } }; handleUploadFailure(localMessageID: string, localUploadID: string) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: { localID: string, localMessageID: string, serverID: ?string }, mediaMission: MediaMission, ) { const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, id: generateReportID(), }; this.props.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } return values(pendingUploads).some(upload => upload.failed); }; editInputMessage = (params: EditInputBarMessageParameters) => { this.editInputBarCallbacks.forEach(addEditInputBarCallback => addEditInputBarCallback(params), ); }; addEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks.push(callbackEditInputBar); }; removeEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks = this.editInputBarCallbacks.filter( candidate => candidate !== callbackEditInputBar, ); }; retryTextMessage = async ( rawMessageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { await this.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, threadInfo, parentThreadInfo, ); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, threadInfo: ThreadInfo, ) => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { invariant( singleMedia.type === 'photo' || singleMedia.type === 'video', 'Retry selection must be unencrypted', ); let updatedMedia = singleMedia; const oldMediaID = updatedMedia.id; if ( // not complete isLocalUploadID(oldMediaID) && // not still ongoing (!pendingUploads[oldMediaID] || pendingUploads[oldMediaID].failed) ) { // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const mediaID = pendingUploads[oldMediaID] ? oldMediaID : getNextLocalUploadID(); if (updatedMedia.type === 'photo') { updatedMedia = { type: 'photo', ...updatedMedia, id: mediaID, }; } else { updatedMedia = { type: 'video', ...updatedMedia, id: mediaID, }; } } if (updatedMedia.type === 'video') { const oldThumbnailID = updatedMedia.thumbnailID; invariant(oldThumbnailID, 'oldThumbnailID not null or undefined'); if ( // not complete isLocalUploadID(oldThumbnailID) && // not still ongoing (!pendingUploads[oldThumbnailID] || pendingUploads[oldThumbnailID].failed) ) { const thumbnailID = pendingUploads[oldThumbnailID] ? oldThumbnailID : getNextLocalUploadID(); updatedMedia = { ...updatedMedia, thumbnailID, }; } } if (updatedMedia === singleMedia) { return singleMedia; } const oldSelection = updatedMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_paste') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (updatedMedia.type === 'photo') { return { type: 'photo', ...updatedMedia, localMediaSelection: selection, }; } return { type: 'video', ...updatedMedia, localMediaSelection: selection, }; }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (const singleMedia of newRawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (const singleMedia of retryMedia) { pendingUploads[singleMedia.id] = { failed: false, progressPercent: 0, processingStep: null, }; if (singleMedia.type === 'video') { const { thumbnailID } = singleMedia; invariant(thumbnailID, 'thumbnailID not null or undefined'); pendingUploads[thumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const uploadFileInputs = retryMedia.map(singleMedia => { invariant( singleMedia.localMediaSelection, 'localMediaSelection should be set on locally created Media', ); let ids; if (singleMedia.type === 'photo') { ids = { type: 'photo', localMediaID: singleMedia.id }; } else { invariant( singleMedia.thumbnailID, 'singleMedia.thumbnailID should be set for videos', ); ids = { type: 'video', localMediaID: singleMedia.id, localThumbnailID: singleMedia.thumbnailID, }; } return { selection: singleMedia.localMediaSelection, ids, }; }); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; retryMessage = async ( localMessageID: string, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { await this.retryTextMessage(rawMessageInfo, threadInfo, parentThreadInfo); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { await this.retryMultimediaMessage( rawMessageInfo, localMessageID, threadInfo, ); } }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( candidate => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (const callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise(resolve => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } setPendingThreadUpdateHandler = ( threadID: string, pendingThreadUpdateHandler: ?(ThreadInfo) => mixed, ) => { if (!pendingThreadUpdateHandler) { this.pendingThreadUpdateHandlers.delete(threadID); } else { this.pendingThreadUpdateHandlers.set( threadID, pendingThreadUpdateHandler, ); } }; render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); const ConnectedInputStateContainer: React.ComponentType = React.memo(function ConnectedInputStateContainer( props: BaseProps, ) { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useNextLocalID(); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( state => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const calendarQuery = useCalendarQuery(); const callUploadMultimedia = useServerCall(uploadMultimedia); const callUploadMediaMetadata = useServerCall(uploadMediaMetadata); const callBlobServiceUpload = useServerCall(blobServiceUpload); - const callSendMultimediaMessage = useServerCall(sendMultimediaMessage); - const callSendTextMessage = useServerCall(sendTextMessage); + const callSendMultimediaMessage = useSendMultimediaMessage(); + const callSendTextMessage = useSendTextMessage(); const callNewThread = useServerCall(newThread); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); const staffCanSee = useStaffCanSee(); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); return ( ); }); export default ConnectedInputStateContainer; diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index 10454d542..893dcd11e 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,483 +1,486 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import { fetchMessagesBeforeCursorActionTypes, - fetchMessagesBeforeCursor, + useFetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, - fetchMostRecentMessages, + useFetchMostRecentMessages, +} from 'lib/actions/message-actions.js'; +import type { + FetchMostRecentMessagesInput, + FetchMessagesBeforeCursorInput, } from 'lib/actions/message-actions.js'; import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js'; import { useOldestMessageServerID } from 'lib/hooks/message-hooks.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, - useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { editBoxHeight, defaultMaxTextAreaHeight } from './chat-constants.js'; import css from './chat-message-list.css'; import type { ScrollToMessageCallback } from './edit-message-provider.js'; import { useEditModalContext } from './edit-message-provider.js'; import { MessageListContext } from './message-list-types.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; import { useTooltipContext } from './tooltip-provider.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; const browser = detectBrowser(); const supportsReverseFlex = !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81; // Margin between the top of the maximum height edit box // and the top of the container const editBoxTopMargin = 10; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +messageListData: ?$ReadOnlyArray, +startReached: boolean, +dispatchActionPromise: DispatchActionPromise, +fetchMessagesBeforeCursor: ( - threadID: string, - beforeMessageID: string, + input: FetchMessagesBeforeCursorInput, ) => Promise, +fetchMostRecentMessages: ( - threadID: string, + input: FetchMostRecentMessagesInput, ) => Promise, +inputState: ?InputState, +clearTooltip: () => mixed, +oldestMessageServerID: ?string, +isEditState: boolean, +addScrollToMessageListener: ScrollToMessageCallback => mixed, +removeScrollToMessageListener: ScrollToMessageCallback => mixed, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; type State = { +scrollingEndCallback: ?() => mixed, }; class ChatMessageList extends React.PureComponent { container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; constructor(props: Props) { super(props); this.state = { scrollingEndCallback: null, }; } componentDidMount() { this.scrollToBottom(); this.props.addScrollToMessageListener(this.scrollToMessage); } componentWillUnmount() { this.props.removeScrollToMessageListener(this.scrollToMessage); } getSnapshotBeforeUpdate(prevProps: Props) { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props) { const { messageListData } = props; if (!messageListData || messageListData.length === 0) { return false; } const prevMessageListData = prevProps.messageListData; if (!prevMessageListData || prevMessageListData.length === 0) { return true; } return ( ChatMessageList.keyExtractor(prevMessageListData[0]) !== ChatMessageList.keyExtractor(messageListData[0]) ); } componentDidUpdate(prevProps: Props, prevState, snapshot: ?Snapshot) { const { messageListData } = this.props; const prevMessageListData = prevProps.messageListData; const { messageContainer } = this; if (messageContainer && prevMessageListData !== messageListData) { this.onScroll(); } // We'll scroll to the bottom if the user was already scrolled to the bottom // before the new message, or if the new message was composed locally const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps); if ( this.props.activeChatThreadID !== prevProps.activeChatThreadID || (hasNewMessage && messageListData && messageListData[0].itemType === 'message' && messageListData[0].messageInfo.localID) || (hasNewMessage && snapshot && Math.abs(snapshot.scrollTop) <= 1 && !this.props.isEditState) ) { this.scrollToBottom(); } else if (hasNewMessage && messageContainer && snapshot) { const { scrollTop, scrollHeight } = messageContainer; if ( scrollHeight > snapshot.scrollHeight && scrollTop === snapshot.scrollTop ) { const newHeight = scrollHeight - snapshot.scrollHeight; const newScrollTop = Math.abs(scrollTop) + newHeight; if (supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } static keyExtractor(item: ChatMessageItem) { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } renderItem = item => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; scrollingEndCallbackWrapper = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ): (() => mixed) => { return () => { const maxHeight = this.getMaxEditTextAreaHeight(composedMessageID); callback(maxHeight); }; }; scrollToMessage = ( composedMessageID: string, callback: (maxHeight: number) => mixed, ) => { const element = document.getElementById(composedMessageID); if (!element) { return; } const scrollingEndCallback = this.scrollingEndCallbackWrapper( composedMessageID, callback, ); if (!this.willMessageEditWindowOverflow(composedMessageID)) { scrollingEndCallback(); return; } this.setState( { scrollingEndCallback, }, () => { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // It covers the case when browser decide not to scroll to the message // because it's already in the view. // In this case, the 'scroll' event won't be triggered, // so we need to call the callback manually. this.debounceEditModeAfterScrollToMessage(); }, ); }; getMaxEditTextAreaHeight = (composedMessageID: string): number => { const { messageContainer } = this; if (!messageContainer) { return defaultMaxTextAreaHeight; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return defaultMaxTextAreaHeight; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const messageBottom = msgPos.bottom; const containerTop = containerPos.top; const maxHeight = messageBottom - containerTop - editBoxHeight - editBoxTopMargin; return maxHeight; }; willMessageEditWindowOverflow(composedMessageID: string) { const { messageContainer } = this; if (!messageContainer) { return false; } const messageElement = document.getElementById(composedMessageID); if (!messageElement) { console.log(`couldn't find the message element`); return false; } const msgPos = messageElement.getBoundingClientRect(); const containerPos = messageContainer.getBoundingClientRect(); const containerTop = containerPos.top; const containerBottom = containerPos.bottom; const availableTextAreaHeight = (containerBottom - containerTop) / 2 - editBoxHeight; const messageHeight = msgPos.height; const expectedMinimumHeight = Math.min( defaultMaxTextAreaHeight, availableTextAreaHeight, ); const offset = Math.max( 0, expectedMinimumHeight + editBoxHeight + editBoxTopMargin - messageHeight, ); const messageTop = msgPos.top - offset; const messageBottom = msgPos.bottom; return messageBottom > containerBottom || messageTop < containerTop; } render() { const { messageListData, threadInfo, inputState, isEditState } = this.props; if (!messageListData) { return
; } invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); let relationshipPrompt = null; if (threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ; } const messageContainerStyle = classNames({ [css.disableAnchor]: this.state.scrollingEndCallback !== null || isEditState, [css.messageContainer]: true, [css.mirroredMessageContainer]: !supportsReverseFlex, }); return (
{relationshipPrompt}
{messages}
); } messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } this.props.clearTooltip(); this.possiblyLoadMoreMessages(); this.debounceEditModeAfterScrollToMessage(); }; debounceEditModeAfterScrollToMessage = _debounce(() => { if (this.state.scrollingEndCallback) { this.state.scrollingEndCallback(); } this.setState({ scrollingEndCallback: null }); }, 100); async possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); try { const { oldestMessageServerID } = this.props; if (oldestMessageServerID) { await this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, - this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), + this.props.fetchMessagesBeforeCursor({ + threadID, + beforeMessageID: oldestMessageServerID, + }), ); } else { await this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, - this.props.fetchMostRecentMessages(threadID), + this.props.fetchMostRecentMessages({ threadID }), ); } } finally { this.loadingFromScroll = false; } } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedChatMessageList: React.ComponentType = React.memo(function ConnectedChatMessageList( props: BaseProps, ): React.Node { const { threadInfo } = props; const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); const startReached = !!useSelector(state => { const activeID = threadInfo.id; if (!activeID) { return null; } if (threadIsPending(activeID)) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); - const callFetchMessagesBeforeCursor = useServerCall( - fetchMessagesBeforeCursor, - ); - const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); + const callFetchMessagesBeforeCursor = useFetchMessagesBeforeCursor(); + const callFetchMostRecentMessages = useFetchMostRecentMessages(); const inputState = React.useContext(InputStateContext); const { clearTooltip } = useTooltipContext(); const chatMentionCandidates = useThreadChatMentionCandidates(threadInfo); const getTextMessageMarkdownRules = useTextMessageRulesFunc( threadInfo, chatMentionCandidates, ); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); const oldestMessageServerID = useOldestMessageServerID(threadInfo.id); const { editState, addScrollToMessageListener, removeScrollToMessageListener, } = useEditModalContext(); const isEditState = editState !== null; return ( ); }); export default ConnectedChatMessageList; diff --git a/web/chat/reaction-message-utils.js b/web/chat/reaction-message-utils.js index 66169abd0..6ab51fbb9 100644 --- a/web/chat/reaction-message-utils.js +++ b/web/chat/reaction-message-utils.js @@ -1,217 +1,214 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { - sendReactionMessage, + useSendReactionMessage, sendReactionMessageActionTypes, } from 'lib/actions/message-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ReactionInfo } from 'lib/selectors/chat-selectors'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawReactionMessageInfo } from 'lib/types/messages/reaction.js'; -import { - useDispatchActionPromise, - useServerCall, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { cloneError } from 'lib/utils/errors.js'; import Alert from '../modals/alert.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { type TooltipSize, type TooltipPositionStyle, } from '../utils/tooltip-utils.js'; import { getAppContainerPositionInfo } from '../utils/window-utils.js'; function useSendReaction( messageID: ?string, localID: string, threadID: string, reactions: ReactionInfo, ): (reaction: string) => mixed { const { pushModal } = useModalContext(); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); - const callSendReactionMessage = useServerCall(sendReactionMessage); + const callSendReactionMessage = useSendReactionMessage(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( reaction => { if (!messageID) { return; } invariant(viewerID, 'viewerID should be set'); const viewerReacted = reactions[reaction] ? reactions[reaction].viewerReacted : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; const reactionMessagePromise = (async () => { try { const result = await callSendReactionMessage({ threadID, localID, targetMessageID: messageID, reaction, action, }); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { pushModal( Please try again later , ); const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } })(); const startingPayload: RawReactionMessageInfo = { type: messageTypes.REACTION, threadID, localID, creatorID: viewerID, time: Date.now(), targetMessageID: messageID, reaction, action, }; dispatchActionPromise( sendReactionMessageActionTypes, reactionMessagePromise, undefined, startingPayload, ); }, [ messageID, viewerID, reactions, threadID, localID, dispatchActionPromise, callSendReactionMessage, pushModal, ], ); } type EmojiKeyboardPosition = { +bottom: number, +left: number, }; function getEmojiKeyboardPosition( emojiKeyboard: ?HTMLDivElement, tooltipPositionStyle: TooltipPositionStyle, tooltipSize: TooltipSize, ): ?EmojiKeyboardPosition { const { alignment, anchorPoint } = tooltipPositionStyle; const tooltipAnchorX = anchorPoint.x; const tooltipAnchorY = anchorPoint.y; const tooltipWidth = tooltipSize.width; const tooltipHeight = tooltipSize.height; const appContainerPositionInfo = getAppContainerPositionInfo(); if (!appContainerPositionInfo) { return null; } let emojiKeyboardWidth = 352; let emojiKeyboardHeight = 435; if (emojiKeyboard) { const { width, height } = emojiKeyboard.getBoundingClientRect(); emojiKeyboardWidth = width; emojiKeyboardHeight = height; } const { top: containerTop, left: containerLeft, right: containerRight, bottom: containerBottom, } = appContainerPositionInfo; const padding = 16; const canBeDisplayedOnRight = tooltipAnchorX + tooltipWidth + emojiKeyboardWidth <= containerRight; const canBeDisplayedOnLeft = tooltipAnchorX - emojiKeyboardWidth >= containerLeft; const canBeDisplayedOnTop = tooltipAnchorY - emojiKeyboardHeight - padding >= containerTop; const canBeDisplayedOnBottom = tooltipAnchorY + tooltipHeight + emojiKeyboardHeight + padding <= containerBottom; const emojiKeyboardOverflowTop = containerTop - (tooltipAnchorY + tooltipHeight - emojiKeyboardHeight); const emojiKeyboardOverflowTopCorrection = emojiKeyboardOverflowTop > 0 ? -emojiKeyboardOverflowTop - padding : 0; const emojiKeyboardOverflowRight = tooltipAnchorX + emojiKeyboardWidth - containerRight; const emojiKeyboardOverflowRightCorrection = emojiKeyboardOverflowRight > 0 ? -emojiKeyboardOverflowRight - padding : 0; if (alignment === 'left' && canBeDisplayedOnRight) { return { left: tooltipWidth, bottom: emojiKeyboardOverflowTopCorrection, }; } if (alignment === 'right' && canBeDisplayedOnLeft) { return { left: -emojiKeyboardWidth, bottom: emojiKeyboardOverflowTopCorrection, }; } if (canBeDisplayedOnTop) { return { bottom: tooltipHeight + padding, left: emojiKeyboardOverflowRightCorrection, }; } if (canBeDisplayedOnBottom) { return { bottom: -emojiKeyboardHeight - padding, left: emojiKeyboardOverflowRightCorrection, }; } return { left: alignment === 'left' ? -emojiKeyboardWidth : tooltipWidth, bottom: emojiKeyboardOverflowTopCorrection, }; } export { useSendReaction, getEmojiKeyboardPosition }; diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js index fffc0a470..a15e59c50 100644 --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -1,1674 +1,1668 @@ // @flow import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _omit from 'lodash/fp/omit.js'; import _partition from 'lodash/fp/partition.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, - legacySendMultimediaMessage, + useLegacySendMultimediaMessage, sendTextMessageActionTypes, - sendTextMessage, + useSendTextMessage, +} from 'lib/actions/message-actions.js'; +import type { + LegacySendMultimediaMessageInput, + SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { newThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, uploadMediaMetadata, updateMultimediaMessageMediaActionType, deleteUpload, blobServiceUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, type BlobServiceUploadAction, } from 'lib/actions/upload-actions.js'; import { useModalContext, type PushModal, } from 'lib/components/modal-provider.react.js'; import blobService from 'lib/facts/blob-service.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.js'; import { getNextLocalUploadID } from 'lib/media/media-utils.js'; import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js'; import { createMediaMessageInfo, localIDPrefix, useMessageCreationSideEffectsFunc, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread, draftKeyFromThreadID, threadIsPending, threadIsPendingSidebar, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, UploadMediaMetadataRequest, MediaMissionStep, MediaMissionFailure, MediaMissionResult, MediaMission, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { reportTypes } from 'lib/types/report-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ClientNewThreadRequest, type NewThreadResult, type ThreadInfo, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { makeBlobServiceEndpointURL, isBlobServiceURI, blobHashFromBlobServiceURI, } from 'lib/utils/blob-service.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, cloneError } from 'lib/utils/errors.js'; import { generateReportID } from 'lib/utils/report-utils.js'; import { type PendingMultimediaUpload, type TypeaheadState, InputStateContext, } from './input-state.js'; import { encryptFile } from '../media/encryption-utils.js'; import { generateThumbHash } from '../media/image-utils.js'; import { preloadMediaResource, validateFile, preloadImage, } from '../media/media-utils.js'; import InvalidUploadModal from '../modals/chat/invalid-upload.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +drafts: { +[key: string]: string }, +viewerID: ?string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +pendingRealizedThreadIDs: $ReadOnlyMap, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +calendarQuery: () => CalendarQuery, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +blobServiceUpload: BlobServiceUploadAction, +uploadMediaMetadata: ( input: UploadMediaMetadataRequest, ) => Promise, +deleteUpload: (id: string) => Promise, +sendMultimediaMessage: ( - threadID: string, - localID: string, - mediaIDs: $ReadOnlyArray, - sidebarCreation?: boolean, - ) => Promise, - +sendTextMessage: ( - threadID: string, - localID: string, - text: string, - sidebarCreation?: boolean, + input: LegacySendMultimediaMessageInput, ) => Promise, + +sendTextMessage: (input: SendTextMessageInput) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, +pushModal: PushModal, +sendCallbacks: $ReadOnlyArray<() => mixed>, +registerSendCallback: (() => mixed) => void, +unregisterSendCallback: (() => mixed) => void, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, }; type State = { +pendingUploads: { [threadID: string]: { [localUploadID: string]: PendingMultimediaUpload }, }, +textCursorPositions: { [threadID: string]: number }, +typeaheadState: TypeaheadState, }; type PropsAndState = { ...Props, ...State, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, textCursorPositions: {}, typeaheadState: { canBeVisible: false, keepUpdatingThreadMembers: true, frozenUserMentionsCandidates: [], frozenChatMentionsCandidates: {}, moveChoiceUp: null, moveChoiceDown: null, close: null, accept: null, }, }; replyCallbacks: Array<(message: string) => void> = []; pendingThreadCreations = new Map>(); // TODO: flip the switch // Note that this enables Blob service for encrypted media only useBlobServiceUploads = false; // When the user sends a multimedia message that triggers the creation of a // sidebar, the sidebar gets created right away, but the message needs to wait // for the uploads to complete before sending. We use this Set to track the // message localIDs that need sidebarCreation: true. pendingSidebarCreationMessageLocalIDs = new Set(); static reassignToRealizedThreads( state: { +[threadID: string]: T }, props: Props, ): ?{ [threadID: string]: T } { const newState = {}; let updated = false; for (const threadID in state) { const newThreadID = props.pendingRealizedThreadIDs.get(threadID) ?? threadID; if (newThreadID !== threadID) { updated = true; } newState[newThreadID] = state[threadID]; } return updated ? newState : null; } static getDerivedStateFromProps(props: Props, state: State) { const pendingUploads = InputStateContainer.reassignToRealizedThreads( state.pendingUploads, props, ); const textCursorPositions = InputStateContainer.reassignToRealizedThreads( state.textCursorPositions, props, ); if (!pendingUploads && !textCursorPositions) { return null; } const stateUpdate = {}; if (pendingUploads) { stateUpdate.pendingUploads = pendingUploads; } if (textCursorPositions) { stateUpdate.textCursorPositions = textCursorPositions; } return stateUpdate; } static completedMessageIDs(state: State) { const completed = new Map(); for (const threadID in state.pendingUploads) { const pendingUploads = state.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID, serverID, failed } = upload; if (!messageID || !messageID.startsWith(localIDPrefix)) { continue; } if (!serverID || failed) { completed.set(messageID, false); continue; } if (completed.get(messageID) === undefined) { completed.set(messageID, true); } } } const messageIDs = new Set(); for (const [messageID, isCompleted] of completed) { if (isCompleted) { messageIDs.add(messageID); } } return messageIDs; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const previouslyAssignedMessageIDs = new Set(); for (const threadID in prevState.pendingUploads) { const pendingUploads = prevState.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const { messageID } = pendingUploads[localUploadID]; if (messageID) { previouslyAssignedMessageIDs.add(messageID); } } } const newlyAssignedUploads = new Map(); for (const threadID in this.state.pendingUploads) { const pendingUploads = this.state.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID } = upload; if ( !messageID || !messageID.startsWith(localIDPrefix) || previouslyAssignedMessageIDs.has(messageID) ) { continue; } const { shouldEncrypt } = upload; let assignedUploads = newlyAssignedUploads.get(messageID); if (!assignedUploads) { assignedUploads = { threadID, shouldEncrypt, uploads: [] }; newlyAssignedUploads.set(messageID, assignedUploads); } if (shouldEncrypt !== assignedUploads.shouldEncrypt) { console.warn( `skipping upload ${localUploadID} ` + "because shouldEncrypt doesn't match", ); continue; } assignedUploads.uploads.push(upload); } } const newMessageInfos = new Map(); for (const [messageID, assignedUploads] of newlyAssignedUploads) { const { uploads, threadID, shouldEncrypt } = assignedUploads; const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = uploads.map( ({ localID, serverID, uri, mediaType, dimensions, encryptionKey, thumbHash, }) => { // We can get into this state where dimensions are null if the user is // uploading a file type that the browser can't render. In that case // we fake the dimensions here while we wait for the server to tell us // the true dimensions. const shimmedDimensions = dimensions ?? { height: 0, width: 0 }; invariant( mediaType === 'photo' || mediaType === 'encrypted_photo', "web InputStateContainer can't handle video", ); if ( mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video' ) { return { id: serverID ? serverID : localID, uri, type: 'photo', dimensions: shimmedDimensions, thumbHash, }; } invariant( encryptionKey, 'encrypted media must have an encryption key', ); return { id: serverID ? serverID : localID, blobURI: uri, type: 'encrypted_photo', encryptionKey, dimensions: shimmedDimensions, thumbHash, }; }, ); const messageInfo = createMediaMessageInfo( { localID: messageID, threadID, creatorID, media, }, { forceMultimediaMessageType: shouldEncrypt }, ); newMessageInfos.set(messageID, messageInfo); } const currentlyCompleted = InputStateContainer.completedMessageIDs( this.state, ); const previouslyCompleted = InputStateContainer.completedMessageIDs(prevState); for (const messageID of currentlyCompleted) { if (previouslyCompleted.has(messageID)) { continue; } let rawMessageInfo = newMessageInfos.get(messageID); if (rawMessageInfo) { newMessageInfos.delete(messageID); } else { rawMessageInfo = this.getRawMultimediaMessageInfo(messageID); } this.sendMultimediaMessage(rawMessageInfo); } for (const [, messageInfo] of newMessageInfos) { this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); } } getRawMultimediaMessageInfo( localMessageID: string, ): RawMultimediaMessageInfo { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); return rawMessageInfo; } shouldEncryptMedia(threadInfo: ThreadInfo): boolean { return threadInfoInsideCommunity(threadInfo, commStaffCommunity.id); } async sendMultimediaMessage(messageInfo: RawMultimediaMessageInfo) { if (!threadIsPending(messageInfo.threadID)) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } newThreadID = await threadCreationPromise; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } // While the thread was being created, the image preload may have completed, // and we might have a finalized URI now. So we fetch from Redux again const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should exist for locally-created RawMessageInfo', ); const latestMessageInfo = this.getRawMultimediaMessageInfo(localID); // Conditional is necessary for Flow let newMessageInfo; if (latestMessageInfo.type === messageTypes.MULTIMEDIA) { newMessageInfo = { ...latestMessageInfo, threadID: newThreadID, time: Date.now(), }; } else { newMessageInfo = { ...latestMessageInfo, threadID: newThreadID, time: Date.now(), }; } this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const mediaIDs = []; for (const { id } of messageInfo.media) { mediaIDs.push(id); } try { - const result = await this.props.sendMultimediaMessage( + const result = await this.props.sendMultimediaMessage({ threadID, localID, mediaIDs, sidebarCreation, - ); + }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const prevUploads = prevState.pendingUploads[newThreadID]; const newUploads = {}; for (const localUploadID in prevUploads) { const upload = prevUploads[localUploadID]; if (upload.messageID !== localID) { newUploads[localUploadID] = upload; } else if (!upload.uriIsReal) { newUploads[localUploadID] = { ...upload, messageID: result.id, }; } } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newUploads, }, }; }); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } startThreadCreation(threadInfo: ThreadInfo): Promise { if (!threadIsPending(threadInfo.id)) { return Promise.resolve(threadInfo.id); } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } inputBaseStateSelector = _memoize((threadID: ?string) => createSelector( (propsAndState: PropsAndState) => threadID ? propsAndState.pendingUploads[threadID] : null, (propsAndState: PropsAndState) => threadID ? propsAndState.drafts[draftKeyFromThreadID(threadID)] : null, (propsAndState: PropsAndState) => threadID ? propsAndState.textCursorPositions[threadID] : null, ( pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload }, draft: ?string, textCursorPosition: ?number, ) => { let threadPendingUploads = []; const assignedUploads = {}; if (pendingUploads) { const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] = _partition('messageID')(pendingUploads); threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs); const threadAssignedUploads = _groupBy('messageID')( uploadsWithMessageIDs, ); for (const messageID in threadAssignedUploads) { // lodash libdefs don't return $ReadOnlyArray assignedUploads[messageID] = [...threadAssignedUploads[messageID]]; } } return { pendingUploads: threadPendingUploads, assignedUploads, draft: draft ?? '', textCursorPosition: textCursorPosition ?? 0, appendFiles: (threadInfo: ThreadInfo, files: $ReadOnlyArray) => this.appendFiles(threadInfo, files), cancelPendingUpload: (localUploadID: string) => this.cancelPendingUpload(threadID, localUploadID), sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo), createMultimediaMessage: (localID: number, threadInfo: ThreadInfo) => this.createMultimediaMessage(localID, threadInfo), setDraft: (newDraft: string) => this.setDraft(threadID, newDraft), setTextCursorPosition: (newPosition: number) => this.setTextCursorPosition(threadID, newPosition), messageHasUploadFailure: (localMessageID: string) => this.messageHasUploadFailure(assignedUploads[localMessageID]), retryMultimediaMessage: ( localMessageID: string, threadInfo: ThreadInfo, ) => this.retryMultimediaMessage( localMessageID, threadInfo, assignedUploads[localMessageID], ), addReply: (message: string) => this.addReply(message), addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, registerSendCallback: this.props.registerSendCallback, unregisterSendCallback: this.props.unregisterSendCallback, }; }, ), ); typeaheadStateSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.typeaheadState, (typeaheadState: TypeaheadState) => ({ typeaheadState, setTypeaheadState: this.setTypeaheadState, }), ); inputStateSelector = createSelector( state => state.inputBaseState, state => state.typeaheadState, (inputBaseState, typeaheadState) => ({ ...inputBaseState, ...typeaheadState, }), ); getRealizedOrPendingThreadID(threadID: string): string { return this.props.pendingRealizedThreadIDs.get(threadID) ?? threadID; } async appendFiles( threadInfo: ThreadInfo, files: $ReadOnlyArray, ): Promise { const selectionTime = Date.now(); const { pushModal } = this.props; const appendResults = await Promise.all( files.map(file => this.appendFile(threadInfo, file, selectionTime)), ); if (appendResults.some(({ result }) => !result.success)) { pushModal(); const time = Date.now() - selectionTime; const reports = []; for (const appendResult of appendResults) { const { steps } = appendResult; let { result } = appendResult; let uploadLocalID; if (result.success) { uploadLocalID = result.pendingUpload.localID; result = { success: false, reason: 'web_sibling_validation_failed' }; } const mediaMission = { steps, result, userTime: time, totalTime: time }; reports.push({ mediaMission, uploadLocalID }); } this.queueMediaMissionReports(reports); return false; } const newUploads = appendResults.map(({ result }) => { invariant(result.success, 'any failed validation should be caught above'); return result.pendingUpload; }); const newUploadsObject = _keyBy('localID')(newUploads); this.setState( prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const prevUploads = prevState.pendingUploads[newThreadID]; const mergedUploads = prevUploads ? { ...prevUploads, ...newUploadsObject } : newUploadsObject; return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: mergedUploads, }, }; }, () => this.uploadFiles(threadInfo.id, newUploads), ); return true; } async appendFile( threadInfo: ThreadInfo, file: File, selectTime: number, ): Promise<{ steps: $ReadOnlyArray, result: | MediaMissionFailure | { success: true, pendingUpload: PendingMultimediaUpload }, }> { const steps = [ { step: 'web_selection', filename: file.name, size: file.size, mime: file.type, selectTime, }, ]; let response; const validationStart = Date.now(); try { response = await validateFile(file); } catch (e) { return { steps, result: { success: false, reason: 'processing_exception', time: Date.now() - validationStart, exceptionMessage: getMessageForException(e), }, }; } const { steps: validationSteps, result } = response; steps.push(...validationSteps); if (!result.success) { return { steps, result }; } const { uri, file: fixedFile, mediaType, dimensions } = result; const shouldEncrypt = this.shouldEncryptMedia(threadInfo); let encryptionResult; if (shouldEncrypt) { let encryptionResponse; const encryptionStart = Date.now(); try { encryptionResponse = await encryptFile(fixedFile); } catch (e) { return { steps, result: { success: false, reason: 'encryption_exception', time: Date.now() - encryptionStart, exceptionMessage: getMessageForException(e), }, }; } steps.push(...encryptionResponse.steps); encryptionResult = encryptionResponse.result; } if (encryptionResult && !encryptionResult.success) { return { steps, result: encryptionResult }; } const { steps: thumbHashSteps, result: thumbHashResult } = await generateThumbHash(fixedFile, encryptionResult?.encryptionKey); const thumbHash = thumbHashResult.success ? thumbHashResult.thumbHash : null; steps.push(...thumbHashSteps); return { steps, result: { success: true, pendingUpload: { localID: getNextLocalUploadID(), serverID: null, messageID: null, failed: false, file: encryptionResult?.file ?? fixedFile, mediaType: encryptionResult ? 'encrypted_photo' : mediaType, dimensions, uri: encryptionResult?.uri ?? uri, loop: false, uriIsReal: false, blobHolder: null, blobHash: encryptionResult?.sha256Hash, encryptionKey: encryptionResult?.encryptionKey, thumbHash, progressPercent: 0, abort: null, steps, selectTime, shouldEncrypt, }, }, }; } uploadFiles( threadID: string, uploads: $ReadOnlyArray, ) { return Promise.all( uploads.map(upload => this.uploadFile(threadID, upload)), ); } async uploadFile(threadID: string, upload: PendingMultimediaUpload) { const { selectTime, localID, encryptionKey } = upload; const isEncrypted = !!encryptionKey && (upload.mediaType === 'encrypted_photo' || upload.mediaType === 'encrypted_video'); const steps = [...upload.steps]; let userTime; const sendReport = (missionResult: MediaMissionResult) => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const latestUpload = this.state.pendingUploads[newThreadID][localID]; invariant( latestUpload, `pendingUpload ${localID} for ${newThreadID} missing in sendReport`, ); const { serverID, messageID } = latestUpload; const totalTime = Date.now() - selectTime; userTime = userTime ? userTime : totalTime; const mission = { steps, result: missionResult, totalTime, userTime }; this.queueMediaMissionReports([ { mediaMission: mission, uploadLocalID: localID, uploadServerID: serverID, messageLocalID: messageID, }, ]); }; let uploadResult, uploadExceptionMessage; const uploadStart = Date.now(); try { const callbacks = { onProgress: (percent: number) => this.setProgress(threadID, localID, percent), abortHandler: (abort: () => void) => this.handleAbortCallback(threadID, localID, abort), }; if ( this.useBlobServiceUploads && (upload.mediaType === 'encrypted_photo' || upload.mediaType === 'encrypted_video') ) { const { blobHash, dimensions, thumbHash } = upload; invariant( encryptionKey && blobHash && dimensions, 'incomplete encrypted upload', ); uploadResult = await this.props.blobServiceUpload({ input: { blobInput: { type: 'file', file: upload.file, }, blobHash, encryptionKey, dimensions, loop: false, thumbHash, }, callbacks, }); } else { let uploadExtras = { ...upload.dimensions, loop: false, thumbHash: upload.thumbHash, }; if (encryptionKey) { uploadExtras = { ...uploadExtras, encryptionKey }; } uploadResult = await this.props.uploadMultimedia( upload.file, uploadExtras, callbacks, ); } } catch (e) { uploadExceptionMessage = getMessageForException(e); this.handleUploadFailure(threadID, localID); } userTime = Date.now() - selectTime; steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: upload.file.name, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, }); if (!uploadResult) { sendReport({ success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }); return; } const result = uploadResult; const outputMediaType = isEncrypted ? 'encrypted_photo' : result.mediaType; const successThreadID = this.getRealizedOrPendingThreadID(threadID); const uploadAfterSuccess = this.state.pendingUploads[successThreadID][localID]; invariant( uploadAfterSuccess, `pendingUpload ${localID}/${result.id} for ${successThreadID} missing ` + `after upload`, ); if (uploadAfterSuccess.messageID) { this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterSuccess.messageID, currentMediaID: localID, mediaUpdate: { id: result.id, }, }, }); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${newThreadID} ` + `missing while assigning serverID`, ); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localID]: { ...currentUpload, serverID: result.id, blobHolder: result.blobHolder, abort: null, }, }, }, }; }); if (encryptionKey) { const { steps: preloadSteps } = await preloadMediaResource(result.uri); steps.push(...preloadSteps); } else { const { steps: preloadSteps } = await preloadImage(result.uri); steps.push(...preloadSteps); } sendReport({ success: true }); const preloadThreadID = this.getRealizedOrPendingThreadID(threadID); const uploadAfterPreload = this.state.pendingUploads[preloadThreadID][localID]; invariant( uploadAfterPreload, `pendingUpload ${localID}/${result.id} for ${preloadThreadID} missing ` + `after preload`, ); if (uploadAfterPreload.messageID) { const { mediaType, uri, dimensions, loop } = result; const { thumbHash } = upload; let mediaUpdate = { loop, dimensions, ...(thumbHash ? { thumbHash } : undefined), }; if (!isEncrypted) { mediaUpdate = { ...mediaUpdate, type: mediaType, uri, }; } else { mediaUpdate = { ...mediaUpdate, type: outputMediaType, blobURI: uri, encryptionKey, }; } this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterPreload.messageID, currentMediaID: result.id ?? uploadAfterPreload.localID, mediaUpdate, }, }); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${newThreadID} ` + `missing while assigning URI`, ); const { messageID } = currentUpload; if (messageID && !messageID.startsWith(localIDPrefix)) { const newPendingUploads = _omit([localID])(uploads); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localID]: { ...currentUpload, uri: result.uri, mediaType: outputMediaType, dimensions: result.dimensions, uriIsReal: true, loop: result.loop, }, }, }, }; }); } handleAbortCallback( threadID: string, localUploadID: string, abort: () => void, ) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been cancelled before we were even handed the // abort function. We should immediately abort. abort(); } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localUploadID]: { ...upload, abort, }, }, }, }; }); } handleUploadFailure(threadID: string, localUploadID: string) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const upload = uploads[localUploadID]; if (!upload || !upload.abort || upload.serverID) { // The upload has been cancelled or completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, abort: null, }, }, }, }; }); } queueMediaMissionReports( partials: $ReadOnlyArray<{ mediaMission: MediaMission, uploadLocalID?: ?string, uploadServerID?: ?string, messageLocalID?: ?string, }>, ) { const reports = partials.map( ({ mediaMission, uploadLocalID, uploadServerID, messageLocalID }) => ({ type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID, uploadLocalID, messageLocalID, id: generateReportID(), }), ); this.props.dispatch({ type: queueReportsActionType, payload: { reports } }); } cancelPendingUpload(threadID: ?string, localUploadID: string) { invariant(threadID, 'threadID should be set in cancelPendingUpload'); let revokeURL, abortRequest; this.setState( prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const currentPendingUploads = prevState.pendingUploads[newThreadID]; if (!currentPendingUploads) { return {}; } const pendingUpload = currentPendingUploads[localUploadID]; if (!pendingUpload) { return {}; } if (!pendingUpload.uriIsReal) { revokeURL = pendingUpload.uri; } if (pendingUpload.abort) { abortRequest = pendingUpload.abort; } if (pendingUpload.serverID) { this.props.deleteUpload(pendingUpload.serverID); if (isBlobServiceURI(pendingUpload.uri)) { invariant( pendingUpload.blobHolder, 'blob service upload has no holder', ); const endpoint = blobService.httpEndpoints.DELETE_BLOB; const holder = pendingUpload.blobHolder; const blobHash = blobHashFromBlobServiceURI(pendingUpload.uri); fetch(makeBlobServiceEndpointURL(endpoint), { method: endpoint.method, body: JSON.stringify({ holder, blob_hash: blobHash, }), headers: { 'content-type': 'application/json', }, }); } } const newPendingUploads = _omit([localUploadID])(currentPendingUploads); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }, () => { if (revokeURL) { URL.revokeObjectURL(revokeURL); } if (abortRequest) { abortRequest(); } }, ); } async sendTextMessage( messageInfo: RawTextMessageInfo, inputThreadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) { this.props.sendCallbacks.forEach(callback => callback()); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); if (threadIsPendingSidebar(inputThreadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localID); } if (!threadIsPending(inputThreadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { this.props.dispatch({ type: updateNavInfoActionType, payload: { pendingThread: threadInfo }, }); } } let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; const newThreadInfo = { ...threadInfo, id: newThreadID, }; this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); } async sendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): Promise { try { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); - const result = await this.props.sendTextMessage( - messageInfo.threadID, + const result = await this.props.sendTextMessage({ + threadID: messageInfo.threadID, localID, - messageInfo.text, + text: messageInfo.text, sidebarCreation, - ); + }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } // Creates a MultimediaMessage from the unassigned pending uploads, // if there are any createMultimediaMessage(localID: number, threadInfo: ThreadInfo) { this.props.sendCallbacks.forEach(callback => callback()); const localMessageID = `${localIDPrefix}${localID}`; this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const currentPendingUploads = prevState.pendingUploads[newThreadID]; if (!currentPendingUploads) { return {}; } const newPendingUploads = {}; let uploadAssigned = false; for (const localUploadID in currentPendingUploads) { const upload = currentPendingUploads[localUploadID]; if (upload.messageID) { newPendingUploads[localUploadID] = upload; } else { const newUpload = { ...upload, messageID: localMessageID, }; uploadAssigned = true; newPendingUploads[localUploadID] = newUpload; } } if (!uploadAssigned) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); } setDraft(threadID: ?string, draft: string) { invariant(threadID, 'threadID should be set in setDraft'); const newThreadID = this.getRealizedOrPendingThreadID(threadID); this.props.dispatch({ type: 'UPDATE_DRAFT', payload: { key: draftKeyFromThreadID(newThreadID), text: draft, }, }); } setTextCursorPosition(threadID: ?string, newPosition: number) { invariant(threadID, 'threadID should be set in setTextCursorPosition'); this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); return { textCursorPositions: { ...prevState.textCursorPositions, [newThreadID]: newPosition, }, }; }); } setTypeaheadState = (newState: $Shape) => { this.setState(prevState => ({ typeaheadState: { ...prevState.typeaheadState, ...newState, }, })); }; setProgress( threadID: string, localUploadID: string, progressPercent: number, ) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const pendingUploads = prevState.pendingUploads[newThreadID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); } messageHasUploadFailure( pendingUploads: ?$ReadOnlyArray, ) { if (!pendingUploads) { return false; } return pendingUploads.some(upload => upload.failed); } retryMultimediaMessage( localMessageID: string, threadInfo: ThreadInfo, pendingUploads: ?$ReadOnlyArray, ) { this.props.sendCallbacks.forEach(callback => callback()); const rawMessageInfo = this.getRawMultimediaMessageInfo(localMessageID); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawMediaMessageInfo); } else { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawImagesMessageInfo); } this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const completed = InputStateContainer.completedMessageIDs(this.state); if (completed.has(localMessageID)) { this.sendMultimediaMessage(newRawMessageInfo); return; } if (!pendingUploads) { return; } // We're not actually starting the send here, // we just use this action to update the message's timestamp in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); const uploadIDsToRetry = new Set(); const uploadsToRetry = []; for (const pendingUpload of pendingUploads) { const { serverID, messageID, localID, abort } = pendingUpload; if (serverID || messageID !== localMessageID) { continue; } if (abort) { abort(); } uploadIDsToRetry.add(localID); uploadsToRetry.push(pendingUpload); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const prevPendingUploads = prevState.pendingUploads[newThreadID]; if (!prevPendingUploads) { return {}; } const newPendingUploads = {}; let pendingUploadChanged = false; for (const localID in prevPendingUploads) { const pendingUpload = prevPendingUploads[localID]; if (uploadIDsToRetry.has(localID) && !pendingUpload.serverID) { newPendingUploads[localID] = { ...pendingUpload, failed: false, progressPercent: 0, abort: null, }; pendingUploadChanged = true; } else { newPendingUploads[localID] = pendingUpload; } } if (!pendingUploadChanged) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); this.uploadFiles(threadInfo.id, uploadsToRetry); } addReply = (message: string) => { this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(message)); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( candidate => candidate !== callbackReply, ); }; render() { const { activeChatThreadID } = this.props; // we're going with two selectors as we want to avoid // recreation of chat state setter functions on typeahead state updates const inputBaseState = this.inputBaseStateSelector(activeChatThreadID)({ ...this.state, ...this.props, }); const typeaheadState = this.typeaheadStateSelector({ ...this.state, ...this.props, }); const inputState = this.inputStateSelector({ inputBaseState, typeaheadState, }); return ( {this.props.children} ); } } const ConnectedInputStateContainer: React.ComponentType = React.memo(function ConnectedInputStateContainer(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const drafts = useSelector(state => state.draftStore.drafts); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); const calendarQuery = useSelector(nonThreadCalendarQuery); const callUploadMultimedia = useServerCall(uploadMultimedia); const callBlobServiceUpload = useServerCall(blobServiceUpload); const callUploadMediaMetadata = useServerCall(uploadMediaMetadata); const callDeleteUpload = useServerCall(deleteUpload); - const callSendMultimediaMessage = useServerCall( - legacySendMultimediaMessage, - ); - const callSendTextMessage = useServerCall(sendTextMessage); + const callSendMultimediaMessage = useLegacySendMultimediaMessage(); + const callSendTextMessage = useSendTextMessage(); const callNewThread = useServerCall(newThread); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const [sendCallbacks, setSendCallbacks] = React.useState< $ReadOnlyArray<() => mixed>, >([]); const registerSendCallback = React.useCallback((callback: () => mixed) => { setSendCallbacks(prevCallbacks => [...prevCallbacks, callback]); }, []); const unregisterSendCallback = React.useCallback( (callback: () => mixed) => { setSendCallbacks(prevCallbacks => prevCallbacks.filter(candidate => candidate !== callback), ); }, [], ); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); return ( ); }); export default ConnectedInputStateContainer; diff --git a/web/modals/chat/message-results-modal.react.js b/web/modals/chat/message-results-modal.react.js index 0fe3ef393..1732d5e44 100644 --- a/web/modals/chat/message-results-modal.react.js +++ b/web/modals/chat/message-results-modal.react.js @@ -1,152 +1,149 @@ // @flow import * as React from 'react'; import { - fetchPinnedMessages, fetchPinnedMessageActionTypes, + useFetchPinnedMessages, } from 'lib/actions/message-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { messageListData } from 'lib/selectors/chat-selectors.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { createMessageInfo, isInvalidPinSourceForThread, modifyItemForResultScreen, } from 'lib/shared/message-utils.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; -import { - useServerCall, - useDispatchActionPromise, -} from 'lib/utils/action-utils.js'; +import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import css from './message-results-modal.css'; import MessageResult from '../../components/message-result.react.js'; import LoadingIndicator from '../../loading-indicator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import Modal from '../modal.react.js'; type MessageResultsModalProps = { +threadInfo: ThreadInfo, +modalName: string, }; const loadingStatusSelector = createLoadingStatusSelector( fetchPinnedMessageActionTypes, ); function MessageResultsModal(props: MessageResultsModalProps): React.Node { const { threadInfo, modalName } = props; const { id: threadID } = threadInfo; const { popModal } = useModalContext(); const [rawMessageResults, setRawMessageResults] = React.useState([]); - const callFetchPinnedMessages = useServerCall(fetchPinnedMessages); + const callFetchPinnedMessages = useFetchPinnedMessages(); const dispatchActionPromise = useDispatchActionPromise(); const userInfos = useSelector(state => state.userStore.userInfos); const loadingStatus = useSelector(loadingStatusSelector); React.useEffect(() => { dispatchActionPromise( fetchPinnedMessageActionTypes, (async () => { const result = await callFetchPinnedMessages({ threadID }); setRawMessageResults(result.pinnedMessages); })(), ); }, [dispatchActionPromise, callFetchPinnedMessages, threadID]); const translatedMessageResults = React.useMemo(() => { const threadInfos = { [threadID]: threadInfo }; return rawMessageResults .map(messageInfo => createMessageInfo(messageInfo, null, userInfos, threadInfos), ) .filter(Boolean); }, [rawMessageResults, userInfos, threadID, threadInfo]); const chatMessageInfos = useSelector( messageListData(threadInfo.id, translatedMessageResults), ); const sortedUniqueChatMessageInfoItems = React.useMemo(() => { if (!chatMessageInfos) { return []; } const chatMessageInfoItems = chatMessageInfos.filter( item => item.itemType === 'message' && item.isPinned && !isInvalidPinSourceForThread(item.messageInfo, threadInfo), ); // By the nature of using messageListData and passing in // the desired translatedMessageResults as additional // messages, we will have duplicate ChatMessageInfoItems. const uniqueChatMessageInfoItemsMap = new Map(); chatMessageInfoItems.forEach( item => item.messageInfo && item.messageInfo.id && uniqueChatMessageInfoItemsMap.set(item.messageInfo.id, item), ); // Push the items in the order they appear in the rawMessageResults // since the messages fetched from the server are already sorted // in the order of pin_time (newest first). const sortedChatMessageInfoItems = []; for (let i = 0; i < rawMessageResults.length; i++) { sortedChatMessageInfoItems.push( uniqueChatMessageInfoItemsMap.get(rawMessageResults[i].id), ); } return sortedChatMessageInfoItems; }, [chatMessageInfos, rawMessageResults, threadInfo]); const modifiedItems = React.useMemo( () => sortedUniqueChatMessageInfoItems .filter(Boolean) .map(item => modifyItemForResultScreen(item)), [sortedUniqueChatMessageInfoItems], ); const messageResultsToDisplay = React.useMemo(() => { const items = modifiedItems.map(item => ( )); return <>{items}; }, [modifiedItems, threadInfo]); const loadingIndicator = React.useMemo(() => { if (loadingStatus === 'loading') { return (
); } return null; }, [loadingStatus]); return (
{loadingIndicator}
{messageResultsToDisplay}
); } export default MessageResultsModal;