diff --git a/lib/actions/upload-actions.js b/lib/actions/upload-actions.js index dedf3d0b8..380fd47bb 100644 --- a/lib/actions/upload-actions.js +++ b/lib/actions/upload-actions.js @@ -1,455 +1,386 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { storeEstablishedHolderActionType } from './holder-actions.js'; import blobService from '../facts/blob-service.js'; import type { CallSingleKeyserverEndpoint } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { extractKeyserverIDFromID, extractKeyserverIDFromIDOptional, } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import { type PerformHTTPMultipartUpload } from '../keyserver-conn/multipart-upload.js'; import { mediaConfig } from '../media/file-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { AuthMetadata } from '../shared/identity-client-context.js'; import type { UploadMultimediaResult, Dimensions } from '../types/media-types'; import type { Dispatch } from '../types/redux-types.js'; import { toBase64URL } from '../utils/base64.js'; import { blobServiceUploadHandler, type BlobServiceUploadHandler, } from '../utils/blob-service-upload.js'; import { makeBlobServiceEndpointURL, makeBlobServiceURI, generateBlobHolder, } from '../utils/blob-service.js'; import { getMessageForException } from '../utils/errors.js'; import { useDispatch } from '../utils/redux-utils.js'; import { handleHTTPResponseError, createDefaultHTTPRequestHeaders, } from '../utils/services-utils.js'; export type MultimediaUploadCallbacks = Partial<{ +onProgress: (percent: number) => void, +abortHandler: (abort: () => void) => void, +performHTTPMultipartUpload: PerformHTTPMultipartUpload, +blobServiceUploadHandler: BlobServiceUploadHandler, +timeout: ?number, }>; -export type MultimediaUploadExtras = $ReadOnly< - Partial<{ - ...Dimensions, - +loop: boolean, - +encryptionKey: string, - +thumbHash: ?string, - }>, ->; - -const uploadMultimedia = - ( - callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, - ): (( - multimedia: Object, - extras: MultimediaUploadExtras, - callbacks?: MultimediaUploadCallbacks, - ) => Promise) => - async (multimedia, extras, callbacks) => { - const onProgress = callbacks && callbacks.onProgress; - const abortHandler = callbacks && callbacks.abortHandler; - const performHTTPMultipartUpload = - callbacks && callbacks.performHTTPMultipartUpload; - - 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 callSingleKeyserverEndpoint( - 'upload_multimedia', - { - ...stringExtras, - multimedia: [multimedia], - }, - { - onProgress, - abortHandler, - performHTTPMultipartUpload: performHTTPMultipartUpload - ? performHTTPMultipartUpload - : 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: string = extractKeyserverIDFromIDOptional(keyserverOrThreadID) ?? 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 = $ReadOnly<{ ...UploadMultimediaResult, +blobHolder: string, }>; export type BlobServiceUploadAction = (input: { +uploadInput: BlobServiceUploadInput, // use `null` to skip metadata upload to keyserver +keyserverOrThreadID: ?string, +callbacks?: MultimediaUploadCallbacks, }) => Promise; const blobServiceUpload = ( callKeyserverEndpoint: CallKeyserverEndpoint, dispatch: Dispatch, authMetadata: AuthMetadata, ): BlobServiceUploadAction => async input => { const { uploadInput, callbacks, keyserverOrThreadID } = input; const { encryptionKey, loop, dimensions, thumbHash, blobInput } = uploadInput; const blobHash = toBase64URL(uploadInput.blobHash); const defaultHeaders = createDefaultHTTPRequestHeaders(authMetadata); let maybeKeyserverID; if (keyserverOrThreadID) { maybeKeyserverID = extractKeyserverIDFromIDOptional(keyserverOrThreadID) ?? keyserverOrThreadID; } // don't prefix keyserver-owned holders with deviceID const holderPrefix = maybeKeyserverID ? null : authMetadata.deviceID; const blobHolder = generateBlobHolder(holderPrefix); // 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: { ...defaultHeaders, '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, }, authMetadata, { ...callbacks }, ); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } } // 3. Optionally upload metadata to keyserver const mimeType = blobInput.type === 'file' ? blobInput.file.type : blobInput.mimeType; if (!maybeKeyserverID) { dispatch({ type: storeEstablishedHolderActionType, payload: { blobHash, holder: blobHolder, }, }); if (!dimensions) { throw new Error('dimensions are required for non-keyserver uploads'); } const mediaType = mediaConfig[mimeType]?.mediaType; if (mediaType !== 'photo' && mediaType !== 'video') { throw new Error(`mediaType for ${mimeType} should be photo or video`); } return { id: uuid.v4(), uri: makeBlobServiceURI(blobHash), mediaType, dimensions, loop: loop ?? false, blobHolder, }; } // for Flow const keyserverID: string = maybeKeyserverID; const requests = { [keyserverID]: { blobHash, blobHolder, encryptionKey, filename: blobInput.type === 'file' ? blobInput.file.name : blobInput.filename, 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 { const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; const dispatch = useDispatch(); const blobUploadAction = React.useCallback( ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): BlobServiceUploadAction => async input => { const authMetadata = await getAuthMetadata(); const authenticatedUploadAction = blobServiceUpload( callSingleKeyserverEndpoint, dispatch, authMetadata, ); return authenticatedUploadAction(input); }, [dispatch, getAuthMetadata], ); return useKeyserverCall(blobUploadAction); } export type ThickThreadMediaMetadataInput = { +blobHash: string, +encryptionKey: string, +mimeType: string, +dimensions: ?Dimensions, +filename?: ?string, +thumbHash?: ?string, +loop?: boolean, }; export type MediaMetadataReassignmentAction = (input: { +mediaMetadataInput: ThickThreadMediaMetadataInput, +keyserverOrThreadID: string, }) => Promise; const reassignThickThreadMediaForThinThread = ( callKeyserverEndpoint: CallKeyserverEndpoint, authMetadata: AuthMetadata, ): MediaMetadataReassignmentAction => async input => { const { mediaMetadataInput, keyserverOrThreadID } = input; const { encryptionKey, loop, dimensions, thumbHash, mimeType } = mediaMetadataInput; const blobHolder = generateBlobHolder(); const blobHash = toBase64URL(mediaMetadataInput.blobHash); const defaultHeaders = createDefaultHTTPRequestHeaders(authMetadata); let filename = mediaMetadataInput.filename; if (!filename) { const basename = Math.random().toString(36).slice(-10); const extension = mediaConfig[mimeType]?.extension; filename = extension ? `${basename}.${extension}` : basename; } // 1. Assign new holder for blob with given blobHash 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: { ...defaultHeaders, 'content-type': 'application/json', }, }, ); handleHTTPResponseError(assignHolderResponse); } catch (e) { throw new Error( `Failed to assign holder: ${ getMessageForException(e) ?? 'unknown error' }`, ); } // 2. Upload media metadata to keyserver const keyserverID = extractKeyserverIDFromID(keyserverOrThreadID); const requests = { [keyserverID]: { blobHash, blobHolder, encryptionKey, filename, 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, }; }; function useMediaMetadataReassignment(): MediaMetadataReassignmentAction { const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; const thickThreadMediaReassignmentAction = React.useCallback( ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): MediaMetadataReassignmentAction => async input => { const authMetadata = await getAuthMetadata(); const authenticatedAction = reassignThickThreadMediaForThinThread( callSingleKeyserverEndpoint, authMetadata, ); return authenticatedAction(input); }, [getAuthMetadata], ); return useKeyserverCall(thickThreadMediaReassignmentAction); } export { - uploadMultimedia, useBlobServiceUpload, useMediaMetadataReassignment, updateMultimediaMessageMediaActionType, useDeleteUpload, }; diff --git a/lib/facts/comm-staff-community.js b/lib/facts/comm-staff-community.js deleted file mode 100644 index f4e6a64a5..000000000 --- a/lib/facts/comm-staff-community.js +++ /dev/null @@ -1,11 +0,0 @@ -// @flow - -type CommStaffCommunity = { - +id: string, -}; - -const commStaffCommunity: CommStaffCommunity = { - id: process.env['KEYSERVER'] ? '311733' : '256|311733', -}; - -export default commStaffCommunity; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 36c5663ce..6d0e958a6 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1975 +1,1967 @@ // @flow import invariant from 'invariant'; import _find from 'lodash/fp/find.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omit from 'lodash/fp/omit.js'; import _omitBy from 'lodash/fp/omitBy.js'; import * as React from 'react'; import { getUserAvatarForThread } from './avatar-utils.js'; import { generatePendingThreadColor } from './color-utils.js'; import { extractUserMentionsFromText } from './mention-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import ashoat from '../facts/ashoat.js'; import genesis from '../facts/genesis.js'; import { useLoggedInUserInfo } from '../hooks/account-hooks.js'; import { type UserSearchResult } from '../hooks/thread-search-hooks.js'; import { useUsersSupportThickThreads } from '../hooks/user-identities-hooks.js'; import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { hasPermission, permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, } from '../permissions/minimally-encoded-thread-permissions.js'; import { specialRoles } from '../permissions/special-roles.js'; import type { SpecialRole } from '../permissions/special-roles.js'; import { permissionLookup, getAllThreadPermissions, makePermissionsBlob, } from '../permissions/thread-permissions.js'; import type { ChatThreadItem, SidebarItem, } from '../selectors/chat-selectors.js'; import { threadInfoSelector, pendingToRealizedThreadIDsSelector, threadInfosSelectorForThreadType, onScreenThreadInfos, } from '../selectors/thread-selectors.js'; import { getRelativeMemberInfos, usersWithPersonalThreadSelector, } from '../selectors/user-selectors.js'; import type { AuxUserInfos } from '../types/aux-user-types.js'; import type { RawDeviceList } from '../types/identity-service-types.js'; import type { RelativeMemberInfo, RawThreadInfo, MemberInfoWithPermissions, RoleInfo, ThreadInfo, MinimallyEncodedThickMemberInfo, ThinRawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeMemberInfo, minimallyEncodeRawThreadInfoWithMemberPermissions, minimallyEncodeRoleInfo, minimallyEncodeThreadCurrentUserInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { defaultThreadSubscription } from '../types/subscription-types.js'; import { threadPermissionPropagationPrefixes, threadPermissions, type ThreadPermission, type ThreadPermissionsInfo, type ThreadRolePermissionsBlob, type UserSurfacedPermission, threadPermissionFilterPrefixes, threadPermissionsDisabledByBlock, type ThreadPermissionNotAffectedByBlock, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes, threadTypeIsCommunityRoot, assertThreadType, threadTypeIsThick, assertThinThreadType, assertThickThreadType, threadTypeIsSidebar, threadTypeIsPrivate, threadTypeIsPersonal, type ThinThreadType, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ClientLegacyRoleInfo, ServerThreadInfo, ThickMemberInfo, UserProfileThreadInfo, MixedRawThreadInfos, LegacyThinRawThreadInfo, ThreadTimestamps, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { ET, type ThreadEntity, type UserEntity, } from '../utils/entity-text.js'; import { stripMemberPermissionsFromRawThreadInfo, type ThinRawThreadInfoWithPermissions, } from '../utils/member-info-utils.js'; import { entries, values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; import { userSurfacedPermissionsFromRolePermissions } from '../utils/role-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { pendingThreadIDRegex, pendingThickSidebarURLPrefix, pendingSidebarURLPrefix, } from '../utils/validation-utils.js'; function threadHasPermission( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), permission: ThreadPermissionNotAffectedByBlock, ): boolean { if (!threadInfo) { return false; } invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (threadInfo.minimallyEncoded) { return hasPermission(threadInfo.currentUser.permissions, permission); } return permissionLookup(threadInfo.currentUser.permissions, permission); } type CommunityRootMembersToRoleType = { +[threadID: ?string]: { +[memberID: string]: ?RoleInfo, }, }; function useCommunityRootMembersToRole( threadInfos: $ReadOnlyArray, ): CommunityRootMembersToRoleType { const communityRootMembersToRole = React.useMemo(() => { const communityThreadInfos = threadInfos.filter(threadInfo => threadTypeIsCommunityRoot(threadInfo.type), ); if (communityThreadInfos.length === 0) { return {}; } const communityRoots = _keyBy('id')(communityThreadInfos); return _mapValues((threadInfo: ThreadInfo) => { const keyedMembers = _keyBy('id')(threadInfo.members); const keyedMembersToRole = _mapValues( (member: MemberInfoWithPermissions | RelativeMemberInfo) => { return member.role ? threadInfo.roles[member.role] : null; }, )(keyedMembers); return keyedMembersToRole; })(communityRoots); }, [threadInfos]); return communityRootMembersToRole; } // This function returns true for all thick threads, as well as all channels // inside GENESIS. Channels inside GENESIS were used in place of thick threads // before thick threads were launched, and as such we mirror "freezing" behavior // between them and thick threads. "Freezing" a thread can occur when a user // blocks another user, and those two users are the only members of a given // chat. Note that we exclude the GENESIS community root here, as the root // itself has never been used in place of thick threads. Also note that // grandchild channels of GENESIS get this behavior too, even though we don't // currently support channels inside thick threads. function threadIsThickOrChannelInsideGenesis(threadInfo: ThreadInfo): boolean { if (threadTypeIsThick(threadInfo.type)) { return true; } if (getCommunity(threadInfo) !== genesis().id) { return false; } return threadInfo.id !== genesis().id; } function useThreadsWithPermission( threadInfos: $ReadOnlyArray, permission: ThreadPermission, ): $ReadOnlyArray { const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); return React.useMemo(() => { return threadInfos.filter((threadInfo: ThreadInfo) => { const isGroupChat = threadIsThickOrChannelInsideGenesis(threadInfo); if (!isGroupChat || !loggedInUserInfo) { return hasPermission(threadInfo.currentUser.permissions, permission); } const threadFrozen = threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo, loggedInUserInfo.id, userInfos, false, ); const permissions = threadFrozen ? filterOutDisabledPermissions(threadInfo.currentUser.permissions) : threadInfo.currentUser.permissions; return hasPermission(permissions, permission); }); }, [threadInfos, loggedInUserInfo, userInfos, permission]); } function useThreadHasPermission( threadInfo: ?ThreadInfo, permission: ThreadPermission, ): boolean { const threadInfos = React.useMemo( () => (threadInfo ? [threadInfo] : []), [threadInfo], ); const threads = useThreadsWithPermission(threadInfos, permission); return threads.length === 1; } function viewerIsMember( threadInfo: ?(ThreadInfo | LegacyRawThreadInfo | RawThreadInfo), ): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function isMemberActive( memberInfo: MemberInfoWithPermissions | MinimallyEncodedThickMemberInfo, ): boolean { const role = memberInfo.role; return role !== null && role !== undefined; } function threadIsInHome(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function useIsThreadInChatList(threadInfo: ?ThreadInfo): boolean { const threadIsVisible = useThreadHasPermission( threadInfo, threadPermissions.VISIBLE, ); return viewerIsMember(threadInfo) && threadIsVisible; } function useThreadsInChatList( threadInfos: $ReadOnlyArray, ): $ReadOnlyArray { const visibleThreads = useThreadsWithPermission( threadInfos, threadPermissions.VISIBLE, ); return React.useMemo( () => visibleThreads.filter(viewerIsMember), [visibleThreads], ); } function threadIsTopLevel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInChatList(threadInfo) && threadIsChannel(threadInfo); } function threadIsChannel(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && !threadTypeIsSidebar(threadInfo.type)); } function threadIsSidebar(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return !!(threadInfo && threadTypeIsSidebar(threadInfo.type)); } function threadInBackgroundChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(RawThreadInfo | ThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(LegacyRawThreadInfo | RawThreadInfo | ThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && !threadTypeIsSidebar(threadInfo.type) ); } function userIsMember( threadInfo: ?(RawThreadInfo | ThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } if (threadInfo.id === genesis().id) { return true; } return threadInfo.members.some(member => member.id === userID && member.role); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter(memberInfo => memberInfo.role) .map(memberInfo => memberInfo.id); } type MemberIDAndRole = { +id: string, +role: ?string, ... }; function threadOtherMembers( memberInfos: $ReadOnlyArray, viewerID: ?string, ): $ReadOnlyArray { return memberInfos.filter( memberInfo => memberInfo.role && memberInfo.id !== viewerID, ); } function threadMembersWithoutAddedAdmin< T: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, >(threadInfo: T): $PropertyType { if (threadInfo.community !== genesis().id) { return threadInfo.members; } const adminID = extractKeyserverIDFromIDOptional(threadInfo.id); return threadInfo.members.filter( member => member.id !== adminID || member.role, ); } function threadIsGroupChat(threadInfo: ThreadInfo): boolean { return threadInfo.members.length > 2; } function threadOrParentThreadIsGroupChat( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ) { return threadMembersWithoutAddedAdmin(threadInfo).length > 2; } function threadIsPending(threadID: ?string): boolean { return !!threadID?.startsWith('pending'); } function threadIsPendingSidebar(threadID: ?string): boolean { return ( !!threadID?.startsWith(`pending/${pendingSidebarURLPrefix}/`) || !!threadID?.startsWith(`pending/${pendingThickSidebarURLPrefix}`) ); } function getSingleOtherUser( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, ): ?string { if (!viewerID) { return undefined; } const otherMembers = threadOtherMembers(threadInfo.members, viewerID); if (otherMembers.length !== 1) { return undefined; } return otherMembers[0].id; } function getPendingThreadID( threadType: ThreadType, memberIDs: $ReadOnlyArray, sourceMessageID: ?string, ): string { let pendingThreadKey; if (sourceMessageID && threadTypeIsThick(threadType)) { pendingThreadKey = `${pendingThickSidebarURLPrefix}/${sourceMessageID}`; } else if (sourceMessageID) { pendingThreadKey = `${pendingSidebarURLPrefix}/${sourceMessageID}`; } else { pendingThreadKey = [...memberIDs].sort().join('+'); } const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); let threadType; if (threadTypeString === pendingThickSidebarURLPrefix) { threadType = threadTypes.THICK_SIDEBAR; } else if (threadTypeString === pendingSidebarURLPrefix) { threadType = threadTypes.SIDEBAR; } else { threadType = assertThreadType(Number(threadTypeString.replace('type', ''))); } const threadTypeStringIsSidebar = threadTypeString === pendingSidebarURLPrefix || threadTypeString === pendingThickSidebarURLPrefix; const memberIDs = threadTypeStringIsSidebar ? [] : threadKey.split('+'); const sourceMessageID = threadTypeStringIsSidebar ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: ?string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions: ThreadRolePermissionsBlob = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, }; const membershipPermissions = getAllThreadPermissions( makePermissionsBlob(permissions, null, threadID, threadType), threadID, ); const role: RoleInfo = { ...minimallyEncodeRoleInfo({ id: `${threadID}/role`, name: 'Members', permissions, isDefault: true, }), specialRole: specialRoles.DEFAULT_ROLE, }; let rawThreadInfo: RawThreadInfo; if (threadTypeIsThick(threadType)) { const thickThreadType = assertThickThreadType(threadType); rawThreadInfo = { minimallyEncoded: true, thick: true, id: threadID, type: thickThreadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID( parentThreadInfo, thickThreadType, ), members: members.map(member => minimallyEncodeMemberInfo({ id: member.id, role: role.id, permissions: membershipPermissions, isSender: false, subscription: defaultThreadSubscription, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: defaultThreadSubscription, unread: false, }), repliesCount: 0, sourceMessageID, pinnedCount: 0, timestamps: createThreadTimestamps(now, memberIDs), }; } else { const thinThreadType = assertThinThreadType(threadType); rawThreadInfo = { minimallyEncoded: true, id: threadID, type: thinThreadType, name: name ?? null, description: null, color: threadColor ?? generatePendingThreadColor(memberIDs), creationTime: now, parentThreadID: parentThreadInfo?.id ?? null, containingThreadID: getContainingThreadID( parentThreadInfo, thinThreadType, ), community: getCommunity(parentThreadInfo), members: members.map(member => ({ id: member.id, role: role.id, minimallyEncoded: true, isSender: false, })), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: defaultThreadSubscription, unread: false, }), repliesCount: 0, sourceMessageID, pinnedCount: 0, }; } const userInfos: { [string]: UserInfo } = {}; for (const member of members) { const { id, username } = member; userInfos[id] = { id, username }; } return threadInfoFromRawThreadInfo(rawThreadInfo, viewerID, userInfos); } type PendingPersonalThread = { +threadInfo: ThreadInfo, +pendingPersonalThreadUserInfo: UserInfo, }; function createPendingPersonalOrPrivateThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: ?string, supportThickThreads: boolean, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const members: Array = [loggedInUserInfo]; let threadType; if (loggedInUserInfo.id === userID) { threadType = supportThickThreads ? threadTypes.PRIVATE : threadTypes.GENESIS_PRIVATE; } else { threadType = supportThickThreads ? threadTypes.PERSONAL : threadTypes.GENESIS_PERSONAL; members.push(pendingPersonalThreadUserInfo); } const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType, members, }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, supportThickThreads: boolean, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalOrPrivateThread( loggedInUserInfo, user.id, user.username, supportThickThreads, ); return { type: 'chatThreadItem', threadInfo, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } function pendingThreadType( numberOfOtherMembers: number, thickOrThin: 'thick' | 'thin', ): 4 | 6 | 7 | 13 | 14 | 15 { if (thickOrThin === 'thick') { if (numberOfOtherMembers === 0) { return threadTypes.PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.PERSONAL; } else { return threadTypes.LOCAL; } } else { if (numberOfOtherMembers === 0) { return threadTypes.GENESIS_PRIVATE; } else if (numberOfOtherMembers === 1) { return threadTypes.GENESIS_PERSONAL; } else { return threadTypes.COMMUNITY_SECRET_SUBTHREAD; } } } function threadTypeCanBePending(threadType: ThreadType): boolean { return ( threadType === threadTypes.GENESIS_PERSONAL || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.SIDEBAR || threadType === threadTypes.GENESIS_PRIVATE || threadType === threadTypes.PERSONAL || threadType === threadTypes.LOCAL || threadType === threadTypes.THICK_SIDEBAR || threadType === threadTypes.PRIVATE ); } type RawThreadInfoOptions = { +filterThreadEditAvatarPermission?: boolean, +excludePinInfo?: boolean, +filterManageInviteLinksPermission?: boolean, +filterVoicedInAnnouncementChannelsPermission?: boolean, +minimallyEncodePermissions?: boolean, +includeSpecialRoleFieldInRoles?: boolean, +allowAddingUsersToCommunityRoot?: boolean, +filterManageFarcasterChannelTagsPermission?: boolean, +stripMemberPermissions?: boolean, +canDisplayFarcasterThreadAvatars?: boolean, }; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?LegacyThinRawThreadInfo | ?ThinRawThreadInfo { const filterThreadEditAvatarPermission = options?.filterThreadEditAvatarPermission; const excludePinInfo = options?.excludePinInfo; const filterManageInviteLinksPermission = options?.filterManageInviteLinksPermission; const filterVoicedInAnnouncementChannelsPermission = options?.filterVoicedInAnnouncementChannelsPermission; const shouldMinimallyEncodePermissions = options?.minimallyEncodePermissions; const shouldIncludeSpecialRoleFieldInRoles = options?.includeSpecialRoleFieldInRoles; const allowAddingUsersToCommunityRoot = options?.allowAddingUsersToCommunityRoot; const filterManageFarcasterChannelTagsPermission = options?.filterManageFarcasterChannelTagsPermission; const stripMemberPermissions = options?.stripMemberPermissions; const canDisplayFarcasterThreadAvatars = options?.canDisplayFarcasterThreadAvatars; const filterThreadPermissions = ( innerThreadPermissions: ThreadPermissionsInfo, ) => { if ( allowAddingUsersToCommunityRoot && (serverThreadInfo.type === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || serverThreadInfo.type === threadTypes.COMMUNITY_ROOT) ) { innerThreadPermissions = { ...innerThreadPermissions, [threadPermissions.ADD_MEMBERS]: { value: true, source: serverThreadInfo.id, }, }; } return _omitBy( (v, k) => (filterThreadEditAvatarPermission && [ threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, ].includes(k)) || (excludePinInfo && [ threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, ].includes(k)) || (filterManageInviteLinksPermission && [threadPermissions.MANAGE_INVITE_LINKS].includes(k)) || (filterVoicedInAnnouncementChannelsPermission && [ threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ].includes(k)) || (filterManageFarcasterChannelTagsPermission && [threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS].includes(k)), )(innerThreadPermissions); }; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( serverThreadInfo.id === genesis().id && serverMember.id !== viewerID && serverMember.id !== ashoat.id ) { continue; } const memberPermissions = filterThreadPermissions(serverMember.permissions); members.push({ id: serverMember.id, role: serverMember.role, permissions: memberPermissions, isSender: serverMember.isSender, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: memberPermissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = filterThreadPermissions( getAllThreadPermissions(null, serverThreadInfo.id), ); currentUser = { role: null, permissions: currentUserPermissions, subscription: defaultThreadSubscription, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rolesWithFilteredThreadPermissions = _mapValues(role => ({ ...role, permissions: filterThreadPermissions(role.permissions), }))(serverThreadInfo.roles); const rolesWithoutSpecialRoleField = _mapValues(role => { const { specialRole, ...roleSansSpecialRole } = role; return roleSansSpecialRole; })(rolesWithFilteredThreadPermissions); let rawThreadInfo: any = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: rolesWithoutSpecialRoleField, currentUser, repliesCount: serverThreadInfo.repliesCount, containingThreadID: serverThreadInfo.containingThreadID, community: serverThreadInfo.community, }; const sourceMessageID = serverThreadInfo.sourceMessageID; if (sourceMessageID) { rawThreadInfo = { ...rawThreadInfo, sourceMessageID }; } if (serverThreadInfo.avatar) { const avatar = serverThreadInfo.avatar.type === 'farcaster' && !canDisplayFarcasterThreadAvatars ? null : serverThreadInfo.avatar; rawThreadInfo = { ...rawThreadInfo, avatar }; } if (!excludePinInfo) { rawThreadInfo = { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } if (!shouldMinimallyEncodePermissions) { return rawThreadInfo; } const minimallyEncodedRawThreadInfoWithMemberPermissions = minimallyEncodeRawThreadInfoWithMemberPermissions(rawThreadInfo); invariant( !minimallyEncodedRawThreadInfoWithMemberPermissions.thick, 'ServerThreadInfo should be thin thread', ); if (!shouldIncludeSpecialRoleFieldInRoles) { const minimallyEncodedRolesWithoutSpecialRoleField = Object.fromEntries( entries(minimallyEncodedRawThreadInfoWithMemberPermissions.roles).map( ([key, role]) => [ key, { ..._omit('specialRole')(role), isDefault: roleIsDefaultRole(role), }, ], ), ); return { ...minimallyEncodedRawThreadInfoWithMemberPermissions, roles: minimallyEncodedRolesWithoutSpecialRoleField, }; } if (!stripMemberPermissions) { return minimallyEncodedRawThreadInfoWithMemberPermissions; } // The return value of `deprecatedMinimallyEncodeRawThreadInfo` is typed // as `RawThreadInfo`, but still includes thread member permissions. // This was to prevent introducing "Legacy" types that would need to be // maintained going forward. This `any`-cast allows us to more precisely // type the obj being passed to `stripMemberPermissionsFromRawThreadInfo`. const rawThreadInfoWithMemberPermissions: ThinRawThreadInfoWithPermissions = (minimallyEncodedRawThreadInfoWithMemberPermissions: any); return stripMemberPermissionsFromRawThreadInfo( rawThreadInfoWithMemberPermissions, ); } function threadUIName(threadInfo: ThreadInfo): string | ThreadEntity { if (threadInfo.name) { return firstLine(threadInfo.name); } const threadMembers: $ReadOnlyArray = threadInfo.members.filter(memberInfo => memberInfo.role); const memberEntities: $ReadOnlyArray = threadMembers.map(member => ET.user({ userInfo: member }), ); return { type: 'thread', id: threadInfo.id, name: threadInfo.name, display: 'uiName', uiName: memberEntities, ifJustViewer: threadTypeIsPrivate(threadInfo.type) ? 'viewer_username' : 'just_you_string', }; } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { let threadInfo: ThreadInfo = { minimallyEncoded: true, id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: '', description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, containingThreadID: rawThreadInfo.containingThreadID, community: rawThreadInfo.community, members: getRelativeMemberInfos(rawThreadInfo, viewerID, userInfos), roles: rawThreadInfo.roles, currentUser: rawThreadInfo.currentUser, repliesCount: rawThreadInfo.repliesCount, }; threadInfo = { ...threadInfo, uiName: threadUIName(threadInfo), }; const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo; if (sourceMessageID) { threadInfo = { ...threadInfo, sourceMessageID }; } if (avatar) { threadInfo = { ...threadInfo, avatar }; } else if ( threadTypeIsPrivate(rawThreadInfo.type) || threadTypeIsPersonal(rawThreadInfo.type) ) { threadInfo = { ...threadInfo, avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos), }; } if (pinnedCount) { threadInfo = { ...threadInfo, pinnedCount }; } return threadInfo; } function filterOutDisabledPermissions(permissionsBitmask: string): string { const decodedPermissions: ThreadPermissionsInfo = threadPermissionsFromBitmaskHex(permissionsBitmask); const updatedPermissions = { ...decodedPermissions, ...disabledPermissions }; const encodedUpdatedPermissions: string = permissionsToBitmaskHex(updatedPermissions); return encodedUpdatedPermissions; } function baseThreadIsWithBlockedUserOnly( threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ) { const otherUserID = getSingleOtherUser(threadInfo, viewerID); if (!otherUserID) { return false; } const otherUserRelationshipStatus = userInfos[otherUserID]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( !!otherUserRelationshipStatus && relationshipBlockedInEitherDirection(otherUserRelationshipStatus) ); } function threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo: ThreadInfo | RawThreadInfo | LegacyRawThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock: boolean, ): boolean { if (threadOrParentThreadIsGroupChat(threadInfo)) { return false; } return baseThreadIsWithBlockedUserOnly( threadInfo, viewerID, userInfos, checkOnlyViewerBlock, ); } function useThreadFrozenDueToViewerBlock( threadInfo: ThreadInfo, communityThreadInfo: ?ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { const communityThreadInfoArray = React.useMemo( () => (communityThreadInfo ? [communityThreadInfo] : []), [communityThreadInfo], ); const communityRootsMembersToRole = useCommunityRootMembersToRole( communityThreadInfoArray, ); const memberToRole = communityRootsMembersToRole[communityThreadInfo?.id]; const memberHasAdminRole = threadMembersWithoutAddedAdmin(threadInfo).some( m => roleIsAdminRole(memberToRole?.[m.id]), ); return React.useMemo(() => { if (memberHasAdminRole) { return false; } return threadIsWithBlockedUserOnlyWithoutAdminRoleCheck( threadInfo, viewerID, userInfos, true, ); }, [memberHasAdminRole, threadInfo, userInfos, viewerID]); } const threadTypeDescriptions: { [ThreadType]: string } = { [threadTypes.COMMUNITY_OPEN_SUBTHREAD]: 'Anybody in the parent channel can see an open subchannel.', [threadTypes.COMMUNITY_SECRET_SUBTHREAD]: 'Only visible to its members and admins of ancestor channels.', }; function roleIsDefaultRole( roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo, ): boolean { if (roleInfo?.specialRole === specialRoles.DEFAULT_ROLE) { return true; } return !!(roleInfo && roleInfo.isDefault); } function roleIsAdminRole(roleInfo: ?ClientLegacyRoleInfo | ?RoleInfo): boolean { if (roleInfo?.specialRole === specialRoles.ADMIN_ROLE) { return true; } return !!(roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'); } function threadHasAdminRole( threadInfo: ?( | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo | ServerThreadInfo ), ): boolean { if (!threadInfo) { return false; } let hasSpecialRoleFieldBeenEncountered = false; for (const role of Object.values(threadInfo.roles)) { if (role.specialRole === specialRoles.ADMIN_ROLE) { return true; } if (role.specialRole !== undefined) { hasSpecialRoleFieldBeenEncountered = true; } } if (hasSpecialRoleFieldBeenEncountered) { return false; } return !!_find({ name: 'Admins' })(threadInfo.roles); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = values( threadPermissionsDisabledByBlock, ); const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText: string = `Muted chats are just like normal chats, except they don't ` + `contribute to your unread count.\n\n` + `To move a chat over here, switch the “Muted” option in its settings.`; function threadNoun(threadType: ThreadType, parentThreadID: ?string): string { if (threadTypeIsSidebar(threadType)) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis().id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.GENESIS_PERSONAL) { return 'Personal'; } else if (threadTypeIsSidebar(threadType)) { return 'Thread'; } else if (threadType === threadTypes.GENESIS_PRIVATE) { return 'Private'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS ) { return 'Community'; } else if (threadTypeIsThick(threadType)) { return 'Local DM'; } else { return 'Secret'; } } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, +allUsersSupportThickThreads: boolean, }; type ExistingThreadInfoFinder = ( params: ExistingThreadInfoFinderParams, ) => ?ThreadInfo; function useExistingThreadInfoFinder( baseThreadInfo: ?ThreadInfo, ): ExistingThreadInfoFinder { const threadInfos = useSelector(threadInfoSelector); const loggedInUserInfo = useLoggedInUserInfo(); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); return React.useCallback( (params: ExistingThreadInfoFinderParams): ?ThreadInfo => { if (!baseThreadInfo) { return null; } const realizedThreadInfo = threadInfos[baseThreadInfo.id]; if (realizedThreadInfo) { return realizedThreadInfo; } if (!loggedInUserInfo || !threadIsPending(baseThreadInfo.id)) { return baseThreadInfo; } const viewerID = loggedInUserInfo?.id; invariant( threadTypeCanBePending(baseThreadInfo.type), `ThreadInfo has pending ID ${baseThreadInfo.id}, but type that ` + `should not be pending ${baseThreadInfo.type}`, ); const { searching, userInfoInputArray } = params; const { sourceMessageID } = baseThreadInfo; let pendingThreadID; if (searching) { pendingThreadID = getPendingThreadID( pendingThreadType(userInfoInputArray.length, 'thick'), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ); } else { pendingThreadID = getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); } const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } if (!searching) { return baseThreadInfo; } return createPendingThread({ viewerID, threadType: pendingThreadType( userInfoInputArray.length, params.allUsersSupportThickThreads ? 'thick' : 'thin', ), members: [ { ...loggedInUserInfo, isViewer: true }, ...userInfoInputArray, ], }); }, [baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThinThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadTypeIsSidebar(threadType) ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.GENESIS_PERSONAL || threadType === threadTypes.GENESIS_PRIVATE ) { return 'disabled'; } else { return 'optional'; } } function checkIfDefaultMembersAreVoiced(threadInfo: ThreadInfo): boolean { const defaultRoleID = Object.keys(threadInfo.roles).find(roleID => roleIsDefaultRole(threadInfo.roles[roleID]), ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = threadInfo.roles[defaultRoleID]; const defaultRolePermissions = decodeMinimallyEncodedRoleInfo(defaultRole).permissions; return !!defaultRolePermissions[threadPermissions.VOICED]; } const draftKeySuffix = '/message_composer'; function draftKeyFromThreadID(threadID: string): string { return `${threadID}${draftKeySuffix}`; } function getContainingThreadID( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, threadType: ThreadType, ): ?string { if (!parentThreadInfo) { return null; } if (threadTypeIsSidebar(threadType)) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( threadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!threadInfo) { return null; } const { id, community, type } = threadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getSearchResultsForEmptySearchText( chatListData: $ReadOnlyArray, threadFilter: ThreadInfo => boolean, ): Array { const threadHomeSubscriptions = Object.fromEntries( chatListData.map(chatThreadItem => [ chatThreadItem.threadInfo.id, threadIsInHome(chatThreadItem.threadInfo), ]), ); return chatListData .filter((item: ChatThreadItem) => { const { threadInfo } = item; const { parentThreadID } = threadInfo; const isInFilteredChatList = threadInChatList(threadInfo) && threadFilter(threadInfo); if (!isInFilteredChatList) { return false; } if (!threadIsSidebar(threadInfo) || !parentThreadID) { return true; } const isParentInHome = threadHomeSubscriptions[parentThreadID]; const isThreadInHome = threadIsInHome(threadInfo); return isParentInHome !== isThreadInHome; }) .map((item: ChatThreadItem) => { if (threadIsSidebar(item.threadInfo)) { return item; } const sidebarsOnlyInSameTab = item.sidebars.filter( (sidebar: SidebarItem) => sidebar.type !== 'sidebar' || threadIsInHome(sidebar.threadInfo) === threadIsInHome(item.threadInfo), ); return { ...item, sidebars: sidebarsOnlyInSameTab, }; }); } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return getSearchResultsForEmptySearchText(chatListData, threadFilter); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (threadTypeIsPrivate(item.threadInfo.type)) { privateThreads.push({ ...item, sidebars: [] }); } else if (threadTypeIsPersonal(item.threadInfo.type)) { personalThreads.push({ ...item, sidebars: [] }); } else { otherThreads.push({ ...item, sidebars: [] }); } } const chatItems: ChatThreadItem[] = [ ...privateThreads, ...personalThreads, ...otherThreads, ]; if (loggedInUserInfo) { chatItems.push( ...usersSearchResults.map(user => createPendingThreadItem( loggedInUserInfo, user, user.supportThickThreads, ), ), ); } return chatItems; } function reorderThreadSearchResults( threadInfos: $ReadOnlyArray, threadSearchResults: $ReadOnlyArray, ): T[] { const privateThreads = []; const personalThreads = []; const otherThreads = []; const threadSearchResultsSet = new Set(threadSearchResults); for (const threadInfo of threadInfos) { if (!threadSearchResultsSet.has(threadInfo.id)) { continue; } if (threadTypeIsPrivate(threadInfo.type)) { privateThreads.push(threadInfo); } else if (threadTypeIsPersonal(threadInfo.type)) { personalThreads.push(threadInfo); } else { otherThreads.push(threadInfo); } } return [...privateThreads, ...personalThreads, ...otherThreads]; } function useAvailableThreadMemberActions( memberInfo: RelativeMemberInfo, threadInfo: ThreadInfo, canEdit: ?boolean = true, ): $ReadOnlyArray<'change_role' | 'remove_user'> { const canRemoveMembers = useThreadHasPermission( threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = useThreadHasPermission( threadInfo, threadPermissions.CHANGE_ROLE, ); return React.useMemo(() => { const { role } = memberInfo; if (!canEdit || !role) { return []; } const result = []; if ( canChangeRoles && memberInfo.username && threadHasAdminRole(threadInfo) ) { result.push('change_role'); } if ( canRemoveMembers && !memberInfo.isViewer && (canChangeRoles || roleIsDefaultRole(threadInfo.roles[role])) ) { result.push('remove_user'); } return result; }, [canChangeRoles, canEdit, canRemoveMembers, memberInfo, threadInfo]); } function patchThreadInfoToIncludeMentionedMembersOfParent( threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, messageText: string, viewerID: string, ): ThreadInfo { const members: UserIDAndUsername[] = threadInfo.members .map(({ id, username }) => username ? ({ id, username }: UserIDAndUsername) : null, ) .filter(Boolean); const mentionedNewMembers = extractNewMentionedParentMembers( messageText, threadInfo, parentThreadInfo, ); if (mentionedNewMembers.length === 0) { return threadInfo; } members.push(...mentionedNewMembers); const threadType = threadTypeIsThick(parentThreadInfo.type) ? threadTypes.THICK_SIDEBAR : threadTypes.SIDEBAR; return createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } -function threadInfoInsideCommunity( - threadInfo: RawThreadInfo | ThreadInfo, - communityID: string, -): boolean { - return threadInfo.community === communityID || threadInfo.id === communityID; -} - type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [string]: string } = {}; Object.keys(threadInfo.roles).forEach(roleID => { roleIDsToNames[roleID] = threadInfo.roles[roleID].name; }); const roleNamesToMemberCount: RoleAndMemberCount = {}; threadInfo.members.forEach(({ role: roleID }) => { invariant(roleID, 'Community member should have a role'); const roleName = roleIDsToNames[roleID]; roleNamesToMemberCount[roleName] = (roleNamesToMemberCount[roleName] ?? 0) + 1; }); // For all community roles with no members, add them to the list with 0 Object.keys(roleIDsToNames).forEach(roleName => { if (roleNamesToMemberCount[roleIDsToNames[roleName]] === undefined) { roleNamesToMemberCount[roleIDsToNames[roleName]] = 0; } }); return roleNamesToMemberCount; }, [threadInfo.members, threadInfo.roles]); } function useRoleNamesToSpecialRole(threadInfo: ThreadInfo): { +[roleName: string]: ?SpecialRole, } { return React.useMemo(() => { const roleNamesToSpecialRole: { [roleName: string]: ?SpecialRole } = {}; values(threadInfo.roles).forEach(role => { if (roleNamesToSpecialRole[role.name] !== undefined) { return; } if (roleIsDefaultRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.DEFAULT_ROLE; } else if (roleIsAdminRole(role)) { roleNamesToSpecialRole[role.name] = specialRoles.ADMIN_ROLE; } else { roleNamesToSpecialRole[role.name] = null; } }); return roleNamesToSpecialRole; }, [threadInfo.roles]); } type RoleUserSurfacedPermissions = { +[roleName: string]: $ReadOnlySet, }; // Iterates through the existing roles in the community and 'reverse maps' // the set of permission literals for each role to user-facing permission enums // to help pre-populate the permission checkboxes when editing roles. function useRoleUserSurfacedPermissions( threadInfo: ThreadInfo, ): RoleUserSurfacedPermissions { return React.useMemo(() => { const roleNamesToPermissions: { [string]: Set } = {}; Object.keys(threadInfo.roles).forEach(roleID => { const roleName = threadInfo.roles[roleID].name; const rolePermissions = decodeMinimallyEncodedRoleInfo( threadInfo.roles[roleID], ).permissions; roleNamesToPermissions[roleName] = userSurfacedPermissionsFromRolePermissions(rolePermissions); }); return roleNamesToPermissions; }, [threadInfo.roles]); } function communityOrThreadNoun(threadInfo: RawThreadInfo | ThreadInfo): string { return threadTypeIsCommunityRoot(threadInfo.type) ? 'community' : threadNoun(threadInfo.type, threadInfo.parentThreadID); } function getThreadsToDeleteText( threadInfo: RawThreadInfo | ThreadInfo, ): string { return `${ threadTypeIsCommunityRoot(threadInfo.type) ? 'Subchannels and threads' : 'Threads' } within this ${communityOrThreadNoun(threadInfo)}`; } type OldestCreatedInput = { +creationTime: number, ... }; function getOldestCreated(arr: $ReadOnlyArray): ?T { return arr.reduce( (a, b) => (!b || (a && a.creationTime < b.creationTime) ? a : b), null, ); } function useOldestPrivateThreadInfo(): ?ThreadInfo { const genesisPrivateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.GENESIS_PRIVATE, ); const genesisPrivateThreadInfos = useSelector( genesisPrivateThreadInfosSelector, ); const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); return React.useMemo( () => getOldestCreated([...privateThreadInfos, ...genesisPrivateThreadInfos]), [privateThreadInfos, genesisPrivateThreadInfos], ); } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const oldestPrivateThreadInfo = useOldestPrivateThreadInfo(); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); const genesisPersonalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.GENESIS_PERSONAL, ); const genesisPersonalThreadInfos = useSelector( genesisPersonalThreadInfosSelector, ); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const allPersonalThreadInfos = React.useMemo( () => [...personalThreadInfos, ...genesisPersonalThreadInfos], [personalThreadInfos, genesisPersonalThreadInfos], ); const [supportThickThreads, setSupportThickThreads] = React.useState(false); const usersSupportThickThreads = useUsersSupportThickThreads(); React.useEffect(() => { void (async () => { if (!userInfo) { setSupportThickThreads(false); return; } const result = await usersSupportThickThreads([userInfo.id]); setSupportThickThreads(result.has(userInfo.id)); })(); }, [userInfo, usersSupportThickThreads]); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile && oldestPrivateThreadInfo) { return { threadInfo: oldestPrivateThreadInfo }; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = getOldestCreated( allPersonalThreadInfos.filter( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalOrPrivateThread( loggedInUserInfo, userID, username, supportThickThreads, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, allPersonalThreadInfos, oldestPrivateThreadInfo, supportThickThreads, userID, username, usersWithPersonalThread, ]); } function assertAllThreadInfosAreLegacy(rawThreadInfos: MixedRawThreadInfos): { [id: string]: LegacyRawThreadInfo, } { return Object.fromEntries( Object.entries(rawThreadInfos).map(([id, rawThreadInfo]) => { invariant( !rawThreadInfo.minimallyEncoded, `rawThreadInfos shouldn't be minimallyEncoded`, ); return [id, rawThreadInfo]; }), ); } function useOnScreenEntryEditableThreadInfos(): $ReadOnlyArray { const visibleThreadInfos = useSelector(onScreenThreadInfos); const editableVisibleThreadInfos = useThreadsWithPermission( visibleThreadInfos, threadPermissions.EDIT_ENTRIES, ); return editableVisibleThreadInfos; } function createThreadTimestamps( timestamp: number, memberIDs: $ReadOnlyArray, ): ThreadTimestamps { return { name: timestamp, avatar: timestamp, description: timestamp, color: timestamp, members: Object.fromEntries( memberIDs.map(id => [ id, { isMember: timestamp, subscription: timestamp }, ]), ), currentUser: { unread: timestamp, }, }; } function userHasDeviceList( userID: string, auxUserInfos: AuxUserInfos, ): boolean { return deviceListIsNonEmpty(auxUserInfos[userID]?.deviceList); } function deviceListIsNonEmpty(deviceList?: RawDeviceList): boolean { return !!deviceList && deviceList.devices.length > 0; } const deviceListRequestTimeout = 20 * 1000; // twenty seconds const expectedAccountDeletionUpdateTimeout = 24 * 60 * 60 * 1000; // one day function deviceListCanBeRequestedForUser( userID: string, auxUserInfos: AuxUserInfos, ): boolean { return ( !auxUserInfos[userID]?.accountMissingStatus || auxUserInfos[userID].accountMissingStatus.lastChecked < Date.now() - deviceListRequestTimeout ); } export { threadHasPermission, useCommunityRootMembersToRole, useThreadHasPermission, viewerIsMember, threadInChatList, useIsThreadInChatList, useThreadsInChatList, threadIsTopLevel, threadIsChannel, threadIsSidebar, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadOtherMembers, threadIsGroupChat, threadIsPending, threadIsPendingSidebar, getSingleOtherUser, getPendingThreadID, parsePendingThreadID, createPendingThread, extractNewMentionedParentMembers, pendingThreadType, filterOutDisabledPermissions, useThreadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, threadUIName, threadInfoFromRawThreadInfo, threadTypeDescriptions, threadIsWithBlockedUserOnlyWithoutAdminRoleCheck, roleIsDefaultRole, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadNoun, threadLabel, useExistingThreadInfoFinder, getThreadTypeParentRequirement, checkIfDefaultMembersAreVoiced, draftKeySuffix, draftKeyFromThreadID, threadTypeCanBePending, getContainingThreadID, getCommunity, getThreadListSearchResults, reorderThreadSearchResults, useAvailableThreadMemberActions, threadMembersWithoutAddedAdmin, patchThreadInfoToIncludeMentionedMembersOfParent, - threadInfoInsideCommunity, useRoleMemberCountsForCommunity, useRoleNamesToSpecialRole, useRoleUserSurfacedPermissions, getThreadsToDeleteText, useOldestPrivateThreadInfo, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, useOnScreenEntryEditableThreadInfos, extractMentionedMembers, isMemberActive, createThreadTimestamps, userHasDeviceList, deviceListIsNonEmpty, deviceListCanBeRequestedForUser, expectedAccountDeletionUpdateTimeout, }; diff --git a/native/avatars/avatar-hooks.js b/native/avatars/avatar-hooks.js index 670b774c1..581645e51 100644 --- a/native/avatars/avatar-hooks.js +++ b/native/avatars/avatar-hooks.js @@ -1,622 +1,597 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import * as ImagePicker from 'expo-image-picker'; import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import filesystem from 'react-native-fs'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { - uploadMultimedia, - useBlobServiceUpload, -} from 'lib/actions/upload-actions.js'; +import { useBlobServiceUpload } from 'lib/actions/upload-actions.js'; import { EditThreadAvatarContext } from 'lib/components/base-edit-thread-avatar-provider.react.js'; import { EditUserAvatarContext } from 'lib/components/edit-user-avatar-provider.react.js'; -import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { extensionFromFilename, filenameFromPathOrURI, } from 'lib/media/file-utils.js'; import type { UpdateUserAvatarRequest } from 'lib/types/avatar-types.js'; import type { NativeMediaSelection, MediaLibrarySelection, MediaMissionFailure, } from 'lib/types/media-types.js'; import type { RawThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { encryptMedia } from '../media/encryption-utils.js'; import { getCompatibleMediaURI } from '../media/identifier-utils.js'; import type { MediaResult } from '../media/media-utils.js'; import { processMedia } from '../media/media-utils.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; import blobServiceUploadHandler from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; -const useBlobServiceUploads = true; - function displayAvatarUpdateFailureAlert(): void { Alert.alert( 'Couldn’t save avatar', 'Please try again later', [{ text: 'OK' }], { cancelable: true }, ); } function useUploadProcessedMedia(): ( media: MediaResult, metadataUploadLocation: 'keyserver' | 'none', ) => Promise { - const callUploadMultimedia = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); return React.useCallback( async (processedMedia, metadataUploadLocation) => { - const useBlobService = - metadataUploadLocation !== 'keyserver' || useBlobServiceUploads; - if (!useBlobService) { - const { uploadURI, filename, mime, dimensions } = processedMedia; - const { id } = await callUploadMultimedia( - { - uri: uploadURI, - name: filename, - type: mime, - }, - dimensions, - ); - if (!id) { - return undefined; - } - return { type: 'image', uploadID: id }; - } - const { result: encryptionResult } = await encryptMedia(processedMedia); if (!encryptionResult.success) { throw new Error('Avatar media encryption failed.'); } invariant( encryptionResult.mediaType === 'encrypted_photo', 'Invalid mediaType after encrypting avatar', ); const { uploadURI, filename, mime, blobHash, encryptionKey, dimensions, thumbHash, } = encryptionResult; const { id, uri } = await callBlobServiceUpload({ uploadInput: { blobInput: { type: 'uri', uri: uploadURI, filename, mimeType: mime, }, blobHash, encryptionKey, dimensions, thumbHash, loop: false, }, keyserverOrThreadID: metadataUploadLocation === 'keyserver' ? authoritativeKeyserverID : null, callbacks: { blobServiceUploadHandler }, }); if (metadataUploadLocation !== 'keyserver') { return { type: 'non_keyserver_image', blobURI: uri, thumbHash, encryptionKey, }; } if (!id) { return undefined; } return { type: 'encrypted_image', uploadID: id }; }, - [callUploadMultimedia, callBlobServiceUpload], + [callBlobServiceUpload], ); } function useProcessSelectedMedia(): NativeMediaSelection => Promise< MediaMissionFailure | MediaResult, > { const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const staffCanSee = useStaffCanSee(); const processSelectedMedia = React.useCallback( async (selection: NativeMediaSelection) => { const { resultPromise } = processMedia(selection, { hasWiFi, finalFileHeaderCheck: staffCanSee, }); return await resultPromise; }, [hasWiFi, staffCanSee], ); return processSelectedMedia; } async function selectFromGallery(): Promise { try { const { assets, canceled } = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, allowsMultipleSelection: false, quality: 1, }); if (canceled || assets.length === 0) { return undefined; } const asset = assets.pop(); const { width, height, assetId: mediaNativeID } = asset; const assetFilename = asset.fileName || filenameFromPathOrURI(asset.uri) || ''; const uri = getCompatibleMediaURI( asset.uri, extensionFromFilename(assetFilename), ); const currentTime = Date.now(); const selection: MediaLibrarySelection = { step: 'photo_library', dimensions: { height, width }, uri, filename: assetFilename, mediaNativeID, selectTime: currentTime, sendTime: currentTime, retries: 0, }; return selection; } catch (e) { console.log(e); return undefined; } } function useUploadSelectedMedia( setProcessingOrUploadInProgress?: (inProgress: boolean) => mixed, ): ( selection: NativeMediaSelection, metadataUploadLocation: 'keyserver' | 'none', ) => Promise { const processSelectedMedia = useProcessSelectedMedia(); const uploadProcessedMedia = useUploadProcessedMedia(); return React.useCallback( async (selection: NativeMediaSelection, metadataUploadLocation) => { setProcessingOrUploadInProgress?.(true); const urisToBeDisposed: Set = new Set([selection.uri]); let processedMedia; try { processedMedia = await processSelectedMedia(selection); if (processedMedia.uploadURI) { urisToBeDisposed.add(processedMedia.uploadURI); } } catch (e) { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress?.(false); return undefined; } if (!processedMedia.success) { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media processing failed', 'Unable to process selected media.', ); setProcessingOrUploadInProgress?.(false); return undefined; } let uploadedMedia: ?UpdateUserAvatarRequest; try { uploadedMedia = await uploadProcessedMedia( processedMedia, metadataUploadLocation, ); urisToBeDisposed.forEach(filesystem.unlink); setProcessingOrUploadInProgress?.(false); } catch { urisToBeDisposed.forEach(filesystem.unlink); Alert.alert( 'Media upload failed', 'Unable to upload selected media. Please try again.', ); setProcessingOrUploadInProgress?.(false); return undefined; } return uploadedMedia; }, [ processSelectedMedia, setProcessingOrUploadInProgress, uploadProcessedMedia, ], ); } function useNativeSetUserAvatar(): ( request: UpdateUserAvatarRequest, ) => Promise { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext must be defined'); const { baseSetUserAvatar, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, } = editUserAvatarContext; const nativeSetUserAvatar = React.useCallback( async (request: UpdateUserAvatarRequest) => { const registrationModeEnabled = getRegistrationModeEnabled(); if (registrationModeEnabled) { const successCallback = getRegistrationModeSuccessCallback(); invariant( successCallback, 'successCallback must be defined if registrationModeEnabled is true', ); successCallback({ needsUpload: false, updateUserAvatarRequest: request, }); return; } try { await baseSetUserAvatar(request); } catch { displayAvatarUpdateFailureAlert(); } }, [ getRegistrationModeEnabled, getRegistrationModeSuccessCallback, baseSetUserAvatar, ], ); return nativeSetUserAvatar; } function useNativeUpdateUserImageAvatar(): ( selection: NativeMediaSelection, ) => Promise { const editUserAvatarContext = React.useContext(EditUserAvatarContext); invariant(editUserAvatarContext, 'editUserAvatarContext must be defined'); const { baseSetUserAvatar, getRegistrationModeEnabled, getRegistrationModeSuccessCallback, setUserAvatarMediaUploadInProgress, } = editUserAvatarContext; const uploadSelectedMedia = useUploadSelectedMedia( setUserAvatarMediaUploadInProgress, ); const nativeUpdateUserImageAvatar = React.useCallback( async (selection: NativeMediaSelection) => { const registrationModeEnabled = getRegistrationModeEnabled(); if (registrationModeEnabled) { const successCallback = getRegistrationModeSuccessCallback(); invariant( successCallback, 'successCallback must be defined if registrationModeEnabled is true', ); successCallback({ needsUpload: true, mediaSelection: selection, }); return; } const imageAvatarUpdateRequest = await uploadSelectedMedia( selection, 'keyserver', ); if (!imageAvatarUpdateRequest) { return; } try { await baseSetUserAvatar(imageAvatarUpdateRequest); } catch { displayAvatarUpdateFailureAlert(); } }, [ getRegistrationModeEnabled, getRegistrationModeSuccessCallback, baseSetUserAvatar, uploadSelectedMedia, ], ); return nativeUpdateUserImageAvatar; } function useSelectFromGalleryAndUpdateUserAvatar(): () => Promise { const nativeUpdateUserImageAvatar = useNativeUpdateUserImageAvatar(); const selectFromGalleryAndUpdateUserAvatar = React.useCallback(async (): Promise => { const selection = await selectFromGallery(); if (!selection) { return; } await nativeUpdateUserImageAvatar(selection); }, [nativeUpdateUserImageAvatar]); return selectFromGalleryAndUpdateUserAvatar; } function useNativeSetThreadAvatar(): ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ) => Promise { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext must be defined'); const { baseSetThreadAvatar } = editThreadAvatarContext; const nativeSetThreadAvatar = React.useCallback( async ( threadID: string, avatarRequest: UpdateUserAvatarRequest, ): Promise => { try { await baseSetThreadAvatar(threadID, avatarRequest); } catch (e) { displayAvatarUpdateFailureAlert(); throw e; } }, [baseSetThreadAvatar], ); return nativeSetThreadAvatar; } function useNativeUpdateThreadImageAvatar(): ( selection: NativeMediaSelection, threadInfo: ThreadInfo | RawThreadInfo, ) => Promise { const editThreadAvatarContext = React.useContext(EditThreadAvatarContext); invariant(editThreadAvatarContext, 'editThreadAvatarContext must be defined'); const { baseSetThreadAvatar, updateThreadAvatarMediaUploadInProgress } = editThreadAvatarContext; const uploadSelectedMedia = useUploadSelectedMedia( updateThreadAvatarMediaUploadInProgress, ); const nativeUpdateThreadImageAvatar = React.useCallback( async ( selection: NativeMediaSelection, threadInfo: ThreadInfo | RawThreadInfo, ): Promise => { const metadataUploadLocation = threadTypeIsThick(threadInfo.type) ? 'none' : 'keyserver'; const imageAvatarUpdateRequest = await uploadSelectedMedia( selection, metadataUploadLocation, ); if (!imageAvatarUpdateRequest) { return; } try { await baseSetThreadAvatar(threadInfo.id, imageAvatarUpdateRequest); } catch { displayAvatarUpdateFailureAlert(); } }, [baseSetThreadAvatar, uploadSelectedMedia], ); return nativeUpdateThreadImageAvatar; } function useSelectFromGalleryAndUpdateThreadAvatar(): ( threadInfo: ThreadInfo | RawThreadInfo, ) => Promise { const nativeUpdateThreadImageAvatar = useNativeUpdateThreadImageAvatar(); const selectFromGalleryAndUpdateThreadAvatar = React.useCallback( async (threadInfo: ThreadInfo | RawThreadInfo): Promise => { const selection: ?MediaLibrarySelection = await selectFromGallery(); if (!selection) { return; } await nativeUpdateThreadImageAvatar(selection, threadInfo); }, [nativeUpdateThreadImageAvatar], ); return selectFromGalleryAndUpdateThreadAvatar; } type ShowAvatarActionSheetOptions = { +id: 'emoji' | 'image' | 'camera' | 'ens' | 'farcaster' | 'cancel' | 'remove', +onPress?: () => mixed, }; function useShowAvatarActionSheet( options: $ReadOnlyArray, ): () => void { options = Platform.OS === 'ios' ? [...options, { id: 'cancel' }] : options; const insets = useSafeAreaInsets(); const { showActionSheetWithOptions } = useActionSheet(); const styles = useStyles(unboundStyles); const showAvatarActionSheet = React.useCallback(() => { const texts = options.map((option: ShowAvatarActionSheetOptions) => { if (option.id === 'emoji') { return 'Select emoji'; } else if (option.id === 'image') { return 'Select image'; } else if (option.id === 'camera') { return 'Open camera'; } else if (option.id === 'ens') { return 'Use ENS avatar'; } else if (option.id === 'farcaster') { return 'Use Farcaster avatar'; } else if (option.id === 'remove') { return 'Reset to default'; } else { return 'Cancel'; } }); const cancelButtonIndex = options.findIndex( option => option.id === 'cancel', ); const containerStyle = { paddingBottom: insets.bottom, }; const icons = options.map(option => { if (option.id === 'emoji') { return ( ); } else if (option.id === 'image') { return ( ); } else if (option.id === 'camera') { return ( ); } else if (option.id === 'ens') { return ( ); } else if (option.id === 'farcaster') { return ( ); } else if (option.id === 'remove') { return ( ); } else { return undefined; } }); const onPressAction = (selectedIndex: ?number) => { if ( selectedIndex === null || selectedIndex === undefined || selectedIndex < 0 ) { return; } const option = options[selectedIndex]; if (option.onPress) { option.onPress(); } }; showActionSheetWithOptions( { options: texts, cancelButtonIndex, containerStyle, icons, }, onPressAction, ); }, [ insets.bottom, options, showActionSheetWithOptions, styles.bottomSheetIcon, ]); return showAvatarActionSheet; } const unboundStyles = { bottomSheetIcon: { color: '#000000', }, }; export { displayAvatarUpdateFailureAlert, selectFromGallery, useUploadSelectedMedia, useUploadProcessedMedia, useProcessSelectedMedia, useShowAvatarActionSheet, useSelectFromGalleryAndUpdateUserAvatar, useNativeSetUserAvatar, useNativeUpdateUserImageAvatar, useSelectFromGalleryAndUpdateThreadAvatar, useNativeSetThreadAvatar, useNativeUpdateThreadImageAvatar, }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 9d6478c39..b61db70be 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1816 +1,1735 @@ // @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, sendTextMessageActionTypes, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThinThread } from 'lib/actions/thread-actions.js'; import { type BlobServiceUploadAction, type BlobServiceUploadResult, - type MultimediaUploadCallbacks, - type MultimediaUploadExtras, updateMultimediaMessageMediaActionType, - uploadMultimedia, useBlobServiceUpload, } from 'lib/actions/upload-actions.js'; import { type SendMultimediaMessagePayload, useInputStateContainerSendMultimediaMessage, useInputStateContainerSendTextMessage, } from 'lib/hooks/input-state-container-hooks.js'; import { useNewThickThread } from 'lib/hooks/thread-hooks.js'; import type { CallSingleKeyserverEndpointOptions, CallSingleKeyserverEndpointResponse, } from 'lib/keyserver-conn/call-single-keyserver-endpoint.js'; -import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js'; import { getNextLocalUploadID, isLocalUploadID, } from 'lib/media/media-utils.js'; import { videoDurationLimit } from 'lib/media/video-utils.js'; import { combineLoadingStatuses, createLoadingStatusSelector, } from 'lib/selectors/loading-selectors.js'; import { createMediaMessageInfo, useMessageCreationSideEffectsFunc, getNextLocalID, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread } from 'lib/shared/thread-actions-utils.js'; import { patchThreadInfoToIncludeMentionedMembersOfParent, threadIsPending, threadIsPendingSidebar, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { Media, MediaMission, MediaMissionResult, MediaMissionStep, NativeMediaSelection, - UploadMultimediaResult, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ClientMediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { threadTypeIsThick, threadTypeIsSidebar, } from 'lib/types/thread-types-enum.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; import { type ClientNewThinThreadRequest, type NewThreadResult, type NewThickThreadRequest, } from 'lib/types/thread-types.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, SendMessageError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateReportID, useIsReportEnabled, } from 'lib/utils/report-utils.js'; import { type EditInputBarMessageParameters, type InputState, InputStateContext, type MessagePendingUploads, type MultimediaProcessingStep, type PendingMultimediaUploads, } from './input-state.js'; import { encryptMedia } from '../media/encryption-utils.js'; import { disposeTempFile } from '../media/file-utils.js'; import { processMedia } from '../media/media-utils.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import { useSelector } from '../redux/redux-utils.js'; import blobServiceUploadHandler from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type MediaIDs = | { +type: 'photo', +localMediaID: string } | { +type: 'video', +localMediaID: string, +localThumbnailID: string }; type UploadFileInput = { +selection: NativeMediaSelection, +ids: MediaIDs, }; type WritableCompletedUploads = { [localMessageID: string]: ?$ReadOnlySet, }; type CompletedUploads = $ReadOnly; type ActiveURI = { +count: number, +onClear: $ReadOnlyArray<() => mixed> }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +mediaReportsEnabled: boolean, +calendarQuery: () => CalendarQuery, +dispatch: Dispatch, +staffCanSee: boolean, +dispatchActionPromise: DispatchActionPromise, - +uploadMultimedia: ( - multimedia: Object, - extras: MultimediaUploadExtras, - callbacks: MultimediaUploadCallbacks, - ) => Promise, +blobServiceUpload: BlobServiceUploadAction, +sendMultimediaMessage: ( messageInfo: RawMultimediaMessageInfo, sidebarCreation: boolean, isLegacy: boolean, ) => Promise, +sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, sidebarCreation: boolean, ) => Promise, +newThinThread: ( request: ClientNewThinThreadRequest, ) => Promise, +newThickThread: (request: NewThickThreadRequest) => 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< string, Promise<{ +threadID: string, +threadType: ThreadType, }>, > = new Map(); pendingThreadUpdateHandlers: Map mixed> = new Map(); - useBlobServiceUploads = true; - // When the user sends a multimedia message that triggers the creation of a // sidebar, the sidebar gets created right away, but the message needs to wait // for the uploads to complete before sending. We use this Set to track the // message localIDs that need sidebarCreation: true. pendingSidebarCreationMessageLocalIDs: Set = new Set(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads: WritableCompletedUploads = {}; for (const localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); let allUploadsComplete = true; const completedUploadIDs = new Set(Object.keys(messagePendingUploads)); for (const singleMedia of rawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { allUploadsComplete = false; completedUploadIDs.delete(singleMedia.id); } const { thumbnailID } = singleMedia; if (thumbnailID && isLocalUploadID(thumbnailID)) { allUploadsComplete = false; completedUploadIDs.delete(thumbnailID); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completedUploadIDs.size > 0) { completedUploads[localMessageID] = completedUploadIDs; } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads: PendingMultimediaUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (const localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads: MessagePendingUploads = {}; let uploadsChanged = false; for (const localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (const localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); void this.dispatchMultimediaMessageAction(rawMessageInfo); } } async dispatchMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { if (!threadIsPending(messageInfo.threadID)) { void this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } const result = await threadCreationPromise; newThreadID = result.threadID; } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; const payload = new SendMessageError( `Exception while creating thread: ${exceptionMessage}`, messageInfo.localID ?? '', messageInfo.threadID, ); this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload, 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); try { const { result } = await this.props.sendMultimediaMessage( messageInfo, sidebarCreation, false, ); this.pendingSidebarCreationMessageLocalIDs.delete(localID); return result; } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; throw new SendMessageError( `Exception when sending multimedia message: ${exceptionMessage}`, localID, threadID, ); } } 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 && threadTypeIsSidebar(inputThreadInfo.type)) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { const pendingThreadUpdateHandler = this.pendingThreadUpdateHandlers.get( threadInfo.id, ); pendingThreadUpdateHandler?.(threadInfo); } } let threadCreationResult = null; try { threadCreationResult = await this.startThreadCreation(threadInfo); } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; const payload = new SendMessageError( `Exception while creating thread: ${exceptionMessage}`, messageInfo.localID ?? '', messageInfo.threadID, ); this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: threadCreationResult?.threadID, time: Date.now(), }; const newThreadInfo = { ...threadInfo, id: threadCreationResult?.threadID, type: threadCreationResult?.threadType ?? threadInfo.type, }; void this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); }; startThreadCreation( threadInfo: ThreadInfo, ): Promise<{ +threadID: string, +threadType: ThreadType }> { if (!threadIsPending(threadInfo.id)) { return Promise.resolve({ threadID: threadInfo.id, threadType: threadInfo.type, }); } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThinThread: this.props.newThinThread, createNewThickThread: this.props.newThickThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } async sendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): Promise { try { if (!threadTypeIsThick(threadInfo.type)) { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); } const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const result = await this.props.sendTextMessage( messageInfo, threadInfo, parentThreadInfo, sidebarCreation, ); if (threadTypeIsThick(threadInfo.type)) { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); } this.pendingSidebarCreationMessageLocalIDs.delete(localID); return result; } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; throw new SendMessageError( `Exception when sending text message: ${exceptionMessage}`, messageInfo.localID ?? '', messageInfo.threadID, ); } } - // eslint-disable-next-line no-unused-vars - shouldEncryptMedia(threadInfo: ThreadInfo): boolean { - return true; - } - sendMultimediaMessage = async ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = getNextLocalID(); void this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const uploadFileInputs = [], media: Array = []; for (const selection of selections) { const localMediaID = getNextLocalUploadID(); let ids; if ( selection.step === 'photo_library' || selection.step === 'photo_capture' || selection.step === 'photo_paste' ) { media.push({ id: localMediaID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, thumbHash: null, }); ids = { type: 'photo', localMediaID }; } const localThumbnailID = getNextLocalUploadID(); if (selection.step === 'video_library') { media.push({ id: localMediaID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, thumbnailID: localThumbnailID, thumbnailURI: selection.uri, thumbnailThumbHash: null, }); ids = { type: 'video', localMediaID, localThumbnailID }; } invariant(ids, `unexpected MediaSelection ${selection.step}`); uploadFileInputs.push({ selection, ids }); } const pendingUploads: MessagePendingUploads = {}; for (const uploadFileInput of uploadFileInputs) { const { localMediaID } = uploadFileInput.ids; pendingUploads[localMediaID] = { failed: false, progressPercent: 0, processingStep: null, }; if (uploadFileInput.ids.type === 'video') { const { localThumbnailID } = uploadFileInput.ids; pendingUploads[localThumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState( prevState => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const messageInfo = createMediaMessageInfo( { localID: localMessageID, threadID: threadInfo.id, creatorID, media, }, - { forceMultimediaMessageType: this.shouldEncryptMedia(threadInfo) }, + { forceMultimediaMessageType: true }, ); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; async uploadFiles( localMessageID: string, uploadFileInputs: $ReadOnlyArray, threadInfo: ThreadInfo, ) { const results = await Promise.all( uploadFileInputs.map(uploadFileInput => this.uploadFile(localMessageID, uploadFileInput, threadInfo), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, uploadFileInput: UploadFileInput, threadInfo: ThreadInfo, ): Promise { const { ids, selection } = uploadFileInput; const { localMediaID, localThumbnailID } = ids; const start = selection.sendTime; const steps: Array = [selection]; let encryptionSteps: $ReadOnlyArray = []; let serverID; let userTime; let errorMessage; let reportPromise: ?Promise<$ReadOnlyArray>; const filesToDispose = []; const onUploadFinished = async (result: MediaMissionResult) => { if (!this.props.mediaReportsEnabled) { return errorMessage; } if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); steps.push(...encryptionSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID: localMediaID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const onUploadFailed = (message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, localMediaID, localThumbnailID); userTime = Date.now() - start; }; const onTranscodingProgress = (percent: number) => { this.setProgress(localMessageID, localMediaID, 'transcoding', percent); }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia(selection, { hasWiFi: this.props.hasWiFi, finalFileHeaderCheck: this.props.staffCanSee, onTranscodingProgress, }); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; onUploadFailed(message); return await onUploadFinished(processResult); } if (processResult.shouldDisposePath) { filesToDispose.push(processResult.shouldDisposePath); } processedMedia = processResult; } catch (e) { onUploadFailed('processing failed'); return await onUploadFinished({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } - if (this.shouldEncryptMedia(threadInfo)) { - const encryptionStart = Date.now(); - try { - const { result: encryptionResult, ...encryptionReturn } = - await encryptMedia(processedMedia); - encryptionSteps = encryptionReturn.steps; - if (!encryptionResult.success) { - onUploadFailed(encryptionResult.reason); - return await onUploadFinished(encryptionResult); - } - if (encryptionResult.shouldDisposePath) { - filesToDispose.push(encryptionResult.shouldDisposePath); - } - processedMedia = encryptionResult; - } catch (e) { - onUploadFailed('encryption failed'); - return await onUploadFinished({ - success: false, - reason: 'encryption_exception', - time: Date.now() - encryptionStart, - exceptionMessage: getMessageForException(e), - }); + const encryptionStart = Date.now(); + try { + const { result: encryptionResult, ...encryptionReturn } = + await encryptMedia(processedMedia); + encryptionSteps = encryptionReturn.steps; + if (!encryptionResult.success) { + onUploadFailed(encryptionResult.reason); + return await onUploadFinished(encryptionResult); + } + if (encryptionResult.shouldDisposePath) { + filesToDispose.push(encryptionResult.shouldDisposePath); } + processedMedia = encryptionResult; + } catch (e) { + onUploadFailed('encryption failed'); + return await onUploadFinished({ + success: false, + reason: 'encryption_exception', + time: Date.now() - encryptionStart, + exceptionMessage: getMessageForException(e), + }); } const { uploadURI, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, uploadThumbnailResult, mediaMissionResult; const isThickThread = threadTypeIsThick(threadInfo.type); - const useBlobService = isThickThread || this.useBlobServiceUploads; try { - if ( - useBlobService && - (processedMedia.mediaType === 'encrypted_photo' || - processedMedia.mediaType === 'encrypted_video') - ) { - const uploadMetadataToKeyserver = !isThickThread; - const uploadPromise = this.props.blobServiceUpload({ - uploadInput: { - blobInput: { - type: 'uri', - uri: uploadURI, - filename: filename, - mimeType: mime, - }, - blobHash: processedMedia.blobHash, - encryptionKey: processedMedia.encryptionKey, - dimensions: processedMedia.dimensions, - thumbHash: - processedMedia.mediaType === 'encrypted_photo' - ? processedMedia.thumbHash - : null, - }, - keyserverOrThreadID: uploadMetadataToKeyserver ? threadInfo.id : null, - callbacks: { - blobServiceUploadHandler, - onProgress: (percent: number) => { - this.setProgress( - localMessageID, - localMediaID, - 'uploading', - percent, - ); - }, - }, - }); - - const uploadThumbnailPromise: Promise = - (async () => { - if (processedMedia.mediaType !== 'encrypted_video') { - return undefined; - } - return await this.props.blobServiceUpload({ - uploadInput: { - blobInput: { - type: 'uri', - uri: processedMedia.uploadThumbnailURI, - filename: replaceExtension(`thumb${filename}`, 'jpg'), - mimeType: 'image/jpeg', - }, - blobHash: processedMedia.thumbnailBlobHash, - encryptionKey: processedMedia.thumbnailEncryptionKey, - loop: false, - dimensions: processedMedia.dimensions, - thumbHash: processedMedia.thumbHash, - }, - keyserverOrThreadID: uploadMetadataToKeyserver - ? threadInfo.id - : null, - callbacks: { - blobServiceUploadHandler, - }, - }); - })(); - - [uploadResult, uploadThumbnailResult] = await Promise.all([ - uploadPromise, - uploadThumbnailPromise, - ]); - } else { - const uploadPromise = this.props.uploadMultimedia( - { uri: uploadURI, name: filename, type: mime }, - { - ...processedMedia.dimensions, - loop: - processedMedia.mediaType === 'video' || - processedMedia.mediaType === 'encrypted_video' - ? processedMedia.loop - : undefined, - encryptionKey: processedMedia.encryptionKey, - thumbHash: - processedMedia.mediaType === 'photo' || - processedMedia.mediaType === 'encrypted_photo' - ? processedMedia.thumbHash - : null, + invariant( + processedMedia.mediaType === 'encrypted_photo' || + processedMedia.mediaType === 'encrypted_video', + 'uploaded media should be encrypted', + ); + const uploadMetadataToKeyserver = !isThickThread; + const uploadPromise = this.props.blobServiceUpload({ + uploadInput: { + blobInput: { + type: 'uri', + uri: uploadURI, + filename: filename, + mimeType: mime, }, - { - onProgress: (percent: number) => - this.setProgress( - localMessageID, - localMediaID, - 'uploading', - percent, - ), - performHTTPMultipartUpload: this.performHTTPMultipartUpload, + blobHash: processedMedia.blobHash, + encryptionKey: processedMedia.encryptionKey, + dimensions: processedMedia.dimensions, + thumbHash: + processedMedia.mediaType === 'encrypted_photo' + ? processedMedia.thumbHash + : null, + }, + keyserverOrThreadID: uploadMetadataToKeyserver ? threadInfo.id : null, + callbacks: { + blobServiceUploadHandler, + onProgress: (percent: number) => { + this.setProgress( + localMessageID, + localMediaID, + 'uploading', + percent, + ); }, - ); + }, + }); - const uploadThumbnailPromise: Promise = - (async () => { - if ( - processedMedia.mediaType !== 'video' && - processedMedia.mediaType !== 'encrypted_video' - ) { - return undefined; - } - return await this.props.uploadMultimedia( - { + const uploadThumbnailPromise: Promise = + (async () => { + if (processedMedia.mediaType !== 'encrypted_video') { + return undefined; + } + return await this.props.blobServiceUpload({ + uploadInput: { + blobInput: { + type: 'uri', uri: processedMedia.uploadThumbnailURI, - name: replaceExtension(`thumb${filename}`, 'jpg'), - type: 'image/jpeg', - }, - { - ...processedMedia.dimensions, - loop: false, - encryptionKey: processedMedia.thumbnailEncryptionKey, - thumbHash: processedMedia.thumbHash, + filename: replaceExtension(`thumb${filename}`, 'jpg'), + mimeType: 'image/jpeg', }, - { - performHTTPMultipartUpload: this.performHTTPMultipartUpload, - }, - ); - })(); + blobHash: processedMedia.thumbnailBlobHash, + encryptionKey: processedMedia.thumbnailEncryptionKey, + loop: false, + dimensions: processedMedia.dimensions, + thumbHash: processedMedia.thumbHash, + }, + keyserverOrThreadID: uploadMetadataToKeyserver + ? threadInfo.id + : null, + callbacks: { + blobServiceUploadHandler, + }, + }); + })(); + + [uploadResult, uploadThumbnailResult] = await Promise.all([ + uploadPromise, + uploadThumbnailPromise, + ]); - [uploadResult, uploadThumbnailResult] = await Promise.all([ - uploadPromise, - uploadThumbnailPromise, - ]); - } mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed('upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if ( ((processedMedia.mediaType === 'photo' || processedMedia.mediaType === 'encrypted_photo') && uploadResult) || ((processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video') && uploadResult && uploadThumbnailResult) ) { const { encryptionKey } = processedMedia; const { id, uri, dimensions, loop } = uploadResult; serverID = id; const mediaSourcePayload = processedMedia.mediaType === 'encrypted_photo' || processedMedia.mediaType === 'encrypted_video' ? { type: processedMedia.mediaType, blobURI: uri, encryptionKey, } : { type: uploadResult.mediaType, uri, }; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, ...mediaSourcePayload, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if ( processedMedia.mediaType === 'video' || processedMedia.mediaType === 'encrypted_video' ) { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult; const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } = processedMedia; if (processedMedia.mediaType === 'encrypted_video') { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }, }; } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailID, thumbnailURI, thumbnailThumbHash, }, }; } } else { updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbHash: processedMedia.thumbHash, }, }; } // When we dispatch this action, it updates Redux and triggers the // componentDidUpdate in this class. componentDidUpdate will handle // calling dispatchMultimediaMessageAction once all the uploads are // complete, and does not wait until this function concludes. this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: updateMediaPayload, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push(...encryptionSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const cleanupPromises = []; if (filesToDispose.length > 0) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete filesToDispose.forEach(shouldDisposePath => { cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); }); } // if there's a thumbnail we'll temporarily unlink it here // instead of in media-utils, will be changed in later diffs if (processedMedia.mediaType === 'video') { const { uploadThumbnailURI } = processedMedia; cleanupPromises.push( (async () => { const { steps: clearSteps, result: thumbnailPath } = await this.waitForCaptureURIUnload(uploadThumbnailURI); steps.push(...clearSteps); if (!thumbnailPath) { return; } const disposeStep = await disposeTempFile(thumbnailPath); steps.push(disposeStep); })(), ); } if (selection.captureTime || selection.step === 'photo_paste') { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose. Check out the // Multimedia component to see how the URIs get switched out. const captureURI = selection.uri; cleanupPromises.push( (async () => { const { steps: clearSteps, result: capturePath } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(cleanupPromises); return await onUploadFinished(mediaMissionResult); } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, progressPercent: number, ) { this.setState(prevState => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, processingStep, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } performHTTPMultipartUpload = async ( url: string, cookie: ?string, sessionID: ?string, input: { +[key: string]: mixed }, options?: ?CallSingleKeyserverEndpointOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.performHTTPMultipartUpload sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.performHTTPMultipartUpload sent incorrect input', ); const parameters: { [key: string]: mixed } = {}; parameters.cookie = cookie; parameters.filename = name; for (const key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'performHTTPMultipartUpload calls can only handle string values for ' + 'non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } let uploadOptions = { uploadType: FileSystem.FileSystemUploadType.MULTIPART, fieldName: 'multimedia', headers: { Accept: 'application/json', }, parameters, }; if (Platform.OS === 'android' && path.endsWith('.dat')) { // expo-file-system is not able to deduce the MIME type of .dat files, so // we specify it explicitly here. Without this, we get this error: // guessContentTypeFromName(file.name) must not be null uploadOptions = { ...uploadOptions, mimeType: 'application/octet-stream', }; } const uploadTask = FileSystem.createUploadTask( url, path, uploadOptions, uploadProgress => { if (options && options.onProgress) { const { totalByteSent, totalBytesExpectedToSend } = uploadProgress; options.onProgress(totalByteSent / totalBytesExpectedToSend); } }, ); if (options && options.abortHandler) { options.abortHandler(() => uploadTask.cancelAsync()); } try { const response = await uploadTask.uploadAsync(); return JSON.parse(response.body); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } }; handleUploadFailure( localMessageID: string, localUploadID: string, localThumbnailID: ?string, ) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; const thumbnailUpload = localThumbnailID ? uploads[localThumbnailID] : undefined; if (!upload && !thumbnailUpload) { // The upload has been completed before it failed return {}; } const newUploads = { ...uploads }; newUploads[localUploadID] = { ...upload, failed: true, progressPercent: 0, }; if (localThumbnailID) { newUploads[localThumbnailID] = { processingStep: null, ...thumbnailUpload, failed: true, progressPercent: 0, }; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newUploads, }, }; }); } queueMediaMissionReport( ids: { localID: string, localMessageID: string, serverID: ?string }, mediaMission: MediaMission, ) { const report: ClientMediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, id: generateReportID(), }; this.props.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string): boolean => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } return values(pendingUploads).some(upload => upload.failed); }; editInputMessage = (params: EditInputBarMessageParameters) => { this.editInputBarCallbacks.forEach(addEditInputBarCallback => addEditInputBarCallback(params), ); }; addEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks.push(callbackEditInputBar); }; removeEditInputMessageListener = ( callbackEditInputBar: (params: EditInputBarMessageParameters) => void, ) => { this.editInputBarCallbacks = this.editInputBarCallbacks.filter( candidate => candidate !== callbackEditInputBar, ); }; retryTextMessage = async ( rawMessageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { await this.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, threadInfo, parentThreadInfo, ); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, threadInfo: ThreadInfo, ): Promise => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); void this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { invariant( singleMedia.type === 'photo' || singleMedia.type === 'video', 'Retry selection must be unencrypted', ); let updatedMedia = singleMedia; const oldMediaID = updatedMedia.id; if ( // not complete isLocalUploadID(oldMediaID) && // not still ongoing (!pendingUploads[oldMediaID] || pendingUploads[oldMediaID].failed) ) { // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const mediaID = pendingUploads[oldMediaID] ? oldMediaID : getNextLocalUploadID(); if (updatedMedia.type === 'photo') { updatedMedia = { type: 'photo', ...updatedMedia, id: mediaID, }; } else { updatedMedia = { type: 'video', ...updatedMedia, id: mediaID, }; } } if (updatedMedia.type === 'video') { const oldThumbnailID = updatedMedia.thumbnailID; invariant(oldThumbnailID, 'oldThumbnailID not null or undefined'); if ( // not complete isLocalUploadID(oldThumbnailID) && // not still ongoing (!pendingUploads[oldThumbnailID] || pendingUploads[oldThumbnailID].failed) ) { const thumbnailID = pendingUploads[oldThumbnailID] ? oldThumbnailID : getNextLocalUploadID(); updatedMedia = { ...updatedMedia, thumbnailID, }; } } if (updatedMedia === singleMedia) { return singleMedia; } const oldSelection = updatedMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_paste') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (updatedMedia.type === 'photo') { return { type: 'photo', ...updatedMedia, localMediaSelection: selection, }; } return { type: 'video', ...updatedMedia, localMediaSelection: selection, }; }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (const singleMedia of newRawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { void this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (const singleMedia of retryMedia) { pendingUploads[singleMedia.id] = { failed: false, progressPercent: 0, processingStep: null, }; if (singleMedia.type === 'video') { const { thumbnailID } = singleMedia; invariant(thumbnailID, 'thumbnailID not null or undefined'); pendingUploads[thumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const uploadFileInputs = retryMedia.map(singleMedia => { invariant( singleMedia.localMediaSelection, 'localMediaSelection should be set on locally created Media', ); let ids; if (singleMedia.type === 'photo') { ids = { type: 'photo', localMediaID: singleMedia.id }; } else { invariant( singleMedia.thumbnailID, 'singleMedia.thumbnailID should be set for videos', ); ids = { type: 'video', localMediaID: singleMedia.id, localThumbnailID: singleMedia.thumbnailID, }; } return { selection: singleMedia.localMediaSelection, ids, }; }); await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo); }; retryMessage = async ( localMessageID: string, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { await this.retryTextMessage(rawMessageInfo, threadInfo, parentThreadInfo); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { await this.retryMultimediaMessage( rawMessageInfo, localMessageID, threadInfo, ); } }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( candidate => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (const callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string): Promise<{ +steps: $ReadOnlyArray, +result: ?string, }> { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise(resolve => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } setPendingThreadUpdateHandler = ( threadID: string, pendingThreadUpdateHandler: ?(ThreadInfo) => mixed, ) => { if (!pendingThreadUpdateHandler) { this.pendingThreadUpdateHandlers.delete(threadID); } else { this.pendingThreadUpdateHandlers.set( threadID, pendingThreadUpdateHandler, ); } }; render(): React.Node { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); const ConnectedInputStateContainer: React.ComponentType = React.memo(function ConnectedInputStateContainer( props: BaseProps, ) { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( state => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const calendarQuery = useCalendarQuery(); - const callUploadMultimedia = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const callSendMultimediaMessage = useInputStateContainerSendMultimediaMessage(); const callSendTextMessage = useInputStateContainerSendTextMessage(); const callNewThinThread = useNewThinThread(); const callNewThickThread = useNewThickThread(); 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/avatars/avatar-hooks.react.js b/web/avatars/avatar-hooks.react.js index eaa0c6299..d57917eaf 100644 --- a/web/avatars/avatar-hooks.react.js +++ b/web/avatars/avatar-hooks.react.js @@ -1,102 +1,85 @@ // @flow import * as React from 'react'; -import { - uploadMultimedia, - useBlobServiceUpload, -} from 'lib/actions/upload-actions.js'; -import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; +import { useBlobServiceUpload } from 'lib/actions/upload-actions.js'; import type { UpdateUserAvatarRequest } from 'lib/types/avatar-types.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { encryptFile } from '../media/encryption-utils.js'; import { generateThumbHash } from '../media/image-utils.js'; import { validateFile } from '../media/media-utils.js'; -const useBlobServiceUploads = true; - type AvatarMediaUploadOptions = { +uploadMetadataToKeyserver?: boolean, }; function useUploadAvatarMedia( options: AvatarMediaUploadOptions = {}, ): File => Promise { const { uploadMetadataToKeyserver = true } = options; - const callUploadMultimedia = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const uploadAvatarMedia = React.useCallback( async (file: File): Promise => { const validatedFile = await validateFile(file); const { result } = validatedFile; if (!result.success) { throw new Error('Avatar media validation failed.'); } const { file: fixedFile, dimensions } = result; - const uploadExtras = { - ...dimensions, - loop: false, - }; - const useBlobService = - !uploadMetadataToKeyserver || useBlobServiceUploads; - if (!useBlobService) { - const { id } = await callUploadMultimedia(fixedFile, uploadExtras); - return { type: 'image', uploadID: id }; - } const encryptionResponse = await encryptFile(fixedFile); const { result: encryptionResult } = encryptionResponse; if (!encryptionResult.success) { throw new Error('Avatar media encryption failed.'); } const { file: encryptedFile, sha256Hash: blobHash, encryptionKey, } = encryptionResult; const { result: thumbHashResult } = await generateThumbHash( fixedFile, encryptionKey, ); const thumbHash = thumbHashResult.success ? thumbHashResult.thumbHash : null; const { id, uri } = await callBlobServiceUpload({ uploadInput: { blobInput: { type: 'file', file: encryptedFile, }, blobHash, encryptionKey, dimensions, loop: false, thumbHash, }, keyserverOrThreadID: uploadMetadataToKeyserver ? authoritativeKeyserverID : null, callbacks: {}, }); if (uploadMetadataToKeyserver) { return { type: 'encrypted_image', uploadID: id }; } return { type: 'non_keyserver_image', blobURI: uri, thumbHash, encryptionKey, }; }, - [callBlobServiceUpload, callUploadMultimedia, uploadMetadataToKeyserver], + [callBlobServiceUpload, uploadMetadataToKeyserver], ); return uploadAvatarMedia; } export { useUploadAvatarMedia }; diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js index 4b8399545..819567ea1 100644 --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -1,1772 +1,1724 @@ // @flow import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _omit from 'lodash/fp/omit.js'; import _partition from 'lodash/fp/partition.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendTextMessageActionTypes, } from 'lib/actions/message-actions.js'; import { queueReportsActionType } from 'lib/actions/report-actions.js'; import { useNewThinThread } from 'lib/actions/thread-actions.js'; import { type BlobServiceUploadAction, type DeleteUploadInput, - type MultimediaUploadCallbacks, - type MultimediaUploadExtras, updateMultimediaMessageMediaActionType, - uploadMultimedia, useBlobServiceUpload, useDeleteUpload, } from 'lib/actions/upload-actions.js'; import { type PushModal, useModalContext, } from 'lib/components/modal-provider.react.js'; import blobService from 'lib/facts/blob-service.js'; import { type SendMultimediaMessagePayload, useInputStateContainerSendMultimediaMessage, useInputStateContainerSendTextMessage, } from 'lib/hooks/input-state-container-hooks.js'; import { useNewThickThread } from 'lib/hooks/thread-hooks.js'; -import { useLegacyAshoatKeyserverCall } from 'lib/keyserver-conn/legacy-keyserver-call.js'; import { getNextLocalUploadID } from 'lib/media/media-utils.js'; import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import type { IdentityClientContextType } from 'lib/shared/identity-client-context.js'; import { createMediaMessageInfo, localIDPrefix, useMessageCreationSideEffectsFunc, getNextLocalID, } from 'lib/shared/message-utils.js'; import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js'; import { createRealThreadFromPendingThread } from 'lib/shared/thread-actions-utils.js'; import { draftKeyFromThreadID, patchThreadInfoToIncludeMentionedMembersOfParent, threadIsPending, threadIsPendingSidebar, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { MediaMission, MediaMissionFailure, MediaMissionResult, MediaMissionStep, - UploadMultimediaResult, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessagePayload, } from 'lib/types/message-types.js'; import type { RawImagesMessageInfo } from 'lib/types/messages/images.js'; import type { RawMediaMessageInfo } from 'lib/types/messages/media.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { reportTypes } from 'lib/types/report-types.js'; import { threadTypeIsSidebar, threadTypeIsThick, } from 'lib/types/thread-types-enum.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; import { type ClientNewThinThreadRequest, type NewThreadResult, type NewThickThreadRequest, } from 'lib/types/thread-types.js'; import { blobHashFromBlobServiceURI, isBlobServiceURI, makeBlobServiceEndpointURL, } from 'lib/utils/blob-service.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, SendMessageError } from 'lib/utils/errors.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { generateReportID } from 'lib/utils/report-utils.js'; import { createDefaultHTTPRequestHeaders } from 'lib/utils/services-utils.js'; import { type BaseInputState, type InputState, InputStateContext, type PendingMultimediaUpload, type TypeaheadInputState, type TypeaheadState, } from './input-state.js'; import { encryptFile } from '../media/encryption-utils.js'; import { generateThumbHash } from '../media/image-utils.js'; import { preloadImage, preloadMediaResource, validateFile, } from '../media/media-utils.js'; import InvalidUploadModal from '../modals/chat/invalid-upload.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; type CombinedInputState = { +inputBaseState: BaseInputState, +typeaheadState: TypeaheadInputState, }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +drafts: { +[key: string]: string }, +viewerID: ?string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +pendingRealizedThreadIDs: $ReadOnlyMap, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +calendarQuery: () => CalendarQuery, - +uploadMultimedia: ( - multimedia: Object, - extras: MultimediaUploadExtras, - callbacks: MultimediaUploadCallbacks, - ) => Promise, +blobServiceUpload: BlobServiceUploadAction, +deleteUpload: (input: DeleteUploadInput) => Promise, +sendMultimediaMessage: ( messageInfo: RawMultimediaMessageInfo, sidebarCreation: boolean, isLegacy: boolean, ) => Promise, +sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, sidebarCreation: boolean, ) => Promise, +newThinThread: ( request: ClientNewThinThreadRequest, ) => Promise, +newThickThread: (request: NewThickThreadRequest) => Promise, +pushModal: PushModal, +sendCallbacks: $ReadOnlyArray<() => mixed>, +registerSendCallback: (() => mixed) => void, +unregisterSendCallback: (() => mixed) => void, +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc, +identityContext: ?IdentityClientContextType, }; type WritableState = { pendingUploads: { [threadID: string]: { [localUploadID: string]: PendingMultimediaUpload }, }, textCursorPositions: { [threadID: string]: number }, typeaheadState: TypeaheadState, }; type State = $ReadOnly; type PropsAndState = { ...Props, ...State, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, textCursorPositions: {}, typeaheadState: { canBeVisible: false, keepUpdatingThreadMembers: true, frozenUserMentionsCandidates: [], frozenChatMentionsCandidates: {}, moveChoiceUp: null, moveChoiceDown: null, close: null, accept: null, }, }; replyCallbacks: Array<(message: string) => void> = []; pendingThreadCreations: Map< string, Promise<{ +threadID: string, +threadType: ThreadType, }>, > = new Map< string, Promise<{ +threadID: string, +threadType: ThreadType, }>, >(); - useBlobServiceUploads = true; - // When the user sends a multimedia message that triggers the creation of a // sidebar, the sidebar gets created right away, but the message needs to wait // for the uploads to complete before sending. We use this Set to track the // message localIDs that need sidebarCreation: true. pendingSidebarCreationMessageLocalIDs: Set = new Set(); static reassignToRealizedThreads( state: { +[threadID: string]: T }, props: Props, ): ?{ [threadID: string]: T } { const newState: { [string]: T } = {}; let updated = false; for (const threadID in state) { const newThreadID = props.pendingRealizedThreadIDs.get(threadID) ?? threadID; if (newThreadID !== threadID) { updated = true; } newState[newThreadID] = state[threadID]; } return updated ? newState : null; } static getDerivedStateFromProps(props: Props, state: State): ?Partial { const pendingUploads = InputStateContainer.reassignToRealizedThreads( state.pendingUploads, props, ); const textCursorPositions = InputStateContainer.reassignToRealizedThreads( state.textCursorPositions, props, ); if (!pendingUploads && !textCursorPositions) { return null; } const stateUpdate: Partial = {}; if (pendingUploads) { stateUpdate.pendingUploads = pendingUploads; } if (textCursorPositions) { stateUpdate.textCursorPositions = textCursorPositions; } return stateUpdate; } static completedMessageIDs(state: State): Set { const completed = new Map(); for (const threadID in state.pendingUploads) { const pendingUploads = state.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID, canBeSent, failed } = upload; if (!messageID || !messageID.startsWith(localIDPrefix)) { continue; } if (!canBeSent || failed) { completed.set(messageID, false); continue; } if (completed.get(messageID) === undefined) { completed.set(messageID, true); } } } const messageIDs = new Set(); for (const [messageID, isCompleted] of completed) { if (isCompleted) { messageIDs.add(messageID); } } return messageIDs; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const previouslyAssignedMessageIDs = new Set(); for (const threadID in prevState.pendingUploads) { const pendingUploads = prevState.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const { messageID } = pendingUploads[localUploadID]; if (messageID) { previouslyAssignedMessageIDs.add(messageID); } } } const newlyAssignedUploads = new Map< string, { +threadID: string, - +shouldEncrypt: boolean, +uploads: PendingMultimediaUpload[], }, >(); for (const threadID in this.state.pendingUploads) { const pendingUploads = this.state.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID } = upload; if ( !messageID || !messageID.startsWith(localIDPrefix) || previouslyAssignedMessageIDs.has(messageID) ) { continue; } - const { shouldEncrypt } = upload; let assignedUploads = newlyAssignedUploads.get(messageID); if (!assignedUploads) { - assignedUploads = { threadID, shouldEncrypt, uploads: [] }; + assignedUploads = { threadID, uploads: [] }; newlyAssignedUploads.set(messageID, assignedUploads); } - if (shouldEncrypt !== assignedUploads.shouldEncrypt) { - console.warn( - `skipping upload ${localUploadID} ` + - "because shouldEncrypt doesn't match", - ); - continue; - } assignedUploads.uploads.push(upload); } } const newMessageInfos = new Map(); for (const [messageID, assignedUploads] of newlyAssignedUploads) { - const { uploads, threadID, shouldEncrypt } = assignedUploads; + const { uploads, threadID } = assignedUploads; const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = uploads.map( ({ localID, serverID, uri, mediaType, dimensions, encryptionKey, thumbHash, }) => { // We can get into this state where dimensions are null if the user is // uploading a file type that the browser can't render. In that case // we fake the dimensions here while we wait for the server to tell us // the true dimensions. const shimmedDimensions = dimensions ?? { height: 0, width: 0 }; invariant( mediaType === 'photo' || mediaType === 'encrypted_photo', "web InputStateContainer can't handle video", ); if ( mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video' ) { return { id: serverID ? serverID : localID, uri, type: 'photo', dimensions: shimmedDimensions, thumbHash, }; } invariant( encryptionKey, 'encrypted media must have an encryption key', ); return { id: serverID ? serverID : localID, blobURI: uri, type: 'encrypted_photo', encryptionKey, dimensions: shimmedDimensions, thumbHash, }; }, ); const messageInfo = createMediaMessageInfo( { localID: messageID, threadID, creatorID, media, }, - { forceMultimediaMessageType: shouldEncrypt }, + { forceMultimediaMessageType: true }, ); newMessageInfos.set(messageID, messageInfo); } const currentlyCompleted = InputStateContainer.completedMessageIDs( this.state, ); const previouslyCompleted = InputStateContainer.completedMessageIDs(prevState); for (const messageID of currentlyCompleted) { if (previouslyCompleted.has(messageID)) { continue; } let rawMessageInfo = newMessageInfos.get(messageID); if (rawMessageInfo) { newMessageInfos.delete(messageID); } else { rawMessageInfo = this.getRawMultimediaMessageInfo(messageID); } void this.sendMultimediaMessage(rawMessageInfo); } for (const [, messageInfo] of newMessageInfos) { this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); } } getRawMultimediaMessageInfo( localMessageID: string, ): RawMultimediaMessageInfo { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); return rawMessageInfo; } - // eslint-disable-next-line no-unused-vars - shouldEncryptMedia(threadInfo: ThreadInfo): boolean { - return true; - } - async sendMultimediaMessage( messageInfo: RawMultimediaMessageInfo, ): Promise { if (!threadIsPending(messageInfo.threadID)) { void this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } const result = await threadCreationPromise; newThreadID = result.threadID; } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; const payload = new SendMessageError( `Exception when creating thread: ${exceptionMessage}`, messageInfo.localID ?? '', messageInfo.threadID, ); this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } // While the thread was being created, the image preload may have completed, // and we might have a finalized URI now. So we fetch from Redux again const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should exist for locally-created RawMessageInfo', ); const latestMessageInfo = this.getRawMultimediaMessageInfo(localID); // Conditional is necessary for Flow let newMessageInfo; if (latestMessageInfo.type === messageTypes.MULTIMEDIA) { newMessageInfo = { ...latestMessageInfo, threadID: newThreadID, time: Date.now(), }; } else { newMessageInfo = { ...latestMessageInfo, threadID: newThreadID, time: Date.now(), }; } void this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); try { const { result, mediaIDUpdates } = await this.props.sendMultimediaMessage( messageInfo, sidebarCreation, true, ); this.pendingSidebarCreationMessageLocalIDs.delete(localID); this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const prevUploads = prevState.pendingUploads[newThreadID]; const newUploads: { [string]: PendingMultimediaUpload } = {}; for (const localUploadID in prevUploads) { const upload = prevUploads[localUploadID]; if (upload.messageID !== localID) { newUploads[localUploadID] = upload; } else if (!upload.uriIsReal) { const { serverID } = upload; let newServerID = serverID; if (serverID && mediaIDUpdates?.[serverID]) { newServerID = mediaIDUpdates[serverID].id; } newUploads[localUploadID] = { ...upload, messageID: result.serverID, serverID: newServerID, }; } } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newUploads, }, }; }); return result; } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; throw new SendMessageError( `Exception while sending multimedia message: ${exceptionMessage}`, localID, threadID, ); } } startThreadCreation(threadInfo: ThreadInfo): Promise<{ +threadID: string, +threadType: ThreadType, }> { if (!threadIsPending(threadInfo.id)) { return Promise.resolve({ threadID: threadInfo.id, threadType: threadInfo.type, }); } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThinThread: this.props.newThinThread, createNewThickThread: this.props.newThickThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } inputBaseStateSelector: (?string) => PropsAndState => BaseInputState = _memoize(threadID => createSelector( (propsAndState: PropsAndState) => threadID ? propsAndState.pendingUploads[threadID] : null, (propsAndState: PropsAndState) => threadID ? propsAndState.drafts[draftKeyFromThreadID(threadID)] : null, (propsAndState: PropsAndState) => threadID ? propsAndState.textCursorPositions[threadID] : null, ( pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload }, draft: ?string, textCursorPosition: ?number, ) => { let threadPendingUploads: $ReadOnlyArray = []; const assignedUploads: { [string]: $ReadOnlyArray, } = {}; if (pendingUploads) { const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] = _partition('messageID')(pendingUploads); threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs); const threadAssignedUploads = _groupBy('messageID')( uploadsWithMessageIDs, ); for (const messageID in threadAssignedUploads) { // lodash libdefs don't return $ReadOnlyArray assignedUploads[messageID] = [ ...threadAssignedUploads[messageID], ]; } } return ({ pendingUploads: threadPendingUploads, assignedUploads, draft: draft ?? '', textCursorPosition: textCursorPosition ?? 0, appendFiles: ( threadInfo: ThreadInfo, files: $ReadOnlyArray, ) => this.appendFiles(threadInfo, files), cancelPendingUpload: this.cancelPendingUpload, sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo), createMultimediaMessage: (threadInfo: ThreadInfo) => this.createMultimediaMessage(threadInfo), setDraft: (newDraft: string) => this.setDraft(threadID, newDraft), setTextCursorPosition: (newPosition: number) => this.setTextCursorPosition(threadID, newPosition), messageHasUploadFailure: (localMessageID: string) => this.messageHasUploadFailure(assignedUploads[localMessageID]), retryMultimediaMessage: ( localMessageID: string, threadInfo: ThreadInfo, ) => this.retryMultimediaMessage( localMessageID, threadInfo, assignedUploads[localMessageID], ), addReply: (message: string) => this.addReply(message), addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, registerSendCallback: this.props.registerSendCallback, unregisterSendCallback: this.props.unregisterSendCallback, }: BaseInputState); }, ), ); typeaheadStateSelector: PropsAndState => TypeaheadInputState = createSelector( (propsAndState: PropsAndState) => propsAndState.typeaheadState, (typeaheadState: TypeaheadState) => ({ typeaheadState, setTypeaheadState: this.setTypeaheadState, }), ); inputStateSelector: CombinedInputState => InputState = createSelector( (state: CombinedInputState) => state.inputBaseState, (state: CombinedInputState) => state.typeaheadState, (inputBaseState: BaseInputState, typeaheadState: TypeaheadInputState) => ({ ...inputBaseState, ...typeaheadState, }), ); getRealizedOrPendingThreadID(threadID: string): string { return this.props.pendingRealizedThreadIDs.get(threadID) ?? threadID; } async appendFiles( threadInfo: ThreadInfo, files: $ReadOnlyArray, ): Promise { const selectionTime = Date.now(); const { pushModal } = this.props; const appendResults = await Promise.all( files.map(file => this.appendFile(threadInfo, file, selectionTime)), ); if (appendResults.some(({ result }) => !result.success)) { pushModal(); const time = Date.now() - selectionTime; const reports = []; for (const appendResult of appendResults) { const { steps } = appendResult; let { result } = appendResult; let uploadLocalID; if (result.success) { uploadLocalID = result.pendingUpload.localID; result = { success: false, reason: 'web_sibling_validation_failed' }; } const mediaMission = { steps, result, userTime: time, totalTime: time }; reports.push({ mediaMission, uploadLocalID }); } this.queueMediaMissionReports(reports); return false; } const newUploads = appendResults.map(({ result }) => { invariant(result.success, 'any failed validation should be caught above'); return result.pendingUpload; }); const newUploadsObject = _keyBy('localID')(newUploads); this.setState( prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const prevUploads = prevState.pendingUploads[newThreadID]; const mergedUploads = prevUploads ? { ...prevUploads, ...newUploadsObject } : newUploadsObject; return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: mergedUploads, }, }; }, () => this.uploadFiles(threadInfo, newUploads), ); return true; } async appendFile( threadInfo: ThreadInfo, file: File, selectTime: number, ): Promise<{ steps: $ReadOnlyArray, result: | MediaMissionFailure | { success: true, pendingUpload: PendingMultimediaUpload }, }> { const steps: MediaMissionStep[] = [ { step: 'web_selection', filename: file.name, size: file.size, mime: file.type, selectTime, }, ]; let response; const validationStart = Date.now(); try { response = await validateFile(file); } catch (e) { return { steps, result: { success: false, reason: 'processing_exception', time: Date.now() - validationStart, exceptionMessage: getMessageForException(e), }, }; } const { steps: validationSteps, result } = response; steps.push(...validationSteps); if (!result.success) { return { steps, result }; } const { uri, file: fixedFile, mediaType, dimensions } = result; - const shouldEncrypt = this.shouldEncryptMedia(threadInfo); - - let encryptionResult; - if (shouldEncrypt) { - let encryptionResponse; - const encryptionStart = Date.now(); - try { - encryptionResponse = await encryptFile(fixedFile); - } catch (e) { - return { - steps, - result: { - success: false, - reason: 'encryption_exception', - time: Date.now() - encryptionStart, - exceptionMessage: getMessageForException(e), - }, - }; - } - steps.push(...encryptionResponse.steps); - encryptionResult = encryptionResponse.result; + let encryptionResponse; + const encryptionStart = Date.now(); + try { + encryptionResponse = await encryptFile(fixedFile); + } catch (e) { + return { + steps, + result: { + success: false, + reason: 'encryption_exception', + time: Date.now() - encryptionStart, + exceptionMessage: getMessageForException(e), + }, + }; } - if (encryptionResult && !encryptionResult.success) { + const { result: encryptionResult, steps: encryptionSteps } = + encryptionResponse; + steps.push(...encryptionSteps); + + if (!encryptionResult.success) { return { steps, result: encryptionResult }; } const { steps: thumbHashSteps, result: thumbHashResult } = await generateThumbHash(fixedFile, encryptionResult?.encryptionKey); const thumbHash = thumbHashResult.success ? thumbHashResult.thumbHash : null; steps.push(...thumbHashSteps); return { steps, result: { success: true, pendingUpload: { localID: getNextLocalUploadID(), serverID: null, messageID: null, failed: false, file: encryptionResult?.file ?? fixedFile, mediaType: encryptionResult ? 'encrypted_photo' : mediaType, dimensions, uri: encryptionResult?.uri ?? uri, loop: false, uriIsReal: false, canBeSent: false, blobHolder: null, blobHash: encryptionResult?.sha256Hash, encryptionKey: encryptionResult?.encryptionKey, thumbHash, progressPercent: 0, abort: null, steps, selectTime, - shouldEncrypt, }, }, }; } uploadFiles( threadInfo: ThreadInfo, uploads: $ReadOnlyArray, ): Promise { return Promise.all( uploads.map(upload => this.uploadFile(threadInfo, upload)), ); } async uploadFile(threadInfo: ThreadInfo, upload: PendingMultimediaUpload) { const { selectTime, localID, encryptionKey } = upload; const threadID = threadInfo.id; const isThickThread = threadTypeIsThick(threadInfo.type); const isEncrypted = !!encryptionKey && (upload.mediaType === 'encrypted_photo' || upload.mediaType === 'encrypted_video'); const steps = [...upload.steps]; let userTime; const { identityContext } = this.props; invariant(identityContext, 'Identity context should be set'); const sendReport = (missionResult: MediaMissionResult) => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const latestUpload = this.state.pendingUploads[newThreadID][localID]; invariant( latestUpload, `pendingUpload ${localID} for ${newThreadID} missing in sendReport`, ); const { serverID, messageID } = latestUpload; const totalTime = Date.now() - selectTime; userTime = userTime ? userTime : totalTime; const mission = { steps, result: missionResult, totalTime, userTime }; this.queueMediaMissionReports([ { mediaMission: mission, uploadLocalID: localID, uploadServerID: serverID, messageLocalID: messageID, }, ]); }; let uploadResult, uploadExceptionMessage; const uploadStart = Date.now(); try { const callbacks = { onProgress: (percent: number) => this.setProgress(threadID, localID, percent), abortHandler: (abort: () => void) => this.handleAbortCallback(threadID, localID, abort), }; - const useBlobService = isThickThread || this.useBlobServiceUploads; - if ( - useBlobService && - (upload.mediaType === 'encrypted_photo' || - upload.mediaType === 'encrypted_video') - ) { - const { blobHash, dimensions, thumbHash } = upload; - invariant( - encryptionKey && blobHash && dimensions, - 'incomplete encrypted upload', - ); - uploadResult = await this.props.blobServiceUpload({ - uploadInput: { - blobInput: { - type: 'file', - file: upload.file, - }, - blobHash, - encryptionKey, - dimensions, - loop: false, - thumbHash, + const { mediaType, blobHash, dimensions, thumbHash } = upload; + invariant( + mediaType === 'encrypted_photo' || mediaType === 'encrypted_video', + 'uploaded media should be encrypted', + ); + invariant( + encryptionKey && blobHash && dimensions, + 'incomplete encrypted upload', + ); + + uploadResult = await this.props.blobServiceUpload({ + uploadInput: { + blobInput: { + type: 'file', + file: upload.file, }, - keyserverOrThreadID: isThickThread ? null : threadID, - callbacks, - }); - } else { - let uploadExtras = { - ...upload.dimensions, + blobHash, + encryptionKey, + dimensions, loop: false, - thumbHash: upload.thumbHash, - }; - if (encryptionKey) { - uploadExtras = { ...uploadExtras, encryptionKey }; - } - uploadResult = await this.props.uploadMultimedia( - upload.file, - uploadExtras, - callbacks, - ); - } + thumbHash, + }, + keyserverOrThreadID: isThickThread ? null : threadID, + callbacks, + }); } catch (e) { uploadExceptionMessage = getMessageForException(e); this.handleUploadFailure(threadID, localID); } userTime = Date.now() - selectTime; steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: upload.file.name, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, }); if (!uploadResult) { sendReport({ success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }); return; } const result = uploadResult; const outputMediaType = isEncrypted ? 'encrypted_photo' : result.mediaType; const successThreadID = this.getRealizedOrPendingThreadID(threadID); const uploadAfterSuccess = this.state.pendingUploads[successThreadID][localID]; invariant( uploadAfterSuccess, `pendingUpload ${localID}/${result.id} for ${successThreadID} missing ` + `after upload`, ); if (uploadAfterSuccess.messageID) { this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterSuccess.messageID, currentMediaID: localID, mediaUpdate: { id: result.id, }, }, }); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${newThreadID} ` + `missing while assigning serverID`, ); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localID]: { ...currentUpload, serverID: result.id, blobHolder: result.blobHolder, abort: null, // For thin threads we can send message right after serverID // is present, but for thick threads we need to wait until // a "real" Blob URI is assigned to the message. canBeSent: !isThickThread, }, }, }, }; }); if (encryptionKey) { const authMetadata = await identityContext.getAuthMetadata(); const { steps: preloadSteps } = await preloadMediaResource( result.uri, authMetadata, ); steps.push(...preloadSteps); } else { const { steps: preloadSteps } = await preloadImage(result.uri); steps.push(...preloadSteps); } sendReport({ success: true }); const preloadThreadID = this.getRealizedOrPendingThreadID(threadID); const uploadAfterPreload = this.state.pendingUploads[preloadThreadID][localID]; invariant( uploadAfterPreload, `pendingUpload ${localID}/${result.id} for ${preloadThreadID} missing ` + `after preload`, ); if (uploadAfterPreload.messageID) { const { mediaType, uri, dimensions } = result; const { thumbHash } = upload; let mediaUpdate = { dimensions, ...(thumbHash ? { thumbHash } : undefined), }; if (!isEncrypted) { mediaUpdate = { ...mediaUpdate, type: mediaType, uri, }; } else { mediaUpdate = { ...mediaUpdate, type: outputMediaType, blobURI: uri, encryptionKey, }; } this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterPreload.messageID, currentMediaID: result.id ?? uploadAfterPreload.localID, mediaUpdate, }, }); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${newThreadID} ` + `missing while assigning URI`, ); const { messageID } = currentUpload; if (messageID && !messageID.startsWith(localIDPrefix)) { const newPendingUploads = _omit([localID])(uploads); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localID]: { ...currentUpload, uri: result.uri, mediaType: outputMediaType, dimensions: result.dimensions, loop: result.loop, uriIsReal: true, canBeSent: true, }, }, }, }; }); } handleAbortCallback( threadID: string, localUploadID: string, abort: () => void, ) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been cancelled before we were even handed the // abort function. We should immediately abort. abort(); } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localUploadID]: { ...upload, abort, }, }, }, }; }); } handleUploadFailure(threadID: string, localUploadID: string) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const upload = uploads[localUploadID]; if (!upload || !upload.abort || upload.serverID) { // The upload has been cancelled or completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, abort: null, }, }, }, }; }); } queueMediaMissionReports( partials: $ReadOnlyArray<{ mediaMission: MediaMission, uploadLocalID?: ?string, uploadServerID?: ?string, messageLocalID?: ?string, }>, ) { const reports = partials.map( ({ mediaMission, uploadLocalID, uploadServerID, messageLocalID }) => ({ type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID, uploadLocalID, messageLocalID, id: generateReportID(), }), ); this.props.dispatch({ type: queueReportsActionType, payload: { reports } }); } cancelPendingUpload = (threadInfo: ThreadInfo, localUploadID: string) => { let revokeURL: ?string, abortRequest: ?() => void; this.setState( prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const currentPendingUploads = prevState.pendingUploads[newThreadID]; if (!currentPendingUploads) { return {}; } const pendingUpload = currentPendingUploads[localUploadID]; if (!pendingUpload) { return {}; } if (!pendingUpload.uriIsReal) { revokeURL = pendingUpload.uri; } if (pendingUpload.abort) { abortRequest = pendingUpload.abort; } if (pendingUpload.serverID) { const { serverID } = pendingUpload; if (!threadTypeIsThick(threadInfo.type)) { void this.props.deleteUpload({ id: serverID, keyserverOrThreadID: threadInfo.id, }); } if (isBlobServiceURI(pendingUpload.uri)) { const identityContext = this.props.identityContext; invariant(identityContext, 'Identity context should be set'); invariant( pendingUpload.blobHolder, 'blob service upload has no holder', ); const endpoint = blobService.httpEndpoints.DELETE_BLOB; const holder = pendingUpload.blobHolder; const blobHash = blobHashFromBlobServiceURI(pendingUpload.uri); void (async () => { const authMetadata = await identityContext.getAuthMetadata(); const defaultHeaders = createDefaultHTTPRequestHeaders(authMetadata); await fetch(makeBlobServiceEndpointURL(endpoint), { method: endpoint.method, body: JSON.stringify({ holder, blob_hash: blobHash, }), headers: { ...defaultHeaders, 'content-type': 'application/json', }, }); })(); } } const newPendingUploads = _omit([localUploadID])(currentPendingUploads); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }, () => { if (revokeURL) { URL.revokeObjectURL(revokeURL); } if (abortRequest) { abortRequest(); } }, ); }; async sendTextMessage( messageInfo: RawTextMessageInfo, inputThreadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) { this.props.sendCallbacks.forEach(callback => callback()); const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); if (threadIsPendingSidebar(inputThreadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localID); } if (!threadIsPending(inputThreadInfo.id)) { void this.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 && threadTypeIsSidebar(inputThreadInfo.type)) { invariant(parentThreadInfo, 'sidebar should have parent'); threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent( inputThreadInfo, parentThreadInfo, messageInfo.text, viewerID, ); if (threadInfo !== inputThreadInfo) { this.props.dispatch({ type: updateNavInfoActionType, payload: { pendingThread: threadInfo }, }); } } let threadCreationResult = null; try { threadCreationResult = await this.startThreadCreation(threadInfo); } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; const payload = new SendMessageError( `Exception while creating thread: ${exceptionMessage}`, messageInfo.localID ?? '', messageInfo.threadID, ); this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: threadCreationResult?.threadID, time: Date.now(), }; const newThreadInfo = { ...threadInfo, id: threadCreationResult?.threadID, type: threadCreationResult?.threadType ?? threadInfo.type, }; void this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction( newMessageInfo, newThreadInfo, parentThreadInfo, ), undefined, newMessageInfo, ); } async sendTextMessageAction( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): Promise { try { if (!threadTypeIsThick(threadInfo.type)) { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); } const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const sidebarCreation = this.pendingSidebarCreationMessageLocalIDs.has(localID); const result = await this.props.sendTextMessage( messageInfo, threadInfo, parentThreadInfo, sidebarCreation, ); if (threadTypeIsThick(threadInfo.type)) { await this.props.textMessageCreationSideEffectsFunc( messageInfo, threadInfo, parentThreadInfo, ); } this.pendingSidebarCreationMessageLocalIDs.delete(localID); return result; } catch (e) { const exceptionMessage = getMessageForException(e) ?? ''; throw new SendMessageError( `Exception while sending text message: ${exceptionMessage}`, messageInfo.localID ?? '', messageInfo.threadID, ); } } // Creates a MultimediaMessage from the unassigned pending uploads, // if there are any createMultimediaMessage(threadInfo: ThreadInfo) { this.props.sendCallbacks.forEach(callback => callback()); const localMessageID = getNextLocalID(); void this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const currentPendingUploads = prevState.pendingUploads[newThreadID]; if (!currentPendingUploads) { return {}; } const newPendingUploads: { [string]: PendingMultimediaUpload } = {}; let uploadAssigned = false; for (const localUploadID in currentPendingUploads) { const upload = currentPendingUploads[localUploadID]; if (upload.messageID) { newPendingUploads[localUploadID] = upload; } else { const newUpload = { ...upload, messageID: localMessageID, }; uploadAssigned = true; newPendingUploads[localUploadID] = newUpload; } } if (!uploadAssigned) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); } setDraft(threadID: ?string, draft: string) { invariant(threadID, 'threadID should be set in setDraft'); const newThreadID = this.getRealizedOrPendingThreadID(threadID); this.props.dispatch({ type: 'UPDATE_DRAFT', payload: { key: draftKeyFromThreadID(newThreadID), text: draft, }, }); } setTextCursorPosition(threadID: ?string, newPosition: number) { invariant(threadID, 'threadID should be set in setTextCursorPosition'); this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); return { textCursorPositions: { ...prevState.textCursorPositions, [newThreadID]: newPosition, }, }; }); } setTypeaheadState = (newState: Partial) => { this.setState(prevState => ({ typeaheadState: { ...prevState.typeaheadState, ...newState, }, })); }; setProgress( threadID: string, localUploadID: string, progressPercent: number, ) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const pendingUploads = prevState.pendingUploads[newThreadID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); } messageHasUploadFailure( pendingUploads: ?$ReadOnlyArray, ): boolean { if (!pendingUploads) { return false; } return pendingUploads.some(upload => upload.failed); } retryMultimediaMessage( localMessageID: string, threadInfo: ThreadInfo, pendingUploads: ?$ReadOnlyArray, ) { this.props.sendCallbacks.forEach(callback => callback()); const rawMessageInfo = this.getRawMultimediaMessageInfo(localMessageID); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawMediaMessageInfo); } else { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawImagesMessageInfo); } void this.startThreadCreation(threadInfo); if (threadIsPendingSidebar(threadInfo.id)) { this.pendingSidebarCreationMessageLocalIDs.add(localMessageID); } const completed = InputStateContainer.completedMessageIDs(this.state); if (completed.has(localMessageID)) { void this.sendMultimediaMessage(newRawMessageInfo); return; } if (!pendingUploads) { return; } // We're not actually starting the send here, // we just use this action to update the message's timestamp in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); const uploadIDsToRetry = new Set(); const uploadsToRetry = []; for (const pendingUpload of pendingUploads) { const { serverID, messageID, localID, abort } = pendingUpload; if (serverID || messageID !== localMessageID) { continue; } if (abort) { abort(); } uploadIDsToRetry.add(localID); uploadsToRetry.push(pendingUpload); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const prevPendingUploads = prevState.pendingUploads[newThreadID]; if (!prevPendingUploads) { return {}; } const newPendingUploads: { [string]: PendingMultimediaUpload } = {}; let pendingUploadChanged = false; for (const localID in prevPendingUploads) { const pendingUpload = prevPendingUploads[localID]; if (uploadIDsToRetry.has(localID) && !pendingUpload.serverID) { newPendingUploads[localID] = { ...pendingUpload, failed: false, progressPercent: 0, abort: null, }; pendingUploadChanged = true; } else { newPendingUploads[localID] = pendingUpload; } } if (!pendingUploadChanged) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); void this.uploadFiles(threadInfo, uploadsToRetry); } addReply = (message: string) => { this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(message)); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( candidate => candidate !== callbackReply, ); }; render(): React.Node { const { activeChatThreadID } = this.props; // we're going with two selectors as we want to avoid // recreation of chat state setter functions on typeahead state updates const inputBaseState = this.inputBaseStateSelector(activeChatThreadID)({ ...this.state, ...this.props, }); const typeaheadState = this.typeaheadStateSelector({ ...this.state, ...this.props, }); const inputState = this.inputStateSelector({ inputBaseState, typeaheadState, }); return ( {this.props.children} ); } } const ConnectedInputStateContainer: React.ComponentType = React.memo(function ConnectedInputStateContainer(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const drafts = useSelector(state => state.draftStore.drafts); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); const calendarQuery = useSelector(nonThreadCalendarQuery); - const callUploadMultimedia = useLegacyAshoatKeyserverCall(uploadMultimedia); const callBlobServiceUpload = useBlobServiceUpload(); const callDeleteUpload = useDeleteUpload(); const callSendMultimediaMessage = useInputStateContainerSendMultimediaMessage(); const callSendTextMessage = useInputStateContainerSendTextMessage(); const callNewThinThread = useNewThinThread(); const callNewThickThread = useNewThickThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const identityContext = React.useContext(IdentityClientContext); const [sendCallbacks, setSendCallbacks] = React.useState< $ReadOnlyArray<() => mixed>, >([]); const registerSendCallback = React.useCallback((callback: () => mixed) => { setSendCallbacks(prevCallbacks => [...prevCallbacks, callback]); }, []); const unregisterSendCallback = React.useCallback( (callback: () => mixed) => { setSendCallbacks(prevCallbacks => prevCallbacks.filter(candidate => candidate !== callback), ); }, [], ); const textMessageCreationSideEffectsFunc = useMessageCreationSideEffectsFunc(messageTypes.TEXT); return ( ); }); export default ConnectedInputStateContainer; diff --git a/web/input/input-state.js b/web/input/input-state.js index fa16caaf8..8e48128ca 100644 --- a/web/input/input-state.js +++ b/web/input/input-state.js @@ -1,105 +1,104 @@ // @flow import * as React from 'react'; import { type Dimensions, type EncryptedMediaType, type MediaMissionStep, type MediaType, } from 'lib/types/media-types.js'; import type { RawTextMessageInfo } from 'lib/types/messages/text.js'; import type { RelativeMemberInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ChatMentionCandidates } from 'lib/types/thread-types.js'; export type PendingMultimediaUpload = { +localID: string, // Pending uploads are assigned a serverID once they are complete +serverID: ?string, // Pending uploads are assigned a messageID once they are sent +messageID: ?string, // This is set to true if the upload fails for whatever reason +failed: boolean, +file: File, +mediaType: MediaType | EncryptedMediaType, +dimensions: ?Dimensions, +uri: string, +blobHolder: ?string, +blobHash: ?string, +encryptionKey: ?string, +thumbHash: ?string, +loop: boolean, // URLs created with createObjectURL aren't considered "real". The distinction // is required because those "fake" URLs must be disposed properly +uriIsReal: boolean, +canBeSent: boolean, +progressPercent: number, // This is set once the network request begins and used if the upload is // cancelled +abort: ?() => void, +steps: MediaMissionStep[], +selectTime: number, - +shouldEncrypt: boolean, }; export type TypeaheadState = { +canBeVisible: boolean, +keepUpdatingThreadMembers: boolean, +frozenUserMentionsCandidates: $ReadOnlyArray, +frozenChatMentionsCandidates: ChatMentionCandidates, +moveChoiceUp: ?() => void, +moveChoiceDown: ?() => void, +close: ?() => void, +accept: ?() => void, }; export type BaseInputState = { +pendingUploads: $ReadOnlyArray, +assignedUploads: { [messageID: string]: $ReadOnlyArray, }, +draft: string, +textCursorPosition: number, +appendFiles: ( threadInfo: ThreadInfo, files: $ReadOnlyArray, ) => Promise, +cancelPendingUpload: (threadInfo: ThreadInfo, localUploadID: string) => void, +sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => mixed, +createMultimediaMessage: (threadInfo: ThreadInfo) => void, +setDraft: (draft: string) => void, +setTextCursorPosition: (newPosition: number) => void, +messageHasUploadFailure: (localMessageID: string) => boolean, +retryMultimediaMessage: ( localMessageID: string, threadInfo: ThreadInfo, ) => void, +addReply: (text: string) => void, +addReplyListener: ((message: string) => void) => void, +removeReplyListener: ((message: string) => void) => void, +registerSendCallback: (() => mixed) => void, +unregisterSendCallback: (() => mixed) => void, }; export type TypeaheadInputState = { +typeaheadState: TypeaheadState, +setTypeaheadState: (Partial) => void, }; // This type represents the input state for a particular thread export type InputState = { ...BaseInputState, ...TypeaheadInputState, }; const InputStateContext: React.Context = React.createContext(null); export { InputStateContext };