diff --git a/lib/actions/link-actions.js b/lib/actions/link-actions.js index 33c968ea2..91d07ef99 100644 --- a/lib/actions/link-actions.js +++ b/lib/actions/link-actions.js @@ -1,147 +1,150 @@ // @flow import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import type { FetchInviteLinksResponse, InviteLinkVerificationRequest, InviteLinkVerificationResponse, CreateOrUpdatePublicLinkRequest, InviteLink, DisableInviteLinkRequest, DisableInviteLinkPayload, } from '../types/link-types.js'; -import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpoint } from '../utils/call-single-keyserver-endpoint.js'; import { useKeyserverCall } from '../utils/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../utils/keyserver-call.js'; const verifyInviteLinkActionTypes = Object.freeze({ started: 'VERIFY_INVITE_LINK_STARTED', success: 'VERIFY_INVITE_LINK_SUCCESS', failed: 'VERIFY_INVITE_LINK_FAILED', }); const verifyInviteLink = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( request: InviteLinkVerificationRequest, ) => Promise) => async request => { - const response = await callServerEndpoint('verify_invite_link', request); + const response = await callSingleKeyserverEndpoint( + 'verify_invite_link', + request, + ); if (response.status === 'valid' || response.status === 'already_joined') { return { status: response.status, community: response.community, }; } return { status: response.status, }; }; const fetchPrimaryInviteLinkActionTypes = Object.freeze({ started: 'FETCH_PRIMARY_INVITE_LINKS_STARTED', success: 'FETCH_PRIMARY_INVITE_LINKS_SUCCESS', failed: 'FETCH_PRIMARY_INVITE_LINKS_FAILED', }); const fetchPrimaryInviteLinks = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): (() => Promise) => async () => { const requests: { [string]: void } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = undefined; } const responses = await callKeyserverEndpoint( 'fetch_primary_invite_links', requests, ); let links: $ReadOnlyArray = []; for (const keyserverID in responses) { links = links.concat(responses[keyserverID].links); } return { links, }; }; function useFetchPrimaryInviteLinks(): () => Promise { return useKeyserverCall(fetchPrimaryInviteLinks); } const createOrUpdatePublicLinkActionTypes = Object.freeze({ started: 'CREATE_OR_UPDATE_PUBLIC_LINK_STARTED', success: 'CREATE_OR_UPDATE_PUBLIC_LINK_SUCCESS', failed: 'CREATE_OR_UPDATE_PUBLIC_LINK_FAILED', }); const createOrUpdatePublicLink = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: CreateOrUpdatePublicLinkRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.communityID); const requests = { [keyserverID]: { name: input.name, communityID: input.communityID, }, }; const responses = await callKeyserverEndpoint( 'create_or_update_public_link', requests, ); const response = responses[keyserverID]; return { name: response.name, primary: response.primary, role: response.role, communityID: response.communityID, expirationTime: response.expirationTime, limitOfUses: response.limitOfUses, numberOfUses: response.numberOfUses, }; }; function useCreateOrUpdatePublicLink(): ( input: CreateOrUpdatePublicLinkRequest, ) => Promise { return useKeyserverCall(createOrUpdatePublicLink); } const disableInviteLinkLinkActionTypes = Object.freeze({ started: 'DISABLE_INVITE_LINK_STARTED', success: 'DISABLE_INVITE_LINK_SUCCESS', failed: 'DISABLE_INVITE_LINK_FAILED', }); const disableInviteLink = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: DisableInviteLinkRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.communityID); const requests = { [keyserverID]: input }; await callKeyserverEndpoint('disable_invite_link', requests); return input; }; function useDisableInviteLink(): ( input: DisableInviteLinkRequest, ) => Promise { return useKeyserverCall(disableInviteLink); } export { verifyInviteLinkActionTypes, verifyInviteLink, fetchPrimaryInviteLinkActionTypes, useFetchPrimaryInviteLinks, createOrUpdatePublicLinkActionTypes, useCreateOrUpdatePublicLink, disableInviteLinkLinkActionTypes, useDisableInviteLink, }; diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index 5532db0fe..2e4195982 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,522 +1,530 @@ // @flow import invariant from 'invariant'; import { extractKeyserverIDFromID, sortThreadIDsPerKeyserver, } from '../keyserver-conn/keyserver-call-utils.js'; import type { FetchMessageInfosPayload, SendMessageResult, SendEditMessageResult, SendReactionMessageRequest, SimpleMessagesPayload, SendEditMessageRequest, FetchPinnedMessagesRequest, FetchPinnedMessagesResult, SearchMessagesRequest, SearchMessagesResponse, FetchMessageInfosRequest, RawMessageInfo, MessageTruncationStatuses, } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; import type { ToggleMessagePinRequest, ToggleMessagePinResult, } from '../types/thread-types.js'; -import type { CallServerEndpointResultInfo } from '../utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpointResultInfo } from '../utils/call-single-keyserver-endpoint.js'; import { useKeyserverCall } from '../utils/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../utils/keyserver-call.js'; const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); export type FetchMessagesBeforeCursorInput = { +threadID: string, +beforeMessageID: string, }; const fetchMessagesBeforeCursor = ( 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: CallServerEndpointResultInfo) => { + 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: CallServerEndpointResultInfo) => { + 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: CallServerEndpointResultInfo) => { + 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: CallServerEndpointResultInfo) => { + 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: SearchMessagesRequest) => 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 { return useKeyserverCall(searchMessages); } 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/actions/relationship-actions.js b/lib/actions/relationship-actions.js index 8d5b4c2a6..2ce499ea9 100644 --- a/lib/actions/relationship-actions.js +++ b/lib/actions/relationship-actions.js @@ -1,34 +1,37 @@ // @flow import type { RelationshipRequest, RelationshipErrors, } from '../types/relationship-types.js'; -import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpoint } from '../utils/call-single-keyserver-endpoint.js'; import { ServerError } from '../utils/errors.js'; const updateRelationshipsActionTypes = Object.freeze({ started: 'UPDATE_RELATIONSHIPS_STARTED', success: 'UPDATE_RELATIONSHIPS_SUCCESS', failed: 'UPDATE_RELATIONSHIPS_FAILED', }); const updateRelationships = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((request: RelationshipRequest) => Promise) => async request => { - const errors = await callServerEndpoint('update_relationships', request); + const errors = await callSingleKeyserverEndpoint( + 'update_relationships', + request, + ); const { invalid_user, already_friends, user_blocked } = errors; if (invalid_user) { throw new ServerError('invalid_user', errors); } else if (already_friends) { throw new ServerError('already_friends', errors); } else if (user_blocked) { throw new ServerError('user_blocked', errors); } return errors; }; export { updateRelationshipsActionTypes, updateRelationships }; diff --git a/lib/actions/siwe-actions.js b/lib/actions/siwe-actions.js index 3321389ab..eaae1596d 100644 --- a/lib/actions/siwe-actions.js +++ b/lib/actions/siwe-actions.js @@ -1,84 +1,86 @@ // @flow import { mergeUserInfos } from './user-actions.js'; import threadWatcher from '../shared/thread-watcher.js'; import { type LogInResult, logInActionSources, } from '../types/account-types.js'; import type { SIWEAuthServerCall } from '../types/siwe-types.js'; import type { - CallServerEndpoint, - CallServerEndpointOptions, -} from '../utils/call-server-endpoint.js'; + CallSingleKeyserverEndpoint, + CallSingleKeyserverEndpointOptions, +} from '../utils/call-single-keyserver-endpoint.js'; import { getConfig } from '../utils/config.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const getSIWENonceActionTypes = Object.freeze({ started: 'GET_SIWE_NONCE_STARTED', success: 'GET_SIWE_NONCE_SUCCESS', failed: 'GET_SIWE_NONCE_FAILED', }); const getSIWENonce = - (callServerEndpoint: CallServerEndpoint): (() => Promise) => + ( + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, + ): (() => Promise) => async () => { - const response = await callServerEndpoint('siwe_nonce'); + const response = await callSingleKeyserverEndpoint('siwe_nonce'); return response.nonce; }; const siweAuthActionTypes = Object.freeze({ started: 'SIWE_AUTH_STARTED', success: 'SIWE_AUTH_SUCCESS', failed: 'SIWE_AUTH_FAILED', }); -const siweAuthCallServerEndpointOptions = { timeout: 60000 }; +const siweAuthCallSingleKeyserverEndpointOptions = { timeout: 60000 }; const siweAuth = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( siweAuthPayload: SIWEAuthServerCall, - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => Promise) => async (siweAuthPayload, options) => { const watchedIDs = threadWatcher.getWatchedIDs(); const deviceTokenUpdateRequest = siweAuthPayload.deviceTokenUpdateRequest[ashoatKeyserverID]; - const response = await callServerEndpoint( + const response = await callSingleKeyserverEndpoint( 'siwe_auth', { ...siweAuthPayload, watchedIDs, deviceTokenUpdateRequest, platformDetails: getConfig().platformDetails, }, { - ...siweAuthCallServerEndpointOptions, + ...siweAuthCallSingleKeyserverEndpointOptions, ...options, }, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: siweAuthPayload.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: { [ashoatKeyserverID]: response.serverTime }, }, userInfos, updatesCurrentAsOf: { [ashoatKeyserverID]: response.serverTime }, logInActionSource: logInActionSources.logInFromNativeSIWE, notAcknowledgedPolicies: response.notAcknowledgedPolicies, }; }; export { getSIWENonceActionTypes, getSIWENonce, siweAuthActionTypes, siweAuth }; diff --git a/lib/actions/upload-actions.js b/lib/actions/upload-actions.js index 5bce5cca8..f86b9dd26 100644 --- a/lib/actions/upload-actions.js +++ b/lib/actions/upload-actions.js @@ -1,251 +1,251 @@ // @flow import uuid from 'uuid'; import blobService from '../facts/blob-service.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import type { UploadMultimediaResult, Dimensions } from '../types/media-types'; import { toBase64URL } from '../utils/base64.js'; import { blobServiceUploadHandler, type BlobServiceUploadHandler, } from '../utils/blob-service-upload.js'; import { makeBlobServiceEndpointURL } from '../utils/blob-service.js'; -import type { CallServerEndpoint } from '../utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpoint } from '../utils/call-single-keyserver-endpoint.js'; import { getMessageForException } from '../utils/errors.js'; import { useKeyserverCall } from '../utils/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../utils/keyserver-call.js'; import { handleHTTPResponseError } from '../utils/services-utils.js'; import { type UploadBlob } from '../utils/upload-blob.js'; export type MultimediaUploadCallbacks = Partial<{ +onProgress: (percent: number) => void, +abortHandler: (abort: () => void) => void, +uploadBlob: UploadBlob, +blobServiceUploadHandler: BlobServiceUploadHandler, +timeout: ?number, }>; export type MultimediaUploadExtras = $ReadOnly< Partial<{ ...Dimensions, +loop: boolean, +encryptionKey: string, +thumbHash: ?string, }>, >; const uploadMultimedia = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( multimedia: Object, extras: MultimediaUploadExtras, callbacks?: MultimediaUploadCallbacks, ) => Promise) => async (multimedia, extras, callbacks) => { const onProgress = callbacks && callbacks.onProgress; const abortHandler = callbacks && callbacks.abortHandler; const uploadBlob = callbacks && callbacks.uploadBlob; const stringExtras: { [string]: string } = {}; if (extras.height !== null && extras.height !== undefined) { stringExtras.height = extras.height.toString(); } if (extras.width !== null && extras.width !== undefined) { stringExtras.width = extras.width.toString(); } if (extras.loop) { stringExtras.loop = '1'; } if (extras.encryptionKey) { stringExtras.encryptionKey = extras.encryptionKey; } if (extras.thumbHash) { stringExtras.thumbHash = extras.thumbHash; } // also pass MIME type if available if (multimedia.type && typeof multimedia.type === 'string') { stringExtras.mimeType = multimedia.type; } - const response = await callServerEndpoint( + const response = await callSingleKeyserverEndpoint( 'upload_multimedia', { ...stringExtras, multimedia: [multimedia], }, { onProgress, abortHandler, blobUpload: uploadBlob ? uploadBlob : true, }, ); const [uploadResult] = response.results; return { id: uploadResult.id, uri: uploadResult.uri, dimensions: uploadResult.dimensions, mediaType: uploadResult.mediaType, loop: uploadResult.loop, }; }; export type DeleteUploadInput = { +id: string, +keyserverOrThreadID: string, }; const updateMultimediaMessageMediaActionType = 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA'; const deleteUpload = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: DeleteUploadInput) => Promise) => async input => { const { id, keyserverOrThreadID } = input; const keyserverID = extractKeyserverIDFromID(keyserverOrThreadID); const requests = { [keyserverID]: { id } }; await callKeyserverEndpoint('delete_upload', requests); }; function useDeleteUpload(): (input: DeleteUploadInput) => Promise { return useKeyserverCall(deleteUpload); } export type BlobServiceUploadFile = | { +type: 'file', +file: File } | { +type: 'uri', +uri: string, +filename: string, +mimeType: string, }; export type BlobServiceUploadInput = { +blobInput: BlobServiceUploadFile, +blobHash: string, +encryptionKey: string, +dimensions: ?Dimensions, +thumbHash?: ?string, +loop?: boolean, }; export type BlobServiceUploadResult = { ...UploadMultimediaResult, blobHolder: ?string, }; export type BlobServiceUploadAction = (input: { +uploadInput: BlobServiceUploadInput, +keyserverOrThreadID: string, +callbacks?: MultimediaUploadCallbacks, }) => Promise; const blobServiceUpload = (callKeyserverEndpoint: CallKeyserverEndpoint): BlobServiceUploadAction => async input => { const { uploadInput, callbacks, keyserverOrThreadID } = input; const { encryptionKey, loop, dimensions, thumbHash, blobInput } = uploadInput; const blobHolder = uuid.v4(); const blobHash = toBase64URL(uploadInput.blobHash); // 1. Assign new holder for blob with given blobHash let blobAlreadyExists: boolean; try { const assignHolderEndpoint = blobService.httpEndpoints.ASSIGN_HOLDER; const assignHolderResponse = await fetch( makeBlobServiceEndpointURL(assignHolderEndpoint), { method: assignHolderEndpoint.method, body: JSON.stringify({ holder: blobHolder, blob_hash: blobHash, }), headers: { 'content-type': 'application/json', }, }, ); handleHTTPResponseError(assignHolderResponse); const { data_exists: dataExistsResponse } = await assignHolderResponse.json(); blobAlreadyExists = dataExistsResponse; } catch (e) { throw new Error( `Failed to assign holder: ${ getMessageForException(e) ?? 'unknown error' }`, ); } // 2. Upload blob contents if blob doesn't exist if (!blobAlreadyExists) { const uploadEndpoint = blobService.httpEndpoints.UPLOAD_BLOB; let blobServiceUploadCallback = blobServiceUploadHandler; if (callbacks && callbacks.blobServiceUploadHandler) { blobServiceUploadCallback = callbacks.blobServiceUploadHandler; } try { await blobServiceUploadCallback( makeBlobServiceEndpointURL(uploadEndpoint), uploadEndpoint.method, { blobHash, blobInput, }, { ...callbacks }, ); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } } // 3. Upload metadata to keyserver const keyserverID = extractKeyserverIDFromID(keyserverOrThreadID); const requests = { [keyserverID]: { blobHash, blobHolder, encryptionKey, filename: blobInput.type === 'file' ? blobInput.file.name : blobInput.filename, mimeType: blobInput.type === 'file' ? blobInput.file.type : blobInput.mimeType, loop, thumbHash, ...dimensions, }, }; const responses = await callKeyserverEndpoint( 'upload_media_metadata', requests, ); const response = responses[keyserverID]; return { id: response.id, uri: response.uri, mediaType: response.mediaType, dimensions: response.dimensions, loop: response.loop, blobHolder, }; }; function useBlobServiceUpload(): BlobServiceUploadAction { return useKeyserverCall(blobServiceUpload); } export { uploadMultimedia, useBlobServiceUpload, updateMultimediaMessageMediaActionType, useDeleteUpload, }; diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index 7ce7e4624..e1e70fc15 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,754 +1,752 @@ // @flow import * as React from 'react'; import { extractKeyserverIDFromID, sortThreadIDsPerKeyserver, sortCalendarQueryPerKeyserver, } from '../keyserver-conn/keyserver-call-utils.js'; import { preRequestUserStateSelector } from '../selectors/account-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import threadWatcher from '../shared/thread-watcher.js'; import type { LogInInfo, LogInResult, RegisterResult, RegisterInfo, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, ClaimUsernameResponse, LogInRequest, KeyserverAuthResult, KeyserverAuthInfo, KeyserverAuthRequest, ClientLogInResponse, KeyserverLogOutResult, } from '../types/account-types.js'; import type { UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from '../types/avatar-types.js'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types.js'; import type { IdentityRegisterResult } from '../types/identity-service-types.js'; import type { RawMessageInfo, MessageTruncationStatuses, } from '../types/message-types.js'; import type { GetSessionPublicKeysArgs, GetOlmSessionInitializationDataResponse, } from '../types/request-types.js'; import type { UserSearchResult, ExactUserSearchResult, } from '../types/search-types.js'; import type { SessionPublicKeys, PreRequestUserState, } from '../types/session-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types.js'; import type { MinimallyEncodedRawThreadInfos } from '../types/thread-types'; import type { CurrentUserInfo, UserInfo, PasswordUpdate, LoggedOutUserInfo, } from '../types/user-types.js'; import type { - CallServerEndpoint, - CallServerEndpointOptions, -} from '../utils/call-server-endpoint.js'; + CallSingleKeyserverEndpoint, + CallSingleKeyserverEndpointOptions, +} from '../utils/call-single-keyserver-endpoint.js'; import { getConfig } from '../utils/config.js'; import type { CallKeyserverEndpoint } from '../utils/keyserver-call'; import { useKeyserverCall } from '../utils/keyserver-call.js'; import { useSelector } from '../utils/redux-utils.js'; import sleep from '../utils/sleep.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const loggedOutUserInfo: LoggedOutUserInfo = { anonymous: true, }; export type KeyserverLogOutInput = { +preRequestUserState: PreRequestUserState, +keyserverIDs?: $ReadOnlyArray, }; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); const logOut = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: KeyserverLogOutInput) => Promise) => async input => { const { preRequestUserState } = input; const keyserverIDs = input.keyserverIDs ?? allKeyserverIDs; const requests: { [string]: {} } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = {}; } let response = null; try { response = await Promise.race([ callKeyserverEndpoint('log_out', requests), (async () => { await sleep(500); throw new Error('log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? loggedOutUserInfo : null; return { currentUserInfo, preRequestUserState, keyserverIDs }; }; function useLogOut(): ( keyserverIDs?: $ReadOnlyArray, ) => Promise { const preRequestUserState = useSelector(preRequestUserStateSelector); const callKeyserverLogOut = useKeyserverCall(logOut); return React.useCallback( (keyserverIDs?: $ReadOnlyArray) => callKeyserverLogOut({ preRequestUserState, keyserverIDs }), [callKeyserverLogOut, preRequestUserState], ); } const claimUsernameActionTypes = Object.freeze({ started: 'CLAIM_USERNAME_STARTED', success: 'CLAIM_USERNAME_SUCCESS', failed: 'CLAIM_USERNAME_FAILED', }); -const claimUsernameCallServerEndpointOptions = { timeout: 500 }; +const claimUsernameCallSingleKeyserverEndpointOptions = { timeout: 500 }; const claimUsername = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (() => Promise) => async () => { const requests = { [ashoatKeyserverID]: {} }; const responses = await callKeyserverEndpoint('claim_username', requests, { - ...claimUsernameCallServerEndpointOptions, + ...claimUsernameCallSingleKeyserverEndpointOptions, }); const response = responses[ashoatKeyserverID]; return { message: response.message, signature: response.signature, }; }; function useClaimUsername(): () => Promise { return useKeyserverCall(claimUsername); } const deleteKeyserverAccountActionTypes = Object.freeze({ started: 'DELETE_KEYSERVER_ACCOUNT_STARTED', success: 'DELETE_KEYSERVER_ACCOUNT_SUCCESS', failed: 'DELETE_KEYSERVER_ACCOUNT_FAILED', }); const deleteKeyserverAccount = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: KeyserverLogOutInput) => Promise) => async input => { const { preRequestUserState } = input; const keyserverIDs = input.keyserverIDs ?? allKeyserverIDs; const requests: { [string]: {} } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = {}; } await callKeyserverEndpoint('delete_account', requests); return { currentUserInfo: loggedOutUserInfo, preRequestUserState, keyserverIDs, }; }; function useDeleteKeyserverAccount(): ( keyserverIDs?: $ReadOnlyArray, ) => Promise { const preRequestUserState = useSelector(preRequestUserStateSelector); const callKeyserverDeleteAccount = useKeyserverCall(deleteKeyserverAccount); return React.useCallback( (keyserverIDs?: $ReadOnlyArray) => callKeyserverDeleteAccount({ preRequestUserState, keyserverIDs }), [callKeyserverDeleteAccount, preRequestUserState], ); } const deleteIdentityAccountActionTypes = Object.freeze({ started: 'DELETE_IDENTITY_ACCOUNT_STARTED', success: 'DELETE_IDENTITY_ACCOUNT_SUCCESS', failed: 'DELETE_IDENTITY_ACCOUNT_FAILED', }); function useDeleteIdentityAccount(): () => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; return React.useCallback(() => { if (!identityClient) { throw new Error('Identity service client is not initialized'); } return identityClient.deleteUser(); }, [identityClient]); } const keyserverRegisterActionTypes = Object.freeze({ started: 'KEYSERVER_REGISTER_STARTED', success: 'KEYSERVER_REGISTER_SUCCESS', failed: 'KEYSERVER_REGISTER_FAILED', }); -const registerCallServerEndpointOptions = { timeout: 60000 }; +const registerCallSingleKeyserverEndpointOptions = { timeout: 60000 }; const keyserverRegister = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( registerInfo: RegisterInfo, - options?: CallServerEndpointOptions, + options?: CallSingleKeyserverEndpointOptions, ) => Promise) => async (registerInfo, options) => { const deviceTokenUpdateRequest = registerInfo.deviceTokenUpdateRequest[ashoatKeyserverID]; - const response = await callServerEndpoint( + const response = await callSingleKeyserverEndpoint( 'create_account', { ...registerInfo, deviceTokenUpdateRequest, platformDetails: getConfig().platformDetails, }, { - ...registerCallServerEndpointOptions, + ...registerCallSingleKeyserverEndpointOptions, ...options, }, ); return { currentUserInfo: response.currentUserInfo, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; }; export type KeyserverAuthInput = $ReadOnly<{ ...KeyserverAuthInfo, +preRequestUserInfo: ?CurrentUserInfo, }>; const keyserverAuthActionTypes = Object.freeze({ started: 'KEYSERVER_AUTH_STARTED', success: 'KEYSERVER_AUTH_SUCCESS', failed: 'KEYSERVER_AUTH_FAILED', }); -const keyserverAuthCallServerEndpointOptions = { timeout: 60000 }; +const keyserverAuthCallSingleKeyserverEndpointOptions = { timeout: 60000 }; const keyserverAuth = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: KeyserverAuthInput) => Promise) => async keyserverAuthInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { logInActionSource, calendarQuery, keyserverData, deviceTokenUpdateInput, preRequestUserInfo, ...restLogInInfo } = keyserverAuthInfo; const keyserverIDs = Object.keys(keyserverData); const watchedIDsPerKeyserver = sortThreadIDsPerKeyserver(watchedIDs); const calendarQueryPerKeyserver = sortCalendarQueryPerKeyserver( calendarQuery, keyserverIDs, ); const requests: { [string]: KeyserverAuthRequest } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = { ...restLogInInfo, deviceTokenUpdateRequest: deviceTokenUpdateInput[keyserverID], watchedIDs: watchedIDsPerKeyserver[keyserverID] ?? [], calendarQuery: calendarQueryPerKeyserver[keyserverID], platformDetails: getConfig().platformDetails, initialContentEncryptedMessage: keyserverData[keyserverID].initialContentEncryptedMessage, initialNotificationsEncryptedMessage: keyserverData[keyserverID].initialNotificationsEncryptedMessage, source: logInActionSource, }; } const responses: { +[string]: ClientLogInResponse } = await callKeyserverEndpoint( 'keyserver_auth', requests, - keyserverAuthCallServerEndpointOptions, + keyserverAuthCallSingleKeyserverEndpointOptions, ); const userInfosArrays = []; let threadInfos: MinimallyEncodedRawThreadInfos = {}; const calendarResult: WritableCalendarResult = { calendarQuery: keyserverAuthInfo.calendarQuery, rawEntryInfos: [], }; const messagesResult: WritableGenericMessagesResult = { messageInfos: [], truncationStatus: {}, watchedIDsAtRequestTime: watchedIDs, currentAsOf: {}, }; let updatesCurrentAsOf: { +[string]: number } = {}; for (const keyserverID in responses) { threadInfos = { ...responses[keyserverID].cookieChange.threadInfos, ...threadInfos, }; if (responses[keyserverID].rawEntryInfos) { calendarResult.rawEntryInfos = calendarResult.rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); } messagesResult.messageInfos = messagesResult.messageInfos.concat( responses[keyserverID].rawMessageInfos, ); messagesResult.truncationStatus = { ...messagesResult.truncationStatus, ...responses[keyserverID].truncationStatuses, }; messagesResult.currentAsOf = { ...messagesResult.currentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; updatesCurrentAsOf = { ...updatesCurrentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; userInfosArrays.push(responses[keyserverID].userInfos); userInfosArrays.push(responses[keyserverID].cookieChange.userInfos); } const userInfos = mergeUserInfos(...userInfosArrays); return { threadInfos, currentUserInfo: responses[ashoatKeyserverID].currentUserInfo, calendarResult, messagesResult, userInfos, updatesCurrentAsOf, logInActionSource: keyserverAuthInfo.logInActionSource, notAcknowledgedPolicies: responses[ashoatKeyserverID].notAcknowledgedPolicies, preRequestUserInfo, }; }; function useKeyserverAuth(): ( input: KeyserverAuthInfo, ) => Promise { const preRequestUserInfo = useSelector(state => state.currentUserInfo); const callKeyserverAuth = useKeyserverCall(keyserverAuth); return React.useCallback( (input: KeyserverAuthInfo) => callKeyserverAuth({ preRequestUserInfo, ...input }), [callKeyserverAuth, preRequestUserInfo], ); } const identityRegisterActionTypes = Object.freeze({ started: 'IDENTITY_REGISTER_STARTED', success: 'IDENTITY_REGISTER_SUCCESS', failed: 'IDENTITY_REGISTER_FAILED', }); function useIdentityRegister(): ( username: string, password: string, ) => Promise { const client = React.useContext(IdentityClientContext); const identityClient = client?.identityClient; return React.useCallback( (username, password) => { if (!identityClient) { throw new Error('Identity service client is not initialized'); } if (!identityClient.registerUser) { throw new Error('Register user method unimplemented'); } return identityClient.registerUser(username, password); }, [identityClient], ); } function mergeUserInfos( ...userInfoArrays: Array<$ReadOnlyArray> ): UserInfo[] { const merged: { [string]: UserInfo } = {}; for (const userInfoArray of userInfoArrays) { for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (const id in merged) { flattened.push(merged[id]); } return flattened; } type WritableGenericMessagesResult = { messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: string[], currentAsOf: { [keyserverID: string]: number }, }; type WritableCalendarResult = { rawEntryInfos: RawEntryInfo[], calendarQuery: CalendarQuery, }; const tempIdentityLoginActionTypes = Object.freeze({ started: 'TEMP_IDENTITY_LOG_IN_STARTED', success: 'TEMP_IDENTITY_LOG_IN_SUCCESS', failed: 'TEMP_IDENTITY_LOG_IN_FAILED', }); const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); -const logInCallServerEndpointOptions = { timeout: 60000 }; +const logInCallSingleKeyserverEndpointOptions = { timeout: 60000 }; const logIn = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: LogInInfo) => Promise) => async logInInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { logInActionSource, calendarQuery, keyserverIDs: inputKeyserverIDs, ...restLogInInfo } = logInInfo; // Eventually the list of keyservers will be fetched from the // identity service const keyserverIDs = inputKeyserverIDs ?? [ashoatKeyserverID]; const watchedIDsPerKeyserver = sortThreadIDsPerKeyserver(watchedIDs); const calendarQueryPerKeyserver = sortCalendarQueryPerKeyserver( calendarQuery, keyserverIDs, ); const requests: { [string]: LogInRequest } = {}; for (const keyserverID of keyserverIDs) { requests[keyserverID] = { ...restLogInInfo, deviceTokenUpdateRequest: logInInfo.deviceTokenUpdateRequest[keyserverID], source: logInActionSource, watchedIDs: watchedIDsPerKeyserver[keyserverID] ?? [], calendarQuery: calendarQueryPerKeyserver[keyserverID], platformDetails: getConfig().platformDetails, }; } const responses: { +[string]: ClientLogInResponse } = await callKeyserverEndpoint( 'log_in', requests, - logInCallServerEndpointOptions, + logInCallSingleKeyserverEndpointOptions, ); const userInfosArrays = []; let threadInfos: MinimallyEncodedRawThreadInfos = {}; const calendarResult: WritableCalendarResult = { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: [], }; const messagesResult: WritableGenericMessagesResult = { messageInfos: [], truncationStatus: {}, watchedIDsAtRequestTime: watchedIDs, currentAsOf: {}, }; let updatesCurrentAsOf: { +[string]: number } = {}; for (const keyserverID in responses) { threadInfos = { ...responses[keyserverID].cookieChange.threadInfos, ...threadInfos, }; if (responses[keyserverID].rawEntryInfos) { calendarResult.rawEntryInfos = calendarResult.rawEntryInfos.concat( responses[keyserverID].rawEntryInfos, ); } messagesResult.messageInfos = messagesResult.messageInfos.concat( responses[keyserverID].rawMessageInfos, ); messagesResult.truncationStatus = { ...messagesResult.truncationStatus, ...responses[keyserverID].truncationStatuses, }; messagesResult.currentAsOf = { ...messagesResult.currentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; updatesCurrentAsOf = { ...updatesCurrentAsOf, [keyserverID]: responses[keyserverID].serverTime, }; userInfosArrays.push(responses[keyserverID].userInfos); userInfosArrays.push(responses[keyserverID].cookieChange.userInfos); } const userInfos = mergeUserInfos(...userInfosArrays); return { threadInfos, currentUserInfo: responses[ashoatKeyserverID].currentUserInfo, calendarResult, messagesResult, userInfos, updatesCurrentAsOf, logInActionSource: logInInfo.logInActionSource, notAcknowledgedPolicies: responses[ashoatKeyserverID].notAcknowledgedPolicies, }; }; function useLogIn(): (input: LogInInfo) => Promise { return useKeyserverCall(logIn); } const changeKeyserverUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_KEYSERVER_USER_PASSWORD_STARTED', success: 'CHANGE_KEYSERVER_USER_PASSWORD_SUCCESS', failed: 'CHANGE_KEYSERVER_USER_PASSWORD_FAILED', }); const changeKeyserverUserPassword = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((passwordUpdate: PasswordUpdate) => Promise) => async passwordUpdate => { - await callServerEndpoint('update_account', passwordUpdate); + await callSingleKeyserverEndpoint('update_account', passwordUpdate); }; const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); const searchUsers = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((usernamePrefix: string) => Promise) => async usernamePrefix => { - const response = await callServerEndpoint('search_users', { + const response = await callSingleKeyserverEndpoint('search_users', { prefix: usernamePrefix, }); return { userInfos: response.userInfos, }; }; const exactSearchUserActionTypes = Object.freeze({ started: 'EXACT_SEARCH_USER_STARTED', success: 'EXACT_SEARCH_USER_SUCCESS', failed: 'EXACT_SEARCH_USER_FAILED', }); const exactSearchUser = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((username: string) => Promise) => async username => { - const response = await callServerEndpoint('exact_search_user', { + const response = await callSingleKeyserverEndpoint('exact_search_user', { username, }); return { userInfo: response.userInfo, }; }; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); const updateSubscription = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: SubscriptionUpdateRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'update_user_subscription', requests, ); const response = responses[keyserverID]; return { threadID: input.threadID, subscription: response.threadSubscription, }; }; function useUpdateSubscription(): ( input: SubscriptionUpdateRequest, ) => Promise { return useKeyserverCall(updateSubscription); } const setUserSettingsActionTypes = Object.freeze({ started: 'SET_USER_SETTINGS_STARTED', success: 'SET_USER_SETTINGS_SUCCESS', failed: 'SET_USER_SETTINGS_FAILED', }); const setUserSettings = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: UpdateUserSettingsRequest) => Promise) => async input => { const requests: { [string]: UpdateUserSettingsRequest } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = input; } await callKeyserverEndpoint('update_user_settings', requests); }; function useSetUserSettings(): ( input: UpdateUserSettingsRequest, ) => Promise { return useKeyserverCall(setUserSettings); } const getSessionPublicKeys = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((data: GetSessionPublicKeysArgs) => Promise) => async data => { - return await callServerEndpoint('get_session_public_keys', data); + return await callSingleKeyserverEndpoint('get_session_public_keys', data); }; const getOlmSessionInitializationDataActionTypes = Object.freeze({ started: 'GET_OLM_SESSION_INITIALIZATION_DATA_STARTED', success: 'GET_OLM_SESSION_INITIALIZATION_DATA_SUCCESS', failed: 'GET_OLM_SESSION_INITIALIZATION_DATA_FAILED', }); const getOlmSessionInitializationData = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => Promise) => async options => { - return await callServerEndpoint( + return await callSingleKeyserverEndpoint( 'get_olm_session_initialization_data', {}, options, ); }; const policyAcknowledgmentActionTypes = Object.freeze({ started: 'POLICY_ACKNOWLEDGMENT_STARTED', success: 'POLICY_ACKNOWLEDGMENT_SUCCESS', failed: 'POLICY_ACKNOWLEDGMENT_FAILED', }); const policyAcknowledgment = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((policyRequest: PolicyAcknowledgmentRequest) => Promise) => async policyRequest => { - await callServerEndpoint('policy_acknowledgment', policyRequest); + await callSingleKeyserverEndpoint('policy_acknowledgment', policyRequest); }; const updateUserAvatarActionTypes = Object.freeze({ started: 'UPDATE_USER_AVATAR_STARTED', success: 'UPDATE_USER_AVATAR_SUCCESS', failed: 'UPDATE_USER_AVATAR_FAILED', }); const updateUserAvatar = ( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( avatarDBContent: UpdateUserAvatarRequest, ) => Promise) => async avatarDBContent => { - const { updates }: UpdateUserAvatarResponse = await callServerEndpoint( - 'update_user_avatar', - avatarDBContent, - ); + const { updates }: UpdateUserAvatarResponse = + await callSingleKeyserverEndpoint('update_user_avatar', avatarDBContent); return { updates }; }; const resetUserStateActionType = 'RESET_USER_STATE'; const setAccessTokenActionType = 'SET_ACCESS_TOKEN'; export { changeKeyserverUserPasswordActionTypes, changeKeyserverUserPassword, claimUsernameActionTypes, useClaimUsername, useDeleteKeyserverAccount, deleteKeyserverAccountActionTypes, getSessionPublicKeys, getOlmSessionInitializationDataActionTypes, getOlmSessionInitializationData, mergeUserInfos, logIn as logInRawAction, tempIdentityLoginActionTypes, useLogIn, logInActionTypes, useLogOut, logOutActionTypes, keyserverRegister, keyserverRegisterActionTypes, searchUsers, searchUsersActionTypes, exactSearchUser, exactSearchUserActionTypes, useSetUserSettings, setUserSettingsActionTypes, useUpdateSubscription, updateSubscriptionActionTypes, policyAcknowledgment, policyAcknowledgmentActionTypes, updateUserAvatarActionTypes, updateUserAvatar, resetUserStateActionType, setAccessTokenActionType, deleteIdentityAccountActionTypes, useDeleteIdentityAccount, keyserverAuthActionTypes, useKeyserverAuth, identityRegisterActionTypes, useIdentityRegister, }; diff --git a/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js b/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js index 55c614fa6..100aeb4f0 100644 --- a/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js +++ b/lib/keyserver-conn/call-keyserver-endpoint-provider.react.js @@ -1,263 +1,268 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createSelector } from 'reselect'; import { setNewSession } from './keyserver-conn-types.js'; import { canResolveKeyserverSessionInvalidation, resolveKeyserverSessionInvalidation, } from './recovery-utils.js'; import { logInActionSources } from '../types/account-types.js'; import type { PlatformDetails } from '../types/device-types.js'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints.js'; import type { Dispatch } from '../types/redux-types.js'; import type { ClientSessionChange } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; import type { - CallServerEndpoint, - CallServerEndpointOptions, -} from '../utils/call-server-endpoint.js'; -import callServerEndpoint from '../utils/call-server-endpoint.js'; + CallSingleKeyserverEndpoint, + CallSingleKeyserverEndpointOptions, +} from '../utils/call-single-keyserver-endpoint.js'; +import callSingleKeyserverEndpoint from '../utils/call-single-keyserver-endpoint.js'; type CreateCallSingleKeyserverEndpointSelector = ( keyserverID: string, -) => ServerCallSelectorParams => CallServerEndpoint; +) => ServerCallSelectorParams => CallSingleKeyserverEndpoint; type CallKeyserverEndpointContextType = { +createCallSingleKeyserverEndpointSelector: CreateCallSingleKeyserverEndpointSelector, }; const CallKeyserverEndpointContext: React.Context = React.createContext(); type OngoingRecoveryAttempt = { - +waitingCalls: Array<(callServerEndpoint: ?CallServerEndpoint) => mixed>, + +waitingCalls: Array< + (callSingleKeyserverEndpoint: ?CallSingleKeyserverEndpoint) => mixed, + >, }; export type ServerCallSelectorParams = { +dispatch: Dispatch, +cookie: ?string, +urlPrefix: string, +sessionID: ?string, +currentUserInfo: ?CurrentUserInfo, +isSocketConnected: boolean, +lastCommunicatedPlatformDetails: ?PlatformDetails, }; type BindServerCallsParams = $ReadOnly<{ ...ServerCallSelectorParams, +keyserverID: string, }>; type Props = { +children: React.Node, }; function CallKeyserverEndpointProvider(props: Props): React.Node { const ongoingRecoveryAttemptsRef = React.useRef< Map, >(new Map()); - const bindCookieAndUtilsIntoCallServerEndpoint: ( + const bindCookieAndUtilsIntoCallSingleKeyserverEndpoint: ( params: BindServerCallsParams, - ) => CallServerEndpoint = React.useCallback(params => { + ) => CallSingleKeyserverEndpoint = React.useCallback(params => { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, isSocketConnected, lastCommunicatedPlatformDetails, keyserverID, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookiesAndSessions: { [keyserverID]: { cookie, sessionID } }, }, error, undefined, keyserverID, ); const canResolveInvalidation = canResolveKeyserverSessionInvalidation(); - // This function gets called before callServerEndpoint sends a request, - // to make sure that we're not in the middle of trying to recover + // This function gets called before callSingleKeyserverEndpoint sends a + // request, to make sure that we're not in the middle of trying to recover // an invalidated cookie const waitIfCookieInvalidated = () => { if (!canResolveInvalidation) { // If there is no way to resolve the session invalidation, - // just let the caller callServerEndpoint instance continue + // just let the caller callSingleKeyserverEndpoint instance continue return Promise.resolve(null); } const ongoingRecoveryAttempt = ongoingRecoveryAttemptsRef.current.get(keyserverID); if (!ongoingRecoveryAttempt) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie - return new Promise(r => + return new Promise(r => ongoingRecoveryAttempt.waitingCalls.push(r), ); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await resolveKeyserverSessionInvalidation( dispatch, newAnonymousCookie, urlPrefix, logInActionSources.cookieInvalidationResolutionAttempt, keyserverID, ); const ongoingRecoveryAttempt = ongoingRecoveryAttemptsRef.current.get(keyserverID); ongoingRecoveryAttemptsRef.current.delete(keyserverID); const currentWaitingCalls = ongoingRecoveryAttempt?.waitingCalls ?? []; - const newCallServerEndpoint = newSessionChange - ? bindCookieAndUtilsIntoCallServerEndpoint({ + const newCallSingleKeyserverEndpoint = newSessionChange + ? bindCookieAndUtilsIntoCallSingleKeyserverEndpoint({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { - func(newCallServerEndpoint); + func(newCallSingleKeyserverEndpoint); } - return newCallServerEndpoint; + return newCallSingleKeyserverEndpoint; }; - // If this function is called, callServerEndpoint got a response + // If this function is called, callSingleKeyserverEndpoint got a response // invalidating its cookie, and is wondering if it should just like... // give up? Or if there's a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!canResolveInvalidation) { // If there is no way to resolve the session invalidation, - // just let the caller callServerEndpoint instance continue + // just let the caller callSingleKeyserverEndpoint instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } const ongoingRecoveryAttempt = ongoingRecoveryAttemptsRef.current.get(keyserverID); if (ongoingRecoveryAttempt) { - return new Promise(r => + return new Promise(r => ongoingRecoveryAttempt.waitingCalls.push(r), ); } ongoingRecoveryAttemptsRef.current.set(keyserverID, { waitingCalls: [] }); return attemptToResolveInvalidation(sessionChange); }; return ( endpoint: Endpoint, data: Object, - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => - callServerEndpoint( + callSingleKeyserverEndpoint( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, isSocketConnected, lastCommunicatedPlatformDetails, socketAPIHandler, endpoint, data, dispatch, options, loggedIn, keyserverID, ); }, []); // For each keyserver, we have a set of params that configure our connection - // to it. These params get bound into callServerEndpoint before it's passed to - // an ActionFunc. This helper function lets us create a selector for a given - // keyserverID that will regenerate the bound callServerEndpoint function only - // if one of the params changes. This lets us skip some React render cycles. + // to it. These params get bound into callSingleKeyserverEndpoint before it's + // passed to an ActionFunc. This helper function lets us create a selector for + // a given keyserverID that will regenerate the bound + // callSingleKeyserverEndpoint function only if one of the params changes. + // This lets us skip some React render cycles. const createCallSingleKeyserverEndpointSelector = React.useCallback( - (keyserverID: string): (ServerCallSelectorParams => CallServerEndpoint) => + ( + keyserverID: string, + ): (ServerCallSelectorParams => CallSingleKeyserverEndpoint) => createSelector( (params: ServerCallSelectorParams) => params.dispatch, (params: ServerCallSelectorParams) => params.cookie, (params: ServerCallSelectorParams) => params.urlPrefix, (params: ServerCallSelectorParams) => params.sessionID, (params: ServerCallSelectorParams) => params.currentUserInfo, (params: ServerCallSelectorParams) => params.isSocketConnected, (params: ServerCallSelectorParams) => params.lastCommunicatedPlatformDetails, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, isSocketConnected: boolean, lastCommunicatedPlatformDetails: ?PlatformDetails, ) => - bindCookieAndUtilsIntoCallServerEndpoint({ + bindCookieAndUtilsIntoCallSingleKeyserverEndpoint({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, isSocketConnected, lastCommunicatedPlatformDetails, keyserverID, }), ), - [bindCookieAndUtilsIntoCallServerEndpoint], + [bindCookieAndUtilsIntoCallSingleKeyserverEndpoint], ); const value = React.useMemo( () => ({ createCallSingleKeyserverEndpointSelector, }), [createCallSingleKeyserverEndpointSelector], ); return ( {props.children} ); } function useCallKeyserverEndpointContext(): CallKeyserverEndpointContextType { const callKeyserverEndpointContext = React.useContext( CallKeyserverEndpointContext, ); invariant( callKeyserverEndpointContext, 'callKeyserverEndpointContext should be set', ); return callKeyserverEndpointContext; } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { CallKeyserverEndpointProvider, useCallKeyserverEndpointContext, registerActiveSocket, }; diff --git a/lib/keyserver-conn/recovery-utils.js b/lib/keyserver-conn/recovery-utils.js index 740049990..28cfab36d 100644 --- a/lib/keyserver-conn/recovery-utils.js +++ b/lib/keyserver-conn/recovery-utils.js @@ -1,153 +1,153 @@ // @flow import { type ActionTypes, setNewSession } from './keyserver-conn-types.js'; import type { LogInActionSource, LogInStartingPayload, LogInResult, } from '../types/account-types.js'; import type { Endpoint } from '../types/endpoints.js'; import type { Dispatch } from '../types/redux-types.js'; import type { ClientSessionChange } from '../types/session-types.js'; -import callServerEndpoint from '../utils/call-server-endpoint.js'; -import type { CallServerEndpointOptions } from '../utils/call-server-endpoint.js'; +import callSingleKeyServerEndpoint from '../utils/call-single-keyserver-endpoint.js'; +import type { CallSingleKeyserverEndpointOptions } from '../utils/call-single-keyserver-endpoint.js'; import { getConfig } from '../utils/config.js'; import { promiseAll } from '../utils/promises.js'; import { wrapActionPromise } from '../utils/redux-promise-utils.js'; import { usingCommServicesAccessToken } from '../utils/services-utils.js'; // This function is a shortcut that tells us whether it's worth even trying to // call resolveKeyserverSessionInvalidation function canResolveKeyserverSessionInvalidation(): boolean { if (usingCommServicesAccessToken) { // We can always try to resolve a keyserver session invalidation // automatically using the Olm auth responder return true; } const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); // If we can't use the Olm auth responder, then we can only resolve a // keyserver session invalidation on native, where we have access to the // user's native credentials. Note that we can't do this for ETH users, but we // don't know if the user is an ETH user from this function return !!resolveKeyserverSessionInvalidationUsingNativeCredentials; } // This function attempts to resolve an invalid keyserver session. A session can // become invalid when a keyserver invalidates it, or due to inconsistent client // state. If the client is usingCommServicesAccessToken, then the invalidation // recovery will try to go through the keyserver's Olm auth responder. // Otherwise, it will attempt to use the user's credentials to log in with the // legacy auth responder, which won't work on web and won't work for ETH users. async function resolveKeyserverSessionInvalidation( dispatch: Dispatch, cookie: ?string, urlPrefix: string, logInActionSource: LogInActionSource, keyserverID: string, getInitialNotificationsEncryptedMessage?: () => Promise, ): Promise { const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); if (!resolveKeyserverSessionInvalidationUsingNativeCredentials) { return null; } let newSessionChange = null; - let callServerEndpointCallback = null; - const boundCallServerEndpoint = async ( + let callSingleKeyserverEndpointCallback = null; + const boundCallSingleKeyserverEndpoint = async ( endpoint: Endpoint, data: { +[key: string]: mixed }, - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession( dispatch, sessionChange, null, error, logInActionSource, keyserverID, ); }; try { - const result = await callServerEndpoint( + const result = await callSingleKeyServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, false, null, null, endpoint, data, dispatch, options, false, keyserverID, ); - if (callServerEndpointCallback) { - callServerEndpointCallback(!!newSessionChange); + if (callSingleKeyserverEndpointCallback) { + callSingleKeyserverEndpointCallback(!!newSessionChange); } return result; } catch (e) { - if (callServerEndpointCallback) { - callServerEndpointCallback(!!newSessionChange); + if (callSingleKeyserverEndpointCallback) { + callSingleKeyserverEndpointCallback(!!newSessionChange); } throw e; } }; const boundCallKeyserverEndpoint = ( endpoint: Endpoint, requests: { +[keyserverID: string]: ?{ +[string]: mixed } }, - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => { if (requests[keyserverID]) { const promises = { - [keyserverID]: boundCallServerEndpoint( + [keyserverID]: boundCallSingleKeyserverEndpoint( endpoint, requests[keyserverID], options, ), }; return promiseAll(promises); } return Promise.resolve({}); }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, logInActionSource }; void dispatch( wrapActionPromise(actionTypes, promise, null, startingPayload), ); - return new Promise(r => (callServerEndpointCallback = r)); + return new Promise(r => (callSingleKeyserverEndpointCallback = r)); }; await resolveKeyserverSessionInvalidationUsingNativeCredentials( - boundCallServerEndpoint, + boundCallSingleKeyserverEndpoint, boundCallKeyserverEndpoint, dispatchRecoveryAttempt, logInActionSource, keyserverID, getInitialNotificationsEncryptedMessage, ); return newSessionChange; } export { canResolveKeyserverSessionInvalidation, resolveKeyserverSessionInvalidation, }; diff --git a/lib/shared/crypto-utils.js b/lib/shared/crypto-utils.js index 59031952b..a5b2717b1 100644 --- a/lib/shared/crypto-utils.js +++ b/lib/shared/crypto-utils.js @@ -1,115 +1,118 @@ -//@flow +// @flow import * as React from 'react'; import { getOlmSessionInitializationData, getOlmSessionInitializationDataActionTypes, } from '../actions/user-actions.js'; import type { OLMIdentityKeys, OLMOneTimeKeys, OLMPrekey, } from '../types/crypto-types'; import type { OlmSessionInitializationInfo } from '../types/request-types'; import { useServerCall } from '../utils/action-utils.js'; import type { - CallServerEndpointOptions, - CallServerEndpoint, -} from '../utils/call-server-endpoint.js'; + CallSingleKeyserverEndpointOptions, + CallSingleKeyserverEndpoint, +} from '../utils/call-single-keyserver-endpoint.js'; import { values, entries } from '../utils/objects.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; export type InitialNotifMessageOptions = { - +callServerEndpoint?: ?CallServerEndpoint, - +callServerEndpointOptions?: ?CallServerEndpointOptions, + +callSingleKeyserverEndpoint?: ?CallSingleKeyserverEndpoint, + +callSingleKeyserverEndpointOptions?: ?CallSingleKeyserverEndpointOptions, }; const initialEncryptedMessageContent = { type: 'init', }; function useInitialNotificationsEncryptedMessage( platformSpecificSessionCreator: ( notificationsIdentityKeys: OLMIdentityKeys, notificationsInitializationInfo: OlmSessionInitializationInfo, ) => Promise, ): (options?: ?InitialNotifMessageOptions) => Promise { const callGetOlmSessionInitializationData = useServerCall( getOlmSessionInitializationData, ); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( async options => { - const callServerEndpoint = options?.callServerEndpoint; - const callServerEndpointOptions = options?.callServerEndpointOptions; + const callSingleKeyserverEndpoint = options?.callSingleKeyserverEndpoint; + const callSingleKeyserverEndpointOptions = + options?.callSingleKeyserverEndpointOptions; - const initDataAction = callServerEndpoint - ? getOlmSessionInitializationData(callServerEndpoint) + const initDataAction = callSingleKeyserverEndpoint + ? getOlmSessionInitializationData(callSingleKeyserverEndpoint) : callGetOlmSessionInitializationData; - const olmSessionDataPromise = initDataAction(callServerEndpointOptions); + const olmSessionDataPromise = initDataAction( + callSingleKeyserverEndpointOptions, + ); void dispatchActionPromise( getOlmSessionInitializationDataActionTypes, olmSessionDataPromise, ); const { signedIdentityKeysBlob, notifInitializationInfo } = await olmSessionDataPromise; const { notificationIdentityPublicKeys } = JSON.parse( signedIdentityKeysBlob.payload, ); return await platformSpecificSessionCreator( notificationIdentityPublicKeys, notifInitializationInfo, ); }, [ callGetOlmSessionInitializationData, dispatchActionPromise, platformSpecificSessionCreator, ], ); } function getOneTimeKeyValues( oneTimeKeys: OLMOneTimeKeys, ): $ReadOnlyArray { return values(oneTimeKeys.curve25519); } function getPrekeyValue(prekey: OLMPrekey): string { const [prekeyValue] = values(prekey.curve25519); return prekeyValue; } function getOneTimeKeyValuesFromBlob(keyBlob: string): $ReadOnlyArray { const oneTimeKeys: OLMOneTimeKeys = JSON.parse(keyBlob); return getOneTimeKeyValues(oneTimeKeys); } function getPrekeyValueFromBlob(prekeyBlob: string): string { const prekey: OLMPrekey = JSON.parse(prekeyBlob); return getPrekeyValue(prekey); } function getOneTimeKeyArray( oneTimeKeys: OLMOneTimeKeys, ): $ReadOnlyArray { return entries(oneTimeKeys.curve25519).map(([key, value]: [string, string]) => JSON.stringify({ curve25519: { [key]: value } }), ); } export { getOneTimeKeyValues, getPrekeyValue, getOneTimeKeyValuesFromBlob, getPrekeyValueFromBlob, initialEncryptedMessageContent, useInitialNotificationsEncryptedMessage, getOneTimeKeyArray, }; diff --git a/lib/shared/timeouts.js b/lib/shared/timeouts.js index e9fb03f7e..02301115a 100644 --- a/lib/shared/timeouts.js +++ b/lib/shared/timeouts.js @@ -1,38 +1,38 @@ // @flow // Sometimes the connection can just go "away", without the client knowing it. // To detect this happening, the client hits the server with a "ping" at // interval specified below when there hasn't been any other communication. export const pingFrequency = 3000; // in milliseconds // Time for request to get response after which we consider our connection // questionable. We won't close and reopen the socket yet, but we will visually // indicate to the user that their connection doesn't seem to be working. export const clientRequestVisualTimeout = 5000; // in milliseconds // Time for request to get response after which we assume our socket // is dead. It's possible this happened because of some weird shit on // the server side, so we try to close and reopen the socket in the // hopes that it will fix the problem. Of course, this is rather // unlikely, as the connectivity issue is likely on the client side. export const clientRequestSocketTimeout = 10000; // in milliseconds -// Time after which CallServerEndpoint will timeout a request. When using -// sockets this is preempted by the above timeout, so it really only applies -// for HTTP requests. -export const callServerEndpointTimeout = 10000; // in milliseconds +// Time after which CallSingleKeyserverEndpoint will timeout a request. When +// using sockets this is preempted by the above timeout, so it really only +// applies for HTTP requests. +export const callSingleKeyserverEndpointTimeout = 10000; // in milliseconds // The server expects to get a request at least every three // seconds from the client. If server doesn't get a request // after time window specified below, it will close connection export const serverRequestSocketTimeout = 120000; // in milliseconds // Time server allows itself to respond to client message. If it // takes it longer to respond, it will timeout and send an error // response. This is better than letting the request timeout on the // client, since the client will assume network issues and close the socket. export const serverResponseTimeout = 5000; // in milliseconds // Time after which the client consider the Tunnelbroker connection // as unhealthy and chooses to close the socket. export const tunnelbrokerHeartbeatTimeout = 9000; // in milliseconds diff --git a/lib/types/message-types.js b/lib/types/message-types.js index a931e4511..53a3b2f21 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,684 +1,684 @@ // @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 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, rawUpdateRelationshipMessageInfoValidator, type UpdateRelationshipMessageData, type UpdateRelationshipMessageInfo, } from './messages/update-relationship.js'; import { type RelativeUserInfo, type UserInfos } from './user-types.js'; -import type { CallServerEndpointResultInfoInterface } from '../utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpointResultInfoInterface } from '../utils/call-single-keyserver-endpoint.js'; import { values } from '../utils/objects.js'; import { tID, tNumber, tShape } 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 | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo; 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 | UpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData | ReactionMessageData | EditMessageData | TogglePinMessageData; 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 | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawTogglePinMessageInfo; const rawRobotextMessageInfoValidator = t.union([ rawCreateThreadMessageInfoValidator, rawAddMembersMessageInfoValidator, rawCreateSubthreadMessageInfoValidator, rawChangeSettingsMessageInfoValidator, rawRemoveMembersMessageInfoValidator, rawChangeRoleMessageInfoValidator, rawLeaveThreadMessageInfoValidator, rawJoinThreadMessageInfoValidator, rawCreateEntryMessageInfoValidator, rawEditEntryMessageInfoValidator, rawDeleteEntryMessageInfoValidator, rawRestoreEntryMessageInfoValidator, rawUpdateRelationshipMessageInfoValidator, rawCreateSidebarMessageInfoValidator, rawUnsupportedMessageInfoValidator, rawTogglePinMessageInfoValidator, ]); export type RawSidebarSourceMessageInfo = { ...SidebarSourceMessageData, id: string, }; export const rawSidebarSourceMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.SIDEBAR_SOURCE), threadID: tID, creatorID: t.String, 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 | UpdateRelationshipMessageInfo | CreateSidebarMessageInfo | TogglePinMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo | ReactionMessageInfo; export type ValidSidebarSourceMessageInfo = | TextMessageInfo | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | ImagesMessageInfo | MediaMessageInfo | UpdateRelationshipMessageInfo | CreateSidebarMessageInfo | UnsupportedMessageInfo; 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. export type LocalMessageInfo = { +sendFailed?: boolean, }; const localMessageInfoValidator: TInterface = tShape({ sendFailed: t.maybe(t.Boolean), }); export type MessageStoreThreads = { +[threadID: string]: ThreadMessageInfo, }; const messageStoreThreadsValidator: TDict = t.dict( tID, threadMessageInfoValidator, ); export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, +threads: MessageStoreThreads, +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: { +[keyserverID: string]: number }, }; export const messageStoreValidator: TInterface = tShape({ messages: t.dict(tID, rawMessageInfoValidator), threads: messageStoreThreadsValidator, local: t.dict(t.String, localMessageInfoValidator), 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 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 FetchMessageInfosRequest = { +cursors: ThreadCursors, +numberPerThread?: ?number, }; export type FetchMessageInfosResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type FetchMessageInfosResult = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export type FetchMessageInfosPayload = { +threadID: string, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, }; export type MessagesResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +currentAsOf: number, }; export const messagesResponseValidator: TInterface = tShape({ rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, currentAsOf: t.Number, }); export type SimpleMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; 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: CallServerEndpointResultInfoInterface, + +interface: CallSingleKeyserverEndpointResultInfoInterface, }; export type SendMessagePayload = { +localID: string, +serverID: string, +threadID: string, +time: number, - +interface: CallServerEndpointResultInfoInterface, + +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, +cursor?: ?string, }; export type SearchMessagesResponse = { +messages: $ReadOnlyArray, +endReached: boolean, }; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 2cbf83210..0e26f109e 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,52 +1,54 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; -import type { CallServerEndpoint } from './call-server-endpoint.js'; +import type { CallSingleKeyserverEndpoint } from './call-single-keyserver-endpoint.js'; import { useSelector, useDispatch } from './redux-utils.js'; import { ashoatKeyserverID } from './validation-utils.js'; import { type ServerCallSelectorParams, useCallKeyserverEndpointContext, } from '../keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { serverCallStateSelector } from '../selectors/server-calls.js'; -type ActionFunc = (callServerEndpoint: CallServerEndpoint) => F; +type ActionFunc = ( + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, +) => F; function useServerCall( serverCall: ActionFunc, paramOverride?: ?Partial, ): F { const dispatch = useDispatch(); const serverCallState = useSelector( serverCallStateSelector(ashoatKeyserverID), ); const { createCallSingleKeyserverEndpointSelector } = useCallKeyserverEndpointContext(); const selector = React.useMemo( () => createCallSingleKeyserverEndpointSelector(ashoatKeyserverID), [createCallSingleKeyserverEndpointSelector], ); return React.useMemo(() => { const { urlPrefix, isSocketConnected } = serverCallState; invariant( !!urlPrefix && isSocketConnected !== undefined && isSocketConnected !== null, 'keyserver missing from keyserverStore', ); - const callServerEndpoint = selector({ + const callSingleKeyserverEndpoint = selector({ ...serverCallState, urlPrefix, isSocketConnected, dispatch, ...paramOverride, }); - return serverCall(callServerEndpoint); + return serverCall(callSingleKeyserverEndpoint); }, [serverCall, serverCallState, dispatch, paramOverride, selector]); } export { useServerCall }; diff --git a/lib/utils/call-server-endpoint.js b/lib/utils/call-single-keyserver-endpoint.js similarity index 81% rename from lib/utils/call-server-endpoint.js rename to lib/utils/call-single-keyserver-endpoint.js index 014616adb..ea86918a4 100644 --- a/lib/utils/call-server-endpoint.js +++ b/lib/utils/call-single-keyserver-endpoint.js @@ -1,229 +1,231 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { getConfig } from './config.js'; import { ServerError, FetchTimeout, SocketOffline, SocketTimeout, } from './errors.js'; import sleep from './sleep.js'; import { uploadBlob, type UploadBlob } from './upload-blob.js'; import { updateLastCommunicatedPlatformDetailsActionType } from '../actions/device-actions.js'; -import { callServerEndpointTimeout } from '../shared/timeouts.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'; -export type CallServerEndpointOptions = Partial<{ +export type CallSingleKeyserverEndpointOptions = Partial<{ // null timeout means no timeout, which is the default for uploadBlob +timeout: ?number, // in milliseconds - // getResultInfo will be called right before callServerEndpoint successfully - // resolves and includes additional information about the request - +getResultInfo: (resultInfo: CallServerEndpointResultInfo) => mixed, + // getResultInfo will be called right before callSingleKeyserverEndpoint + // successfully resolves and includes additional information about the request + +getResultInfo: (resultInfo: CallSingleKeyserverEndpointResultInfo) => mixed, +blobUpload: boolean | UploadBlob, // the rest (onProgress, abortHandler) only work with blobUpload +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 CallServerEndpointResultInfoInterface = 'socket' | 'REST'; -export type CallServerEndpointResultInfo = { - +interface: CallServerEndpointResultInfoInterface, +export type CallSingleKeyserverEndpointResultInfoInterface = 'socket' | 'REST'; +export type CallSingleKeyserverEndpointResultInfo = { + +interface: CallSingleKeyserverEndpointResultInfoInterface, }; -export type CallServerEndpointResponse = Partial<{ +export type CallSingleKeyserverEndpointResponse = Partial<{ +cookieChange: ServerSessionChange, +currentUserInfo: CurrentUserInfo, +error: string, +payload: Object, }>; -// You'll notice that this is not the type of the callServerEndpoint +// 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 lib/utils/action-utils.js. -// This type represents the form of the callServerEndpoint function that -// gets passed to the action function in lib/actions. -export type CallServerEndpoint = ( +// This type represents the form of the callSingleKeyserverEndpoint function +// that gets passed to the action function in lib/actions. +export type CallSingleKeyserverEndpoint = ( endpoint: Endpoint, input: Object, - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => Promise; type RequestData = { input: { +[key: string]: mixed }, cookie?: ?string, sessionID?: ?string, platformDetails?: PlatformDetails, }; -async function callServerEndpoint( +async function callSingleKeyserverEndpoint( cookie: ?string, setNewSession: (sessionChange: ClientSessionChange, error: ?string) => void, - waitIfCookieInvalidated: () => Promise, + waitIfCookieInvalidated: () => Promise, cookieInvalidationRecovery: ( sessionChange: ClientSessionChange, - ) => Promise, + ) => Promise, urlPrefix: string, sessionID: ?string, isSocketConnected: boolean, lastCommunicatedPlatformDetails: ?PlatformDetails, socketAPIHandler: ?SocketAPIHandler, endpoint: Endpoint, input: { +[key: string]: mixed }, dispatch: Dispatch, - options?: ?CallServerEndpointOptions, + 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; } 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.blobUpload) { const uploadBlobCallback = typeof options.blobUpload === 'function' ? options.blobUpload : uploadBlob; json = await uploadBlobCallback(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 => { + (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 : callServerEndpointTimeout; + options && options.timeout + ? options.timeout + : callSingleKeyserverEndpointTimeout; if (!timeout) { json = await callEndpointPromise; } else { const rejectPromise = (async () => { await sleep(timeout); throw new FetchTimeout( - `callServerEndpoint timed out call to ${endpoint}`, + `callSingleKeyserverEndpoint timed out call to ${endpoint}`, endpoint, ); })(); json = await Promise.race([callEndpointPromise, rejectPromise]); } } 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); if (maybeReplacement) { return await maybeReplacement(endpoint, input, options); } } 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 callServerEndpoint; +export default callSingleKeyserverEndpoint; diff --git a/lib/utils/config.js b/lib/utils/config.js index ab389b3cf..3d076714a 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,36 +1,36 @@ // @flow import invariant from 'invariant'; -import type { CallServerEndpoint } from './call-server-endpoint.js'; +import type { CallSingleKeyserverEndpoint } from './call-single-keyserver-endpoint.js'; import type { CallKeyserverEndpoint } from './keyserver-call.js'; import type { DispatchRecoveryAttempt } from '../keyserver-conn/keyserver-conn-types.js'; import type { LogInActionSource } from '../types/account-types.js'; import type { PlatformDetails } from '../types/device-types.js'; export type Config = { +resolveKeyserverSessionInvalidationUsingNativeCredentials: ?( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, logInActionSource: LogInActionSource, keyserverID: string, getInitialNotificationsEncryptedMessage?: () => Promise, ) => Promise, +setSessionIDOnRequest: boolean, +calendarRangeInactivityLimit: ?number, +platformDetails: PlatformDetails, }; let registeredConfig: ?Config = null; const registerConfig = (config: Config) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; export { registerConfig, getConfig }; diff --git a/lib/utils/keyserver-call.js b/lib/utils/keyserver-call.js index 6d86b8d78..0a860e739 100644 --- a/lib/utils/keyserver-call.js +++ b/lib/utils/keyserver-call.js @@ -1,121 +1,120 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; -import type { CallServerEndpointOptions } from './call-server-endpoint.js'; +import type { CallSingleKeyserverEndpointOptions } from './call-single-keyserver-endpoint.js'; import { promiseAll } from './promises.js'; import { useSelector, useDispatch } from './redux-utils.js'; import { useCallKeyserverEndpointContext } from '../keyserver-conn/call-keyserver-endpoint-provider.react.js'; import { useKeyserverCallInfos, type KeyserverInfoPartial, } from '../keyserver-conn/keyserver-call-infos.js'; import type { Endpoint } from '../types/endpoints.js'; import type { Dispatch } from '../types/redux-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; export type CallKeyserverEndpoint = ( endpoint: Endpoint, requests: { +[keyserverID: string]: ?{ +[string]: mixed } }, - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => Promise<{ +[keyserverID: string]: any }>; type ActionFunc = ( - callServerEndpoint: CallKeyserverEndpoint, + callSingleKeyserverEndpoint: CallKeyserverEndpoint, // The second argument is only used in actions that call all keyservers, // and the request to all keyservers are exactly the same. // An example of such action is fetchEntries. allKeyserverIDs: $ReadOnlyArray, ) => Args => Promise; export type KeyserverCallParamOverride = Partial<{ +dispatch: Dispatch, +currentUserInfo: ?CurrentUserInfo, +keyserverInfos: { +[keyserverID: string]: KeyserverInfoPartial }, }>; function useKeyserverCall( keyserverCall: ActionFunc, paramOverride?: ?KeyserverCallParamOverride, ): Args => Promise { const baseDispatch = useDispatch(); const baseCurrentUserInfo = useSelector(state => state.currentUserInfo); const keyserverInfos = useSelector( state => state.keyserverStore.keyserverInfos, ); const baseCombinedInfo = { dispatch: baseDispatch, currentUserInfo: baseCurrentUserInfo, keyserverInfos, ...paramOverride, }; const { dispatch, currentUserInfo, keyserverInfos: keyserverInfoPartials, } = baseCombinedInfo; const keyserverCallInfos = useKeyserverCallInfos(keyserverInfoPartials); const { createCallSingleKeyserverEndpointSelector } = useCallKeyserverEndpointContext(); const getCallSingleKeyserverEndpointSelector: typeof createCallSingleKeyserverEndpointSelector = React.useMemo( () => _memoize(createCallSingleKeyserverEndpointSelector), [createCallSingleKeyserverEndpointSelector], ); return React.useMemo(() => { const callKeyserverEndpoint = ( endpoint: Endpoint, requests: { +[keyserverID: string]: ?{ +[string]: mixed } }, - options?: ?CallServerEndpointOptions, + options?: ?CallSingleKeyserverEndpointOptions, ) => { const makeCallToSingleKeyserver = (keyserverID: string) => { const { cookie, urlPrefix, sessionID, isSocketConnected, lastCommunicatedPlatformDetails, } = keyserverCallInfos[keyserverID]; - const boundCallServerEndpoint = getCallSingleKeyserverEndpointSelector( - keyserverID, - )({ - dispatch, - currentUserInfo, - cookie, - urlPrefix, - sessionID, - isSocketConnected, - lastCommunicatedPlatformDetails, - }); + const boundCallSingleKeyserverEndpoint = + getCallSingleKeyserverEndpointSelector(keyserverID)({ + dispatch, + currentUserInfo, + cookie, + urlPrefix, + sessionID, + isSocketConnected, + lastCommunicatedPlatformDetails, + }); - return boundCallServerEndpoint( + return boundCallSingleKeyserverEndpoint( endpoint, requests[keyserverID], options, ); }; const promises: { [string]: Promise } = {}; for (const keyserverID in requests) { promises[keyserverID] = makeCallToSingleKeyserver(keyserverID); } return promiseAll(promises); }; const keyserverIDs = Object.keys(keyserverCallInfos); return keyserverCall(callKeyserverEndpoint, keyserverIDs); }, [ dispatch, currentUserInfo, keyserverCallInfos, getCallSingleKeyserverEndpointSelector, keyserverCall, ]); } export { useKeyserverCall }; diff --git a/lib/utils/upload-blob.js b/lib/utils/upload-blob.js index 1e1eaa659..d3c808cc0 100644 --- a/lib/utils/upload-blob.js +++ b/lib/utils/upload-blob.js @@ -1,116 +1,116 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import type { - CallServerEndpointOptions, - CallServerEndpointResponse, -} from './call-server-endpoint.js'; + CallSingleKeyserverEndpointOptions, + CallSingleKeyserverEndpointResponse, +} from './call-single-keyserver-endpoint.js'; import { getConfig } from './config.js'; function uploadBlob( url: string, cookie: ?string, sessionID: ?string, input: { +[key: string]: mixed }, - options?: ?CallServerEndpointOptions, -): Promise { + options?: ?CallSingleKeyserverEndpointOptions, +): Promise { const formData = new FormData(); formData.append('cookie', cookie ? cookie : ''); 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. formData.append('sessionID', sessionID ? sessionID : ''); } for (const key in input) { if (key === 'multimedia' || key === 'cookie' || key === 'sessionID') { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); formData.append(key, value); } const { multimedia } = input; if (multimedia && Array.isArray(multimedia)) { for (const media of multimedia) { // We perform an any-cast here because of React Native. Though Blob // support was introduced in react-native@0.54, it isn't compatible with // FormData. Instead, React Native requires a specific object format. formData.append('multimedia', (media: any)); } } const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.setRequestHeader('Accept', 'application/json'); if (options && options.timeout) { xhr.timeout = options.timeout; } if (options && options.onProgress) { const { onProgress } = options; xhr.upload.onprogress = _throttle( ({ loaded, total }) => onProgress(loaded / total), 50, ); } let failed = false; - const responsePromise = new Promise( + const responsePromise = new Promise( (resolve, reject) => { xhr.onload = () => { if (failed) { return; } const text = xhr.responseText; try { resolve(JSON.parse(text)); } catch (e) { console.log(text); reject(e); } }; xhr.onabort = () => { failed = true; reject(new Error('request aborted')); }; xhr.onerror = event => { failed = true; reject(event); }; if (options && options.timeout) { xhr.ontimeout = event => { failed = true; reject(event); }; } if (options && options.abortHandler) { options.abortHandler(() => { failed = true; reject(new Error('request aborted')); xhr.abort(); }); } }, ); if (!failed) { xhr.send(formData); } return responsePromise; } export type UploadBlob = typeof uploadBlob; export { uploadBlob }; diff --git a/native/account/resolve-invalidated-cookie.js b/native/account/resolve-invalidated-cookie.js index c762c20c6..bb409a604 100644 --- a/native/account/resolve-invalidated-cookie.js +++ b/native/account/resolve-invalidated-cookie.js @@ -1,49 +1,51 @@ // @flow import { logInActionTypes, logInRawAction } from 'lib/actions/user-actions.js'; import type { DispatchRecoveryAttempt } from 'lib/keyserver-conn/keyserver-conn-types.js'; import type { InitialNotifMessageOptions } from 'lib/shared/crypto-utils.js'; import type { LogInActionSource } from 'lib/types/account-types.js'; -import type { CallServerEndpoint } from 'lib/utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpoint } from 'lib/utils/call-single-keyserver-endpoint.js'; import type { CallKeyserverEndpoint } from 'lib/utils/keyserver-call.js'; import { fetchNativeKeychainCredentials } from './native-credentials.js'; import { store } from '../redux/redux-setup.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; async function resolveKeyserverSessionInvalidationUsingNativeCredentials( - callServerEndpoint: CallServerEndpoint, + callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, logInActionSource: LogInActionSource, keyserverID: string, getInitialNotificationsEncryptedMessage?: ( ?InitialNotifMessageOptions, ) => Promise, ) { const keychainCredentials = await fetchNativeKeychainCredentials(); if (!keychainCredentials) { return; } let extraInfo = await nativeLogInExtraInfoSelector(store.getState())(); if (getInitialNotificationsEncryptedMessage) { const initialNotificationsEncryptedMessage = - await getInitialNotificationsEncryptedMessage({ callServerEndpoint }); + await getInitialNotificationsEncryptedMessage({ + callSingleKeyserverEndpoint, + }); extraInfo = { ...extraInfo, initialNotificationsEncryptedMessage }; } const { calendarQuery } = extraInfo; await dispatchRecoveryAttempt( logInActionTypes, logInRawAction(callKeyserverEndpoint)({ ...keychainCredentials, ...extraInfo, logInActionSource, keyserverIDs: [keyserverID], }), { calendarQuery }, ); } export { resolveKeyserverSessionInvalidationUsingNativeCredentials }; diff --git a/native/account/siwe-hooks.js b/native/account/siwe-hooks.js index 983017a87..77bfdfad1 100644 --- a/native/account/siwe-hooks.js +++ b/native/account/siwe-hooks.js @@ -1,95 +1,95 @@ // @flow import * as React from 'react'; import { siweAuth, siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { useInitialNotificationsEncryptedMessage } from 'lib/shared/crypto-utils.js'; import type { LogInStartingPayload, LogInExtraInfo, } from 'lib/types/account-types.js'; import { useServerCall } from 'lib/utils/action-utils.js'; -import type { CallServerEndpointOptions } from 'lib/utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpointOptions } from 'lib/utils/call-single-keyserver-endpoint.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useSelector } from '../redux/redux-utils.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; import { nativeNotificationsSessionCreator } from '../utils/crypto-utils.js'; type SIWEServerCallParams = { +message: string, +signature: string, +doNotRegister?: boolean, ... }; function useSIWEServerCall(): ( SIWEServerCallParams, - ?CallServerEndpointOptions, + ?CallSingleKeyserverEndpointOptions, ) => Promise { const siweAuthCall = useServerCall(siweAuth); const callSIWE = React.useCallback( ( message: string, signature: string, extraInfo: $ReadOnly<{ ...LogInExtraInfo, +doNotRegister?: boolean }>, - callServerEndpointOptions: ?CallServerEndpointOptions, + callSingleKeyserverEndpointOptions: ?CallSingleKeyserverEndpointOptions, ) => siweAuthCall( { message, signature, ...extraInfo, }, - callServerEndpointOptions, + callSingleKeyserverEndpointOptions, ), [siweAuthCall], ); const logInExtraInfo = useSelector(nativeLogInExtraInfoSelector); const getInitialNotificationsEncryptedMessage = useInitialNotificationsEncryptedMessage(nativeNotificationsSessionCreator); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( async ( { message, signature, doNotRegister }, - callServerEndpointOptions, + callSingleKeyserverEndpointOptions, ) => { const extraInfo = await logInExtraInfo(); const initialNotificationsEncryptedMessage = await getInitialNotificationsEncryptedMessage({ - callServerEndpointOptions, + callSingleKeyserverEndpointOptions, }); const siwePromise = callSIWE( message, signature, { ...extraInfo, initialNotificationsEncryptedMessage, doNotRegister, }, - callServerEndpointOptions, + callSingleKeyserverEndpointOptions, ); void dispatchActionPromise( siweAuthActionTypes, siwePromise, undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); await siwePromise; }, [ logInExtraInfo, dispatchActionPromise, callSIWE, getInitialNotificationsEncryptedMessage, ], ); } export { useSIWEServerCall }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 38c7dcaf5..751a2a89a 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1752 +1,1752 @@ // @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 { createLocalMessageActionType, sendMultimediaMessageActionTypes, useSendMultimediaMessage, sendTextMessageActionTypes, useSendTextMessage, } from 'lib/actions/message-actions.js'; import type { SendMultimediaMessageInput, SendTextMessageInput, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, useBlobServiceUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, type BlobServiceUploadAction, type BlobServiceUploadResult, } from 'lib/actions/upload-actions.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.js'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js'; import { isLocalUploadID, getNextLocalUploadID, } from 'lib/media/media-utils.js'; import { videoDurationLimit } from 'lib/media/video-utils.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { createMediaMessageInfo, useNextLocalID, useMessageCreationSideEffectsFunc, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread, threadIsPending, threadIsPendingSidebar, patchThreadInfoToIncludeMentionedMembersOfParent, threadInfoInsideCommunity, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, MediaMissionStep, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ClientMediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type ClientNewThreadRequest, type NewThreadResult, type ThreadInfo, } from 'lib/types/thread-types.js'; import { useServerCall } from 'lib/utils/action-utils.js'; import type { - CallServerEndpointOptions, - CallServerEndpointResponse, -} from 'lib/utils/call-server-endpoint.js'; + CallSingleKeyserverEndpointOptions, + CallSingleKeyserverEndpointResponse, +} from 'lib/utils/call-single-keyserver-endpoint.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, cloneError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { useDispatchActionPromise, type DispatchActionPromise, } 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, InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, type MessagePendingUploads, type InputState, } 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, +nextLocalID: string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +mediaReportsEnabled: boolean, +calendarQuery: () => CalendarQuery, +dispatch: Dispatch, +staffCanSee: boolean, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +blobServiceUpload: BlobServiceUploadAction, +sendMultimediaMessage: ( input: SendMultimediaMessageInput, ) => Promise, +sendTextMessage: (input: SendTextMessageInput) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs: 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); } } 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), ); }; 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.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( messageInfo, inputThreadInfo, parentThreadInfo, ), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let threadInfo = inputThreadInfo; const { viewerID } = this.props; if (viewerID && inputThreadInfo.type === threadTypes.SIDEBAR) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { const pendingThreadUpdateHandler = this.pendingThreadUpdateHandlers.get( threadInfo.id, ); pendingThreadUpdateHandler?.(threadInfo); } } let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; // Branching to appease `flow`. const newThreadInfo = threadInfo.minimallyEncoded ? { ...threadInfo, id: newThreadID, } : { ...threadInfo, id: newThreadID, }; void this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); }; startThreadCreation(threadInfo: ThreadInfo): Promise { if (!threadIsPending(threadInfo.id)) { return Promise.resolve(threadInfo.id); } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } async sendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): Promise { try { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const result = await this.props.sendTextMessage({ 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 threadInfoInsideCommunity(threadInfo, commStaffCommunity.id); } sendMultimediaMessage = async ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = this.props.nextLocalID; 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 } = 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 = (mediaID: string, message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, mediaID); userTime = Date.now() - start; }; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localMediaID, 'transcoding', percent); }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia(selection, { hasWiFi: this.props.hasWiFi, finalFileHeaderCheck: this.props.staffCanSee, onTranscodingProgress, }); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; onUploadFailed(localMediaID, message); return await onUploadFinished(processResult); } if (processResult.shouldDisposePath) { filesToDispose.push(processResult.shouldDisposePath); } processedMedia = processResult; } catch (e) { onUploadFailed(localMediaID, 'processing failed'); return await onUploadFinished({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } if (this.shouldEncryptMedia(threadInfo)) { const encryptionStart = Date.now(); try { const { result: encryptionResult, ...encryptionReturn } = await encryptMedia(processedMedia); encryptionSteps = encryptionReturn.steps; if (!encryptionResult.success) { onUploadFailed(localMediaID, encryptionResult.reason); return await onUploadFinished(encryptionResult); } if (encryptionResult.shouldDisposePath) { filesToDispose.push(encryptionResult.shouldDisposePath); } processedMedia = encryptionResult; } catch (e) { onUploadFailed(localMediaID, 'encryption failed'); return await onUploadFinished({ success: false, reason: 'encryption_exception', time: Date.now() - encryptionStart, exceptionMessage: getMessageForException(e), }); } } const { uploadURI, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, uploadThumbnailResult, mediaMissionResult; try { if ( this.useBlobServiceUploads && (processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video') ) { 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: threadInfo.id, 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: threadInfo.id, 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, ), uploadBlob: this.uploadBlob, }, ); 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, }, { uploadBlob: this.uploadBlob, }, ); })(); [uploadResult, uploadThumbnailResult] = await Promise.all([ uploadPromise, uploadThumbnailPromise, ]); } mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed(localMediaID, 'upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if ( ((processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo') && uploadResult) || ((processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video') && uploadResult && uploadThumbnailResult) ) { const { encryptionKey } = processedMedia; const { id, uri, dimensions, loop } = uploadResult; serverID = id; const mediaSourcePayload = processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video' ? { type: processedMedia.mediaType, blobURI: uri, encryptionKey, } : { type: uploadResult.mediaType, uri, }; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, ...mediaSourcePayload, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult; const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } = processedMedia; if (processedMedia.mediaType === 'encrypted_video') { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }, }; } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailURI, thumbnailThumbHash, }, }; } } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbHash: processedMedia.thumbHash, }, }; } // When we dispatch this action, it updates Redux and triggers the // componentDidUpdate in this class. componentDidUpdate will handle // calling dispatchMultimediaMessageAction once all the uploads are // complete, and does not wait until this function concludes. this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: updateMediaPayload, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push(...encryptionSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const cleanupPromises = []; if (filesToDispose.length > 0) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete filesToDispose.forEach(shouldDisposePath => { cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); }); } // if there's a thumbnail we'll temporarily unlink it here // instead of in media-utils, will be changed in later diffs if (processedMedia.mediaType === 'video') { const { uploadThumbnailURI } = processedMedia; cleanupPromises.push( (async () => { const { steps: clearSteps, result: thumbnailPath } = await this.waitForCaptureURIUnload(uploadThumbnailURI); steps.push(...clearSteps); if (!thumbnailPath) { return; } const disposeStep = await disposeTempFile(thumbnailPath); steps.push(disposeStep); })(), ); } if (selection.captureTime || selection.step === 'photo_paste') { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose. Check out the // Multimedia component to see how the URIs get switched out. const captureURI = selection.uri; cleanupPromises.push( (async () => { const { steps: clearSteps, result: capturePath } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(cleanupPromises); return await onUploadFinished(mediaMissionResult); } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, progressPercent: number, ) { this.setState(prevState => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, processingStep, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { +[key: string]: mixed }, - options?: ?CallServerEndpointOptions, - ): Promise => { + options?: ?CallSingleKeyserverEndpointOptions, + ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters: { [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', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadTask = FileSystem.createUploadTask( url, path, { uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'multimedia', headers: { Accept: 'application/json', }, parameters, }, uploadProgress => { if (options && options.onProgress) { const { totalByteSent, totalBytesExpectedToSend } = uploadProgress; options.onProgress(totalByteSent / totalBytesExpectedToSend); } }, ); if (options && options.abortHandler) { options.abortHandler(() => uploadTask.cancelAsync()); } try { const response = await uploadTask.uploadAsync(); return JSON.parse(response.body); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } }; handleUploadFailure(localMessageID: string, localUploadID: string) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: { localID: string, localMessageID: string, serverID: ?string }, mediaMission: MediaMission, ) { const report: 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 nextLocalID = useNextLocalID(); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( state => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const calendarQuery = useCalendarQuery(); const callUploadMultimedia = useServerCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const callSendMultimediaMessage = useSendMultimediaMessage(); const callSendTextMessage = useSendTextMessage(); const callNewThread = useNewThread(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); const staffCanSee = useStaffCanSee(); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); return ( ); }); export default ConnectedInputStateContainer; diff --git a/web/chat/reaction-message-utils.js b/web/chat/reaction-message-utils.js index 8e1dcfe40..aa4f26935 100644 --- a/web/chat/reaction-message-utils.js +++ b/web/chat/reaction-message-utils.js @@ -1,219 +1,219 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useSendReactionMessage, sendReactionMessageActionTypes, } from 'lib/actions/message-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ReactionInfo } from 'lib/selectors/chat-selectors'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { RawReactionMessageInfo } from 'lib/types/messages/reaction.js'; -import type { CallServerEndpointResultInfoInterface } from 'lib/utils/call-server-endpoint.js'; +import type { CallSingleKeyserverEndpointResultInfoInterface } from 'lib/utils/call-single-keyserver-endpoint.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, localID: string, threadID: string, reactions: ReactionInfo, ): (reaction: string) => mixed { const { pushModal } = useModalContext(); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const callSendReactionMessage = useSendReactionMessage(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( reaction => { if (!messageID) { return; } invariant(viewerID, 'viewerID should be set'); const viewerReacted = reactions[reaction] ? reactions[reaction].viewerReacted : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; const reactionMessagePromise = (async () => { try { const result = await callSendReactionMessage({ threadID, localID, targetMessageID: messageID, reaction, action, }); const serverID: string = result.id; const time: number = result.time; - const interfaceInfo: CallServerEndpointResultInfoInterface = + 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, threadID, localID, dispatchActionPromise, callSendReactionMessage, pushModal, ], ); } type EmojiKeyboardPosition = { +bottom: number, +left: number, }; function getEmojiKeyboardPosition( emojiKeyboard: ?HTMLDivElement, tooltipPositionStyle: TooltipPositionStyle, tooltipSize: TooltipSize, ): ?EmojiKeyboardPosition { const { alignment, anchorPoint } = tooltipPositionStyle; const tooltipAnchorX = anchorPoint.x; const tooltipAnchorY = anchorPoint.y; const tooltipWidth = tooltipSize.width; const tooltipHeight = tooltipSize.height; const appContainerPositionInfo = getAppContainerPositionInfo(); if (!appContainerPositionInfo) { return null; } let emojiKeyboardWidth = 352; let emojiKeyboardHeight = 435; if (emojiKeyboard) { const { width, height } = emojiKeyboard.getBoundingClientRect(); emojiKeyboardWidth = width; emojiKeyboardHeight = height; } const { top: containerTop, left: containerLeft, right: containerRight, bottom: containerBottom, } = appContainerPositionInfo; const padding = 16; const canBeDisplayedOnRight = tooltipAnchorX + tooltipWidth + emojiKeyboardWidth <= containerRight; const canBeDisplayedOnLeft = tooltipAnchorX - emojiKeyboardWidth >= containerLeft; const canBeDisplayedOnTop = tooltipAnchorY - emojiKeyboardHeight - padding >= containerTop; const canBeDisplayedOnBottom = tooltipAnchorY + tooltipHeight + emojiKeyboardHeight + padding <= containerBottom; const emojiKeyboardOverflowTop = containerTop - (tooltipAnchorY + tooltipHeight - emojiKeyboardHeight); const emojiKeyboardOverflowTopCorrection = emojiKeyboardOverflowTop > 0 ? -emojiKeyboardOverflowTop - padding : 0; const emojiKeyboardOverflowRight = tooltipAnchorX + emojiKeyboardWidth - containerRight; const emojiKeyboardOverflowRightCorrection = emojiKeyboardOverflowRight > 0 ? -emojiKeyboardOverflowRight - padding : 0; if (alignment === 'left' && canBeDisplayedOnRight) { return { left: tooltipWidth, bottom: emojiKeyboardOverflowTopCorrection, }; } if (alignment === 'right' && canBeDisplayedOnLeft) { return { left: -emojiKeyboardWidth, bottom: emojiKeyboardOverflowTopCorrection, }; } if (canBeDisplayedOnTop) { return { bottom: tooltipHeight + padding, left: emojiKeyboardOverflowRightCorrection, }; } if (canBeDisplayedOnBottom) { return { bottom: -emojiKeyboardHeight - padding, left: emojiKeyboardOverflowRightCorrection, }; } return { left: alignment === 'left' ? -emojiKeyboardWidth : tooltipWidth, bottom: emojiKeyboardOverflowTopCorrection, }; } export { useSendReaction, getEmojiKeyboardPosition }; diff --git a/web/redux/action-types.js b/web/redux/action-types.js index 5bda74ad5..2241e5d57 100644 --- a/web/redux/action-types.js +++ b/web/redux/action-types.js @@ -1,184 +1,186 @@ // @flow import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { useKeyserverCall } from 'lib/utils/keyserver-call.js'; import type { CallKeyserverEndpoint } from 'lib/utils/keyserver-call.js'; import type { URLInfo } from 'lib/utils/url-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import type { ExcludedData, InitialReduxState, InitialReduxStateResponse, InitialKeyserverInfo, InitialReduxStateRequest, } from '../types/redux-types.js'; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensionsActionType = 'UPDATE_WINDOW_DIMENSIONS'; export const updateWindowActiveActionType = 'UPDATE_WINDOW_ACTIVE'; export const setInitialReduxState = 'SET_INITIAL_REDUX_STATE'; -const getInitialReduxStateCallServerEndpointOptions = { timeout: 300000 }; +const getInitialReduxStateCallSingleKeyserverEndpointOptions = { + timeout: 300000, +}; type GetInitialReduxStateInput = { +urlInfo: URLInfo, +excludedData: ExcludedData, +allUpdatesCurrentAsOf: { +[keyserverID: string]: number, }, }; const getInitialReduxState = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: GetInitialReduxStateInput) => Promise) => async input => { const requests: { [string]: InitialReduxStateRequest } = {}; const { urlInfo, excludedData, allUpdatesCurrentAsOf } = input; const { thread, inviteSecret, ...rest } = urlInfo; const threadKeyserverID = thread ? extractKeyserverIDFromID(thread) : null; for (const keyserverID of allKeyserverIDs) { // As of Nov 2023, the only validation we have for adding a new keyserver // is we check if the keyserver URL is valid. This is not a very // extensive check, and gives the user the feeling of a false sucesses // when they add new keyservers to the keyserver store. ENG-5371 tracks // the task for initialzing a proper connection with the newly added // keyserver, and at that point we can make the validation checks // for adding a new keyserver more extensive. However, for the time being // we need to add this check below so that we aren't trying to make calls // to nonexistant keyservers that are in our keyserver store. if (keyserverID !== ashoatKeyserverID) { continue; } const clientUpdatesCurrentAsOf = allUpdatesCurrentAsOf[keyserverID]; const keyserverExcludedData: ExcludedData = { threadStore: !!excludedData.threadStore && !!clientUpdatesCurrentAsOf, }; if (keyserverID === threadKeyserverID) { requests[keyserverID] = { urlInfo, excludedData: keyserverExcludedData, clientUpdatesCurrentAsOf, }; } else { requests[keyserverID] = { urlInfo: rest, excludedData: keyserverExcludedData, clientUpdatesCurrentAsOf, }; } } const responses: { +[string]: InitialReduxStateResponse } = await callKeyserverEndpoint( 'get_initial_redux_state', requests, - getInitialReduxStateCallServerEndpointOptions, + getInitialReduxStateCallSingleKeyserverEndpointOptions, ); const { currentUserInfo, userInfos, pushApiPublicKey, commServicesAccessToken, navInfo, } = responses[ashoatKeyserverID]; const dataLoaded = currentUserInfo && !currentUserInfo.anonymous; const actualizedCalendarQuery = { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: defaultCalendarFilters, }; const entryStore = { daysToEntries: {}, entryInfos: {}, lastUserInteractionCalendar: 0, }; const threadStore = { threadInfos: {}, }; const messageStore = { currentAsOf: {}, local: {}, messages: {}, threads: {}, }; const inviteLinksStore = { links: {}, }; let keyserverInfos: { [keyserverID: string]: InitialKeyserverInfo } = {}; for (const keyserverID in responses) { entryStore.daysToEntries = { ...entryStore.daysToEntries, ...responses[keyserverID].entryStore.daysToEntries, }; entryStore.entryInfos = { ...entryStore.entryInfos, ...responses[keyserverID].entryStore.entryInfos, }; entryStore.lastUserInteractionCalendar = Math.max( entryStore.lastUserInteractionCalendar, responses[keyserverID].entryStore.lastUserInteractionCalendar, ); threadStore.threadInfos = { ...threadStore.threadInfos, ...responses[keyserverID].threadStore.threadInfos, }; messageStore.currentAsOf = { ...messageStore.currentAsOf, ...responses[keyserverID].messageStore.currentAsOf, }; messageStore.messages = { ...messageStore.messages, ...responses[keyserverID].messageStore.messages, }; messageStore.threads = { ...messageStore.threads, ...responses[keyserverID].messageStore.threads, }; inviteLinksStore.links = { ...inviteLinksStore.links, ...responses[keyserverID].inviteLinksStore.links, }; keyserverInfos = { ...keyserverInfos, [keyserverID]: responses[keyserverID].keyserverInfo, }; } return { navInfo: { ...navInfo, inviteSecret, }, currentUserInfo, entryStore, threadStore, userInfos, actualizedCalendarQuery, messageStore, dataLoaded, pushApiPublicKey, commServicesAccessToken, inviteLinksStore, keyserverInfos, }; }; function useGetInitialReduxState(): ( input: GetInitialReduxStateInput, ) => Promise { return useKeyserverCall(getInitialReduxState); } export { useGetInitialReduxState };