diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index 762c32d45..ce65128bf 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,569 +1,510 @@ // @flow -import invariant from 'invariant'; import * as React from 'react'; -import type { CallSingleKeyserverEndpointResultInfo } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { extractKeyserverIDFromIDOptional, extractKeyserverIDFromID, sortThreadIDsPerKeyserver, } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import type { FetchMessageInfosPayload, SendMessageResult, SendEditMessageResult, SendReactionMessageRequest, SimpleMessagesPayload, SendEditMessageRequest, FetchPinnedMessagesRequest, FetchPinnedMessagesResult, SearchMessagesRequest, SearchMessagesKeyserverRequest, SearchMessagesResponse, FetchMessageInfosRequest, RawMessageInfo, MessageTruncationStatuses, } from '../types/message-types.js'; import { defaultNumberPerThread } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; import type { ToggleMessagePinRequest, ToggleMessagePinResult, } from '../types/thread-types.js'; import { getConfig } from '../utils/config.js'; import { translateClientDBMessageInfoToRawMessageInfo } from '../utils/message-ops-utils.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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: FetchMessagesBeforeCursorInput, ) => Promise) => 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: 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 = ( 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: 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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((threadIDs: $ReadOnlyArray) => Promise) => async threadIDs => { const sortedThreadIDs = sortThreadIDsPerKeyserver(threadIDs); const requests: { [string]: FetchMessageInfosRequest } = {}; 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: $ReadOnlyArray = []; let truncationStatuses: MessageTruncationStatuses = {}; for (const keyserverID in responses) { rawMessageInfos = rawMessageInfos.concat( responses[keyserverID].rawMessageInfos, ); truncationStatuses = { ...truncationStatuses, ...responses[keyserverID].truncationStatuses, }; } return { 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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SendTextMessageInput) => Promise) => async input => { - let resultInfo; - const getResultInfo = ( - passedResultInfo: CallSingleKeyserverEndpointResultInfo, - ) => { - resultInfo = passedResultInfo; - }; const { threadID, localID, text, sidebarCreation } = input; let payload = { threadID, localID, text }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } 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 callKeyserverEndpoint resolves', ); return { 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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SendMultimediaMessageInput) => Promise) => async input => { - let resultInfo; - const getResultInfo = ( - passedResultInfo: CallSingleKeyserverEndpointResultInfo, - ) => { - resultInfo = passedResultInfo; - }; const { threadID, localID, mediaMessageContents, sidebarCreation } = input; let payload = { threadID, localID, mediaMessageContents }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: payload }; const responses = await callKeyserverEndpoint( 'create_multimedia_message', requests, - { getResultInfo }, - ); - const resultInterface = resultInfo?.interface; - invariant( - resultInterface, - 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { 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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: LegacySendMultimediaMessageInput, ) => Promise) => async input => { - let resultInfo; - const getResultInfo = ( - passedResultInfo: CallSingleKeyserverEndpointResultInfo, - ) => { - resultInfo = passedResultInfo; - }; const { threadID, localID, mediaIDs, sidebarCreation } = input; let payload = { threadID, localID, mediaIDs }; if (sidebarCreation) { payload = { ...payload, sidebarCreation }; } const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: payload }; const responses = await callKeyserverEndpoint( 'create_multimedia_message', requests, - { getResultInfo }, - ); - const resultInterface = resultInfo?.interface; - invariant( - resultInterface, - 'getResultInfo not called before callKeyserverEndpoint resolves', ); return { 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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: SendReactionMessageRequest) => Promise) => async input => { - let resultInfo; - const getResultInfo = ( - passedResultInfo: CallSingleKeyserverEndpointResultInfo, - ) => { - resultInfo = passedResultInfo; - }; - 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 callKeyserverEndpoint resolves', - ); - return { 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 = ( 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: 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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: FetchPinnedMessagesRequest, ) => Promise) => 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 = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: SearchMessagesKeyserverRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint('search_messages', requests); return { messages: responses[keyserverID].messages, endReached: responses[keyserverID].endReached, }; }; function useSearchMessages(): ( input: SearchMessagesRequest, ) => Promise { const thinThreadCallback = useKeyserverCall(searchMessages); return React.useCallback( async (input: SearchMessagesRequest) => { const isThreadThin = !!extractKeyserverIDFromIDOptional(input.threadID); if (isThreadThin) { return await thinThreadCallback({ query: input.query, threadID: input.threadID, cursor: input.messageIDCursor, }); } const { sqliteAPI } = getConfig(); const timestampCursor = input.timestampCursor?.toString(); const clientDBMessageInfos = await sqliteAPI.searchMessages( input.query, input.threadID, timestampCursor, input.messageIDCursor, ); const messages = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); return { endReached: messages.length < defaultNumberPerThread, messages, }; }, [thinThreadCallback], ); } const toggleMessagePinActionTypes = Object.freeze({ started: 'TOGGLE_MESSAGE_PIN_STARTED', success: 'TOGGLE_MESSAGE_PIN_SUCCESS', failed: 'TOGGLE_MESSAGE_PIN_FAILED', }); const toggleMessagePin = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: ToggleMessagePinRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.messageID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'toggle_message_pin', requests, ); const response = responses[keyserverID]; return { newMessageInfos: response.newMessageInfos, threadID: response.threadID, }; }; function useToggleMessagePin(): ( input: ToggleMessagePinRequest, ) => Promise { return useKeyserverCall(toggleMessagePin); } export { fetchMessagesBeforeCursorActionTypes, useFetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, useFetchMostRecentMessages, fetchSingleMostRecentMessagesFromThreadsActionTypes, useFetchSingleMostRecentMessagesFromThreads, sendTextMessageActionTypes, useSendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, useSendMultimediaMessage, useLegacySendMultimediaMessage, searchMessagesActionTypes, useSearchMessages, sendReactionMessageActionTypes, useSendReactionMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, sendEditMessageActionTypes, useSendEditMessage, useFetchPinnedMessages, fetchPinnedMessageActionTypes, toggleMessagePinActionTypes, useToggleMessagePin, }; diff --git a/lib/keyserver-conn/call-single-keyserver-endpoint.js b/lib/keyserver-conn/call-single-keyserver-endpoint.js index d92e95a3a..fd1fb23d3 100644 --- a/lib/keyserver-conn/call-single-keyserver-endpoint.js +++ b/lib/keyserver-conn/call-single-keyserver-endpoint.js @@ -1,252 +1,241 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { defaultPerformHTTPMultipartUpload, type PerformHTTPMultipartUpload, } from './multipart-upload.js'; import { updateLastCommunicatedPlatformDetailsActionType } from '../actions/device-actions.js'; import { callSingleKeyserverEndpointTimeout } from '../shared/timeouts.js'; import type { PlatformDetails } from '../types/device-types.js'; import { type Endpoint, type SocketAPIHandler, endpointIsSocketPreferred, endpointIsSocketOnly, } from '../types/endpoints.js'; import { forcePolicyAcknowledgmentActionType } from '../types/policy-types.js'; import type { Dispatch } from '../types/redux-types.js'; import type { ServerSessionChange, ClientSessionChange, } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; import { ServerError, FetchTimeout, SocketOffline, SocketTimeout, } from '../utils/errors.js'; import sleep from '../utils/sleep.js'; import { extractAndPersistUserInfosFromEndpointResponse } from '../utils/user-info-extraction-utils.js'; export type CallSingleKeyserverEndpointOptions = Partial<{ // null timeout means no timeout, which is the default for // defaultPerformHTTPMultipartUpload +timeout: ?number, // in milliseconds - // getResultInfo will be called right before callSingleKeyserverEndpoint - // successfully resolves and includes additional information about the request - +getResultInfo: (resultInfo: CallSingleKeyserverEndpointResultInfo) => mixed, +performHTTPMultipartUpload: boolean | PerformHTTPMultipartUpload, // onProgress and abortHandler only work with performHTTPMultipartUpload +onProgress: (percent: number) => void, // abortHandler will receive an abort function once the upload starts +abortHandler: (abort: () => void) => void, // Overrides urlPrefix in Redux +urlPrefixOverride: string, }>; -export type CallSingleKeyserverEndpointResultInfoInterface = 'socket' | 'REST'; -export type CallSingleKeyserverEndpointResultInfo = { - +interface: CallSingleKeyserverEndpointResultInfoInterface, -}; - export type CallSingleKeyserverEndpointResponse = Partial<{ +cookieChange: ServerSessionChange, +currentUserInfo: CurrentUserInfo, +error: string, +payload: Object, }>; // You'll notice that this is not the type of the callSingleKeyserverEndpoint // function below. This is because the first several parameters to that // function get bound in by the helpers in legacy-keyserver-call.js // This type represents the form of the callSingleKeyserverEndpoint function // that gets passed to the action functions in lib/actions. export type CallSingleKeyserverEndpoint = ( endpoint: Endpoint, input: Object, options?: ?CallSingleKeyserverEndpointOptions, ) => Promise; type RequestData = { input: { +[key: string]: mixed }, cookie?: ?string, sessionID?: ?string, platformDetails?: PlatformDetails, }; async function callSingleKeyserverEndpoint( cookie: ?string, setNewSession: (sessionChange: ClientSessionChange, error: ?string) => void, waitIfCookieInvalidated: () => Promise, cookieInvalidationRecovery: ( sessionChange: ClientSessionChange, error: ?string, ) => Promise, urlPrefix: string, sessionID: ?string, isSocketConnected: boolean, lastCommunicatedPlatformDetails: ?PlatformDetails, socketAPIHandler: ?SocketAPIHandler, endpoint: Endpoint, input: { +[key: string]: mixed }, dispatch: Dispatch, options?: ?CallSingleKeyserverEndpointOptions, loggedIn: boolean, keyserverID: string, ): Promise { const possibleReplacement = await waitIfCookieInvalidated(); if (possibleReplacement) { return await possibleReplacement(endpoint, input, options); } const shouldSendPlatformDetails = lastCommunicatedPlatformDetails && !_isEqual(lastCommunicatedPlatformDetails)(getConfig().platformDetails); if ( endpointIsSocketPreferred(endpoint) && isSocketConnected && socketAPIHandler && !options?.urlPrefixOverride ) { try { - const result = await socketAPIHandler({ endpoint, input }); - options?.getResultInfo?.({ interface: 'socket' }); - return result; + return socketAPIHandler({ endpoint, input }); } catch (e) { if (endpointIsSocketOnly(endpoint)) { throw e; } else if (e instanceof SocketOffline) { // nothing } else if (e instanceof SocketTimeout) { // nothing } else { throw e; } } } if (endpointIsSocketOnly(endpoint)) { throw new SocketOffline('socket_offline'); } const resolvedURLPrefix = options?.urlPrefixOverride ?? urlPrefix; const url = resolvedURLPrefix ? `${resolvedURLPrefix}/${endpoint}` : endpoint; let json; if (options && options.performHTTPMultipartUpload) { const performHTTPMultipartUpload = typeof options.performHTTPMultipartUpload === 'function' ? options.performHTTPMultipartUpload : defaultPerformHTTPMultipartUpload; json = await performHTTPMultipartUpload( url, cookie, sessionID, input, options, ); } else { const mergedData: RequestData = { input }; mergedData.cookie = cookie ? cookie : null; if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user // is not logged in on web. mergedData.sessionID = sessionID ? sessionID : null; } if (shouldSendPlatformDetails) { mergedData.platformDetails = getConfig().platformDetails; } const callEndpointPromise = (async (): Promise => { const response = await fetch(url, { method: 'POST', // This is necessary to allow cookie headers to get passed down to us credentials: 'same-origin', body: JSON.stringify(mergedData), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); const text = await response.text(); try { return JSON.parse(text); } catch (e) { console.log(text); throw e; } })(); const timeout = options && options.timeout ? options.timeout : callSingleKeyserverEndpointTimeout; if (!timeout) { json = await callEndpointPromise; } else { const rejectPromise = (async () => { await sleep(timeout); throw new FetchTimeout( `callSingleKeyserverEndpoint timed out call to ${endpoint}`, endpoint, ); })(); json = await Promise.race([callEndpointPromise, rejectPromise]); } } extractAndPersistUserInfosFromEndpointResponse(json, endpoint, dispatch); const { cookieChange, error, payload, currentUserInfo } = json; const sessionChange: ?ServerSessionChange = cookieChange; if (sessionChange) { const { threadInfos, userInfos, ...rest } = sessionChange; const clientSessionChange = rest.cookieInvalidated ? rest : { cookieInvalidated: false, currentUserInfo, ...rest }; if (clientSessionChange.cookieInvalidated) { const maybeReplacement = await cookieInvalidationRecovery( clientSessionChange, error, ); if (maybeReplacement) { return await maybeReplacement(endpoint, input, options); } } else { // We don't want to call setNewSession when cookieInvalidated. If the // cookie is invalidated, the cookieInvalidationRecovery call above will // either trigger a invalidation recovery attempt (if supported), or it // will call setNewSession itself. If the invalidation recovery is // attempted, it will result in a setNewSession call when it concludes. setNewSession(clientSessionChange, error); } } if (!error && shouldSendPlatformDetails) { dispatch({ type: updateLastCommunicatedPlatformDetailsActionType, payload: { platformDetails: getConfig().platformDetails, keyserverID }, }); } if (error === 'policies_not_accepted' && loggedIn) { dispatch({ type: forcePolicyAcknowledgmentActionType, payload, }); } if (error) { throw new ServerError(error, payload); } - options?.getResultInfo?.({ interface: 'REST' }); return json; } export default callSingleKeyserverEndpoint; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 5ed9f609b..4476e2b39 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,714 +1,711 @@ // @flow import invariant from 'invariant'; import t, { type TDict, type TEnums, type TInterface, type TUnion, } from 'tcomb'; import { type ClientDBMediaInfo } from './media-types.js'; import { type MessageType, messageTypes } from './message-types-enum.js'; import { type AddMembersMessageData, type AddMembersMessageInfo, type RawAddMembersMessageInfo, rawAddMembersMessageInfoValidator, } from './messages/add-members.js'; import { type ChangeRoleMessageData, type ChangeRoleMessageInfo, type RawChangeRoleMessageInfo, rawChangeRoleMessageInfoValidator, } from './messages/change-role.js'; import { type ChangeSettingsMessageData, type ChangeSettingsMessageInfo, type RawChangeSettingsMessageInfo, rawChangeSettingsMessageInfoValidator, } from './messages/change-settings.js'; import { type CreateEntryMessageData, type CreateEntryMessageInfo, type RawCreateEntryMessageInfo, rawCreateEntryMessageInfoValidator, } from './messages/create-entry.js'; import { type CreateSidebarMessageData, type CreateSidebarMessageInfo, type RawCreateSidebarMessageInfo, rawCreateSidebarMessageInfoValidator, } from './messages/create-sidebar.js'; import { type CreateSubthreadMessageData, type CreateSubthreadMessageInfo, type RawCreateSubthreadMessageInfo, rawCreateSubthreadMessageInfoValidator, } from './messages/create-subthread.js'; import { type CreateThreadMessageData, type CreateThreadMessageInfo, type RawCreateThreadMessageInfo, rawCreateThreadMessageInfoValidator, } from './messages/create-thread.js'; import { type DeleteEntryMessageData, type DeleteEntryMessageInfo, type RawDeleteEntryMessageInfo, rawDeleteEntryMessageInfoValidator, } from './messages/delete-entry.js'; import { type EditEntryMessageData, type EditEntryMessageInfo, type RawEditEntryMessageInfo, rawEditEntryMessageInfoValidator, } from './messages/edit-entry.js'; import { type EditMessageData, type EditMessageInfo, type RawEditMessageInfo, rawEditMessageInfoValidator, } from './messages/edit.js'; import { type ImagesMessageData, type ImagesMessageInfo, type RawImagesMessageInfo, rawImagesMessageInfoValidator, } from './messages/images.js'; import { type JoinThreadMessageData, type JoinThreadMessageInfo, type RawJoinThreadMessageInfo, rawJoinThreadMessageInfoValidator, } from './messages/join-thread.js'; import { type LeaveThreadMessageData, type LeaveThreadMessageInfo, type RawLeaveThreadMessageInfo, rawLeaveThreadMessageInfoValidator, } from './messages/leave-thread.js'; import { type RawLegacyUpdateRelationshipMessageInfo, rawLegacyUpdateRelationshipMessageInfoValidator, type LegacyUpdateRelationshipMessageData, type LegacyUpdateRelationshipMessageInfo, } from './messages/legacy-update-relationship.js'; import { type MediaMessageData, type MediaMessageInfo, type MediaMessageServerDBContent, type RawMediaMessageInfo, rawMediaMessageInfoValidator, } from './messages/media.js'; import { type RawReactionMessageInfo, rawReactionMessageInfoValidator, type ReactionMessageData, type ReactionMessageInfo, } from './messages/reaction.js'; import { type RawRemoveMembersMessageInfo, rawRemoveMembersMessageInfoValidator, type RemoveMembersMessageData, type RemoveMembersMessageInfo, } from './messages/remove-members.js'; import { type RawRestoreEntryMessageInfo, rawRestoreEntryMessageInfoValidator, type RestoreEntryMessageData, type RestoreEntryMessageInfo, } from './messages/restore-entry.js'; import { type RawTextMessageInfo, rawTextMessageInfoValidator, type TextMessageData, type TextMessageInfo, } from './messages/text.js'; import { type RawTogglePinMessageInfo, rawTogglePinMessageInfoValidator, type TogglePinMessageData, type TogglePinMessageInfo, } from './messages/toggle-pin.js'; import { type RawUnsupportedMessageInfo, rawUnsupportedMessageInfoValidator, type UnsupportedMessageInfo, } from './messages/unsupported.js'; import type { RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageData, UpdateRelationshipMessageInfo, } from './messages/update-relationship.js'; import { rawUpdateRelationshipMessageInfoValidator } from './messages/update-relationship.js'; import { type RelativeUserInfo, type UserInfos } from './user-types.js'; -import type { CallSingleKeyserverEndpointResultInfoInterface } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { values } from '../utils/objects.js'; import { tID, tNumber, tShape, tUserID } from '../utils/validation-utils.js'; const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function assertComposableRawMessage( message: RawMessageInfo, ): RawComposableMessageInfo { invariant( message.type === messageTypes.TEXT || message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, 'Message is not composable', ); return message; } export function messageDataLocalID(messageData: MessageData): ?string { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA && messageData.type !== messageTypes.REACTION ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assertMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type ValidRawSidebarSourceMessageInfo = | RawTextMessageInfo | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawImagesMessageInfo | RawMediaMessageInfo | RawLegacyUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawUpdateRelationshipMessageInfo; export type SidebarSourceMessageData = { +type: 17, +threadID: string, +creatorID: string, +time: number, +sourceMessage?: ValidRawSidebarSourceMessageInfo, }; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | LegacyUpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData | ReactionMessageData | EditMessageData | TogglePinMessageData | UpdateRelationshipMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export const rawMultimediaMessageInfoValidator: TUnion = t.union([rawImagesMessageInfoValidator, rawMediaMessageInfoValidator]); export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; const rawComposableMessageInfoValidator = t.union([ rawTextMessageInfoValidator, rawMultimediaMessageInfoValidator, ]); export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawLegacyUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawTogglePinMessageInfo | RawUpdateRelationshipMessageInfo; const rawRobotextMessageInfoValidator = t.union([ rawCreateThreadMessageInfoValidator, rawAddMembersMessageInfoValidator, rawCreateSubthreadMessageInfoValidator, rawChangeSettingsMessageInfoValidator, rawRemoveMembersMessageInfoValidator, rawChangeRoleMessageInfoValidator, rawLeaveThreadMessageInfoValidator, rawJoinThreadMessageInfoValidator, rawCreateEntryMessageInfoValidator, rawEditEntryMessageInfoValidator, rawDeleteEntryMessageInfoValidator, rawRestoreEntryMessageInfoValidator, rawLegacyUpdateRelationshipMessageInfoValidator, rawCreateSidebarMessageInfoValidator, rawUnsupportedMessageInfoValidator, rawTogglePinMessageInfoValidator, rawUpdateRelationshipMessageInfoValidator, ]); export type RawSidebarSourceMessageInfo = { ...SidebarSourceMessageData, id: string, }; export const rawSidebarSourceMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.SIDEBAR_SOURCE), threadID: tID, creatorID: tUserID, time: t.Number, sourceMessage: t.maybe( t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, ]), ), id: tID, }); export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo | RawReactionMessageInfo | RawEditMessageInfo; export const rawMessageInfoValidator: TUnion = t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, rawSidebarSourceMessageInfoValidator, rawReactionMessageInfoValidator, rawEditMessageInfoValidator, ]); export type LocallyComposedMessageInfo = | ({ ...RawImagesMessageInfo, +localID: string, } & RawImagesMessageInfo) | ({ ...RawMediaMessageInfo, +localID: string, } & RawMediaMessageInfo) | ({ ...RawTextMessageInfo, +localID: string, } & RawTextMessageInfo) | ({ ...RawReactionMessageInfo, +localID: string, } & RawReactionMessageInfo); export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | UnsupportedMessageInfo | LegacyUpdateRelationshipMessageInfo | CreateSidebarMessageInfo | TogglePinMessageInfo | UpdateRelationshipMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo | ReactionMessageInfo; export type ValidSidebarSourceMessageInfo = | TextMessageInfo | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | ImagesMessageInfo | MediaMessageInfo | LegacyUpdateRelationshipMessageInfo | CreateSidebarMessageInfo | UnsupportedMessageInfo | UpdateRelationshipMessageInfo; export type SidebarSourceMessageInfo = { +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessage: ValidSidebarSourceMessageInfo, }; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo | ReactionMessageInfo | EditMessageInfo; export type ThreadMessageInfo = { messageIDs: string[], startReached: boolean, }; const threadMessageInfoValidator: TInterface = tShape({ messageIDs: t.list(tID), startReached: t.Boolean, }); // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. // For DMs, it keeps track of P2P Messages that are not yet sent. // As soon as messages are queued on Tunnelbroker, it will // clear the LocalMessageInfo. export type LocalMessageInfo = { +sendFailed?: boolean, +outboundP2PMessageIDs?: $ReadOnlyArray, }; const localMessageInfoValidator: TInterface = tShape({ sendFailed: t.maybe(t.Boolean), }); export type MessageStoreThreads = { +[threadID: string]: ThreadMessageInfo, }; const messageStoreThreadsValidator: TDict = t.dict( tID, threadMessageInfoValidator, ); export type MessageStoreLocalMessageInfos = { +[id: string]: LocalMessageInfo, }; const messageStoreLocalMessageInfosValidator: TDict = t.dict(tID, localMessageInfoValidator); export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, +threads: MessageStoreThreads, +local: MessageStoreLocalMessageInfos, +currentAsOf: { +[keyserverID: string]: number }, }; export const messageStoreValidator: TInterface = tShape({ messages: t.dict(tID, rawMessageInfoValidator), threads: messageStoreThreadsValidator, local: messageStoreLocalMessageInfosValidator, currentAsOf: t.dict(t.String, t.Number), }); // We were initially using `number`s` for `thread`, `type`, `future_type`, etc. // However, we ended up changing `thread` to `string` to account for thread IDs // including information about the keyserver (eg 'GENESIS|123') in the future. // // At that point we discussed whether we should switch the remaining `number` // fields to `string`s for consistency and flexibility. We researched whether // there was any performance cost to using `string`s instead of `number`s and // found the differences to be negligible. We also concluded using `string`s // may be safer after considering `jsi::Number` and the various C++ number // representations on the CommCoreModule side. export type ClientDBMessageInfo = { +id: string, +local_id: ?string, +thread: string, +user: string, +type: string, +future_type: ?string, +content: ?string, +time: string, +media_infos: ?$ReadOnlyArray, }; export type ClientDBThreadMessageInfo = { +id: string, +start_reached: string, }; export type ClientDBLocalMessageInfo = { +id: string, +localMessageInfo: string, }; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: MessageSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export const messageTruncationStatusValidator: TEnums = t.enums.of( values(messageTruncationStatus), ); export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export const messageTruncationStatusesValidator: TDict = t.dict(tID, messageTruncationStatusValidator); export type ThreadCursors = { +[threadID: string]: ?string }; export type MessageSelectionCriteria = { +threadCursors?: ?ThreadCursors, +joinedThreads?: ?boolean, +newerThan?: ?number, }; export type SimpleMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export type FetchMessageInfosRequest = { +cursors: ThreadCursors, +numberPerThread?: ?number, }; export type FetchMessageInfosResponse = $ReadOnly<{ ...SimpleMessagesPayload, +userInfos: UserInfos, }>; export type FetchMessageInfosResult = SimpleMessagesPayload; export type FetchMessageInfosPayload = { +threadID: string, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, }; export type MessagesResponse = $ReadOnly<{ ...SimpleMessagesPayload, +currentAsOf: number, }>; export const messagesResponseValidator: TInterface = tShape({ rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, currentAsOf: t.Number, }); export const defaultNumberPerThread = 20; export const defaultMaxMessageAge = 14 * 24 * 60 * 60 * 1000; // 2 weeks export type SendMessageResponse = { +newMessageInfo: RawMessageInfo, }; export type SendMessageResult = { +id: string, +time: number, - +interface: CallSingleKeyserverEndpointResultInfoInterface, }; export type SendMessagePayload = { +localID: string, +serverID: string, +threadID: string, +time: number, - +interface: CallSingleKeyserverEndpointResultInfoInterface, }; export type SendTextMessageRequest = { +threadID: string, +localID?: string, +text: string, +sidebarCreation?: boolean, }; export type SendMultimediaMessageRequest = // This option is only used for messageTypes.IMAGES | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaIDs: $ReadOnlyArray, } | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaMessageContents: $ReadOnlyArray, }; export type SendReactionMessageRequest = { +threadID: string, +localID?: string, +targetMessageID: string, +reaction: string, +action: 'add_reaction' | 'remove_reaction', }; export type SendEditMessageRequest = { +targetMessageID: string, +text: string, }; export type SendEditMessageResponse = { +newMessageInfos: $ReadOnlyArray, }; export type EditMessagePayload = SendEditMessageResponse; export type SendEditMessageResult = SendEditMessageResponse; export type EditMessageContent = { +text: string, }; // Used for the message info included in log-in type actions export type GenericMessagesResult = { +messageInfos: RawMessageInfo[], +truncationStatus: MessageTruncationStatuses, +watchedIDsAtRequestTime: $ReadOnlyArray, +currentAsOf: { +[keyserverID: string]: number }, }; export type SaveMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +updatesCurrentAsOf: number, }; export type NewMessagesPayload = { +messagesResult: MessagesResponse, }; export const newMessagesPayloadValidator: TInterface = tShape({ messagesResult: messagesResponseValidator, }); export type MessageStorePrunePayload = { +threadIDs: $ReadOnlyArray, }; export type FetchPinnedMessagesRequest = { +threadID: string, }; export type FetchPinnedMessagesResult = { +pinnedMessages: $ReadOnlyArray, }; export type SearchMessagesRequest = { +query: string, +threadID: string, +timestampCursor?: ?number, +messageIDCursor?: ?string, }; export type SearchMessagesKeyserverRequest = { +query: string, +threadID: string, +cursor?: ?string, }; export type SearchMessagesResponse = { +messages: $ReadOnlyArray, +endReached: boolean, }; diff --git a/native/chat/reaction-message-utils.js b/native/chat/reaction-message-utils.js index 74fa87ed9..5e554c88a 100644 --- a/native/chat/reaction-message-utils.js +++ b/native/chat/reaction-message-utils.js @@ -1,253 +1,252 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { useSendReactionMessage, sendReactionMessageActionTypes, } from 'lib/actions/message-actions.js'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { dmOperationSpecificationTypes, type OutboundDMOperationSpecification, } from 'lib/shared/dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from 'lib/shared/dm-ops/process-dm-ops.js'; import { getNextLocalID } from 'lib/shared/message-utils.js'; import type { DMSendReactionMessageOperation } from 'lib/types/dm-ops.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawReactionMessageInfo } from 'lib/types/messages/reaction.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types'; import { thickThreadTypes, threadTypeIsThick, } from 'lib/types/thread-types-enum.js'; import { cloneError } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.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, threadInfo: ThreadInfo, reactions: ReactionInfo, ): (reaction: string) => mixed { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const callSendReactionMessage = useSendReactionMessage(); const dispatchActionPromise = useDispatchActionPromise(); const processAndSendDMOperation = useProcessAndSendDMOperation(); return React.useCallback( reaction => { if (!messageID) { return; } const localID = getNextLocalID(); invariant(viewerID, 'viewerID should be set'); const viewerReacted = reactions[reaction] ? reactions[reaction].viewerReacted : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; const threadID = threadInfo.id; if (threadTypeIsThick(threadInfo.type)) { const op: DMSendReactionMessageOperation = { type: 'send_reaction_message', threadID, creatorID: viewerID, time: Date.now(), messageID: uuid.v4(), targetMessageID: messageID, reaction, action, }; const opSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op, recipients: { type: 'all_thread_members', threadID: threadInfo.type === thickThreadTypes.THICK_SIDEBAR && threadInfo.parentThreadID ? threadInfo.parentThreadID : threadInfo.id, }, }; void processAndSendDMOperation(opSpecification); return; } 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, }; void dispatchActionPromise( sendReactionMessageActionTypes, reactionMessagePromise, undefined, startingPayload, ); }, [ messageID, viewerID, reactions, threadInfo.id, threadInfo.type, threadInfo.parentThreadID, dispatchActionPromise, processAndSendDMOperation, callSendReactionMessage, ], ); } type ReactionSelectionPopoverPositionArgs = { +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +margin: ?number, }; type WritableContainerStyle = { position: 'absolute', left?: number, right?: number, bottom?: number, top?: number, ... }; type ContainerStyle = $ReadOnly; type ReactionSelectionPopoverPosition = { +containerStyle: ContainerStyle, +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: WritableContainerStyle = { 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 e25594be4..cd50168b8 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1852 +1,1850 @@ // @flow import * as FileSystem from 'expo-file-system'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import { createSelector } from 'reselect'; import uuid from 'uuid'; import type { SendMultimediaMessageInput, SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendTextMessageActionTypes, useSendMultimediaMessage, useSendTextMessage, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThinThread } from 'lib/actions/thread-actions.js'; import { type BlobServiceUploadAction, type BlobServiceUploadResult, type MultimediaUploadCallbacks, type MultimediaUploadExtras, updateMultimediaMessageMediaActionType, uploadMultimedia, useBlobServiceUpload, } from 'lib/actions/upload-actions.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.js'; import { useAllowOlmViaTunnelbrokerForDMs } from 'lib/hooks/flag-hooks.js'; import { useNewThickThread } from 'lib/hooks/thread-hooks.js'; import type { CallSingleKeyserverEndpointOptions, CallSingleKeyserverEndpointResponse, } from 'lib/keyserver-conn/call-single-keyserver-endpoint.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js'; import { getNextLocalUploadID, isLocalUploadID, } from 'lib/media/media-utils.js'; import { videoDurationLimit } from 'lib/media/video-utils.js'; import { combineLoadingStatuses, createLoadingStatusSelector, } from 'lib/selectors/loading-selectors.js'; import { dmOperationSpecificationTypes, type OutboundDMOperationSpecification, } from 'lib/shared/dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from 'lib/shared/dm-ops/process-dm-ops.js'; import { createMediaMessageInfo, useMessageCreationSideEffectsFunc, getNextLocalID, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread } from 'lib/shared/thread-actions-utils.js'; import { patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, threadIsPending, threadIsPendingSidebar, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { Media, MediaMission, MediaMissionResult, MediaMissionStep, NativeMediaSelection, UploadMultimediaResult, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessagePayload, type SendMessageResult, } 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 { getMediaMessageServerDBContentsFromMedia } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ClientMediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { threadTypeIsThick, threadTypeIsSidebar, thickThreadTypes, } from 'lib/types/thread-types-enum.js'; import { type ClientNewThinThreadRequest, type NewThreadResult, type NewThickThreadRequest, } from 'lib/types/thread-types.js'; import { getConfig } from 'lib/utils/config.js'; import { cloneError, getMessageForException } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { type EditInputBarMessageParameters, type InputState, InputStateContext, type MessagePendingUploads, type MultimediaProcessingStep, type PendingMultimediaUploads, } 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 WritableCompletedUploads = { [localMessageID: string]: ?$ReadOnlySet, }; type CompletedUploads = $ReadOnly; type ActiveURI = { +count: number, +onClear: $ReadOnlyArray<() => mixed> }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?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, +blobServiceUpload: BlobServiceUploadAction, +sendMultimediaMessage: ( input: SendMultimediaMessageInput, ) => Promise, +sendTextMessage: (input: SendTextMessageInput) => Promise, +processAndSendDMOperation: ( dmOperationSpecification: OutboundDMOperationSpecification, ) => Promise, +newThinThread: ( request: ClientNewThinThreadRequest, ) => Promise, +newThickThread: (request: NewThickThreadRequest) => Promise, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, +usingOlmViaTunnelbrokerForDMs: boolean, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs: Map = new Map(); editInputBarCallbacks: Array< (params: EditInputBarMessageParameters) => void, > = []; scrollToMessageCallbacks: Array<(messageID: string) => void> = []; pendingThreadCreations: Map> = new Map(); pendingThreadUpdateHandlers: Map mixed> = 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: Set = new Set(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads: WritableCompletedUploads = {}; 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); } const { thumbnailID } = singleMedia; if (thumbnailID && isLocalUploadID(thumbnailID)) { allUploadsComplete = false; completedUploadIDs.delete(thumbnailID); } } 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: PendingMultimediaUploads = {}; 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: MessagePendingUploads = {}; 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`, ); void this.dispatchMultimediaMessageAction(rawMessageInfo); } } async dispatchMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { if (!threadIsPending(messageInfo.threadID)) { void 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(), }; void 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({ 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: State => InputState = 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, }: InputState), ); 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 = (): boolean => { if (this.props.ongoingMessageCreation) { return true; } const { pendingUploads } = this.state; return values(pendingUploads).some(messagePendingUploads => values(messagePendingUploads).some(upload => !upload.failed), ); }; async processAndSendTextMessageDMOperation( messageInfo: RawTextMessageInfo, inputThreadInfo: ThreadInfo, ) { void this.props.processAndSendDMOperation({ type: dmOperationSpecificationTypes.OUTBOUND, op: { type: 'send_text_message', threadID: inputThreadInfo.id, creatorID: messageInfo.creatorID, time: Date.now(), messageID: uuid.v4(), text: messageInfo.text, }, recipients: { type: 'all_thread_members', threadID: inputThreadInfo.type === thickThreadTypes.THICK_SIDEBAR && inputThreadInfo.parentThreadID ? inputThreadInfo.parentThreadID : inputThreadInfo.id, }, }); } async generateAndSendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) { if (threadTypeIsThick(threadInfo.type)) { void this.processAndSendTextMessageDMOperation(messageInfo, threadInfo); return; } void this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo, threadInfo, parentThreadInfo), undefined, messageInfo, ); } 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)) { void this.generateAndSendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ); return; } if (!threadTypeIsThick(inputThreadInfo.type)) { this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); } let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && threadTypeIsSidebar(inputThreadInfo.type)) { 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; if (!threadTypeIsThick(inputThreadInfo.type)) { 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, }; void this.generateAndSendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ); }; 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, createNewThinThread: this.props.newThinThread, createNewThickThread: this.props.newThickThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, usingOlmViaTunnelbrokerForDMs: this.props.usingOlmViaTunnelbrokerForDMs, }); 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({ threadID: messageInfo.threadID, localID, 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 ( threadTypeIsThick(threadInfo.type) || threadInfoInsideCommunity(threadInfo, commStaffCommunity.id) ); } sendMultimediaMessage = async ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = getNextLocalID(); void this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const uploadFileInputs = [], media: Array = []; 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: MessagePendingUploads = {}; 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, localThumbnailID } = ids; const start = selection.sendTime; const steps: Array = [selection]; let encryptionSteps: $ReadOnlyArray = []; let serverID; let userTime; let errorMessage; let reportPromise: ?Promise<$ReadOnlyArray>; 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 = (message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, localMediaID, localThumbnailID); 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(message); return await onUploadFinished(processResult); } if (processResult.shouldDisposePath) { filesToDispose.push(processResult.shouldDisposePath); } processedMedia = processResult; } catch (e) { onUploadFailed('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(encryptionResult.reason); return await onUploadFinished(encryptionResult); } if (encryptionResult.shouldDisposePath) { filesToDispose.push(encryptionResult.shouldDisposePath); } processedMedia = encryptionResult; } catch (e) { onUploadFailed('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; const isThickThread = threadTypeIsThick(threadInfo.type); const useBlobService = isThickThread || this.useBlobServiceUploads; try { if ( useBlobService && (processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video') ) { const uploadMetadataToKeyserver = !isThickThread; const uploadPromise = this.props.blobServiceUpload({ uploadInput: { 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, }, keyserverOrThreadID: uploadMetadataToKeyserver ? threadInfo.id : null, callbacks: { blobServiceUploadHandler, onProgress: (percent: number) => { this.setProgress( localMessageID, localMediaID, 'uploading', percent, ); }, }, }); const uploadThumbnailPromise: Promise = (async () => { if (processedMedia.mediaType !== 'encrypted_video') { return undefined; } return await this.props.blobServiceUpload({ uploadInput: { 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, }, keyserverOrThreadID: uploadMetadataToKeyserver ? threadInfo.id : null, callbacks: { blobServiceUploadHandler, }, }); })(); [uploadResult, uploadThumbnailResult] = await Promise.all([ uploadPromise, uploadThumbnailPromise, ]); } else { const uploadPromise = 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, ), performHTTPMultipartUpload: this.performHTTPMultipartUpload, }, ); const uploadThumbnailPromise: Promise = (async () => { if ( processedMedia.mediaType !== 'video' && processedMedia.mediaType !== 'encrypted_video' ) { return undefined; } return await this.props.uploadMultimedia( { uri: processedMedia.uploadThumbnailURI, name: replaceExtension(`thumb${filename}`, 'jpg'), type: 'image/jpeg', }, { ...processedMedia.dimensions, loop: false, encryptionKey: processedMedia.thumbnailEncryptionKey, thumbHash: processedMedia.thumbHash, }, { performHTTPMultipartUpload: this.performHTTPMultipartUpload, }, ); })(); [uploadResult, uploadThumbnailResult] = await Promise.all([ uploadPromise, uploadThumbnailPromise, ]); } mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed('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, }, }; }); } performHTTPMultipartUpload = async ( url: string, cookie: ?string, sessionID: ?string, input: { +[key: string]: mixed }, options?: ?CallSingleKeyserverEndpointOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.performHTTPMultipartUpload sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.performHTTPMultipartUpload sent incorrect input', ); const parameters: { [key: string]: mixed } = {}; 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', 'performHTTPMultipartUpload 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; } } let uploadOptions = { uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'multimedia', headers: { Accept: 'application/json', }, parameters, }; if (Platform.OS === 'android' && path.endsWith('.dat')) { // expo-file-system is not able to deduce the MIME type of .dat files, so // we specify it explicitly here. Without this, we get this error: // guessContentTypeFromName(file.name) must not be null uploadOptions = { ...uploadOptions, mimeType: 'application/octet-stream', }; } const uploadTask = FileSystem.createUploadTask( url, path, uploadOptions, 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, localThumbnailID: ?string, ) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; const thumbnailUpload = localThumbnailID ? uploads[localThumbnailID] : undefined; if (!upload && !thumbnailUpload) { // The upload has been completed before it failed return {}; } const newUploads = { ...uploads }; newUploads[localUploadID] = { ...upload, failed: true, progressPercent: 0, }; if (localThumbnailID) { newUploads[localThumbnailID] = { processingStep: null, ...thumbnailUpload, failed: true, progressPercent: 0, }; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newUploads, }, }; }); } queueMediaMissionReport( ids: { localID: string, localMessageID: string, serverID: ?string }, mediaMission: MediaMission, ) { const report: ClientMediaMissionReportCreationRequest = { 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): boolean => { 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, ): Promise => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); void 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) { void 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): Promise<{ +steps: $ReadOnlyArray, +result: ?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(): React.Node { 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 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 = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const callSendMultimediaMessage = useSendMultimediaMessage(); const callSendTextMessage = useSendTextMessage(); const callNewThinThread = useNewThinThread(); const callNewThickThread = useNewThickThread(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); const staffCanSee = useStaffCanSee(); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); const processAndSendDMOperation = useProcessAndSendDMOperation(); const usingOlmViaTunnelbrokerForDMs = useAllowOlmViaTunnelbrokerForDMs(); return ( ); }); export default ConnectedInputStateContainer; diff --git a/web/chat/reaction-message-utils.js b/web/chat/reaction-message-utils.js index e7e0a2741..ec578d5a7 100644 --- a/web/chat/reaction-message-utils.js +++ b/web/chat/reaction-message-utils.js @@ -1,265 +1,261 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { useSendReactionMessage, sendReactionMessageActionTypes, } from 'lib/actions/message-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; -import type { CallSingleKeyserverEndpointResultInfoInterface } from 'lib/keyserver-conn/call-single-keyserver-endpoint.js'; import type { ReactionInfo } from 'lib/selectors/chat-selectors'; import { dmOperationSpecificationTypes, type OutboundDMOperationSpecification, } from 'lib/shared/dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from 'lib/shared/dm-ops/process-dm-ops.js'; import { getNextLocalID } from 'lib/shared/message-utils.js'; import { type DMSendReactionMessageOperation } from 'lib/types/dm-ops.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawReactionMessageInfo } from 'lib/types/messages/reaction.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { thickThreadTypes, threadTypeIsThick, } from 'lib/types/thread-types-enum.js'; import { cloneError } from 'lib/utils/errors.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import Alert from '../modals/alert.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { type TooltipSize, type TooltipPositionStyle, } from '../tooltips/tooltip-utils.js'; import { getAppContainerPositionInfo } from '../utils/window-utils.js'; function useSendReaction( messageID: ?string, threadInfo: ThreadInfo, reactions: ReactionInfo, ): (reaction: string) => mixed { const { pushModal } = useModalContext(); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const callSendReactionMessage = useSendReactionMessage(); const dispatchActionPromise = useDispatchActionPromise(); const processAndSendDMOperation = useProcessAndSendDMOperation(); return React.useCallback( reaction => { if (!messageID) { return; } const localID = getNextLocalID(); invariant(viewerID, 'viewerID should be set'); const viewerReacted = reactions[reaction] ? reactions[reaction].viewerReacted : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; const threadID = threadInfo.id; if (threadTypeIsThick(threadInfo.type)) { const op: DMSendReactionMessageOperation = { type: 'send_reaction_message', threadID, creatorID: viewerID, time: Date.now(), messageID: uuid.v4(), targetMessageID: messageID, reaction, action, }; const opSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op, recipients: { type: 'all_thread_members', threadID: threadInfo.type === thickThreadTypes.THICK_SIDEBAR && threadInfo.parentThreadID ? threadInfo.parentThreadID : threadInfo.id, }, }; void processAndSendDMOperation(opSpecification); return; } const reactionMessagePromise = (async () => { try { const result = await callSendReactionMessage({ threadID, localID, targetMessageID: messageID, reaction, action, }); const serverID: string = result.id; const time: number = result.time; - const interfaceInfo: CallSingleKeyserverEndpointResultInfoInterface = - result.interface; return { localID, serverID, threadID, time, - interface: interfaceInfo, }; } 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, }; void dispatchActionPromise( sendReactionMessageActionTypes, reactionMessagePromise, undefined, startingPayload, ); }, [ messageID, viewerID, reactions, threadInfo.id, threadInfo.type, threadInfo.parentThreadID, dispatchActionPromise, processAndSendDMOperation, 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 d1c15ced1..b17f8b69f 100644 --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -1,1794 +1,1792 @@ // @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 { createSelector } from 'reselect'; import uuid from 'uuid'; import type { LegacySendMultimediaMessageInput, SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendTextMessageActionTypes, useLegacySendMultimediaMessage, useSendTextMessage, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThinThread } from 'lib/actions/thread-actions.js'; import { type BlobServiceUploadAction, type DeleteUploadInput, type MultimediaUploadCallbacks, type MultimediaUploadExtras, updateMultimediaMessageMediaActionType, uploadMultimedia, useBlobServiceUpload, useDeleteUpload, } from 'lib/actions/upload-actions.js'; import { type PushModal, useModalContext, } 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 { useAllowOlmViaTunnelbrokerForDMs } from 'lib/hooks/flag-hooks.js'; import { useNewThickThread } from 'lib/hooks/thread-hooks.js'; import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { getNextLocalUploadID } from 'lib/media/media-utils.js'; import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js'; import { dmOperationSpecificationTypes, type OutboundDMOperationSpecification, } from 'lib/shared/dm-ops/dm-op-utils.js'; import { useProcessAndSendDMOperation } from 'lib/shared/dm-ops/process-dm-ops.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import type { IdentityClientContextType } from 'lib/shared/identity-client-context.js'; import { createMediaMessageInfo, localIDPrefix, useMessageCreationSideEffectsFunc, getNextLocalID, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread } from 'lib/shared/thread-actions-utils.js'; import { draftKeyFromThreadID, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, threadIsPending, threadIsPendingSidebar, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { MediaMission, MediaMissionFailure, MediaMissionResult, MediaMissionStep, UploadMultimediaResult, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessagePayload, type SendMessageResult, } 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 { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { reportTypes } from 'lib/types/report-types.js'; import { thickThreadTypes, threadTypeIsSidebar, threadTypeIsThick, } from 'lib/types/thread-types-enum.js'; import { type ClientNewThinThreadRequest, type NewThreadResult, type NewThickThreadRequest, } from 'lib/types/thread-types.js'; import { blobHashFromBlobServiceURI, isBlobServiceURI, makeBlobServiceEndpointURL, } from 'lib/utils/blob-service.js'; import { getConfig } from 'lib/utils/config.js'; import { cloneError, getMessageForException } from 'lib/utils/errors.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateReportID } from 'lib/utils/report-utils.js'; import { createDefaultHTTPRequestHeaders } from 'lib/utils/services-utils.js'; import { type BaseInputState, type InputState, InputStateContext, type PendingMultimediaUpload, type TypeaheadInputState, type TypeaheadState, } from './input-state.js'; import { encryptFile } from '../media/encryption-utils.js'; import { generateThumbHash } from '../media/image-utils.js'; import { preloadImage, preloadMediaResource, validateFile, } 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 CombinedInputState = { +inputBaseState: BaseInputState, +typeaheadState: TypeaheadInputState, }; 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, +deleteUpload: (input: DeleteUploadInput) => Promise, +sendMultimediaMessage: ( input: LegacySendMultimediaMessageInput, ) => Promise, +sendTextMessage: (input: SendTextMessageInput) => Promise, +processAndSendDMOperation: ( dmOperationSpecification: OutboundDMOperationSpecification, ) => Promise, +newThinThread: ( request: ClientNewThinThreadRequest, ) => Promise, +newThickThread: (request: NewThickThreadRequest) => Promise, +pushModal: PushModal, +sendCallbacks: $ReadOnlyArray<() => mixed>, +registerSendCallback: (() => mixed) => void, +unregisterSendCallback: (() => mixed) => void, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, +identityContext: ?IdentityClientContextType, +usingOlmViaTunnelbrokerForDMs: boolean, }; type WritableState = { pendingUploads: { [threadID: string]: { [localUploadID: string]: PendingMultimediaUpload }, }, textCursorPositions: { [threadID: string]: number }, typeaheadState: TypeaheadState, }; type State = $ReadOnly; 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: Map> = new Map< string, Promise, >(); // 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: Set = new Set(); static reassignToRealizedThreads( state: { +[threadID: string]: T }, props: Props, ): ?{ [threadID: string]: T } { const newState: { [string]: T } = {}; 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): ?Partial { const pendingUploads = InputStateContainer.reassignToRealizedThreads( state.pendingUploads, props, ); const textCursorPositions = InputStateContainer.reassignToRealizedThreads( state.textCursorPositions, props, ); if (!pendingUploads && !textCursorPositions) { return null; } const stateUpdate: Partial = {}; if (pendingUploads) { stateUpdate.pendingUploads = pendingUploads; } if (textCursorPositions) { stateUpdate.textCursorPositions = textCursorPositions; } return stateUpdate; } static completedMessageIDs(state: State): Set { 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< string, { +threadID: string, +shouldEncrypt: boolean, +uploads: PendingMultimediaUpload[], }, >(); 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); } void 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 ( threadTypeIsThick(threadInfo.type) || threadInfoInsideCommunity(threadInfo, commStaffCommunity.id) ); } async sendMultimediaMessage( messageInfo: RawMultimediaMessageInfo, ): Promise { if (!threadIsPending(messageInfo.threadID)) { void 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(), }; } void 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({ threadID, localID, mediaIDs, sidebarCreation, }); this.pendingSidebarCreationMessageLocalIDs.delete(localID); this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const prevUploads = prevState.pendingUploads[newThreadID]; const newUploads: { [string]: PendingMultimediaUpload } = {}; 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, createNewThinThread: this.props.newThinThread, createNewThickThread: this.props.newThickThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, usingOlmViaTunnelbrokerForDMs: this.props.usingOlmViaTunnelbrokerForDMs, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } inputBaseStateSelector: (?string) => PropsAndState => BaseInputState = _memoize(threadID => 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: $ReadOnlyArray = []; const assignedUploads: { [string]: $ReadOnlyArray, } = {}; 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: this.cancelPendingUpload, sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo), createMultimediaMessage: (threadInfo: ThreadInfo) => this.createMultimediaMessage(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, }: BaseInputState); }, ), ); typeaheadStateSelector: PropsAndState => TypeaheadInputState = createSelector( (propsAndState: PropsAndState) => propsAndState.typeaheadState, (typeaheadState: TypeaheadState) => ({ typeaheadState, setTypeaheadState: this.setTypeaheadState, }), ); inputStateSelector: CombinedInputState => InputState = createSelector( (state: CombinedInputState) => state.inputBaseState, (state: CombinedInputState) => state.typeaheadState, (inputBaseState: BaseInputState, typeaheadState: TypeaheadInputState) => ({ ...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, newUploads), ); return true; } async appendFile( threadInfo: ThreadInfo, file: File, selectTime: number, ): Promise<{ steps: $ReadOnlyArray, result: | MediaMissionFailure | { success: true, pendingUpload: PendingMultimediaUpload }, }> { const steps: MediaMissionStep[] = [ { 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( threadInfo: ThreadInfo, uploads: $ReadOnlyArray, ): Promise { return Promise.all( uploads.map(upload => this.uploadFile(threadInfo, upload)), ); } async uploadFile(threadInfo: ThreadInfo, upload: PendingMultimediaUpload) { const { selectTime, localID, encryptionKey } = upload; const threadID = threadInfo.id; const isEncrypted = !!encryptionKey && (upload.mediaType === 'encrypted_photo' || upload.mediaType === 'encrypted_video'); const steps = [...upload.steps]; let userTime; const { identityContext } = this.props; invariant(identityContext, 'Identity context should be set'); 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), }; const isThickThread = threadTypeIsThick(threadInfo.type); const useBlobService = isThickThread || this.useBlobServiceUploads; if ( useBlobService && (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({ uploadInput: { blobInput: { type: 'file', file: upload.file, }, blobHash, encryptionKey, dimensions, loop: false, thumbHash, }, keyserverOrThreadID: isThickThread ? null : threadID, 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 authMetadata = await identityContext.getAuthMetadata(); const { steps: preloadSteps } = await preloadMediaResource( result.uri, authMetadata, ); 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 = (threadInfo: ThreadInfo, localUploadID: string) => { let revokeURL: ?string, abortRequest: ?() => void; this.setState( prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); 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) { const { serverID } = pendingUpload; if (!threadTypeIsThick(threadInfo.type)) { void this.props.deleteUpload({ id: serverID, keyserverOrThreadID: threadInfo.id, }); } if (isBlobServiceURI(pendingUpload.uri)) { const identityContext = this.props.identityContext; invariant(identityContext, 'Identity context should be set'); invariant( pendingUpload.blobHolder, 'blob service upload has no holder', ); const endpoint = blobService.httpEndpoints.DELETE_BLOB; const holder = pendingUpload.blobHolder; const blobHash = blobHashFromBlobServiceURI(pendingUpload.uri); void (async () => { const authMetadata = await identityContext.getAuthMetadata(); const defaultHeaders = createDefaultHTTPRequestHeaders(authMetadata); await fetch(makeBlobServiceEndpointURL(endpoint), { method: endpoint.method, body: JSON.stringify({ holder, blob_hash: blobHash, }), headers: { ...defaultHeaders, 'content-type': 'application/json', }, }); })(); } } const newPendingUploads = _omit([localUploadID])(currentPendingUploads); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }, () => { if (revokeURL) { URL.revokeObjectURL(revokeURL); } if (abortRequest) { abortRequest(); } }, ); }; async processAndSendTextMessageDMOperation( messageInfo: RawTextMessageInfo, inputThreadInfo: ThreadInfo, ) { void this.props.processAndSendDMOperation({ type: dmOperationSpecificationTypes.OUTBOUND, op: { type: 'send_text_message', threadID: inputThreadInfo.id, creatorID: messageInfo.creatorID, time: Date.now(), messageID: uuid.v4(), text: messageInfo.text, }, recipients: { type: 'all_thread_members', threadID: inputThreadInfo.type === thickThreadTypes.THICK_SIDEBAR && inputThreadInfo.parentThreadID ? inputThreadInfo.parentThreadID : inputThreadInfo.id, }, }); } async generateAndSendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) { if (threadTypeIsThick(threadInfo.type)) { void this.processAndSendTextMessageDMOperation(messageInfo, threadInfo); return; } void this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo, threadInfo, parentThreadInfo), undefined, messageInfo, ); } 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)) { void this.generateAndSendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ); return; } if (!threadTypeIsThick(inputThreadInfo.type)) { this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); } let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && threadTypeIsSidebar(inputThreadInfo.type)) { 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; if (!threadTypeIsThick(inputThreadInfo.type)) { 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, }; void this.generateAndSendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ); } 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({ threadID: messageInfo.threadID, localID, 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(threadInfo: ThreadInfo) { this.props.sendCallbacks.forEach(callback => callback()); const localMessageID = getNextLocalID(); void 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: { [string]: PendingMultimediaUpload } = {}; 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: Partial) => { 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, ): boolean { 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); } void this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const completed = InputStateContainer.completedMessageIDs(this.state); if (completed.has(localMessageID)) { void 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: { [string]: PendingMultimediaUpload } = {}; 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, }, }; }); void this.uploadFiles(threadInfo, 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(): React.Node { 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 = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const callDeleteUpload = useDeleteUpload(); const callSendMultimediaMessage = useLegacySendMultimediaMessage(); const callSendTextMessage = useSendTextMessage(); const callNewThinThread = useNewThinThread(); const callNewThickThread = useNewThickThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const identityContext = React.useContext(IdentityClientContext); const processAndSendDMOperation = useProcessAndSendDMOperation(); 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); const usingOlmViaTunnelbrokerForDMs = useAllowOlmViaTunnelbrokerForDMs(); return ( ); }); export default ConnectedInputStateContainer;