diff --git a/lib/actions/activity-actions.js b/lib/actions/activity-actions.js index cd68f8897..201b249a1 100644 --- a/lib/actions/activity-actions.js +++ b/lib/actions/activity-actions.js @@ -1,103 +1,112 @@ // @flow -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.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 { ActivityUpdate, ActivityUpdateSuccessPayload, SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, } from '../types/activity-types.js'; export type UpdateActivityInput = { +activityUpdates: $ReadOnlyArray, }; const updateActivityActionTypes = Object.freeze({ started: 'UPDATE_ACTIVITY_STARTED', success: 'UPDATE_ACTIVITY_SUCCESS', failed: 'UPDATE_ACTIVITY_FAILED', }); const updateActivity = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: UpdateActivityInput) => Promise) => async input => { const { activityUpdates } = input; const requests: { [string]: { +updates: ActivityUpdate[] } } = {}; for (const update of activityUpdates) { - const keyserverID = extractKeyserverIDFromID(update.threadID); + const optionalKeyserverID = extractKeyserverIDFromIDOptional( + update.threadID, + ); + if (!optionalKeyserverID) { + continue; + } + const keyserverID: string = optionalKeyserverID; if (!requests[keyserverID]) { requests[keyserverID] = { updates: [] }; } requests[keyserverID].updates.push(update); } const responses = await callKeyserverEndpoint('update_activity', requests); let unfocusedToUnread: $ReadOnlyArray = []; for (const keyserverID in responses) { unfocusedToUnread = unfocusedToUnread.concat( responses[keyserverID].unfocusedToUnread, ); } const sortedActivityUpdates: { [keyserverID: string]: $ReadOnlyArray, } = {}; for (const keyserverID in requests) { sortedActivityUpdates[keyserverID] = requests[keyserverID].updates; } return { activityUpdates: sortedActivityUpdates, result: { unfocusedToUnread, }, }; }; function useUpdateActivity(): ( input: UpdateActivityInput, ) => Promise { return useKeyserverCall(updateActivity); } const setThreadUnreadStatusActionTypes = Object.freeze({ started: 'SET_THREAD_UNREAD_STATUS_STARTED', success: 'SET_THREAD_UNREAD_STATUS_SUCCESS', failed: 'SET_THREAD_UNREAD_STATUS_FAILED', }); const setThreadUnreadStatus = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: SetThreadUnreadStatusRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.threadID); const requests = { [keyserverID]: input }; const responses = await callKeyserverEndpoint( 'set_thread_unread_status', requests, ); return { resetToUnread: responses[keyserverID].resetToUnread, threadID: input.threadID, }; }; function useSetThreadUnreadStatus(): ( request: SetThreadUnreadStatusRequest, ) => Promise { return useKeyserverCall(setThreadUnreadStatus); } export { updateActivityActionTypes, useUpdateActivity, setThreadUnreadStatusActionTypes, useSetThreadUnreadStatus, }; diff --git a/lib/actions/link-actions.js b/lib/actions/link-actions.js index 8f372136d..e67ea6273 100644 --- a/lib/actions/link-actions.js +++ b/lib/actions/link-actions.js @@ -1,195 +1,196 @@ // @flow + import * as React from 'react'; import type { CallSingleKeyserverEndpoint } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { extractKeyserverIDFromID } 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 { FetchInviteLinksResponse, InviteLinkVerificationRequest, InviteLinkVerificationResponse, CreateOrUpdatePublicLinkRequest, InviteLink, DisableInviteLinkRequest, DisableInviteLinkPayload, } from '../types/link-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { useSelector } from '../utils/redux-utils.js'; const verifyInviteLinkActionTypes = Object.freeze({ started: 'VERIFY_INVITE_LINK_STARTED', success: 'VERIFY_INVITE_LINK_SUCCESS', failed: 'VERIFY_INVITE_LINK_FAILED', }); const verifyInviteLink = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): ((input: { +request: InviteLinkVerificationRequest, +keyserverID: string, }) => Promise) => async input => { const { request, keyserverID } = input; const requests = { [keyserverID]: request, }; const responses = await callSingleKeyserverEndpoint( 'verify_invite_link', requests, ); const response = responses[keyserverID]; if (response.status === 'valid' || response.status === 'already_joined') { return { status: response.status, community: response.community, thread: response.thread, }; } return { status: response.status, }; }; function useVerifyInviteLink( keyserverOverride?: ?{ +keyserverID: string, +keyserverURL: string, }, ): ( request: InviteLinkVerificationRequest, ) => Promise { const keyserverID = keyserverOverride?.keyserverID ?? authoritativeKeyserverID(); const isKeyserverKnown = useSelector( state => !!state.keyserverStore.keyserverInfos[keyserverID], ); let paramOverride = null; if (keyserverOverride && !isKeyserverKnown) { paramOverride = { keyserverInfos: { [keyserverOverride.keyserverID]: { urlPrefix: keyserverOverride.keyserverURL, }, }, }; } const callVerifyInviteLink = useKeyserverCall( verifyInviteLink, paramOverride, ); return React.useCallback( (request: InviteLinkVerificationRequest) => callVerifyInviteLink({ request, keyserverID }), [callVerifyInviteLink, keyserverID], ); } const fetchPrimaryInviteLinkActionTypes = Object.freeze({ started: 'FETCH_PRIMARY_INVITE_LINKS_STARTED', success: 'FETCH_PRIMARY_INVITE_LINKS_SUCCESS', failed: 'FETCH_PRIMARY_INVITE_LINKS_FAILED', }); const fetchPrimaryInviteLinks = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): (() => Promise) => async () => { const requests: { [string]: void } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = undefined; } const responses = await callKeyserverEndpoint( 'fetch_primary_invite_links', requests, ); let links: $ReadOnlyArray = []; for (const keyserverID in responses) { links = links.concat(responses[keyserverID].links); } return { links, }; }; function useFetchPrimaryInviteLinks(): () => Promise { return useKeyserverCall(fetchPrimaryInviteLinks); } const createOrUpdatePublicLinkActionTypes = Object.freeze({ started: 'CREATE_OR_UPDATE_PUBLIC_LINK_STARTED', success: 'CREATE_OR_UPDATE_PUBLIC_LINK_SUCCESS', failed: 'CREATE_OR_UPDATE_PUBLIC_LINK_FAILED', }); const createOrUpdatePublicLink = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: CreateOrUpdatePublicLinkRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.communityID); const requests = { [keyserverID]: { name: input.name, communityID: input.communityID, }, }; const responses = await callKeyserverEndpoint( 'create_or_update_public_link', requests, ); const response = responses[keyserverID]; return { name: response.name, primary: response.primary, role: response.role, communityID: response.communityID, expirationTime: response.expirationTime, limitOfUses: response.limitOfUses, numberOfUses: response.numberOfUses, }; }; function useCreateOrUpdatePublicLink(): ( input: CreateOrUpdatePublicLinkRequest, ) => Promise { return useKeyserverCall(createOrUpdatePublicLink); } const disableInviteLinkLinkActionTypes = Object.freeze({ started: 'DISABLE_INVITE_LINK_STARTED', success: 'DISABLE_INVITE_LINK_SUCCESS', failed: 'DISABLE_INVITE_LINK_FAILED', }); const disableInviteLink = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: DisableInviteLinkRequest) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.communityID); const requests = { [keyserverID]: input }; await callKeyserverEndpoint('disable_invite_link', requests); return input; }; function useDisableInviteLink(): ( input: DisableInviteLinkRequest, ) => Promise { return useKeyserverCall(disableInviteLink); } export { verifyInviteLinkActionTypes, useVerifyInviteLink, fetchPrimaryInviteLinkActionTypes, useFetchPrimaryInviteLinks, createOrUpdatePublicLinkActionTypes, useCreateOrUpdatePublicLink, disableInviteLinkLinkActionTypes, useDisableInviteLink, }; diff --git a/lib/actions/upload-actions.js b/lib/actions/upload-actions.js index ad810f0c4..7c7f61e8b 100644 --- a/lib/actions/upload-actions.js +++ b/lib/actions/upload-actions.js @@ -1,285 +1,289 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import blobService from '../facts/blob-service.js'; import type { CallSingleKeyserverEndpoint } from '../keyserver-conn/call-single-keyserver-endpoint.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { 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 { 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 { toBase64URL } from '../utils/base64.js'; import { blobServiceUploadHandler, type BlobServiceUploadHandler, } from '../utils/blob-service-upload.js'; import { makeBlobServiceEndpointURL } from '../utils/blob-service.js'; import { getMessageForException } from '../utils/errors.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 = extractKeyserverIDFromID(keyserverOrThreadID); + 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 = { ...UploadMultimediaResult, blobHolder: ?string, }; export type BlobServiceUploadAction = (input: { +uploadInput: BlobServiceUploadInput, +keyserverOrThreadID: string, +callbacks?: MultimediaUploadCallbacks, }) => Promise; const blobServiceUpload = ( callKeyserverEndpoint: CallKeyserverEndpoint, authMetadata: AuthMetadata, ): BlobServiceUploadAction => async input => { const { uploadInput, callbacks, keyserverOrThreadID } = input; const { encryptionKey, loop, dimensions, thumbHash, blobInput } = uploadInput; const blobHolder = uuid.v4(); const blobHash = toBase64URL(uploadInput.blobHash); const defaultHeaders = createDefaultHTTPRequestHeaders(authMetadata); // 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. Upload metadata to keyserver - const keyserverID = extractKeyserverIDFromID(keyserverOrThreadID); + const keyserverID: string = + extractKeyserverIDFromIDOptional(keyserverOrThreadID) ?? + keyserverOrThreadID; const requests = { [keyserverID]: { blobHash, blobHolder, encryptionKey, filename: blobInput.type === 'file' ? blobInput.file.name : blobInput.filename, mimeType: blobInput.type === 'file' ? blobInput.file.type : blobInput.mimeType, loop, thumbHash, ...dimensions, }, }; const responses = await callKeyserverEndpoint( 'upload_media_metadata', requests, ); const response = responses[keyserverID]; return { id: response.id, uri: response.uri, mediaType: response.mediaType, dimensions: response.dimensions, loop: response.loop, blobHolder, }; }; function useBlobServiceUpload(): BlobServiceUploadAction { const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; const blobUploadAction = React.useCallback( ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): BlobServiceUploadAction => async input => { const authMetadata = await getAuthMetadata(); const authenticatedUploadAction = blobServiceUpload( callSingleKeyserverEndpoint, authMetadata, ); return authenticatedUploadAction(input); }, [getAuthMetadata], ); return useKeyserverCall(blobUploadAction); } export { uploadMultimedia, useBlobServiceUpload, updateMultimediaMessageMediaActionType, useDeleteUpload, }; diff --git a/lib/keyserver-conn/keyserver-auth.js b/lib/keyserver-conn/keyserver-auth.js index b5d80f2fd..b4c96ef16 100644 --- a/lib/keyserver-conn/keyserver-auth.js +++ b/lib/keyserver-conn/keyserver-auth.js @@ -1,202 +1,203 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useCallKeyserverEndpointContext } from './call-keyserver-endpoint-provider.react.js'; -import { extractKeyserverIDFromID } from './keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from './keyserver-call-utils.js'; import { CANCELLED_ERROR, type CallKeyserverEndpoint, } from './keyserver-conn-types.js'; import { keyserverAuthActionTypes, keyserverAuthRawAction, } from '../actions/user-actions.js'; import { filterThreadIDsInFilterList } from '../reducers/calendar-filters-reducer.js'; import { cookieSelector, deviceTokenSelector, } from '../selectors/keyserver-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { AuthActionSource } from '../types/account-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import sleep from '../utils/sleep.js'; const AUTH_RETRY_DELAY_MS = 60000; type KeyserverAuthInputs = { +authActionSource: AuthActionSource, +setInProgress: boolean => mixed, +hasBeenCancelled: () => boolean, +doNotRegister: boolean, +password?: ?string, }; type RawKeyserverAuthFunc = KeyserverAuthInputs => CallKeyserverEndpoint => Promise; function useRawKeyserverAuth(keyserverID: string): RawKeyserverAuthFunc { const navInfo = useSelector(state => state.navInfo); const calendarFilters = useSelector(state => state.calendarFilters); const calendarQuery = React.useMemo(() => { const filters = filterThreadIDsInFilterList( calendarFilters, - (threadID: string) => extractKeyserverIDFromID(threadID) === keyserverID, + (threadID: string) => + extractKeyserverIDFromIDOptional(threadID) === keyserverID, ); return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters, }; }, [calendarFilters, keyserverID, navInfo.endDate, navInfo.startDate]); const cookie = useSelector(cookieSelector(keyserverID)); const keyserverDeviceToken = useSelector(deviceTokenSelector(keyserverID)); // We have an assumption that we should be always connected to the // authoritative keyserver. It is possible that a token which it has is // correct, so we can try to use it. In worst case it is invalid and our // push-handler will try to fix it. const authoritativeKeyserverDeviceToken = useSelector( deviceTokenSelector(authoritativeKeyserverID()), ); const deviceToken = keyserverDeviceToken ?? authoritativeKeyserverDeviceToken; const dispatchActionPromise = useDispatchActionPromise(); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { identityClient, getAuthMetadata } = identityContext; const { olmAPI } = getConfig(); const currentUserInfo = useSelector(state => state.currentUserInfo); return React.useCallback( (inputs: KeyserverAuthInputs) => async (innerCallKeyserverEndpoint: CallKeyserverEndpoint) => { const { authActionSource, setInProgress, hasBeenCancelled, doNotRegister, password, } = inputs; try { const [keyserverKeys] = await Promise.all([ identityClient.getKeyserverKeys(keyserverID), olmAPI.initializeCryptoAccount(), ]); if (hasBeenCancelled()) { throw new Error(CANCELLED_ERROR); } const [notifsSession, contentSession, { userID, deviceID }] = await Promise.all([ olmAPI.notificationsSessionCreator( cookie, keyserverKeys.identityKeysBlob.notificationIdentityPublicKeys, keyserverKeys.notifInitializationInfo, keyserverID, ), olmAPI.contentOutboundSessionCreator( keyserverKeys.identityKeysBlob.primaryIdentityPublicKeys, keyserverKeys.contentInitializationInfo, ), getAuthMetadata(), ]); invariant(userID, 'userID should be set'); invariant(deviceID, 'deviceID should be set'); const deviceTokenUpdateInput = deviceToken ? { [keyserverID]: { deviceToken } } : {}; if (hasBeenCancelled()) { throw new Error(CANCELLED_ERROR); } const authPromise = keyserverAuthRawAction( innerCallKeyserverEndpoint, )({ userID, deviceID, doNotRegister, calendarQuery, deviceTokenUpdateInput, authActionSource, keyserverData: { [keyserverID]: { initialContentEncryptedMessage: contentSession.encryptedData.message, initialNotificationsEncryptedMessage: notifsSession, }, }, preRequestUserInfo: currentUserInfo, password, }); void dispatchActionPromise(keyserverAuthActionTypes, authPromise); await authPromise; } catch (e) { if (hasBeenCancelled()) { return; } console.log( `Error while authenticating to keyserver with id ${keyserverID}`, e, ); throw e; } finally { if (!hasBeenCancelled()) { void (async () => { await sleep(AUTH_RETRY_DELAY_MS); setInProgress(false); })(); } } }, [ calendarQuery, cookie, deviceToken, dispatchActionPromise, getAuthMetadata, identityClient, keyserverID, olmAPI, currentUserInfo, ], ); } type KeyserverAuthFunc = KeyserverAuthInputs => Promise; function useKeyserverAuthWithRetry(keyserverID: string): KeyserverAuthFunc { const rawKeyserverAuth = useRawKeyserverAuth(keyserverID); const { callKeyserverEndpoint } = useCallKeyserverEndpointContext(); return React.useCallback( async (inputs: KeyserverAuthInputs) => { try { return await rawKeyserverAuth(inputs)(callKeyserverEndpoint); } catch (e) { if (getMessageForException(e) === 'olm_session_creation_failure') { // We retry in case we were accidentally vended an invalid OTK the // first time return await rawKeyserverAuth(inputs)(callKeyserverEndpoint); } throw e; } }, [rawKeyserverAuth, callKeyserverEndpoint], ); } export { useRawKeyserverAuth, useKeyserverAuthWithRetry }; diff --git a/lib/keyserver-conn/keyserver-call-utils.js b/lib/keyserver-conn/keyserver-call-utils.js index 8b406785e..ef5cf237a 100644 --- a/lib/keyserver-conn/keyserver-call-utils.js +++ b/lib/keyserver-conn/keyserver-call-utils.js @@ -1,107 +1,121 @@ // @flow import invariant from 'invariant'; import type { CalendarQuery } from '../types/entry-types.js'; import type { NotDeletedFilter } from '../types/filter-types.js'; -function extractKeyserverIDFromID(id: string): string { +function extractKeyserverIDFromIDOptional(id: string): ?string { + if (!id.includes('|')) { + return null; + } return id.split('|')[0]; } +function extractKeyserverIDFromID(id: string): string { + const keyserverID = extractKeyserverIDFromIDOptional(id); + invariant(keyserverID, 'Keyserver ID should be present'); + return keyserverID; +} + function sortThreadIDsPerKeyserver(threadIDs: $ReadOnlyArray): { +[keyserverID: string]: $ReadOnlyArray, } { const results: { [string]: string[] } = {}; for (const threadID of threadIDs) { - const keyserverID = extractKeyserverIDFromID(threadID); - invariant(keyserverID, 'keyserver data missing from thread id'); + const optionalKeyserverID = extractKeyserverIDFromIDOptional(threadID); + if (!optionalKeyserverID) { + continue; + } + const keyserverID: string = optionalKeyserverID; if (results[keyserverID] === undefined) { results[keyserverID] = []; } results[keyserverID].push(threadID); } return results; } type CalendarThreadFilterWithWritableThreadIDs = { +type: 'threads', +threadIDs: string[], }; type CalendarFilterWithWritableThreadIDs = | NotDeletedFilter | CalendarThreadFilterWithWritableThreadIDs; type CalendarQueryWithWritableFilters = { +startDate: string, +endDate: string, +filters: CalendarFilterWithWritableThreadIDs[], }; function sortCalendarQueryPerKeyserver( calendarQuery: CalendarQuery, keyserverIDs: $ReadOnlyArray, ): { +[keyserverID: string]: CalendarQuery, } { const { startDate, endDate, filters } = calendarQuery; const results: { [string]: CalendarQueryWithWritableFilters } = {}; for (const keyserverID of keyserverIDs) { results[keyserverID] = { startDate, endDate, filters: [], }; } for (const filter of filters) { if (filter.type === 'not_deleted') { for (const keyserverID in results) { results[keyserverID].filters.push({ type: 'not_deleted' }); } } else if (filter.type === 'threads') { for (const threadID of filter.threadIDs) { - const keyserverID = extractKeyserverIDFromID(threadID); - if (results[keyserverID] === undefined) { + const keyserverID = extractKeyserverIDFromIDOptional(threadID); + if (!keyserverID || results[keyserverID] === undefined) { continue; } let threadFilter = results[keyserverID].filters.find( flt => flt.type === 'threads', ); invariant( !threadFilter || threadFilter.type === 'threads', 'should only match CalendarThreadFilter', ); if (!threadFilter) { threadFilter = { type: 'threads', threadIDs: [] }; results[keyserverID].filters.push(threadFilter); } threadFilter.threadIDs.push(threadID); } } else { console.warn('unhandled filter in sortCalendarQueryPerKeyserver'); } } return results; } function getThreadIDsForKeyservers( threadIDs: $ReadOnlyArray, keyserverIDs: $ReadOnlyArray, ): $ReadOnlyArray { if (keyserverIDs.length === 0) { return []; } const keyserverIDsSet = new Set(keyserverIDs); - return threadIDs.filter(threadID => - keyserverIDsSet.has(extractKeyserverIDFromID(threadID)), - ); + return threadIDs.filter(threadID => { + const keyserverID = extractKeyserverIDFromIDOptional(threadID); + return keyserverID && keyserverIDsSet.has(keyserverID); + }); } export { + extractKeyserverIDFromIDOptional, extractKeyserverIDFromID, sortThreadIDsPerKeyserver, sortCalendarQueryPerKeyserver, getThreadIDsForKeyservers, }; diff --git a/lib/keyserver-conn/keyserver-call-utils.test.js b/lib/keyserver-conn/keyserver-call-utils.test.js index a53069f89..6d7aa23f0 100644 --- a/lib/keyserver-conn/keyserver-call-utils.test.js +++ b/lib/keyserver-conn/keyserver-call-utils.test.js @@ -1,123 +1,137 @@ // @flow import { extractKeyserverIDFromID, sortCalendarQueryPerKeyserver, getThreadIDsForKeyservers, + extractKeyserverIDFromIDOptional, } from './keyserver-call-utils.js'; import type { CalendarQuery } from '../types/entry-types'; describe('extractKeyserverIDFromID', () => { it('should return for |', () => { const keyserverID = '404'; const id = keyserverID + '|1234'; expect(extractKeyserverIDFromID(id)).toBe(keyserverID); }); - it('should return for ', () => { + it('should throw for non-keyserver ID', () => { + const id = '404'; + expect(() => extractKeyserverIDFromID(id)).toThrow(); + }); +}); + +describe('extractKeyserverIDFromIDOptional', () => { + it('should return for |', () => { const keyserverID = '404'; - expect(extractKeyserverIDFromID(keyserverID)).toBe(keyserverID); + const id = keyserverID + '|1234'; + expect(extractKeyserverIDFromIDOptional(id)).toBe(keyserverID); + }); + + it('should return null for non-keyserver ID', () => { + const id = '404'; + expect(extractKeyserverIDFromIDOptional(id)).toBe(null); }); }); describe('sortCalendarQueryPerKeyserver', () => { it('should split the calendar query into multiple queries, one for every \ keyserver, that have all the properties of the original one, \ but only the thread ids that the keyserver should get', () => { const query: CalendarQuery = { startDate: '1463588881886', endDate: '1463588889886', filters: [ { type: 'not_deleted' }, { type: 'threads', threadIDs: ['256|1', '256|2', '100|100', '100|101'], }, ], }; const queriesPerKeyserver = { ['256']: { startDate: '1463588881886', endDate: '1463588889886', filters: [ { type: 'not_deleted' }, { type: 'threads', threadIDs: ['256|1', '256|2'], }, ], }, ['100']: { startDate: '1463588881886', endDate: '1463588889886', filters: [ { type: 'not_deleted' }, { type: 'threads', threadIDs: ['100|100', '100|101'], }, ], }, }; expect(sortCalendarQueryPerKeyserver(query, ['100', '256'])).toEqual( queriesPerKeyserver, ); }); it('should create calendar query for every keyserver in the second argument', () => { const query: CalendarQuery = { startDate: '1463588881886', endDate: '1463588889886', filters: [{ type: 'not_deleted' }], }; const queriesPerKeyserver = { ['256']: { startDate: '1463588881886', endDate: '1463588889886', filters: [{ type: 'not_deleted' }], }, ['100']: { startDate: '1463588881886', endDate: '1463588889886', filters: [{ type: 'not_deleted' }], }, }; expect(sortCalendarQueryPerKeyserver(query, ['100', '256'])).toEqual( queriesPerKeyserver, ); }); }); const keyserver1 = '256'; const keyserver2 = '100'; const keyserver3 = '200'; const keyserver1ThreadIDs = [ keyserver1 + '|1', keyserver1 + '|2', keyserver1 + '|3', ]; const keyserver2ThreadIDs = [ keyserver2 + '|1', keyserver2 + '|2', keyserver2 + '|3', ]; const keyserver3ThreadIDs = [ keyserver3 + '|1', keyserver3 + '|2', keyserver3 + '|3', ]; describe('getThreadIDsForKeyservers', () => { it('should return thread belonging to specified keyservers', () => { expect( getThreadIDsForKeyservers( [ ...keyserver1ThreadIDs, ...keyserver2ThreadIDs, ...keyserver3ThreadIDs, ], [keyserver1, keyserver2], ), ).toEqual([...keyserver1ThreadIDs, ...keyserver2ThreadIDs]); }); }); diff --git a/lib/reducers/calendar-filters-reducer.js b/lib/reducers/calendar-filters-reducer.js index eb5a5e6dc..ab098afe8 100644 --- a/lib/reducers/calendar-filters-reducer.js +++ b/lib/reducers/calendar-filters-reducer.js @@ -1,233 +1,235 @@ // @flow import { updateCalendarCommunityFilter, clearCalendarCommunityFilter, } from '../actions/community-actions.js'; import { legacySiweAuthActionTypes } from '../actions/siwe-actions.js'; import { newThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, deleteThreadActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { keyserverAuthActionTypes, deleteKeyserverAccountActionTypes, legacyLogInActionTypes, legacyKeyserverRegisterActionTypes, } from '../actions/user-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { filteredThreadIDs, nonThreadCalendarFilters, nonExcludeDeletedCalendarFilters, } from '../selectors/calendar-filter-selectors.js'; import { threadInFilterList } from '../shared/thread-utils.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; import { type CalendarFilter, defaultCalendarFilters, updateCalendarThreadFilter, clearCalendarThreadFilter, setCalendarDeletedFilter, calendarThreadFilterTypes, } from '../types/filter-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, stateSyncPayloadTypes, } from '../types/socket-types.js'; import type { RawThreadInfos, ThreadStore } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { filterThreadIDsBelongingToCommunity } from '../utils/drawer-utils.react.js'; export default function reduceCalendarFilters( state: $ReadOnlyArray, action: BaseAction, threadStore: ThreadStore, ): $ReadOnlyArray { if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success || action.type === legacyKeyserverRegisterActionTypes.success ) { return defaultCalendarFilters; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated ) { return removeKeyserverThreadIDsFromFilterList(state, [ action.payload.keyserverID, ]); } else if (action.type === deleteKeyserverAccountActionTypes.success) { return removeKeyserverThreadIDsFromFilterList( state, action.payload.keyserverIDs, ); } else if (action.type === keyserverAuthActionTypes.success) { const keyserverIDs = Object.keys(action.payload.updatesCurrentAsOf); return removeKeyserverThreadIDsFromFilterList(state, keyserverIDs); } else if (action.type === updateCalendarThreadFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: action.payload.threadIDs, }, ]; } else if (action.type === clearCalendarThreadFilter) { return nonThreadCalendarFilters(state); } else if (action.type === setCalendarDeletedFilter) { const otherFilters = nonExcludeDeletedCalendarFilters(state); if (action.payload.includeDeleted && otherFilters.length === state.length) { // Attempting to remove NOT_DELETED filter, but it doesn't exist return state; } else if (action.payload.includeDeleted) { // Removing NOT_DELETED filter return otherFilters; } else if (otherFilters.length < state.length) { // Attempting to add NOT_DELETED filter, but it already exists return state; } else { // Adding NOT_DELETED filter return [...state, { type: calendarThreadFilterTypes.NOT_DELETED }]; } } else if ( action.type === newThreadActionTypes.success || action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === processUpdatesActionType ) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === incrementalStateSyncActionType) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.INCREMENTAL ) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === fullStateSyncActionType) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.FULL ) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } else if (action.type === updateCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); const threadIDs = Array.from( filterThreadIDsBelongingToCommunity( action.payload, threadStore.threadInfos, ), ); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs, }, ]; } else if (action.type === clearCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return nonThreadFilters; } return state; } function updateFilterListFromUpdateInfos( state: $ReadOnlyArray, updateInfos: $ReadOnlyArray, ): $ReadOnlyArray { const currentlyFilteredIDs: ?$ReadOnlySet = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const newFilteredThreadIDs = updateInfos.reduce( (reducedFilteredThreadIDs, update) => { const { reduceCalendarThreadFilters } = updateSpecs[update.type]; return reduceCalendarThreadFilters ? reduceCalendarThreadFilters(reducedFilteredThreadIDs, update) : reducedFilteredThreadIDs; }, currentlyFilteredIDs, ); if (currentlyFilteredIDs !== newFilteredThreadIDs) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: [...newFilteredThreadIDs] }, ]; } return state; } function filterThreadIDsInFilterList( state: $ReadOnlyArray, filterCondition: (threadID: string) => boolean, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const filtered = [...currentlyFilteredIDs].filter(filterCondition); if (filtered.length < currentlyFilteredIDs.size) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: filtered }, ]; } return state; } function removeDeletedThreadIDsFromFilterList( state: $ReadOnlyArray, threadInfos: RawThreadInfos, ): $ReadOnlyArray { const filterCondition = (threadID: string) => threadInFilterList(threadInfos[threadID]); return filterThreadIDsInFilterList(state, filterCondition); } function removeKeyserverThreadIDsFromFilterList( state: $ReadOnlyArray, keyserverIDs: $ReadOnlyArray, ): $ReadOnlyArray { const keyserverIDsSet = new Set(keyserverIDs); - const filterCondition = (threadID: string) => - !keyserverIDsSet.has(extractKeyserverIDFromID(threadID)); + const filterCondition = (threadID: string) => { + const keyserverID = extractKeyserverIDFromIDOptional(threadID); + return !keyserverID || !keyserverIDsSet.has(keyserverID); + }; return filterThreadIDsInFilterList(state, filterCondition); } export { filterThreadIDsInFilterList, removeDeletedThreadIDsFromFilterList, removeKeyserverThreadIDsFromFilterList, }; diff --git a/lib/reducers/draft-reducer.js b/lib/reducers/draft-reducer.js index 091ea5466..fab016d4f 100644 --- a/lib/reducers/draft-reducer.js +++ b/lib/reducers/draft-reducer.js @@ -1,113 +1,114 @@ // @flow import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { moveDraftActionType, updateDraftActionType, } from '../actions/draft-actions.js'; import { deleteKeyserverAccountActionTypes } from '../actions/user-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { DraftStore, DraftStoreOperation } from '../types/draft-types.js'; import type { BaseAction } from '../types/redux-types.js'; type ReduceDraftStoreResult = { +draftStoreOperations: $ReadOnlyArray, +draftStore: DraftStore, }; function removeKeyserversDraftsFromStore( draftStore: DraftStore, keyserverIDs: $ReadOnlyArray, ): ReduceDraftStoreResult { const keyserverIDsSet = new Set(keyserverIDs); const drafts: { [key: string]: string } = {}; const ids: string[] = []; for (const key in draftStore.drafts) { - if (keyserverIDsSet.has(extractKeyserverIDFromID(key))) { + const keyserverID = extractKeyserverIDFromIDOptional(key); + if (keyserverID && keyserverIDsSet.has(keyserverID)) { ids.push(key); } else { drafts[key] = draftStore.drafts[key]; } } return { draftStoreOperations: [{ type: 'remove', payload: { ids } }], draftStore: { ...draftStore, drafts }, }; } function reduceDraftStore( draftStore: DraftStore, action: BaseAction, ): ReduceDraftStoreResult { if (action.type === deleteKeyserverAccountActionTypes.success) { return removeKeyserversDraftsFromStore( draftStore, action.payload.keyserverIDs, ); } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated ) { return removeKeyserversDraftsFromStore(draftStore, [ action.payload.keyserverID, ]); } else if (action.type === updateDraftActionType) { const { key, text } = action.payload; const draftStoreOperations = [ { type: 'update', payload: { key, text }, }, ]; return { draftStoreOperations, draftStore: { ...draftStore, drafts: { ...draftStore.drafts, [key]: text, }, }, }; } else if (action.type === moveDraftActionType) { const { oldKey, newKey } = action.payload; const draftStoreOperations = [ { type: 'move', payload: { oldKey, newKey }, }, ]; const { [oldKey]: text, ...draftsWithoutOldKey } = draftStore.drafts; return { draftStoreOperations, draftStore: { ...draftStore, drafts: { ...draftsWithoutOldKey, [newKey]: text, }, }, }; } else if (action.type === setClientDBStoreActionType) { const drafts: { [string]: string } = {}; for (const dbDraftInfo of action.payload.drafts) { drafts[dbDraftInfo.key] = dbDraftInfo.text; } return { draftStoreOperations: [], draftStore: { ...draftStore, drafts: drafts, }, }; } return { draftStore, draftStoreOperations: [] }; } export { reduceDraftStore, removeKeyserversDraftsFromStore }; diff --git a/lib/reducers/invite-links-reducer.js b/lib/reducers/invite-links-reducer.js index 6f6c9989a..c15433cb1 100644 --- a/lib/reducers/invite-links-reducer.js +++ b/lib/reducers/invite-links-reducer.js @@ -1,90 +1,91 @@ // @flow import { createOrUpdatePublicLinkActionTypes, disableInviteLinkLinkActionTypes, fetchPrimaryInviteLinkActionTypes, } from '../actions/link-actions.js'; import { deleteKeyserverAccountActionTypes } from '../actions/user-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { InviteLinksStore, CommunityLinks } from '../types/link-types.js'; import type { BaseAction } from '../types/redux-types.js'; function reduceInviteLinks( state: InviteLinksStore, action: BaseAction, ): InviteLinksStore { if (action.type === fetchPrimaryInviteLinkActionTypes.success) { const links: { [string]: CommunityLinks } = {}; for (const link of action.payload.links) { links[link.communityID] = { ...state.links[link.communityID], primaryLink: link, }; } return { links, }; } else if (action.type === createOrUpdatePublicLinkActionTypes.success) { const communityID = action.payload.communityID; return { ...state, links: { ...state.links, [communityID]: { ...state.links[communityID], primaryLink: action.payload, }, }, }; } else if (action.type === disableInviteLinkLinkActionTypes.success) { const communityID = action.payload.communityID; const currentPrimaryLink = state.links[communityID]?.primaryLink; if (currentPrimaryLink?.name !== action.payload.name) { return state; } const communityLinks = { ...state.links[communityID], primaryLink: null, }; return { ...state, links: { ...state.links, [communityID]: communityLinks, }, }; } else if (action.type === deleteKeyserverAccountActionTypes.success) { const keyserverIDsSet = new Set(action.payload.keyserverIDs); const newLinks: { [communityID: string]: CommunityLinks } = {}; for (const linkID in state.links) { - if (!keyserverIDsSet.has(extractKeyserverIDFromID(linkID))) { + const keyserverID = extractKeyserverIDFromIDOptional(linkID); + if (!keyserverID || !keyserverIDsSet.has(keyserverID)) { newLinks[linkID] = state.links[linkID]; } } return { ...state, links: newLinks, }; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated ) { const { keyserverID } = action.payload; const newLinks: { [communityID: string]: CommunityLinks } = {}; for (const linkID in state.links) { - if (extractKeyserverIDFromID(linkID) !== keyserverID) { + if (extractKeyserverIDFromIDOptional(linkID) !== keyserverID) { newLinks[linkID] = state.links[linkID]; } } return { ...state, links: newLinks, }; } return state; } export default reduceInviteLinks; diff --git a/lib/reducers/keyserver-reducer.js b/lib/reducers/keyserver-reducer.js index 0cc8c089d..611e5841a 100644 --- a/lib/reducers/keyserver-reducer.js +++ b/lib/reducers/keyserver-reducer.js @@ -1,749 +1,749 @@ // @flow import { filterThreadIDsInFilterList } from './calendar-filters-reducer.js'; import { unsupervisedBackgroundActionType } from './lifecycle-state-reducer.js'; import { updateActivityActionTypes } from '../actions/activity-actions.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { updateLastCommunicatedPlatformDetailsActionType, setDeviceTokenActionTypes, } from '../actions/device-actions.js'; import { updateCalendarQueryActionTypes } from '../actions/entry-actions.js'; import { addKeyserverActionType, removeKeyserverActionType, } from '../actions/keyserver-actions.js'; import { legacySiweAuthActionTypes } from '../actions/siwe-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { identityLogInActionTypes, identityRegisterActionTypes, keyserverAuthActionTypes, logOutActionTypes, deleteKeyserverAccountActionTypes, deleteAccountActionTypes, legacyKeyserverRegisterActionTypes, legacyLogInActionTypes, } from '../actions/user-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType, updateConnectionStatusActionType, setLateResponseActionType, updateKeyserverReachabilityActionType, setConnectionIssueActionType, setActiveSessionRecoveryActionType, } from '../keyserver-conn/keyserver-conn-types.js'; import { keyserverStoreOpsHandlers, type ReplaceKeyserverOperation, type RemoveKeyserversOperation, type KeyserverStoreOperation, } from '../ops/keyserver-store-ops.js'; import { nonThreadCalendarFilters } from '../selectors/calendar-filter-selectors.js'; import { queueActivityUpdatesActionType } from '../types/activity-types.js'; import { defaultCalendarQuery } from '../types/entry-types.js'; import type { KeyserverInfos, KeyserverStore, } from '../types/keyserver-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, stateSyncPayloadTypes, type ClientStateSyncIncrementalSocketResult, type StateSyncIncrementalActionPayload, } from '../types/socket-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { processUpdatesActionType } from '../types/update-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { assertObjectsAreEqual } from '../utils/objects.js'; import { setURLPrefix } from '../utils/url-utils.js'; function assertKeyserverStoresAreEqual( processedKeyserverStore: KeyserverInfos, expectedKeyserverStore: KeyserverInfos, location: string, onStateDifference?: (message: string) => mixed, ) { try { assertObjectsAreEqual( processedKeyserverStore, expectedKeyserverStore, `KeyserverInfos - ${location}`, ); } catch (e) { console.log( 'Error processing KeyserverStore ops', processedKeyserverStore, expectedKeyserverStore, ); const message = `Error processing KeyserverStore ops ${ getMessageForException(e) ?? '{no exception message}' }`; onStateDifference?.(message); } } function shouldClearDeviceToken( state: KeyserverStore, payload: | ClientStateSyncIncrementalSocketResult | StateSyncIncrementalActionPayload, ): boolean { const { keyserverID, updatesResult } = payload; for (const update of updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.keyserverInfos[keyserverID].deviceToken ) { return true; } } return false; } const { processStoreOperations: processStoreOps } = keyserverStoreOpsHandlers; export default function reduceKeyserverStore( state: KeyserverStore, action: BaseAction, onStateDifference?: (message: string) => mixed, ): { keyserverStore: KeyserverStore, keyserverStoreOperations: $ReadOnlyArray, } { if (action.type === addKeyserverActionType) { const replaceOperation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: action.payload.keyserverAdminUserID, keyserverInfo: { ...action.payload.newKeyserverInfo, }, }, }; return { keyserverStore: processStoreOps(state, [replaceOperation]), keyserverStoreOperations: [replaceOperation], }; } else if (action.type === removeKeyserverActionType) { const removeOperation: RemoveKeyserversOperation = { type: 'remove_keyservers', payload: { ids: [action.payload.keyserverAdminUserID], }, }; return { keyserverStore: processStoreOps(state, [removeOperation]), keyserverStoreOperations: [removeOperation], }; } else if (action.type === setNewSessionActionType) { const { keyserverID, sessionChange } = action.payload; const gotUserCookie = sessionChange.cookie?.startsWith('user='); if (!state.keyserverInfos[keyserverID]) { if (gotUserCookie) { console.log( 'received sessionChange with user cookie, ' + `but keyserver ${keyserverID} is not in KeyserverStore!`, ); } return { keyserverStore: state, keyserverStoreOperations: [], }; } let newKeyserverInfo = state.keyserverInfos[keyserverID]; if (sessionChange.cookie !== undefined) { newKeyserverInfo = { ...newKeyserverInfo, cookie: sessionChange.cookie, connection: { ...newKeyserverInfo.connection, activeSessionRecovery: null, }, }; } if (sessionChange.cookieInvalidated) { newKeyserverInfo = { ...newKeyserverInfo, actualizedCalendarQuery: { ...newKeyserverInfo.actualizedCalendarQuery, filters: nonThreadCalendarFilters( newKeyserverInfo.actualizedCalendarQuery.filters, ), }, connection: { ...newKeyserverInfo.connection, queuedActivityUpdates: [], }, }; } if (action.payload.error === 'client_version_unsupported') { newKeyserverInfo = { ...newKeyserverInfo, connection: { ...newKeyserverInfo.connection, connectionIssue: 'client_version_unsupported', }, }; } const operations: ReplaceKeyserverOperation[] = []; if (newKeyserverInfo !== state.keyserverInfos[keyserverID]) { operations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: newKeyserverInfo, }, }); } return { keyserverStore: processStoreOps(state, operations), keyserverStoreOperations: operations, }; } else if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success || action.type === keyserverAuthActionTypes.success ) { const { updatesCurrentAsOf } = action.payload; const operations: ReplaceKeyserverOperation[] = []; for (const keyserverID in updatesCurrentAsOf) { const calendarFilters = filterThreadIDsInFilterList( action.payload.calendarResult.calendarQuery.filters, (threadID: string) => - extractKeyserverIDFromID(threadID) === keyserverID, + extractKeyserverIDFromIDOptional(threadID) === keyserverID, ); operations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], updatesCurrentAsOf: updatesCurrentAsOf[keyserverID], lastCommunicatedPlatformDetails: getConfig().platformDetails, actualizedCalendarQuery: { ...action.payload.calendarResult.calendarQuery, filters: calendarFilters, }, connection: { ...state.keyserverInfos[keyserverID].connection, connectionIssue: null, activeSessionRecovery: null, }, }, }, }); } return { keyserverStore: processStoreOps(state, operations), keyserverStoreOperations: operations, }; } else if (action.type === fullStateSyncActionType) { const { keyserverID } = action.payload; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], actualizedCalendarQuery: action.payload.calendarQuery, updatesCurrentAsOf: action.payload.updatesCurrentAsOf, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.FULL ) { const { keyserverID } = action.payload; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], updatesCurrentAsOf: action.payload.updatesCurrentAsOf, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === incrementalStateSyncActionType) { const { payload } = action; const { keyserverID } = payload; const deviceToken = shouldClearDeviceToken(state, payload) ? null : state.keyserverInfos[keyserverID].deviceToken; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], actualizedCalendarQuery: payload.calendarQuery, updatesCurrentAsOf: payload.updatesResult.currentAsOf, deviceToken, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.INCREMENTAL ) { const { payload } = action; const { keyserverID } = payload; const deviceToken = shouldClearDeviceToken(state, payload) ? null : state.keyserverInfos[keyserverID].deviceToken; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], updatesCurrentAsOf: payload.updatesResult.currentAsOf, deviceToken, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === processUpdatesActionType) { const { keyserverID } = action.payload; const updatesCurrentAsOf = Math.max( action.payload.updatesResult.currentAsOf, state.keyserverInfos[keyserverID].updatesCurrentAsOf, ); const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], updatesCurrentAsOf, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === setURLPrefix) { const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: authoritativeKeyserverID(), keyserverInfo: { ...state.keyserverInfos[authoritativeKeyserverID()], urlPrefix: action.payload, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === updateLastCommunicatedPlatformDetailsActionType) { const { keyserverID, platformDetails } = action.payload; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], lastCommunicatedPlatformDetails: platformDetails, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === legacyKeyserverRegisterActionTypes.success) { const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: authoritativeKeyserverID(), keyserverInfo: { ...state.keyserverInfos[authoritativeKeyserverID()], actualizedCalendarQuery: action.payload.calendarQuery, lastCommunicatedPlatformDetails: getConfig().platformDetails, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === updateConnectionStatusActionType) { const { keyserverID, status } = action.payload; if (!state.keyserverInfos[keyserverID]) { return { keyserverStore: state, keyserverStoreOperations: [], }; } const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, status, lateResponses: [], }, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === unsupervisedBackgroundActionType) { const { keyserverID } = action.payload; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, status: 'disconnected', lateResponses: [], }, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === queueActivityUpdatesActionType) { const { activityUpdates, keyserverID } = action.payload; const oldConnection = state.keyserverInfos[keyserverID].connection; const connection = { ...oldConnection, queuedActivityUpdates: [ ...oldConnection.queuedActivityUpdates.filter(existingUpdate => { for (const activityUpdate of activityUpdates) { if ( ((existingUpdate.focus && activityUpdate.focus) || (existingUpdate.focus === false && activityUpdate.focus !== undefined)) && existingUpdate.threadID === activityUpdate.threadID ) { return false; } } return true; }), ...activityUpdates, ], }; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === updateActivityActionTypes.success) { const { activityUpdates } = action.payload; const operations: ReplaceKeyserverOperation[] = []; for (const keyserverID in activityUpdates) { const oldConnection = state.keyserverInfos[keyserverID].connection; const queuedActivityUpdates = oldConnection.queuedActivityUpdates.filter( activityUpdate => !activityUpdates[keyserverID].includes(activityUpdate), ); operations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection: { ...oldConnection, queuedActivityUpdates }, }, }, }); } return { keyserverStore: processStoreOps(state, operations), keyserverStoreOperations: operations, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === identityRegisterActionTypes.success || (action.type === identityLogInActionTypes.success && action.payload.userID !== action.payload.preRequestUserState?.id) ) { // We want to remove all keyservers but Ashoat's keyserver const oldKeyserverInfo = state.keyserverInfos[authoritativeKeyserverID()]; const oldConnection = oldKeyserverInfo.connection; const operations: KeyserverStoreOperation[] = [ { type: 'remove_all_keyservers' }, ]; let cookie = oldKeyserverInfo.cookie; if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || cookie?.startsWith('user=') ) { // To avoid unnecessary rerenders, we won't clear the authoritative // keyserver's cookie unless it's a logout or an account deletion, or if // the cookie belongs to an actual user (as opposed to an anonymous one) cookie = null; } operations.push({ type: 'replace_keyserver', payload: { id: authoritativeKeyserverID(), keyserverInfo: { ...state.keyserverInfos[authoritativeKeyserverID()], actualizedCalendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), connection: { ...oldConnection, connectionIssue: null, activeSessionRecovery: null, queuedActivityUpdates: [], lateResponses: [], }, cookie, }, }, }); return { keyserverStore: processStoreOps(state, operations), keyserverStoreOperations: operations, }; } else if (action.type === deleteKeyserverAccountActionTypes.success) { const operations: KeyserverStoreOperation[] = [ { type: 'remove_keyservers', payload: { ids: action.payload.keyserverIDs }, }, ]; if (action.payload.keyserverIDs.includes(authoritativeKeyserverID())) { const oldConnection = state.keyserverInfos[authoritativeKeyserverID()].connection; operations.push({ type: 'replace_keyserver', payload: { id: authoritativeKeyserverID(), keyserverInfo: { ...state.keyserverInfos[authoritativeKeyserverID()], actualizedCalendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), connection: { ...oldConnection, connectionIssue: null, activeSessionRecovery: null, queuedActivityUpdates: [], lateResponses: [], }, cookie: null, }, }, }); } return { keyserverStore: processStoreOps(state, operations), keyserverStoreOperations: operations, }; } else if (action.type === setLateResponseActionType) { const { messageID, isLate, keyserverID } = action.payload; const lateResponsesSet = new Set( state.keyserverInfos[keyserverID].connection.lateResponses, ); if (isLate) { lateResponsesSet.add(messageID); } else { lateResponsesSet.delete(messageID); } const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, lateResponses: [...lateResponsesSet], }, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === updateKeyserverReachabilityActionType) { const { keyserverID } = action.payload; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, unreachable: action.payload.visible, }, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === setDeviceTokenActionTypes.success) { const { deviceTokens } = action.payload; const operations: ReplaceKeyserverOperation[] = []; for (const keyserverID in deviceTokens) { operations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], deviceToken: deviceTokens[keyserverID], }, }, }); } return { keyserverStore: processStoreOps(state, operations), keyserverStoreOperations: operations, }; } else if (action.type === setConnectionIssueActionType) { const { connectionIssue, keyserverID } = action.payload; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, connectionIssue, }, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === setActiveSessionRecoveryActionType) { const { activeSessionRecovery, keyserverID } = action.payload; const operation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, activeSessionRecovery, }, }, }, }; return { keyserverStore: processStoreOps(state, [operation]), keyserverStoreOperations: [operation], }; } else if (action.type === setClientDBStoreActionType) { // Once the functionality is confirmed to work correctly, // we will proceed with returning keyserverInfos from the payload. assertKeyserverStoresAreEqual( action.payload.keyserverInfos ?? {}, state.keyserverInfos, action.type, onStateDifference, ); return { keyserverStore: state, keyserverStoreOperations: [], }; } else if (action.type === updateCalendarQueryActionTypes.success) { const operations: ReplaceKeyserverOperation[] = []; for (const keyserverID of action.payload.keyserverIDs) { operations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverInfos[keyserverID], actualizedCalendarQuery: { ...action.payload.calendarQuery, filters: filterThreadIDsInFilterList( action.payload.calendarQuery.filters, (threadID: string) => - extractKeyserverIDFromID(threadID) === keyserverID, + extractKeyserverIDFromIDOptional(threadID) === keyserverID, ), }, }, }, }); } return { keyserverStore: processStoreOps(state, operations), keyserverStoreOperations: operations, }; } return { keyserverStore: state, keyserverStoreOperations: [], }; } diff --git a/lib/reducers/thread-activity-reducer.js b/lib/reducers/thread-activity-reducer.js index bd201a1ac..2836a6192 100644 --- a/lib/reducers/thread-activity-reducer.js +++ b/lib/reducers/thread-activity-reducer.js @@ -1,249 +1,250 @@ // @flow import invariant from 'invariant'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { messageStorePruneActionType } from '../actions/message-actions.js'; import { changeThreadMemberRolesActionTypes, changeThreadSettingsActionTypes, deleteCommunityRoleActionTypes, deleteThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, modifyCommunityRoleActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { deleteKeyserverAccountActionTypes } from '../actions/user-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { threadActivityStoreOpsHandlers, type ThreadActivityStoreOperation, } from '../ops/thread-activity-store-ops.js'; import { isWebPlatform } from '../types/device-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { incrementalStateSyncActionType, stateSyncPayloadTypes, } from '../types/socket-types.js'; import type { ThreadActivityStore } from '../types/thread-activity-types.js'; import { updateThreadLastNavigatedActionType } from '../types/thread-activity-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import type { ClientUpdateInfo } from '../types/update-types.js'; import { processUpdatesActionType } from '../types/update-types.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { assertObjectsAreEqual } from '../utils/objects.js'; function assertThreadActivityStoresAreEqual( processedThreadActivityStore: ThreadActivityStore, expectedThreadActivityStore: ThreadActivityStore, location: string, onStateDifference?: (message: string) => mixed, ) { try { assertObjectsAreEqual( processedThreadActivityStore, expectedThreadActivityStore, `ThreadActivityStore - ${location}`, ); } catch (e) { console.log( 'Error processing ThreadActivityStore ops', processedThreadActivityStore, expectedThreadActivityStore, ); const message = `Error processing ThreadActivityStore ops ${ getMessageForException(e) ?? '{no exception message}' }`; onStateDifference?.(message); } } type ReduceThreadActivityResult = { +threadActivityStore: ThreadActivityStore, +threadActivityStoreOperations: $ReadOnlyArray, }; function handleThreadDeletionUpdates( state: ThreadActivityStore, newUpdates: $ReadOnlyArray, ): ReduceThreadActivityResult { if (newUpdates.length === 0) { return { threadActivityStore: state, threadActivityStoreOperations: [], }; } const deleteThreadUpdates = newUpdates.filter( (update: ClientUpdateInfo) => update.type === updateTypes.DELETE_THREAD, ); if (deleteThreadUpdates.length === 0) { return { threadActivityStore: state, threadActivityStoreOperations: [], }; } const threadIDsToRemove = []; for (const update: ClientUpdateInfo of deleteThreadUpdates) { invariant( update.type === updateTypes.DELETE_THREAD, 'update must be of type DELETE_THREAD', ); threadIDsToRemove.push(update.threadID); } const removeOperation = { type: 'remove_thread_activity_entries', payload: { ids: threadIDsToRemove, }, }; return { threadActivityStore: processStoreOps(state, [removeOperation]), threadActivityStoreOperations: [removeOperation], }; } const { processStoreOperations: processStoreOps } = threadActivityStoreOpsHandlers; function reduceThreadActivity( state: ThreadActivityStore, action: BaseAction, onStateDifference?: (message: string) => mixed, ): ReduceThreadActivityResult { if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; const replaceOperation = { type: 'replace_thread_activity_entry', payload: { id: threadID, threadActivityStoreEntry: { ...state[threadID], lastNavigatedTo: time, }, }, }; return { threadActivityStore: processStoreOps(state, [replaceOperation]), threadActivityStoreOperations: [replaceOperation], }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const replaceOperations = []; for (const threadID: string of action.payload.threadIDs) { const replaceOperation = { type: 'replace_thread_activity_entry', payload: { id: threadID, threadActivityStoreEntry: { ...state[threadID], lastPruned: now, }, }, }; replaceOperations.push(replaceOperation); } return { threadActivityStore: processStoreOps(state, replaceOperations), threadActivityStoreOperations: replaceOperations, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success || action.type === modifyCommunityRoleActionTypes.success || action.type === deleteCommunityRoleActionTypes.success ) { return handleThreadDeletionUpdates( state, action.payload.updatesResult.newUpdates, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.INCREMENTAL ) { return handleThreadDeletionUpdates( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === deleteKeyserverAccountActionTypes.success) { const threadIDsToRemove = []; const keyserverIDsSet = new Set(action.payload.keyserverIDs); for (const threadID in state) { - if (!keyserverIDsSet.has(extractKeyserverIDFromID(threadID))) { + const keyserverID = extractKeyserverIDFromIDOptional(threadID); + if (!keyserverID || !keyserverIDsSet.has(keyserverID)) { continue; } threadIDsToRemove.push(threadID); } const removeOperation = { type: 'remove_thread_activity_entries', payload: { ids: threadIDsToRemove, }, }; return { threadActivityStore: processStoreOps(state, [removeOperation]), threadActivityStoreOperations: [removeOperation], }; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated ) { const threadIDsToRemove = []; const { keyserverID } = action.payload; for (const threadID in state) { - if (extractKeyserverIDFromID(threadID) !== keyserverID) { + if (extractKeyserverIDFromIDOptional(threadID) !== keyserverID) { continue; } threadIDsToRemove.push(threadID); } const removeOperation = { type: 'remove_thread_activity_entries', payload: { ids: threadIDsToRemove, }, }; return { threadActivityStore: processStoreOps(state, [removeOperation]), threadActivityStoreOperations: [removeOperation], }; } else if (action.type === setClientDBStoreActionType) { if (!isWebPlatform(getConfig().platformDetails.platform)) { assertThreadActivityStoresAreEqual( action.payload.threadActivityStore ?? {}, state, action.type, onStateDifference, ); } return { threadActivityStore: state, threadActivityStoreOperations: [], }; } return { threadActivityStore: state, threadActivityStoreOperations: [], }; } export { reduceThreadActivity }; diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index ca14a7215..392845abf 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,563 +1,565 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { legacySiweAuthActionTypes } from '../actions/siwe-actions.js'; import { joinThreadActionTypes, newThreadActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { findUserIdentitiesActionTypes, processNewUserIDsActionType, identityLogInActionTypes, identityRegisterActionTypes, deleteAccountActionTypes, keyserverAuthActionTypes, logOutActionTypes, legacyLogInActionTypes, legacyKeyserverRegisterActionTypes, setUserSettingsActionTypes, updateUserAvatarActionTypes, } from '../actions/user-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { convertUserInfosToReplaceUserOps, type UserStoreOperation, userStoreOpsHandlers, } from '../ops/user-store-ops.js'; import { stateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; import type { BaseAction } from '../types/redux-types.js'; import type { ClientUserInconsistencyReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, stateSyncPayloadTypes, type ClientStateSyncIncrementalSocketResult, type StateSyncIncrementalActionPayload, } from '../types/socket-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { processUpdatesActionType, type ClientUpdateInfo, type ClientUpdatesResultWithUserInfos, } from '../types/update-types.js'; import type { CurrentUserInfo, UserInfos, UserStore, } from '../types/user-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { relyingOnAuthoritativeKeyserver, usingCommServicesAccessToken, } from '../utils/services-utils.js'; function handleCurrentUserUpdates( state: ?CurrentUserInfo, newUpdates: $ReadOnlyArray, ): ?CurrentUserInfo { return newUpdates.reduce((reducedState, update) => { const { reduceCurrentUser } = updateSpecs[update.type]; return reduceCurrentUser ? reduceCurrentUser(reducedState, update) : reducedState; }, state); } function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === identityLogInActionTypes.success || action.type === identityRegisterActionTypes.success ) { const newUserInfo = { id: action.payload.userID, username: action.payload.username, }; if (!_isEqual(newUserInfo)(state)) { return newUserInfo; } } else if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success || action.type === legacyKeyserverRegisterActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && action.payload.keyserverID === authoritativeKeyserverID() && relyingOnAuthoritativeKeyserver ) { const actionUserInfo = action.payload.sessionChange.currentUserInfo; if (!actionUserInfo?.id) { return actionUserInfo; } else if (!usingCommServicesAccessToken) { if (!_isEqual(actionUserInfo)(state)) { return actionUserInfo; } } else if (!state?.id || actionUserInfo.id !== state.id) { console.log( 'keyserver auth returned a different user info than identity login', ); } else { const newUserInfo = { ...state, avatar: actionUserInfo.avatar, settings: actionUserInfo.settings, }; if (!_isEqual(newUserInfo)(state)) { return newUserInfo; } } } else if ( (action.type === fullStateSyncActionType || (action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.FULL)) && relyingOnAuthoritativeKeyserver ) { if (action.payload.keyserverID !== authoritativeKeyserverID()) { return state; } const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( (action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType) && relyingOnAuthoritativeKeyserver ) { if (action.payload.keyserverID !== authoritativeKeyserverID()) { return state; } return handleCurrentUserUpdates( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === fetchPendingUpdatesActionTypes.success) { if (!relyingOnAuthoritativeKeyserver) { return state; } const { payload } = action; if (payload.type !== stateSyncPayloadTypes.INCREMENTAL) { return state; } const { newUpdates } = payload.updatesResult; if (action.payload.keyserverID !== authoritativeKeyserverID()) { return state; } return handleCurrentUserUpdates(state, newUpdates); } else if ( action.type === processServerRequestsActionType && relyingOnAuthoritativeKeyserver ) { if (action.payload.keyserverID !== authoritativeKeyserverID()) { return state; } const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); const newCurrentUserInfo = checkStateRequest?.stateChanges?.currentUserInfo; if (newCurrentUserInfo && !_isEqual(newCurrentUserInfo)(state)) { return newCurrentUserInfo; } } else if ( action.type === updateUserAvatarActionTypes.success && state && !state.anonymous ) { const { viewerUpdates } = action.payload.updates; for (const update of viewerUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo.avatar)(state.avatar) ) { return { ...state, avatar: update.currentUserInfo.avatar, }; } } return state; } else if (action.type === setUserSettingsActionTypes.success) { if (state?.settings) { return { ...state, settings: { ...state.settings, ...action.payload, }, }; } } return state; } const { processStoreOperations: processUserStoreOps } = userStoreOpsHandlers; function generateOpsForUserUpdates(payload: { +updatesResult: { +newUpdates: $ReadOnlyArray, ... }, ... }): $ReadOnlyArray { return payload.updatesResult.newUpdates .map(update => updateSpecs[update.type].generateOpsForUserInfoUpdates?.(update), ) .filter(Boolean) .flat(); } type ReduceUserInfosResult = [ UserStore, $ReadOnlyArray, $ReadOnlyArray, ]; function handleUserInfoUpdates( state: UserStore, payload: | ClientStateSyncIncrementalSocketResult | StateSyncIncrementalActionPayload | ClientUpdatesResultWithUserInfos, ): ReduceUserInfosResult { if (payload.keyserverID !== authoritativeKeyserverID()) { return [state, [], []]; } const newUserInfos = _keyBy(userInfo => userInfo.id)(payload.userInfos); const userStoreOps: $ReadOnlyArray = [ ...convertUserInfosToReplaceUserOps(newUserInfos), ...generateOpsForUserUpdates(payload), ]; const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); if (_isEqual(state.userInfos)(processedUserInfos)) { return [state, [], []]; } return [ { ...state, userInfos: processedUserInfos, }, [], userStoreOps, ]; } function reduceUserInfos( state: UserStore, action: BaseAction, ): ReduceUserInfosResult { if (action.type === processNewUserIDsActionType) { const filteredUserIDs = action.payload.userIDs.filter( id => !state.userInfos[id], ); if (filteredUserIDs.length === 0) { return [state, [], []]; } const newUserInfosArray = filteredUserIDs.map(id => ({ id, username: null, })); const newUserInfos: UserInfos = _keyBy(userInfo => userInfo.id)( newUserInfosArray, ); const userStoreOps: $ReadOnlyArray = convertUserInfosToReplaceUserOps(newUserInfos); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); if (!_isEqual(state.userInfos)(processedUserInfos)) { return [ { ...state, userInfos: processedUserInfos, }, [], userStoreOps, ]; } } else if ( (action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success) && relyingOnAuthoritativeKeyserver ) { let keyserverID; if (action.type === joinThreadActionTypes.success) { keyserverID = action.payload.keyserverID; } else { - keyserverID = extractKeyserverIDFromID(action.payload.newThreadID); + keyserverID = extractKeyserverIDFromIDOptional( + action.payload.newThreadID, + ); } if (keyserverID !== authoritativeKeyserverID()) { return [state, [], []]; } const newUserInfos: UserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const userStoreOps: $ReadOnlyArray = convertUserInfosToReplaceUserOps(newUserInfos); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); if (!_isEqual(state.userInfos)(processedUserInfos)) { return [ { ...state, userInfos: processedUserInfos, }, [], userStoreOps, ]; } } else if (action.type === findUserIdentitiesActionTypes.success) { const newUserInfos = action.payload.userInfos.reduce( (acc, userInfo) => ({ ...acc, [userInfo.id]: { ...state.userInfos[userInfo.id], username: userInfo.username, }, }), {}, ); const userStoreOps: $ReadOnlyArray = convertUserInfosToReplaceUserOps(newUserInfos); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); if (!_isEqual(state.userInfos)(processedUserInfos)) { return [ { ...state, userInfos: processedUserInfos, }, [], userStoreOps, ]; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated && action.payload.keyserverID === authoritativeKeyserverID() && relyingOnAuthoritativeKeyserver ) { const userStoreOps: $ReadOnlyArray = [ { type: 'remove_all_users' }, ]; const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); if (Object.keys(state.userInfos).length === 0) { return [state, [], []]; } return [ { userInfos: processedUserInfos, }, [], userStoreOps, ]; } else if ( (action.type === fullStateSyncActionType || (action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.FULL)) && relyingOnAuthoritativeKeyserver ) { if (action.payload.keyserverID !== authoritativeKeyserverID()) { return [state, [], []]; } const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const userStoreOps: $ReadOnlyArray = [ { type: 'remove_all_users' }, ...convertUserInfosToReplaceUserOps(newUserInfos), ]; const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); if (!_isEqual(state.userInfos)(processedUserInfos)) { return [ { userInfos: processedUserInfos, }, [], userStoreOps, ]; } } else if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success || action.type === legacyKeyserverRegisterActionTypes.success ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const userStoreOps: $ReadOnlyArray = [ { type: 'remove_all_users' }, ...convertUserInfosToReplaceUserOps(newUserInfos), ]; const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); return [ { userInfos: processedUserInfos, }, [], userStoreOps, ]; } else if ( action.type === keyserverAuthActionTypes.success && relyingOnAuthoritativeKeyserver ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const userStoreOps: $ReadOnlyArray = convertUserInfosToReplaceUserOps(newUserInfos); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); if (!_isEqual(state.userInfos)(processedUserInfos)) { return [ { ...state, userInfos: processedUserInfos, }, [], userStoreOps, ]; } } else if ( (action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType) && relyingOnAuthoritativeKeyserver ) { return handleUserInfoUpdates(state, action.payload); } else if (action.type === fetchPendingUpdatesActionTypes.success) { if (!relyingOnAuthoritativeKeyserver) { return [state, [], []]; } const { payload } = action; if (payload.type === stateSyncPayloadTypes.INCREMENTAL) { return handleUserInfoUpdates(state, payload); } } else if ( action.type === processServerRequestsActionType && relyingOnAuthoritativeKeyserver ) { if (action.payload.keyserverID !== authoritativeKeyserverID()) { return [state, [], []]; } const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return [state, [], []]; } const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; if (!userInfos && !deleteUserInfoIDs) { return [state, [], []]; } const userStoreOps: UserStoreOperation[] = []; if (userInfos) { for (const userInfo of userInfos) { userStoreOps.push({ type: 'replace_user', payload: { ...userInfo } }); } } if (deleteUserInfoIDs) { userStoreOps.push({ type: 'remove_users', payload: { ids: deleteUserInfoIDs }, }); } const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); const newInconsistencies = stateSyncSpecs.users.findStoreInconsistencies( action, state.userInfos, processedUserInfos, ); return [ { userInfos: processedUserInfos, }, newInconsistencies, userStoreOps, ]; } else if (action.type === updateUserAvatarActionTypes.success) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.updates.userInfos, ); const userStoreOps: $ReadOnlyArray = convertUserInfosToReplaceUserOps(newUserInfos); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); const newState = !_isEqual(state.userInfos)(processedUserInfos) ? { ...state, userInfos: processedUserInfos, } : state; return [newState, [], userStoreOps]; } else if (action.type === setClientDBStoreActionType) { if (!action.payload.users) { return [state, [], []]; } return [{ userInfos: action.payload.users }, [], []]; } return [state, [], []]; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index fb33de972..541a1e86d 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,542 +1,542 @@ // @flow import _compact from 'lodash/fp/compact.js'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _groupBy from 'lodash/fp/groupBy.js'; import _map from 'lodash/fp/map.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _some from 'lodash/fp/some.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors.js'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors.js'; import genesis from '../facts/genesis.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { getAvatarForThread, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { createEntryInfo } from '../shared/entry-utils.js'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasAdminRole, roleIsAdminRole, threadIsPending, getPendingThreadID, } from '../shared/thread-utils.js'; import type { ClientAvatar, ClientEmojiAvatar } from '../types/avatar-types'; import type { EntryInfo } from '../types/entry-types.js'; import type { MessageStore } from '../types/message-types.js'; import type { RelativeMemberInfo, ThreadInfo, RawThreadInfo, ThickRawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { threadTypes, threadTypeIsCommunityRoot, type ThreadType, } from '../types/thread-types-enum.js'; import type { MixedRawThreadInfos, RawThreadInfos, ThickRawThreadInfos, } from '../types/thread-types.js'; import { dateString, dateFromString } from '../utils/date-utils.js'; import { values } from '../utils/objects.js'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); type ThreadInfoSelectorType = (state: BaseAppState<>) => { +[id: string]: ThreadInfo, }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); const communityThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadTypeIsCommunityRoot(threadInfo.type)) { continue; } result.push(threadInfo); } return result; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, ( inputThreadIDs: ?$ReadOnlySet, threadInfos: $ReadOnlyArray, ): $ReadOnlyArray => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id)); }, ); const entryInfoSelector: (state: BaseAppState<>) => { +[id: string]: EntryInfo, } = createObjectSelector( (state: BaseAppState<>) => state.entryStore.entryInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: (state: BaseAppState<>) => { +[dayString: string]: EntryInfo[], } = createSelector( entryInfoSelector, (state: BaseAppState<>) => state.entryStore.daysToEntries, (state: BaseAppState<>) => state.navInfo.startDate, (state: BaseAppState<>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { +[id: string]: EntryInfo }, daysToEntries: { +[day: string]: string[] }, startDateString: string, endDateString: string, onScreen: $ReadOnlyArray, includeDeleted: boolean, ) => { const allDaysWithinRange: { [string]: string[] } = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result: { [string]: ThreadInfo[], } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = ([]: ThreadInfo[]); } result[parentThreadID].push(threadInfo); } return result; }, ); const containedThreadInfos: (state: BaseAppState<>) => { +[id: string]: $ReadOnlyArray, } = createSelector( threadInfoSelector, (threadInfos: { +[id: string]: ThreadInfo }) => { const result: { [string]: ThreadInfo[], } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; const { containingThreadID } = threadInfo; if (containingThreadID === null || containingThreadID === undefined) { continue; } if (result[containingThreadID] === undefined) { result[containingThreadID] = ([]: ThreadInfo[]); } result[containingThreadID].push(threadInfo); } return result; }, ); const thickRawThreadInfosSelector: ( state: BaseAppState<>, ) => ThickRawThreadInfos = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): ThickRawThreadInfos => { const thickRawThreadInfos: { [id: string]: ThickRawThreadInfo } = {}; for (const id in threadInfos) { const threadInfo = threadInfos[id]; if (!threadInfo.thick) { continue; } thickRawThreadInfos[id] = threadInfo; } return thickRawThreadInfos; }, ); const unreadCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const allUnreadCounts: (state: BaseAppState<>) => { +[keyserverID: string]: number, } = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): { +[keyserverID: string]: number } => { const keyserverToThreads = _groupBy(threadInfo => - extractKeyserverIDFromID(threadInfo.id), + extractKeyserverIDFromIDOptional(threadInfo.id), )( values(threadInfos).filter(threadInfo => threadInHomeChatList(threadInfo), ), ); const keyserverUnreadCountPairs = Object.entries(keyserverToThreads).map( ([keyserverID, keyserverThreadInfos]) => [ keyserverID, keyserverThreadInfos.filter(threadInfo => threadInfo.currentUser.unread) .length, ], ); return Object.fromEntries(keyserverUnreadCountPairs); }, ); const unreadBackgroundCount: (state: BaseAppState<>) => number = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => values(threadInfos).filter( threadInfo => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseUnreadCountSelectorForCommunity: ( communityID: string, ) => (BaseAppState<>) => number = (communityID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, (threadInfos: RawThreadInfos): number => Object.values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread && (communityID === threadInfo.community || communityID === threadInfo.id), ).length, ); const unreadCountSelectorForCommunity: ( communityID: string, ) => (state: BaseAppState<>) => number = _memoize( baseUnreadCountSelectorForCommunity, ); const baseAncestorThreadInfos: ( threadID: string, ) => (BaseAppState<>) => $ReadOnlyArray = (threadID: string) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const pathComponents: ThreadInfo[] = []; let node: ?ThreadInfo = threadInfos[threadID]; while (node) { pathComponents.push(node); node = node.parentThreadID ? threadInfos[node.parentThreadID] : null; } pathComponents.reverse(); return pathComponents; }, ); const ancestorThreadInfos: ( threadID: string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseAncestorThreadInfos, ); const baseOtherUsersButNoOtherAdmins: ( threadID: string, ) => (BaseAppState<>) => boolean = (threadID: string) => createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?RawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (const member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentlyReadThread( messageStore: MessageStore, threadInfos: MixedRawThreadInfos, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadInfo.type === threadTypes.SIDEBAR ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } const mostRecentlyReadThreadSelector: (state: BaseAppState<>) => ?string = createSelector( (state: BaseAppState<>) => state.messageStore, (state: BaseAppState<>) => state.threadStore.threadInfos, mostRecentlyReadThread, ); const threadInfoFromSourceMessageIDSelector: (state: BaseAppState<>) => { +[id: string]: ThreadInfo, } = createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos, threadInfoSelector, ( rawThreadInfos: RawThreadInfos, threadInfos: { +[id: string]: ThreadInfo, }, ) => { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(rawThreadInfos); const result: { [string]: ThreadInfo } = {}; for (const realizedID of pendingToRealizedThreadIDs.values()) { const threadInfo = threadInfos[realizedID]; if (threadInfo && threadInfo.sourceMessageID) { result[threadInfo.sourceMessageID] = threadInfo; } } return result; }, ); const pendingToRealizedThreadIDsSelector: ( rawThreadInfos: RawThreadInfos, ) => $ReadOnlyMap = createSelector( (rawThreadInfos: RawThreadInfos) => rawThreadInfos, (rawThreadInfos: RawThreadInfos) => { const result = new Map(); for (const threadID in rawThreadInfos) { const rawThreadInfo = rawThreadInfos[threadID]; if ( threadIsPending(threadID) || (rawThreadInfo.parentThreadID !== genesis().id && rawThreadInfo.type !== threadTypes.SIDEBAR) ) { continue; } const actualMemberIDs = rawThreadInfo.members .filter(member => member.role) .map(member => member.id); const pendingThreadID = getPendingThreadID( rawThreadInfo.type, actualMemberIDs, rawThreadInfo.sourceMessageID, ); const existingResult = result.get(pendingThreadID); if ( !existingResult || rawThreadInfos[existingResult].creationTime > rawThreadInfo.creationTime ) { result.set(pendingThreadID, threadID); } } return result; }, ); const baseSavedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (BaseAppState<>) => () => ClientAvatar = ( threadID: string, containingThreadID: ?string, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state)[threadID], (state: BaseAppState<>) => containingThreadID ? threadInfoSelector(state)[containingThreadID] : null, (threadInfo: ThreadInfo, containingThreadInfo: ?ThreadInfo) => { return () => { let threadAvatar = getAvatarForThread(threadInfo, containingThreadInfo); if (threadAvatar.type !== 'emoji') { threadAvatar = getRandomDefaultEmojiAvatar(); } return threadAvatar; }; }, ); const savedEmojiAvatarSelectorForThread: ( threadID: string, containingThreadID: ?string, ) => (state: BaseAppState<>) => () => ClientEmojiAvatar = _memoize( baseSavedEmojiAvatarSelectorForThread, ); const baseThreadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (BaseAppState<>) => $ReadOnlyArray = ( threadType: ThreadType, ) => createSelector( (state: BaseAppState<>) => threadInfoSelector(state), (threadInfos: { +[id: string]: ThreadInfo, }): $ReadOnlyArray => { const result = []; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type === threadType) { result.push(threadInfo); } } return result; }, ); const threadInfosSelectorForThreadType: ( threadType: ThreadType, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseThreadInfosSelectorForThreadType, ); export { ancestorThreadInfos, threadInfoSelector, communityThreadSelector, onScreenThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, containedThreadInfos, unreadCount, allUnreadCounts, unreadBackgroundCount, unreadCountSelectorForCommunity, otherUsersButNoOtherAdmins, mostRecentlyReadThread, mostRecentlyReadThreadSelector, threadInfoFromSourceMessageIDSelector, pendingToRealizedThreadIDsSelector, savedEmojiAvatarSelectorForThread, threadInfosSelectorForThreadType, thickRawThreadInfosSelector, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 3c7ee89cc..744988a9c 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,710 +1,711 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy.js'; import _orderBy from 'lodash/fp/orderBy.js'; import * as React from 'react'; import uuid from 'uuid'; import { codeBlockRegex, type ParserRules } from './markdown.js'; import type { CreationSideEffectsFunc } from './messages/message-spec.js'; import { messageSpecs } from './messages/message-specs.js'; import { threadIsGroupChat } from './thread-utils.js'; import { useStringForUser } from '../hooks/ens-cache.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { contentStringForMediaArray } from '../media/media-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors.js'; import { type PlatformDetails } from '../types/device-types.js'; import type { Media } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import type { ComposableMessageInfo, MessageData, MessageInfo, MessageStore, MultimediaMessageData, RawComposableMessageInfo, RawMessageInfo, RawMultimediaMessageInfo, RobotextMessageInfo, ThreadMessageInfo, } from '../types/message-types.js'; import type { EditMessageInfo, RawEditMessageInfo, } from '../types/messages/edit.js'; import type { ImagesMessageData } from '../types/messages/images.js'; import type { MediaMessageData } from '../types/messages/media.js'; import type { RawReactionMessageInfo, ReactionMessageInfo, } from '../types/messages/reaction.js'; import type { ThreadInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { LegacyRawThreadInfo } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { type EntityText, ET, useEntityTextAsString, } from '../utils/entity-text.js'; import { useSelector } from '../utils/redux-utils.js'; const localIDPrefix = 'local'; const defaultMediaMessageOptions = Object.freeze({}); // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ?ThreadInfo, parentThreadInfo: ?ThreadInfo, ): EntityText { const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, { threadInfo, parentThreadInfo }); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { +[id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; const creator = { id: rawMessageInfo.creatorID, username: creatorInfo ? creatorInfo.username : 'anonymous', isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } function findMessageIDMaxLength(messageIDs: $ReadOnlyArray): number { let result = 0; for (const id of messageIDs) { if (!id || id.startsWith(localIDPrefix)) { continue; } result = Math.max(result, id.length); } return result; } function extendMessageID(id: ?string, length: number): ?string { if (!id || id.startsWith(localIDPrefix)) { return id; } return id.padStart(length, '0'); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { const length = findMessageIDMaxLength( messageInfos.map(message => message?.id), ); return _orderBy([ 'time', (message: T) => extendMessageID(message?.id ?? message?.localID, length), ])(['desc', 'desc'])(messageInfos); } const sortMessageIDs: (messages: { +[id: string]: RawMessageInfo, }) => (messageIDs: $ReadOnlyArray) => string[] = messages => messageIDs => { const length = findMessageIDMaxLength(messageIDs); return _orderBy([ (id: string) => messages[id].time, (id: string) => extendMessageID(id, length), ])(['desc', 'desc'])(messageIDs); }; function rawMessageInfoFromMessageData( messageData: MessageData, id: ?string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (const messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { return rawMessageInfos.map(rawMessageInfo => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } type MediaMessageDataCreationInput = { +threadID: string, +creatorID: string, +media: $ReadOnlyArray, +localID?: ?string, +time?: ?number, +sidebarCreation?: ?boolean, ... }; function createMediaMessageData( input: MediaMessageDataCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (const singleMedia of input.media) { if (singleMedia.type !== 'photo') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID, sidebarCreation } = input; const { forceMultimediaMessageType = false } = options; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos && !forceMultimediaMessageType) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); if (localID) { messageData = { ...messageData, localID }; } if (sidebarCreation) { messageData = { ...messageData, sidebarCreation }; } } return messageData; } type MediaMessageInfoCreationInput = { ...$Exact, +id?: ?string, }; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, options: { +forceMultimediaMessageType?: boolean, } = defaultMediaMessageOptions, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input, options); const createRawMessageInfo = messageSpecs[messageData.type].rawMessageInfoFromMessageData; invariant( createRawMessageInfo, 'multimedia message spec should have rawMessageInfoFromMessageData', ); const result = createRawMessageInfo(messageData, input.id); invariant( result.type === messageTypes.MULTIMEDIA || result.type === messageTypes.IMAGES, `media messageSpec returned MessageType ${result.type}`, ); return result; } function stripLocalID( rawMessageInfo: | RawComposableMessageInfo | RawReactionMessageInfo | RawEditMessageInfo, ) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string): string { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageQuote(message: string): string { // add `>` to each line to include empty lines in the quote return message.replace(/^/gm, '> '); } function createMessageReply(message: string): string { return createMessageQuote(message) + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find(id => !id.startsWith(localIDPrefix)); } function getOldestNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; if (!thread) { return thread; } const { messageIDs } = thread; for (let i = messageIDs.length - 1; i >= 0; i--) { const id = messageIDs[i]; if (!id.startsWith(localIDPrefix)) { return id; } } return undefined; } function getMessageTitle( messageInfo: | ComposableMessageInfo | RobotextMessageInfo | ReactionMessageInfo | EditMessageInfo, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, markdownRules: ParserRules, ): EntityText { const { messageTitle } = messageSpecs[messageInfo.type]; if (messageTitle) { return messageTitle({ messageInfo, threadInfo, markdownRules }); } invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA && messageInfo.type !== messageTypes.REACTION && messageInfo.type !== messageTypes.EDIT_MESSAGE, 'messageTitle can only be auto-generated for RobotextMessageInfo', ); return robotextForMessageInfo(messageInfo, threadInfo, parentThreadInfo); } function mergeThreadMessageInfos( first: ThreadMessageInfo, second: ThreadMessageInfo, messages: { +[id: string]: RawMessageInfo }, ): ThreadMessageInfo { let firstPointer = 0; let secondPointer = 0; const mergedMessageIDs = []; let firstCandidate = first.messageIDs[firstPointer]; let secondCandidate = second.messageIDs[secondPointer]; while (firstCandidate !== undefined || secondCandidate !== undefined) { if (firstCandidate === undefined) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else if (secondCandidate === undefined) { mergedMessageIDs.push(firstCandidate); firstPointer++; } else if (firstCandidate === secondCandidate) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else { const firstMessage = messages[firstCandidate]; const secondMessage = messages[secondCandidate]; invariant( firstMessage && secondMessage, 'message in messageIDs not present in MessageStore', ); if ( (firstMessage.id && secondMessage.id && firstMessage.id === secondMessage.id) || (firstMessage.localID && secondMessage.localID && firstMessage.localID === secondMessage.localID) ) { mergedMessageIDs.push(firstCandidate); firstPointer++; secondPointer++; } else if (firstMessage.time < secondMessage.time) { mergedMessageIDs.push(secondCandidate); secondPointer++; } else { mergedMessageIDs.push(firstCandidate); firstPointer++; } } firstCandidate = first.messageIDs[firstPointer]; secondCandidate = second.messageIDs[secondPointer]; } return { messageIDs: mergedMessageIDs, startReached: first.startReached && second.startReached, }; } type MessagePreviewPart = { +text: string, // unread has highest contrast, followed by primary, followed by secondary +style: 'unread' | 'primary' | 'secondary', }; export type MessagePreviewResult = { +message: MessagePreviewPart, +username: ?MessagePreviewPart, }; function useMessagePreview( originalMessageInfo: ?MessageInfo, threadInfo: ThreadInfo, markdownRules: ParserRules, ): ?MessagePreviewResult { let messageInfo; if ( originalMessageInfo && originalMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { messageInfo = originalMessageInfo.sourceMessage; } else { messageInfo = originalMessageInfo; } const { parentThreadID } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const hasUsername = threadIsGroupChat(threadInfo) || threadInfo.name !== '' || messageInfo?.creator.isViewer; const shouldDisplayUser = messageInfo?.type === messageTypes.TEXT && hasUsername; const stringForUser = useStringForUser( shouldDisplayUser ? messageInfo?.creator : null, ); const { unread } = threadInfo.currentUser; const username = React.useMemo(() => { if (!shouldDisplayUser) { return null; } invariant( stringForUser, 'useStringForUser should only return falsey if pass null or undefined', ); return { text: stringForUser, style: unread ? 'unread' : 'secondary', }; }, [shouldDisplayUser, stringForUser, unread]); const messageTitleEntityText = React.useMemo(() => { if (!messageInfo) { return messageInfo; } return getMessageTitle( messageInfo, threadInfo, parentThreadInfo, markdownRules, ); }, [messageInfo, threadInfo, parentThreadInfo, markdownRules]); const threadID = threadInfo.id; const entityTextToStringParams = React.useMemo( () => ({ threadID, }), [threadID], ); const messageTitle = useEntityTextAsString( messageTitleEntityText, entityTextToStringParams, ); const isTextMessage = messageInfo?.type === messageTypes.TEXT; const message = React.useMemo(() => { if (messageTitle === null || messageTitle === undefined) { return messageTitle; } let style; if (unread) { style = 'unread'; } else if (isTextMessage) { style = 'primary'; } else { style = 'secondary'; } return { text: messageTitle, style }; }, [messageTitle, unread, isTextMessage]); return React.useMemo(() => { if (!message) { return message; } return { message, username }; }, [message, username]); } function useMessageCreationSideEffectsFunc( messageType: $PropertyType, ): CreationSideEffectsFunc { const messageSpec = messageSpecs[messageType]; invariant(messageSpec, `we're not aware of messageType ${messageType}`); invariant( messageSpec.useCreationSideEffectsFunc, `no useCreationSideEffectsFunc in message spec for ${messageType}`, ); return messageSpec.useCreationSideEffectsFunc(); } function getPinnedContentFromMessage(targetMessage: RawMessageInfo): string { let pinnedContent; if ( targetMessage.type === messageTypes.IMAGES || targetMessage.type === messageTypes.MULTIMEDIA ) { pinnedContent = contentStringForMediaArray(targetMessage.media); } else { pinnedContent = 'a message'; } return pinnedContent; } function modifyItemForResultScreen( item: ChatMessageInfoItem, ): ChatMessageInfoItem { if (item.messageInfoType === 'composable') { return { ...item, startsConversation: false, startsCluster: true, endsCluster: true, messageInfo: { ...item.messageInfo, creator: { ...item.messageInfo.creator, isViewer: false, }, }, }; } return item; } function constructChangeRoleEntityText( affectedUsers: EntityText | string, roleName: ?string, ): EntityText { if (!roleName) { return ET`assigned ${affectedUsers} a new role`; } return ET`assigned ${affectedUsers} the "${roleName}" role`; } function getNextLocalID(): string { const nextLocalID = uuid.v4(); return `${localIDPrefix}${nextLocalID}`; } function isInvalidSidebarSource( message: RawMessageInfo | MessageInfo, ): boolean %checks { return ( (message.type === messageTypes.REACTION || message.type === messageTypes.EDIT_MESSAGE || message.type === messageTypes.SIDEBAR_SOURCE || message.type === messageTypes.TOGGLE_PIN) && !messageSpecs[message.type].canBeSidebarSource ); } // Prefer checking isInvalidPinSourceForThread below. This function doesn't // check whether the user is attempting to pin a SIDEBAR_SOURCE in the context // of its parent thread, so it's not suitable for permission checks. We only // use it in the message-fetchers.js code where we don't have access to the // RawThreadInfo and don't need to do permission checks. function isInvalidPinSource( messageInfo: RawMessageInfo | MessageInfo, ): boolean { return !messageSpecs[messageInfo.type].canBePinned; } function isInvalidPinSourceForThread( messageInfo: RawMessageInfo | MessageInfo, threadInfo: LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): boolean { const isValidPinSource = !isInvalidPinSource(messageInfo); const isFirstMessageInSidebar = threadInfo.sourceMessageID === messageInfo.id; return !isValidPinSource || isFirstMessageInSidebar; } function isUnableToBeRenderedIndependently( message: RawMessageInfo | MessageInfo, ): boolean { return messageSpecs[message.type].canBeRenderedIndependently === false; } function findNewestMessageTimePerKeyserver( messageInfos: $ReadOnlyArray, ): { [keyserverID: string]: number } { const timePerKeyserver: { [keyserverID: string]: number } = {}; for (const messageInfo of messageInfos) { - const keyserverID = extractKeyserverIDFromID(messageInfo.threadID); + const keyserverID = extractKeyserverIDFromIDOptional(messageInfo.threadID); if ( - !timePerKeyserver[keyserverID] || - timePerKeyserver[keyserverID] < messageInfo.time + keyserverID && + (!timePerKeyserver[keyserverID] || + timePerKeyserver[keyserverID] < messageInfo.time) ) { timePerKeyserver[keyserverID] = messageInfo.time; } } return timePerKeyserver; } export { localIDPrefix, messageKey, messageID, robotextForMessageInfo, createMessageInfo, sortMessageInfoList, sortMessageIDs, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, usersInMessageInfos, shimUnsupportedRawMessageInfos, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageQuote, createMessageReply, getMostRecentNonLocalMessageID, getOldestNonLocalMessageID, getMessageTitle, mergeThreadMessageInfos, useMessagePreview, useMessageCreationSideEffectsFunc, getPinnedContentFromMessage, modifyItemForResultScreen, constructChangeRoleEntityText, getNextLocalID, isInvalidSidebarSource, isInvalidPinSource, isInvalidPinSourceForThread, isUnableToBeRenderedIndependently, findNewestMessageTimePerKeyserver, }; diff --git a/lib/shared/state-sync/entries-state-sync-spec.js b/lib/shared/state-sync/entries-state-sync-spec.js index 46c8ba8ff..72f11a980 100644 --- a/lib/shared/state-sync/entries-state-sync-spec.js +++ b/lib/shared/state-sync/entries-state-sync-spec.js @@ -1,138 +1,142 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { createSelector } from 'reselect'; import type { StateSyncSpec, BoundStateSyncSpec } from './state-sync-spec.js'; -import { extractKeyserverIDFromID } from '../../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../../keyserver-conn/keyserver-call-utils.js'; import { type CalendarQuery, type RawEntryInfos, type RawEntryInfo, } from '../../types/entry-types.js'; import type { AppState } from '../../types/redux-types.js'; import { reportTypes, type ClientEntryInconsistencyReportCreationRequest, } from '../../types/report-types.js'; import type { ProcessServerRequestAction } from '../../types/request-types.js'; import { actionLogger } from '../../utils/action-logger.js'; import { getConfig } from '../../utils/config.js'; import { values, combineUnorderedHashes, hash } from '../../utils/objects.js'; import { generateReportID } from '../../utils/report-utils.js'; import { sanitizeActionSecrets } from '../../utils/sanitization.js'; import { filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from '../entry-utils.js'; const selector: ( state: AppState, ) => BoundStateSyncSpec< RawEntryInfos, RawEntryInfo, $ReadOnlyArray, > = createSelector( (state: AppState) => state.entryStore.entryInfos, (entryInfos: RawEntryInfos) => ({ ...entriesStateSyncSpec, getInfoHash: (id: string, keyserverID: string) => hash(entryInfos[`${keyserverID}|${id}`]), getAllInfosHash: (calendarQuery: CalendarQuery, keyserverID: string) => getEntryInfosHash(entryInfos, calendarQuery, keyserverID), getIDs: (calendarQuery: CalendarQuery, keyserverID: string) => getEntryIDs(entryInfos, calendarQuery, keyserverID), canSyncState: () => true, }), ); export const entriesStateSyncSpec: StateSyncSpec< RawEntryInfos, RawEntryInfo, $ReadOnlyArray, > = Object.freeze({ hashKey: 'entryInfos', innerHashSpec: { hashKey: 'entryInfo', deleteKey: 'deleteEntryIDs', rawInfosKey: 'rawEntryInfos', }, findStoreInconsistencies( action: ProcessServerRequestAction, beforeStateCheck: RawEntryInfos, afterStateCheck: RawEntryInfos, ) { const keyserverID = action.payload.keyserverID; const filteredBeforeStateCheck = values(beforeStateCheck).filter( - entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, + entry => + entry.id && extractKeyserverIDFromIDOptional(entry.id) === keyserverID, ); const filteredAfterStateCheck = values(afterStateCheck).filter( - entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, + entry => + entry.id && extractKeyserverIDFromIDOptional(entry.id) === keyserverID, ); const calendarQuery = action.payload.calendarQuery; // We don't want to bother reporting an inconsistency if it's just because // of extraneous EntryInfos (not within the current calendarQuery) on either // side const filteredBeforeResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredBeforeStateCheck), calendarQuery, ); const filteredAfterResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredAfterStateCheck), calendarQuery, ); if (_isEqual(filteredBeforeResult)(filteredAfterResult)) { return emptyArray; } return [ { type: reportTypes.ENTRY_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeActionSecrets(action), calendarQuery, pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), id: generateReportID(), }, ]; }, selector, }); const emptyArray: $ReadOnlyArray = []; function getEntryInfosHash( entryInfos: RawEntryInfos, calendarQuery: CalendarQuery, keyserverID: string, ) { const filteredEntries = values(entryInfos).filter( - entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, + entry => + entry.id && extractKeyserverIDFromIDOptional(entry.id) === keyserverID, ); const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredEntries), calendarQuery, ); return combineUnorderedHashes(Object.values(filteredEntryInfos).map(hash)); } function getEntryIDs( entryInfos: RawEntryInfos, calendarQuery: CalendarQuery, keyserverID: string, ) { const filteredEntries = values(entryInfos).filter( - entry => entry.id && extractKeyserverIDFromID(entry.id) === keyserverID, + entry => + entry.id && extractKeyserverIDFromIDOptional(entry.id) === keyserverID, ); const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(filteredEntries), calendarQuery, ); return Object.keys(filteredEntryInfos).map(id => id.split('|')[1]); } diff --git a/lib/shared/state-sync/threads-state-sync-spec.js b/lib/shared/state-sync/threads-state-sync-spec.js index a96a4941e..df8f5e51b 100644 --- a/lib/shared/state-sync/threads-state-sync-spec.js +++ b/lib/shared/state-sync/threads-state-sync-spec.js @@ -1,100 +1,102 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import _pickBy from 'lodash/fp/pickBy.js'; import { createSelector } from 'reselect'; import type { StateSyncSpec, BoundStateSyncSpec } from './state-sync-spec.js'; -import { extractKeyserverIDFromID } from '../../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../../keyserver-conn/keyserver-call-utils.js'; import type { CalendarQuery } from '../../types/entry-types.js'; import type { RawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { AppState } from '../../types/redux-types.js'; import { reportTypes, type ClientThreadInconsistencyReportCreationRequest, } from '../../types/report-types.js'; import type { ProcessServerRequestAction } from '../../types/request-types.js'; import { type MixedRawThreadInfos, type LegacyRawThreadInfo, } from '../../types/thread-types.js'; import { actionLogger } from '../../utils/action-logger.js'; import { getConfig } from '../../utils/config.js'; import { combineUnorderedHashes, entries } from '../../utils/objects.js'; import { generateReportID } from '../../utils/report-utils.js'; import { sanitizeActionSecrets } from '../../utils/sanitization.js'; const selector: ( state: AppState, ) => BoundStateSyncSpec< MixedRawThreadInfos, LegacyRawThreadInfo | RawThreadInfo, $ReadOnlyArray, > = createSelector( (state: AppState) => state.integrityStore.threadHashes, (state: AppState) => state.integrityStore.threadHashingStatus === 'completed', (threadHashes: { +[string]: number }, threadHashingComplete: boolean) => ({ ...threadsStateSyncSpec, getInfoHash: (id: string, keyserverID: string) => threadHashes[`${keyserverID}|${id}`], getAllInfosHash: threadHashingComplete ? (query: CalendarQuery, keyserverID: string) => combineUnorderedHashes( entries(threadHashes) - .filter(([id]) => extractKeyserverIDFromID(id) === keyserverID) + .filter( + ([id]) => extractKeyserverIDFromIDOptional(id) === keyserverID, + ) .map(([, threadHash]) => threadHash), ) : () => null, getIDs: threadHashingComplete ? (query: CalendarQuery, keyserverID: string) => Object.keys(threadHashes) - .filter(id => extractKeyserverIDFromID(id) === keyserverID) + .filter(id => extractKeyserverIDFromIDOptional(id) === keyserverID) .map(id => id.split('|')[1]) : () => null, canSyncState: () => threadHashingComplete, }), ); export const threadsStateSyncSpec: StateSyncSpec< MixedRawThreadInfos, LegacyRawThreadInfo | RawThreadInfo, $ReadOnlyArray, > = Object.freeze({ hashKey: 'threadInfos', innerHashSpec: { hashKey: 'threadInfo', deleteKey: 'deleteThreadIDs', rawInfosKey: 'rawThreadInfos', }, findStoreInconsistencies( action: ProcessServerRequestAction, beforeStateCheck: MixedRawThreadInfos, afterStateCheck: MixedRawThreadInfos, ) { const keyserverID = action.payload.keyserverID; const filter = _pickBy( - thread => extractKeyserverIDFromID(thread.id) === keyserverID, + thread => extractKeyserverIDFromIDOptional(thread.id) === keyserverID, ); const filteredBeforeStateCheck = filter(beforeStateCheck); const filteredAfterStateCheck = filter(afterStateCheck); if (_isEqual(filteredBeforeStateCheck)(filteredAfterStateCheck)) { return emptyArray; } return [ { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeActionSecrets(action), pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), id: generateReportID(), }, ]; }, selector, }); const emptyArray: $ReadOnlyArray = []; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 460db49e0..c3cc38939 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,1766 +1,1766 @@ // @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 { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.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 } 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 { RelativeMemberInfo, RawThreadInfo, MemberInfoWithPermissions, RoleInfo, ThreadInfo, MinimallyEncodedThickMemberInfo, ThinRawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { decodeMinimallyEncodedRoleInfo, minimallyEncodeMemberInfo, deprecatedMinimallyEncodeRawThreadInfo, 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, configurableCommunityPermissions, 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, } from '../types/thread-types-enum.js'; import type { LegacyRawThreadInfo, ClientLegacyRoleInfo, ServerThreadInfo, ThickMemberInfo, UserProfileThreadInfo, MixedRawThreadInfos, LegacyThinRawThreadInfo, } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo } from '../types/update-types.js'; import type { GlobalAccountUserInfo, UserInfos, AccountUserInfo, LoggedInUserInfo, UserInfo, } from '../types/user-types.js'; import { 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 { usingOlmViaTunnelbrokerForDMs } from '../utils/services-utils.js'; import { firstLine } from '../utils/string-utils.js'; import { pendingThreadIDRegex } 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; } function useThreadsWithPermission( threadInfos: $ReadOnlyArray, permission: ThreadPermission, ): $ReadOnlyArray { const loggedInUserInfo = useLoggedInUserInfo(); const userInfos = useSelector(state => state.userStore.userInfos); const allThreadInfos = useSelector(state => state.threadStore.threadInfos); const allThreadInfosArray = React.useMemo( () => values(allThreadInfos), [allThreadInfos], ); const communityRootMembersToRole = useCommunityRootMembersToRole(allThreadInfosArray); return React.useMemo(() => { return threadInfos.filter((threadInfo: ThreadInfo) => { const membersToRole = communityRootMembersToRole[threadInfo.id]; const memberHasAdminRole = threadMembersWithoutAddedAdmin( threadInfo, ).some(member => roleIsAdminRole(membersToRole?.[member.id])); if (memberHasAdminRole || !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, communityRootMembersToRole, loggedInUserInfo, userInfos, permission, ]); } function useThreadHasPermission( threadInfo: ?ThreadInfo, permission: ThreadPermission, ): boolean { const threads = useThreadsWithPermission( threadInfo ? [threadInfo] : [], 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 && threadInfo.type !== threadTypes.SIDEBAR); } function threadIsSidebar(threadInfo: ?(RawThreadInfo | ThreadInfo)): boolean { return threadInfo?.type === threadTypes.SIDEBAR; } 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 && threadInfo.type !== threadTypes.SIDEBAR ); } 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 = extractKeyserverIDFromID(threadInfo.id); + 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/sidebar/'); } 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 { const pendingThreadKey = sourceMessageID ? `sidebar/${sourceMessageID}` : [...memberIDs].sort().join('+'); const pendingThreadTypeString = sourceMessageID ? '' : `type${threadType}/`; return `pending/${pendingThreadTypeString}${pendingThreadKey}`; } type PendingThreadIDContents = { +threadType: ThreadType, +memberIDs: $ReadOnlyArray, +sourceMessageID: ?string, }; function parsePendingThreadID( pendingThreadID: string, ): ?PendingThreadIDContents { const pendingRegex = new RegExp(`^${pendingThreadIDRegex}$`); const pendingThreadIDMatches = pendingRegex.exec(pendingThreadID); if (!pendingThreadIDMatches) { return null; } const [threadTypeString, threadKey] = pendingThreadIDMatches[1].split('/'); const threadType = threadTypeString === 'sidebar' ? threadTypes.SIDEBAR : assertThreadType(Number(threadTypeString.replace('type', ''))); const memberIDs = threadTypeString === 'sidebar' ? [] : threadKey.split('+'); const sourceMessageID = threadTypeString === 'sidebar' ? threadKey : null; return { threadType, memberIDs, sourceMessageID, }; } type UserIDAndUsername = { +id: string, +username: ?string, ... }; type CreatePendingThreadArgs = { +viewerID: string, +threadType: ThreadType, +members: $ReadOnlyArray, +parentThreadInfo?: ?ThreadInfo, +threadColor?: ?string, +name?: ?string, +sourceMessageID?: string, }; function createPendingThread({ viewerID, threadType, members, parentThreadInfo, threadColor, name, sourceMessageID, }: CreatePendingThreadArgs): ThreadInfo { const now = Date.now(); if (!members.some(member => member.id === viewerID)) { throw new Error( 'createPendingThread should be called with the viewer as a member', ); } const memberIDs = members.map(member => member.id); const threadID = getPendingThreadID(threadType, memberIDs, sourceMessageID); const permissions: 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, ), community: getCommunity(parentThreadInfo), 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, }; } 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 createPendingPersonalThread( loggedInUserInfo: LoggedInUserInfo, userID: string, username: ?string, ): PendingPersonalThread { const pendingPersonalThreadUserInfo = { id: userID, username: username, }; const threadInfo = createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.GENESIS_PERSONAL, members: [loggedInUserInfo, pendingPersonalThreadUserInfo], }); return { threadInfo, pendingPersonalThreadUserInfo }; } function createPendingThreadItem( loggedInUserInfo: LoggedInUserInfo, user: UserIDAndUsername, ): ChatThreadItem { const { threadInfo, pendingPersonalThreadUserInfo } = createPendingPersonalThread(loggedInUserInfo, user.id, user.username); return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo: null, mostRecentNonLocalMessage: null, lastUpdatedTime: threadInfo.creationTime, lastUpdatedTimeIncludingSidebars: threadInfo.creationTime, sidebars: [], pendingPersonalThreadUserInfo, }; } // Returns map from lowercase username to AccountUserInfo function memberLowercaseUsernameMap( members: $ReadOnlyArray, ): Map { const memberMap = new Map(); for (const member of members) { const { id, role, username } = member; if (!role || !username) { continue; } memberMap.set(username.toLowerCase(), { id, username }); } return memberMap; } // Returns map from user ID to AccountUserInfo function extractMentionedMembers( text: string, threadInfo: ThreadInfo, ): Map { const memberMap = memberLowercaseUsernameMap(threadInfo.members); const mentions = extractUserMentionsFromText(text); const mentionedMembers = new Map(); for (const mention of mentions) { const userInfo = memberMap.get(mention.toLowerCase()); if (userInfo) { mentionedMembers.set(userInfo.id, userInfo); } } return mentionedMembers; } // When a member of the parent is mentioned in a sidebar, // they will be automatically added to that sidebar function extractNewMentionedParentMembers( messageText: string, threadInfo: ThreadInfo, parentThreadInfo: ThreadInfo, ): AccountUserInfo[] { const mentionedMembersOfParent = extractMentionedMembers( messageText, parentThreadInfo, ); for (const member of threadInfo.members) { if (member.role) { mentionedMembersOfParent.delete(member.id); } } return [...mentionedMembersOfParent.values()]; } function pendingThreadType( numberOfOtherMembers: number, ): 4 | 6 | 7 | 13 | 14 | 15 { if (usingOlmViaTunnelbrokerForDMs) { 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, }; 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 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) { rawThreadInfo = { ...rawThreadInfo, avatar: serverThreadInfo.avatar }; } if (!excludePinInfo) { rawThreadInfo = { ...rawThreadInfo, pinnedCount: serverThreadInfo.pinnedCount, }; } if (!shouldMinimallyEncodePermissions) { return rawThreadInfo; } const minimallyEncodedRawThreadInfoWithMemberPermissions = deprecatedMinimallyEncodeRawThreadInfo(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: threadInfo.type === threadTypes.GENESIS_PRIVATE ? '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 ( rawThreadInfo.type === threadTypes.GENESIS_PERSONAL || rawThreadInfo.type === threadTypes.GENESIS_PRIVATE ) { 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 (threadType === threadTypes.SIDEBAR) { return 'thread'; } else if ( threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD && parentThreadID === genesis().id ) { return 'chat'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.GENESIS ) { return 'channel'; } else { return 'chat'; } } function threadLabel(threadType: ThreadType): string { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD ) { return 'Open'; } else if (threadType === threadTypes.GENESIS_PERSONAL) { return 'Personal'; } else if (threadType === threadTypes.SIDEBAR) { 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 { return 'Secret'; } } type ExistingThreadInfoFinderParams = { +searching: boolean, +userInfoInputArray: $ReadOnlyArray, }; 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; const pendingThreadID = searching ? getPendingThreadID( pendingThreadType(userInfoInputArray.length), [...userInfoInputArray.map(user => user.id), viewerID], sourceMessageID, ) : getPendingThreadID( baseThreadInfo.type, baseThreadInfo.members.map(member => member.id), sourceMessageID, ); const realizedThreadID = pendingToRealizedThreadIDs.get(pendingThreadID); if (realizedThreadID && threadInfos[realizedThreadID]) { return threadInfos[realizedThreadID]; } const updatedThread = searching ? createPendingThread({ viewerID, threadType: pendingThreadType(userInfoInputArray.length), members: [loggedInUserInfo, ...userInfoInputArray], }) : baseThreadInfo; return updatedThread; }, [baseThreadInfo, threadInfos, loggedInUserInfo, pendingToRealizedThreadIDs], ); } type ThreadTypeParentRequirement = 'optional' | 'required' | 'disabled'; function getThreadTypeParentRequirement( threadType: ThreadType, ): ThreadTypeParentRequirement { if ( threadType === threadTypes.COMMUNITY_OPEN_SUBTHREAD || threadType === threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD || //threadType === threadTypes.COMMUNITY_SECRET_SUBTHREAD || threadType === threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD || threadType === threadTypes.SIDEBAR ) { return 'required'; } else if ( threadType === threadTypes.COMMUNITY_ROOT || threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT || threadType === threadTypes.GENESIS || threadType === threadTypes.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 (threadType === threadTypes.SIDEBAR) { return parentThreadInfo.id; } if (!parentThreadInfo.containingThreadID) { return parentThreadInfo.id; } return parentThreadInfo.containingThreadID; } function getCommunity( parentThreadInfo: | ?ServerThreadInfo | LegacyRawThreadInfo | RawThreadInfo | ThreadInfo, ): ?string { if (!parentThreadInfo) { return null; } const { id, community, type } = parentThreadInfo; if (community !== null && community !== undefined) { return community; } if (threadTypeIsCommunityRoot(type)) { return id; } return null; } function getThreadListSearchResults( chatListData: $ReadOnlyArray, searchText: string, threadFilter: ThreadInfo => boolean, threadSearchResults: $ReadOnlySet, usersSearchResults: $ReadOnlyArray, loggedInUserInfo: ?LoggedInUserInfo, ): $ReadOnlyArray { if (!searchText) { return chatListData.filter( item => threadIsTopLevel(item.threadInfo) && threadFilter(item.threadInfo), ); } const privateThreads = []; const personalThreads = []; const otherThreads = []; for (const item of chatListData) { if (!threadSearchResults.has(item.threadInfo.id)) { continue; } if (item.threadInfo.type === threadTypes.GENESIS_PRIVATE) { privateThreads.push({ ...item, sidebars: [] }); } else if (item.threadInfo.type === threadTypes.GENESIS_PERSONAL) { 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), ), ); } 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 (threadInfo.type === threadTypes.GENESIS_PRIVATE) { privateThreads.push(threadInfo); } else if (threadInfo.type === threadTypes.GENESIS_PERSONAL) { 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); return createPendingThread({ viewerID, threadType: threadTypes.SIDEBAR, members, parentThreadInfo, threadColor: threadInfo.color, name: threadInfo.name, sourceMessageID: threadInfo.sourceMessageID, }); } function threadInfoInsideCommunity( threadInfo: RawThreadInfo | ThreadInfo, communityID: string, ): boolean { return threadInfo.community === communityID || threadInfo.id === communityID; } type RoleAndMemberCount = { [roleName: string]: number, }; function useRoleMemberCountsForCommunity( threadInfo: ThreadInfo, ): RoleAndMemberCount { return React.useMemo(() => { const roleIDsToNames: { [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 = Object.keys( decodeMinimallyEncodedRoleInfo(threadInfo.roles[roleID]).permissions, ); const setOfUserSurfacedPermissions = new Set(); rolePermissions.forEach(rolePermission => { const userSurfacedPermission = Object.keys( configurableCommunityPermissions, ).find(key => configurableCommunityPermissions[key].has(rolePermission), ); if (userSurfacedPermission) { setOfUserSurfacedPermissions.add(userSurfacedPermission); } }); roleNamesToPermissions[roleName] = setOfUserSurfacedPermissions; }); return roleNamesToPermissions; }, [threadInfo.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)}`; } function useUserProfileThreadInfo(userInfo: ?UserInfo): ?UserProfileThreadInfo { const userID = userInfo?.id; const username = userInfo?.username; const loggedInUserInfo = useLoggedInUserInfo(); const isViewerProfile = loggedInUserInfo?.id === userID; const privateThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.GENESIS_PRIVATE, ); const privateThreadInfos = useSelector(privateThreadInfosSelector); const personalThreadInfosSelector = threadInfosSelectorForThreadType( threadTypes.GENESIS_PERSONAL, ); const personalThreadInfos = useSelector(personalThreadInfosSelector); const usersWithPersonalThread = useSelector(usersWithPersonalThreadSelector); return React.useMemo(() => { if (!loggedInUserInfo || !userID || !username) { return null; } if (isViewerProfile) { const privateThreadInfo: ?ThreadInfo = privateThreadInfos[0]; return privateThreadInfo ? { threadInfo: privateThreadInfo } : null; } if (usersWithPersonalThread.has(userID)) { const personalThreadInfo: ?ThreadInfo = personalThreadInfos.find( threadInfo => userID === getSingleOtherUser(threadInfo, loggedInUserInfo.id), ); return personalThreadInfo ? { threadInfo: personalThreadInfo } : null; } const pendingPersonalThreadInfo = createPendingPersonalThread( loggedInUserInfo, userID, username, ); return pendingPersonalThreadInfo; }, [ isViewerProfile, loggedInUserInfo, personalThreadInfos, privateThreadInfos, userID, username, usersWithPersonalThread, ]); } 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; } 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, useUserProfileThreadInfo, assertAllThreadInfosAreLegacy, useOnScreenEntryEditableThreadInfos, extractMentionedMembers, isMemberActive, }; diff --git a/lib/socket/activity-handler.react.js b/lib/socket/activity-handler.react.js index 6eeb79c78..646f1975b 100644 --- a/lib/socket/activity-handler.react.js +++ b/lib/socket/activity-handler.react.js @@ -1,146 +1,146 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { updateActivityActionTypes, useUpdateActivity, } from '../actions/activity-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { connectionSelector } from '../selectors/keyserver-selectors.js'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils.js'; import { threadIsPending } from '../shared/thread-utils.js'; import { queueActivityUpdatesActionType, type ActivityUpdate, } from '../types/activity-types.js'; import type { ConnectionStatus } from '../types/socket-types.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector, useDispatch } from '../utils/redux-utils.js'; type Props = { +activeThread: ?string, +frozen: boolean, +keyserverID: string, }; function ActivityHandler(props: Props): React.Node { const { activeThread, frozen, keyserverID } = props; const prevActiveThreadRef = React.useRef(); React.useEffect(() => { prevActiveThreadRef.current = activeThread; }, [activeThread]); const prevActiveThread = prevActiveThreadRef.current; const connection = useSelector(connectionSelector(keyserverID)); invariant(connection, 'keyserver missing from keyserverStore'); const connectionStatus = connection.status; const prevConnectionStatusRef = React.useRef(); React.useEffect(() => { prevConnectionStatusRef.current = connectionStatus; }, [connectionStatus]); const prevConnectionStatus = prevConnectionStatusRef.current; const activeThreadLatestMessage = useSelector(state => { if (!activeThread) { return undefined; } return getMostRecentNonLocalMessageID(activeThread, state.messageStore); }); const prevActiveThreadLatestMessageRef = React.useRef(); React.useEffect(() => { prevActiveThreadLatestMessageRef.current = activeThreadLatestMessage; }, [activeThreadLatestMessage]); const prevActiveThreadLatestMessage = prevActiveThreadLatestMessageRef.current; const canSend = connectionStatus === 'connected' && !frozen; const prevCanSendRef = React.useRef(); React.useEffect(() => { prevCanSendRef.current = canSend; }, [canSend]); const prevCanSend = prevCanSendRef.current; const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateActivity = useUpdateActivity(); React.useEffect(() => { const activityUpdates: ActivityUpdate[] = []; const isActiveThreadPending = threadIsPending(activeThread); if (activeThread !== prevActiveThread) { const isPrevActiveThreadPending = threadIsPending(prevActiveThread); if ( prevActiveThread && !isPrevActiveThreadPending && - extractKeyserverIDFromID(prevActiveThread) === keyserverID + extractKeyserverIDFromIDOptional(prevActiveThread) === keyserverID ) { activityUpdates.push({ focus: false, threadID: prevActiveThread, latestMessage: prevActiveThreadLatestMessage, }); } if ( activeThread && !isActiveThreadPending && - extractKeyserverIDFromID(activeThread) === keyserverID + extractKeyserverIDFromIDOptional(activeThread) === keyserverID ) { activityUpdates.push({ focus: true, threadID: activeThread, latestMessage: activeThreadLatestMessage, }); } } if ( !frozen && connectionStatus !== 'connected' && prevConnectionStatus === 'connected' && activeThread && !isActiveThreadPending && - extractKeyserverIDFromID(activeThread) === keyserverID + extractKeyserverIDFromIDOptional(activeThread) === keyserverID ) { // When the server closes a socket it also deletes any activity rows // associated with that socket's session. If that activity is still // ongoing, we should make sure that we clarify that with the server once // we reconnect. activityUpdates.push({ focus: true, threadID: activeThread, latestMessage: activeThreadLatestMessage, }); } if (activityUpdates.length > 0) { dispatch({ type: queueActivityUpdatesActionType, payload: { activityUpdates, keyserverID }, }); } if (!canSend) { return; } if (!prevCanSend) { activityUpdates.unshift(...connection.queuedActivityUpdates); } if (activityUpdates.length === 0) { return; } void dispatchActionPromise( updateActivityActionTypes, callUpdateActivity({ activityUpdates }), ); }); return null; } export default ActivityHandler; diff --git a/lib/socket/calendar-query-handler.react.js b/lib/socket/calendar-query-handler.react.js index dd5ed4aa3..ed9af6c2c 100644 --- a/lib/socket/calendar-query-handler.react.js +++ b/lib/socket/calendar-query-handler.react.js @@ -1,171 +1,171 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { updateCalendarQueryActionTypes, useUpdateCalendarQuery, } from '../actions/entry-actions.js'; import type { UpdateCalendarQueryInput } from '../actions/entry-actions.js'; -import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { filterThreadIDsInFilterList } from '../reducers/calendar-filters-reducer.js'; import { timeUntilCalendarRangeExpiration } from '../selectors/nav-selectors.js'; import { useIsAppForegrounded } from '../shared/lifecycle-utils.js'; import type { CalendarQuery, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from '../types/entry-types.js'; import { type ConnectionInfo } from '../types/socket-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; type BaseProps = { +currentCalendarQuery: () => CalendarQuery, +frozen: boolean, +keyserverID: string, }; type Props = { ...BaseProps, +connection: ConnectionInfo, +calendarQuery: CalendarQuery, +lastUserInteractionCalendar: number, +foreground: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( input: UpdateCalendarQueryInput, ) => Promise, }; class CalendarQueryHandler extends React.PureComponent { serverCalendarQuery: CalendarQuery; constructor(props: Props) { super(props); this.serverCalendarQuery = this.props.calendarQuery; } componentDidMount() { if (this.props.connection.status === 'connected') { this.possiblyUpdateCalendarQuery(); } } componentDidUpdate(prevProps: Props) { const { calendarQuery } = this.props; if (this.props.connection.status !== 'connected') { if (!_isEqual(this.serverCalendarQuery)(calendarQuery)) { this.serverCalendarQuery = calendarQuery; } return; } if ( !_isEqual(this.serverCalendarQuery)(calendarQuery) && _isEqual(this.props.currentCalendarQuery())(calendarQuery) ) { this.serverCalendarQuery = calendarQuery; } const shouldUpdate = (this.isExpired || prevProps.connection.status !== 'connected' || this.props.currentCalendarQuery !== prevProps.currentCalendarQuery) && this.shouldUpdateCalendarQuery; if (shouldUpdate) { this.updateCalendarQuery(); } } render(): React.Node { return null; } get isExpired(): boolean { const timeUntilExpiration = timeUntilCalendarRangeExpiration( this.props.lastUserInteractionCalendar, ); return ( timeUntilExpiration !== null && timeUntilExpiration !== undefined && timeUntilExpiration <= 0 ); } get shouldUpdateCalendarQuery(): boolean { if (this.props.connection.status !== 'connected' || this.props.frozen) { return false; } const calendarQuery = this.props.currentCalendarQuery(); return !_isEqual(calendarQuery)(this.serverCalendarQuery); } updateCalendarQuery() { const calendarQuery = this.props.currentCalendarQuery(); this.serverCalendarQuery = calendarQuery; void this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery({ calendarQuery, reduxAlreadyUpdated: true, keyserverIDs: [this.props.keyserverID], }), undefined, ({ calendarQuery }: CalendarQueryUpdateStartingPayload), ); } possiblyUpdateCalendarQuery = () => { if (this.shouldUpdateCalendarQuery) { this.updateCalendarQuery(); } }; } const ConnectedCalendarQueryHandler: React.ComponentType = React.memo(function ConnectedCalendarQueryHandler(props) { const { currentCalendarQuery, keyserverID } = props; const keyserverInfo = useSelector( state => state.keyserverStore.keyserverInfos[keyserverID], ); invariant(keyserverInfo, 'keyserver missing from keyserverStore'); const { connection, actualizedCalendarQuery } = keyserverInfo; const lastUserInteractionCalendar = useSelector( state => state.entryStore.lastUserInteractionCalendar, ); // We include this so that componentDidUpdate will be called on foreground const foreground = useIsAppForegrounded(); const callUpdateCalendarQuery = useUpdateCalendarQuery(); const dispatchActionPromise = useDispatchActionPromise(); const getCurrentCalendarQuery = React.useCallback(() => { const query = currentCalendarQuery(); return { ...query, filters: filterThreadIDsInFilterList( query.filters, (threadID: string) => - extractKeyserverIDFromID(threadID) === keyserverID, + extractKeyserverIDFromIDOptional(threadID) === keyserverID, ), }; }, [currentCalendarQuery, keyserverID]); return ( ); }); export default ConnectedCalendarQueryHandler; diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 0aa4c3a78..2dfd975db 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,845 +1,852 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome.js'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _omit from 'lodash/fp/omit.js'; import * as React from 'react'; import { Keyboard, LayoutAnimation, Platform, Text, TextInput as BaseTextInput, TouchableWithoutFeedback, View, } from 'react-native'; import shallowequal from 'shallowequal'; import tinycolor from 'tinycolor2'; import { concurrentModificationResetActionType, createEntryActionTypes, deleteEntryActionTypes, saveEntryActionTypes, useCreateEntry, useDeleteEntry, useSaveEntry, } from 'lib/actions/entry-actions.js'; -import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import type { CalendarQuery, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResult, SaveEntryInfo, SaveEntryPayload, SaveEntryResult, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo, ThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } 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 sleep from 'lib/utils/sleep.js'; import type { EntryInfoWithHeight } from './calendar-screen.react.js'; import type { CalendarNavigationProp } from './calendar.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import Button from '../components/button.react.js'; import SingleLine from '../components/single-line.react.js'; import TextInput from '../components/text-input.react.js'; import Markdown from '../markdown/markdown.react.js'; import { inlineMarkdownRules } from '../markdown/rules.react.js'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { ThreadPickerModalRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { colors, useStyles } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import Alert from '../utils/alert.js'; import { waitForInteractions } from '../utils/timers.js'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement( entryText: string, ): React.Element { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, transform: (Platform.select({ ios: [{ translateY: -1 / 3 }], default: [], }): $ReadOnlyArray<{ +translateY: number }>), }, textInput: { fontFamily: 'System', fontSize: 16, left: ((Platform.OS === 'android' ? 9.8 : 10): number), margin: 0, padding: 0, position: 'absolute', right: 10, top: ((Platform.OS === 'android' ? 4.8 : 0.5): number), }, }; type SharedProps = { +navigation: CalendarNavigationProp<'CalendarScreen'>, +entryInfo: EntryInfoWithHeight, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, }; type BaseProps = { ...SharedProps, +threadInfo: ThreadInfo, }; type Props = { ...SharedProps, +threadInfo: ResolvedThreadInfo, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: $ReadOnly, // Nav state +threadPickerActive: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, +canEditEntry: boolean, }; type State = { +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, }; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating: boolean = false; needsUpdateAfterCreation: boolean = false; needsDeleteAfterSave: boolean = false; nextSaveAttemptIndex: number = 0; mounted: boolean = false; deleted: boolean = false; currentlySaving: ?string; creationPromise: ?Promise; savePromise: ?Promise; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: Partial) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State): boolean { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render(): React.Node { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; return ( ); } textInputRef: (textInput: ?React.ElementRef) => void = textInput => { this.textInput = textInput; if (textInput && this.state.editing) { void this.enterEditMode(); } }; enterEditMode: () => Promise = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // If we don't do this, the TextInput focuses // but the soft keyboard doesn't come up await waitForInteractions(); await sleep(15); } this.focus(); }; focus: () => void = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus: () => void = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive: () => void = () => this.makeActive(true); completeEdit: () => void = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur: () => void = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save: () => void = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout: (event: LayoutEvent) => void = event => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText: (newText: string) => void = newText => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { if (!this.props.canEditEntry) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.creationPromise = this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.savePromise = this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string): Promise { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.createEntry({ text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; this.currentlySaving = null; void (async () => { await this.creationPromise; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterSave) { this.needsDeleteAfterSave = false; this.dispatchDelete(response.entryID, this.state.text); } })(); return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction( entryID: string, newText: string, ): Promise { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.saveEntry({ entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.currentlySaving = null; void (async () => { await this.savePromise; if (this.needsDeleteAfterSave) { this.needsDeleteAfterSave = false; this.dispatchDelete(response.entryID, this.state.text); } })(); return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload?.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete: () => void = () => { this.dispatchDelete(this.props.entryInfo.id, this.props.entryInfo.text); }; onPressEdit: () => void = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string, prevText: string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; void this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID, prevText), undefined, { localID, serverID }, ); } async deleteAction( serverID: ?string, prevText: string, ): Promise { if (serverID) { return await this.props.deleteEntry({ entryID: serverID, prevText, calendarQuery: this.props.calendarQuery(), }); } else if (this.creating || this.currentlySaving) { this.needsDeleteAfterSave = true; } return null; } onPressThreadName: () => void = () => { Keyboard.dismiss(); this.props.navigateToThread({ threadInfo: this.props.threadInfo }); }; } registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry: React.ComponentType = React.memo( function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const styles = useStyles(unboundStyles); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useCreateEntry(); const callSaveEntry = useSaveEntry(); const callDeleteEntry = useDeleteEntry(); const { threadInfo: unresolvedThreadInfo, ...restProps } = props; const threadInfo = useResolvedThreadInfo(unresolvedThreadInfo); - const keyserverID = extractKeyserverIDFromID(threadInfo.id); - const connection = useSelector(connectionSelector(keyserverID)); + const keyserverID = extractKeyserverIDFromIDOptional(threadInfo.id); + const connection = useSelector(state => { + if (!keyserverID) { + return { + status: 'connected', + }; + } + return connectionSelector(keyserverID)(state); + }); invariant( connection, - `keyserver ${keyserverID} missing from keyserverStore`, + `keyserver ${keyserverID ?? 'null'} missing from keyserverStore`, ); const online = connection.status === 'connected'; const canEditEntry = useThreadHasPermission( threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); }, ); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/chat/settings/thread-settings-push-notifs.react.js b/native/chat/settings/thread-settings-push-notifs.react.js index 34439fec7..4f2ec4c2a 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,160 +1,165 @@ // @flow import * as React from 'react'; import { Platform, TouchableOpacity, View } from 'react-native'; import Linking from 'react-native/Libraries/Linking/Linking.js'; -import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { deviceTokenSelector } from 'lib/selectors/keyserver-selectors.js'; import { threadSettingsNotificationsCopy } from 'lib/shared/thread-settings-notifications-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import EditSettingButton from '../../components/edit-setting-button.react.js'; import SingleLine from '../../components/single-line.react.js'; import SWMansionIcon from '../../components/swmansion-icon.react.js'; import { ThreadSettingsNotificationsRouteName } from '../../navigation/route-names.js'; import { CommAndroidNotifications } from '../../push/android.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import Alert from '../../utils/alert.js'; const unboundStyles = { label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, flex: 1, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; type BaseProps = { +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, }; type Props = { ...BaseProps, // Redux state +styles: $ReadOnly, +hasPushPermissions: boolean, }; class ThreadSettingsPushNotifs extends React.PureComponent { render(): React.Node { let componentLabel = threadSettingsNotificationsCopy.HOME; if (!this.props.threadInfo.currentUser.subscription.home) { componentLabel = threadSettingsNotificationsCopy.MUTED; } else if (!this.props.threadInfo.currentUser.subscription.pushNotifs) { componentLabel = threadSettingsNotificationsCopy.NOTIF_COUNT_ONLY; } let editSettingsButton, notifSettingsLinkingButton; if (this.props.hasPushPermissions) { editSettingsButton = ( ); } else { notifSettingsLinkingButton = ( ); } return ( {componentLabel} {editSettingsButton} {notifSettingsLinkingButton} ); } onNotificationsSettingsLinkingIconPress = async () => { let platformRequestsPermission; if (Platform.OS !== 'android') { platformRequestsPermission = true; } else { platformRequestsPermission = await CommAndroidNotifications.canRequestNotificationsPermissionFromUser(); } const alertTitle = platformRequestsPermission ? 'Need notif permissions' : 'Unable to initialize notifs'; const notificationsSettingsPath = Platform.OS === 'ios' ? 'Settings App → Notifications → Comm' : 'Settings → Apps → Comm → Notifications'; let alertMessage; if ( platformRequestsPermission && this.props.threadInfo.currentUser.subscription.pushNotifs ) { alertMessage = 'Notifs for this chat are enabled, but cannot be delivered ' + 'to this device because you haven’t granted notif permissions to Comm. ' + 'Please enable them in ' + notificationsSettingsPath; } else if (platformRequestsPermission) { alertMessage = 'In order to enable push notifs for this chat, ' + 'you need to first grant notif permissions to Comm. ' + 'Please enable them in ' + notificationsSettingsPath; } else { alertMessage = 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.'; } Alert.alert(alertTitle, alertMessage, [ { text: 'Go to settings', onPress: () => Linking.openSettings(), }, { text: 'Cancel', style: 'cancel', }, ]); }; onPressEditThreadNotificationSettings = () => { this.props.navigate(ThreadSettingsNotificationsRouteName, { threadInfo: this.props.threadInfo, }); }; } const ConnectedThreadSettingsPushNotifs: React.ComponentType = React.memo(function ConnectedThreadSettingsPushNotifs( props: BaseProps, ) { - const keyserverID = extractKeyserverIDFromID(props.threadInfo.id); - const deviceToken = useSelector(deviceTokenSelector(keyserverID)); + const keyserverID = extractKeyserverIDFromIDOptional(props.threadInfo.id); + const deviceToken = useSelector(state => { + if (!keyserverID) { + return state.tunnelbrokerDeviceToken; + } + return deviceTokenSelector(keyserverID)(state); + }); const hasPushPermissions = deviceToken !== null && deviceToken !== undefined; const styles = useStyles(unboundStyles); return ( ); }); export default ConnectedThreadSettingsPushNotifs; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 4cfd1fbab..8b910ff95 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,830 +1,833 @@ // @flow import * as Haptics from 'expo-haptics'; import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import { LogBox, Platform } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import { recordAlertActionType } from 'lib/actions/alert-actions.js'; import type { DeviceTokens, SetDeviceTokenActionPayload, } from 'lib/actions/device-actions.js'; import { setDeviceTokenActionTypes, useSetDeviceToken, useSetDeviceTokenFanout, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; -import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; +import { + extractKeyserverIDFromID, + extractKeyserverIDFromIDOptional, +} from 'lib/keyserver-conn/keyserver-call-utils.js'; import { deviceTokensSelector, allUpdatesCurrentAsOfSelector, allConnectionInfosSelector, } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector, allUnreadCounts, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import { alertTypes, type AlertInfo, type RecordAlertActionPayload, } from 'lib/types/alert-types.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import type { ConnectionInfo } from 'lib/types/socket-types.js'; import type { GlobalTheme } from 'lib/types/theme-types.js'; import { convertNonPendingIDToNewSchema, convertNotificationMessageInfoToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert } from 'lib/utils/push-alerts.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import sleep from 'lib/utils/sleep.js'; import { type AndroidMessage, androidNotificationChannelID, CommAndroidNotifications, getCommAndroidNotificationsEventEmitter, handleAndroidMessage, parseAndroidMessage, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { CommIOSNotifications, type CoreIOSNotificationBackgroundData, getCommIOSNotificationsEventEmitter, iosPushPermissionResponseReceived, requestIOSPushPermissions, } from './ios.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { commCoreModule } from '../native-modules.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; import Alert from '../utils/alert.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: { +[keyserverID: string]: number }, +connection: { +[keyserverID: string]: ?ConnectionInfo }, +deviceTokens: { +[keyserverID: string]: ?string, }, +threadInfos: { +[id: string]: ThreadInfo, }, +notifPermissionAlertInfo: AlertInfo, +allUpdatesCurrentAsOf: { +[keyserverID: string]: number, }, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: ( input: DeviceTokens, ) => Promise, +setDeviceTokenFanout: ( deviceToken: ?string, ) => Promise, // withRootContext +rootContext: ?RootContextType, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, this.iosBackgroundNotificationReceived, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } void this.updateBadgeCount(); } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { void this.ensurePushNotifsEnabled(); } else { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for const deviceTokensMap: { [string]: string } = {}; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } void this.updateBadgeCount(); for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if (this.props.loggedIn && !prevProps.loggedIn) { void this.ensurePushNotifsEnabled(); } else { for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; const prevDeviceToken = prevProps.deviceTokens[keyserverID]; if (!deviceToken && prevDeviceToken) { void this.ensurePushNotifsEnabled(); break; } } } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); void this.resetBadgeCount(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } async updateBadgeCount() { const curUnreadCounts = this.props.unreadCount; const curConnections = this.props.connection; const notifStorageUpdates: Array<{ +id: string, +unreadCount: number, }> = []; const notifsStorageQueries: Array = []; for (const keyserverID in curUnreadCounts) { if (curConnections[keyserverID]?.status !== 'connected') { notifsStorageQueries.push(keyserverID); continue; } notifStorageUpdates.push({ id: keyserverID, unreadCount: curUnreadCounts[keyserverID], }); } let queriedKeyserverData: $ReadOnlyArray<{ +id: string, +unreadCount: number, }> = []; try { [queriedKeyserverData] = await Promise.all([ commCoreModule.getKeyserverDataFromNotifStorage(notifsStorageQueries), commCoreModule.updateKeyserverDataInNotifStorage(notifStorageUpdates), ]); } catch (e) { if (__DEV__) { Alert.alert( 'MMKV error', 'Failed to update keyserver data in MMKV.' + e.message, ); } console.log(e); return; } let totalUnreadCount = 0; for (const keyserverData of notifStorageUpdates) { totalUnreadCount += keyserverData.unreadCount; } for (const keyserverData of queriedKeyserverData) { totalUnreadCount += keyserverData.unreadCount; } if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(totalUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(totalUnreadCount); } } async resetBadgeCount() { const keyserversDataToRemove = Object.keys(this.props.unreadCount); try { await commCoreModule.removeKeyserverDataFromNotifStorage( keyserversDataToRemove, ); } catch (e) { if (__DEV__) { Alert.alert( 'MMKV error', 'Failed to remove keyserver from MMKV.' + e.message, ); } console.log(e); return; } if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(0); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(0); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { let missingDeviceToken = false; for (const keyserverID in this.props.deviceTokens) { const deviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken === null || deviceToken === undefined) { missingDeviceToken = true; break; } } await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); return await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = (): Promise => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotifThreadID = await CommAndroidNotifications.getInitialNotificationThreadID(); if (initialNotifThreadID) { await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } const deviceTokensMap: { [string]: ?string } = {}; for (const keyserverID in this.props.deviceTokens) { const keyserverDeviceToken = this.props.deviceTokens[keyserverID]; if (deviceToken !== keyserverDeviceToken) { deviceTokensMap[keyserverID] = deviceToken; } } this.setDeviceToken(deviceTokensMap, { deviceToken }); }; setDeviceToken( deviceTokens: DeviceTokens, payload: ?{ deviceToken: ?string }, ) { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceTokens), undefined, payload, ); } setAllDeviceTokensNull = () => { void this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceTokenFanout(null), undefined, { deviceToken: null }, ); }; failedToRegisterPushPermissionsIOS = () => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setAllDeviceTokensNull(); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } const payload: RecordAlertActionPayload = { alertType: alertTypes.NOTIF_PERMISSION, time: Date.now(), }; this.props.dispatch({ type: recordAlertActionType, payload, }); Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(rawMessageInfos: ?$ReadOnlyArray) { if (!rawMessageInfos) { return; } const keyserverIDToMessageInfos = _groupBy(messageInfos => - extractKeyserverIDFromID(messageInfos.threadID), + extractKeyserverIDFromIDOptional(messageInfos.threadID), )(rawMessageInfos); for (const keyserverID in keyserverIDToMessageInfos) { const updatesCurrentAsOf = this.props.allUpdatesCurrentAsOf[keyserverID]; const messageInfos = keyserverIDToMessageInfos[keyserverID]; if (!updatesCurrentAsOf) { continue; } this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos: messageInfos, updatesCurrentAsOf }, }); } } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; iosBackgroundNotificationReceived = ( backgroundData: CoreIOSNotificationBackgroundData, ) => { const convertedMessageInfos = backgroundData.messageInfosArray .flatMap(convertNotificationMessageInfoToNewIDSchema) .filter(Boolean); if (!convertedMessageInfos.length) { return; } this.saveMessageInfos(convertedMessageInfos); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (threadID: string) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, authoritativeKeyserverID, ); this.onPushNotifBootsApp(); this.onPressNotificationForThread(convertedThreadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); const keyserverID = extractKeyserverIDFromID(message.threadID); const updateCurrentAsOf = this.props.allUpdatesCurrentAsOf[keyserverID]; handleAndroidMessage( parsedMessage, updateCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ): boolean => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render(): React.Node { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(allUnreadCounts); const boundConnection = useSelector(allConnectionInfosSelector); const deviceTokens = useSelector(deviceTokensSelector); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.alertStore.alertInfos[alertTypes.NOTIF_PERMISSION], ); const allUpdatesCurrentAsOf = useSelector(allUpdatesCurrentAsOfSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceToken(); const callSetDeviceTokenFanout = useSetDeviceTokenFanout(); const rootContext = React.useContext(RootContext); return ( ); }); export default ConnectedPushHandler; diff --git a/web/calendar/entry.react.js b/web/calendar/entry.react.js index 208fe5e68..062e38280 100644 --- a/web/calendar/entry.react.js +++ b/web/calendar/entry.react.js @@ -1,511 +1,511 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { createEntryActionTypes, useCreateEntry, saveEntryActionTypes, useSaveEntry, deleteEntryActionTypes, useDeleteEntry, concurrentModificationResetActionType, } from 'lib/actions/entry-actions.js'; import { type PushModal, useModalContext, } from 'lib/components/modal-provider.react.js'; -import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { connectionSelector } from 'lib/selectors/keyserver-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { entryKey } from 'lib/shared/entry-utils.js'; import { useThreadHasPermission } from 'lib/shared/thread-utils.js'; import { type EntryInfo, type CreateEntryInfo, type SaveEntryInfo, type SaveEntryResult, type SaveEntryPayload, type CreateEntryPayload, type DeleteEntryInfo, type DeleteEntryResult, type CalendarQuery, } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ResolvedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { ServerError } from 'lib/utils/errors.js'; import { useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import css from './calendar.css'; import LoadingIndicator from '../loading-indicator.react.js'; import LogInFirstModal from '../modals/account/log-in-first-modal.react.js'; import ConcurrentModificationModal from '../modals/concurrent-modification-modal.react.js'; import HistoryModal from '../modals/history/history-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { HistoryVector, DeleteVector } from '../vectors.react.js'; type BaseProps = { +innerRef: (key: string, me: Entry) => void, +entryInfo: EntryInfo, +focusOnFirstEntryNewerThan: (time: number) => void, +tabIndex: number, }; type Props = { ...BaseProps, +threadInfo: ResolvedThreadInfo, +loggedIn: boolean, +currentUserCanEditEntry: boolean, +calendarQuery: () => CalendarQuery, +online: boolean, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, +pushModal: PushModal, +popModal: () => void, }; type State = { +focused: boolean, +loadingStatus: LoadingStatus, +text: string, }; class Entry extends React.PureComponent { textarea: ?HTMLTextAreaElement; creating: boolean; needsUpdateAfterCreation: boolean; needsDeleteAfterCreation: boolean; nextSaveAttemptIndex: number; mounted: boolean; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { focused: false, loadingStatus: 'inactive', text: props.entryInfo.text, }; this.creating = false; this.needsUpdateAfterCreation = false; this.needsDeleteAfterCreation = false; this.nextSaveAttemptIndex = 0; } guardedSetState(input: Partial) { if (this.mounted) { this.setState(input); } } componentDidMount() { this.mounted = true; this.props.innerRef(entryKey(this.props.entryInfo), this); this.updateHeight(); // Whenever a new Entry is created, focus on it if (!this.props.entryInfo.id) { this.focus(); } } componentDidUpdate(prevProps: Props) { if ( !this.state.focused && this.props.entryInfo.text !== this.state.text && this.props.entryInfo.text !== prevProps.entryInfo.text ) { this.setState({ text: this.props.entryInfo.text }); this.currentlySaving = null; } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } } focus() { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.focus(); } onMouseDown: (event: SyntheticEvent) => void = event => { if (this.state.focused && event.target !== this.textarea) { // Don't lose focus when some non-textarea part is clicked event.preventDefault(); } }; componentWillUnmount() { this.mounted = false; } updateHeight: () => void = () => { invariant( this.textarea instanceof HTMLTextAreaElement, 'textarea ref not set', ); this.textarea.style.height = 'auto'; this.textarea.style.height = this.textarea.scrollHeight + 'px'; }; render(): React.Node { let actionLinks = null; if (this.state.focused) { let historyButton = null; if (this.props.entryInfo.id) { historyButton = ( History ); } const rightActionLinksClassName = `${css.rightActionLinks} ${css.actionLinksText}`; actionLinks = (
Delete {historyButton} {this.props.threadInfo.uiName}
); } const darkColor = colorIsDark(this.props.threadInfo.color); const entryClasses = classNames({ [css.entry]: true, [css.darkEntry]: darkColor, [css.focusedEntry]: this.state.focused, }); const style = { backgroundColor: `#${this.props.threadInfo.color}` }; const loadingIndicatorColor = darkColor ? 'white' : 'black'; return (