diff --git a/lib/ops/message-store-ops.js b/lib/ops/message-store-ops.js new file mode 100644 index 000000000..12cd276ce --- /dev/null +++ b/lib/ops/message-store-ops.js @@ -0,0 +1,78 @@ +// @flow + +import type { + ClientDBMessageInfo, + ClientDBThreadMessageInfo, + MessageStoreThreads, + RawMessageInfo, +} from '../types/message-types.js'; + +// MessageStore messages ops +export type RemoveMessageOperation = { + +type: 'remove', + +payload: { +ids: $ReadOnlyArray }, +}; + +export type RemoveMessagesForThreadsOperation = { + +type: 'remove_messages_for_threads', + +payload: { +threadIDs: $ReadOnlyArray }, +}; + +export type ReplaceMessageOperation = { + +type: 'replace', + +payload: { +id: string, +messageInfo: RawMessageInfo }, +}; + +export type RekeyMessageOperation = { + +type: 'rekey', + +payload: { +from: string, +to: string }, +}; + +export type RemoveAllMessagesOperation = { + +type: 'remove_all', +}; + +// MessageStore threads ops +export type ReplaceMessageStoreThreadsOperation = { + +type: 'replace_threads', + +payload: { +threads: MessageStoreThreads }, +}; + +export type RemoveMessageStoreThreadsOperation = { + +type: 'remove_threads', + +payload: { +ids: $ReadOnlyArray }, +}; + +export type RemoveMessageStoreAllThreadsOperation = { + +type: 'remove_all_threads', +}; + +export type ClientDBReplaceMessageOperation = { + +type: 'replace', + +payload: ClientDBMessageInfo, +}; + +export type ClientDBReplaceThreadsOperation = { + +type: 'replace_threads', + +payload: { +threads: $ReadOnlyArray }, +}; + +export type MessageStoreOperation = + | RemoveMessageOperation + | ReplaceMessageOperation + | RekeyMessageOperation + | RemoveMessagesForThreadsOperation + | RemoveAllMessagesOperation + | ReplaceMessageStoreThreadsOperation + | RemoveMessageStoreThreadsOperation + | RemoveMessageStoreAllThreadsOperation; + +export type ClientDBMessageStoreOperation = + | RemoveMessageOperation + | ClientDBReplaceMessageOperation + | RekeyMessageOperation + | RemoveMessagesForThreadsOperation + | RemoveAllMessagesOperation + | ClientDBReplaceThreadsOperation + | RemoveMessageStoreThreadsOperation + | RemoveMessageStoreAllThreadsOperation; diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 372542ae8..4689fa765 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,1827 +1,1829 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import _flow from 'lodash/fp/flow.js'; import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import _map from 'lodash/fp/map.js'; import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omit from 'lodash/fp/omit.js'; import _omitBy from 'lodash/fp/omitBy.js'; import _pick from 'lodash/fp/pick.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _uniq from 'lodash/fp/uniq.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions.js'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, sendTextMessageActionTypes, sendMultimediaMessageActionTypes, sendReactionMessageActionTypes, sendEditMessageActionTypes, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, createLocalMessageActionType, fetchSingleMostRecentMessagesFromThreadsActionTypes, } from '../actions/message-actions.js'; import { sendMessageReportActionTypes } from '../actions/message-report-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, leaveThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, toggleMessagePinActionTypes, } from '../actions/thread-actions.js'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, } from '../actions/user-actions.js'; +import type { + MessageStoreOperation, + ReplaceMessageOperation, +} from '../ops/message-store-ops.js'; import { pendingToRealizedThreadIDsSelector } from '../selectors/thread-selectors.js'; import { messageID, combineTruncationStatuses, sortMessageInfoList, sortMessageIDs, mergeThreadMessageInfos, } from '../shared/message-utils.js'; import { threadHasPermission, threadInChatList, threadIsPending, } from '../shared/thread-utils.js'; import threadWatcher from '../shared/thread-watcher.js'; import { unshimMessageInfos } from '../shared/unshim-utils.js'; import type { Media, Image } from '../types/media-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RawMessageInfo, type LocalMessageInfo, type MessageStore, - type ReplaceMessageOperation, - type MessageStoreOperation, type MessageTruncationStatus, type MessageTruncationStatuses, messageTruncationStatus, defaultNumberPerThread, type ThreadMessageInfo, } from '../types/message-types.js'; import type { RawImagesMessageInfo } from '../types/messages/images.js'; import type { RawMediaMessageInfo } from '../types/messages/media.js'; import { type BaseAction } from '../types/redux-types.js'; import { processServerRequestsActionType } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type RawThreadInfo } from '../types/thread-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { setNewSessionActionType } from '../utils/action-utils.js'; import { isDev } from '../utils/dev-utils.js'; import { translateClientDBMessageInfosToRawMessageInfos, translateClientDBThreadMessageInfos, } from '../utils/message-ops-utils.js'; import { assertObjectsAreEqual } from '../utils/objects.js'; const PROCESSED_MSG_STORE_INVARIANTS_DISABLED = !isDev; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); // Input must already be ordered! function mapThreadsToMessageIDsFromOrderedMessageInfos( orderedMessageInfos: $ReadOnlyArray, ): { [threadID: string]: string[] } { const threadsToMessageIDs: { [threadID: string]: string[] } = {}; for (const messageInfo of orderedMessageInfos) { const key = messageID(messageInfo); if (!threadsToMessageIDs[messageInfo.threadID]) { threadsToMessageIDs[messageInfo.threadID] = [key]; } else { threadsToMessageIDs[messageInfo.threadID].push(key); } } return threadsToMessageIDs; } function isThreadWatched( threadID: string, threadInfo: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadIsPending(threadID) || (threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadID))) ); } function assertMessageStoreThreadsAreEqual( processedMessageStore: MessageStore, expectedMessageStore: MessageStore, location: string, ) { if (PROCESSED_MSG_STORE_INVARIANTS_DISABLED) { return; } assertObjectsAreEqual( processedMessageStore.threads, expectedMessageStore.threads, `MessageStore.threads - ${location}`, ); } const newThread = () => ({ messageIDs: [], startReached: false, lastNavigatedTo: 0, lastPruned: Date.now(), }); type FreshMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function freshMessageStore( messageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: number, threadInfos: { +[threadID: string]: RawThreadInfo }, ): FreshMessageStoreResult { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = sortMessageInfoList(unshimmed); const messages = _keyBy(messageID)(orderedMessageInfos); const messageStoreReplaceOperations = orderedMessageInfos.map( messageInfo => ({ type: 'replace', payload: { id: messageID(messageInfo), messageInfo }, }), ); const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos(orderedMessageInfos); const threads = _mapValuesWithKeys( (messageIDs: string[], threadID: string) => ({ ...newThread(), messageIDs, startReached: truncationStatus[threadID] === messageTruncationStatus.EXHAUSTIVE, }), )(threadsToMessageIDs); const watchedIDs = threadWatcher.getWatchedIDs(); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( threads[threadID] || !isThreadWatched(threadID, threadInfo, watchedIDs) ) { continue; } threads[threadID] = newThread(); } const messageStoreOperations = [ { type: 'remove_all' }, { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads }, }, ...messageStoreReplaceOperations, ]; return { messageStoreOperations, messageStore: { messages, threads, local: {}, currentAsOf }, }; } type ReassignmentResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function reassignMessagesToRealizedThreads( messageStore: MessageStore, threadInfos: { +[threadID: string]: RawThreadInfo }, ): ReassignmentResult { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(threadInfos); const messageStoreOperations: MessageStoreOperation[] = []; const messages = {}; for (const storeMessageID in messageStore.messages) { const message = messageStore.messages[storeMessageID]; const newThreadID = pendingToRealizedThreadIDs.get(message.threadID); messages[storeMessageID] = newThreadID ? { ...message, threadID: newThreadID, time: threadInfos[newThreadID]?.creationTime ?? message.time, } : message; if (!newThreadID) { continue; } const updateMsgOperation: ReplaceMessageOperation = { type: 'replace', payload: { id: storeMessageID, messageInfo: messages[storeMessageID] }, }; messageStoreOperations.push(updateMsgOperation); } const threads = {}; const reassignedThreadIDs = []; const updatedThreads = {}; const threadsToRemove = []; for (const threadID in messageStore.threads) { const threadMessageInfo = messageStore.threads[threadID]; const newThreadID = pendingToRealizedThreadIDs.get(threadID); if (!newThreadID) { threads[threadID] = threadMessageInfo; continue; } const realizedThread = messageStore.threads[newThreadID]; if (!realizedThread) { reassignedThreadIDs.push(newThreadID); threads[newThreadID] = threadMessageInfo; updatedThreads[newThreadID] = threadMessageInfo; threadsToRemove.push(threadID); continue; } threads[newThreadID] = mergeThreadMessageInfos( threadMessageInfo, realizedThread, messages, ); updatedThreads[newThreadID] = threads[newThreadID]; } if (threadsToRemove.length) { messageStoreOperations.push({ type: 'remove_threads', payload: { ids: threadsToRemove, }, }); } messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads, }, }); return { messageStoreOperations, messageStore: { ...messageStore, threads, messages, }, reassignedThreadIDs, }; } type MergeNewMessagesResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; // oldMessageStore is from the old state // newMessageInfos, truncationStatus come from server function mergeNewMessages( oldMessageStore: MessageStore, newMessageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, threadInfos: { +[threadID: string]: RawThreadInfo }, actionType: string, ): MergeNewMessagesResult { const { messageStoreOperations: updateWithLatestThreadInfosOps, messageStore: messageStoreUpdatedWithLatestThreadInfos, reassignedThreadIDs, } = updateMessageStoreWithLatestThreadInfos(oldMessageStore, threadInfos); const messageStoreAfterUpdateOps = processMessageStoreOperations( oldMessageStore, updateWithLatestThreadInfosOps, ); assertMessageStoreThreadsAreEqual( messageStoreAfterUpdateOps, messageStoreUpdatedWithLatestThreadInfos, `${actionType} | reassignment and filtering`, ); const updatedMessageStore = { ...messageStoreUpdatedWithLatestThreadInfos, messages: messageStoreAfterUpdateOps.messages, }; const localIDsToServerIDs: Map = new Map(); const watchedThreadIDs = [ ...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs, ]; const unshimmedNewMessages = unshimMessageInfos(newMessageInfos); const unshimmedNewMessagesOfWatchedThreads = unshimmedNewMessages.filter( msg => isThreadWatched( msg.threadID, threadInfos[msg.threadID], watchedThreadIDs, ), ); const orderedNewMessageInfos = _flow( _map((messageInfo: RawMessageInfo) => { const { id: inputID } = messageInfo; invariant(inputID, 'new messageInfos should have serverID'); invariant( !threadIsPending(messageInfo.threadID), 'new messageInfos should have realized thread id', ); const currentMessageInfo = updatedMessageStore.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? updatedMessageStore.messages[inputLocalID] : null; if (currentMessageInfo && currentMessageInfo.localID) { // If the client already has a RawMessageInfo with this serverID, keep // any localID associated with the existing one. This is because we // use localIDs as React keys and changing React keys leads to loss of // component state. (The conditional below is for Flow) if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawImagesMessageInfo); } } else if (currentLocalMessageInfo && currentLocalMessageInfo.localID) { // If the client has a RawMessageInfo with this localID, but not with // the serverID, that means the message creation succeeded but the // success action never got processed. We set a key in // localIDsToServerIDs here to fix the messageIDs for the rest of the // MessageStore too. (The conditional below is for Flow) invariant(inputLocalID, 'inputLocalID should be set'); localIDsToServerIDs.set(inputLocalID, inputID); if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentLocalMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawImagesMessageInfo); } } else { // If neither the serverID nor the localID from the delivered // RawMessageInfo exists in the client store, then this message is // brand new to us. Ignore any localID provided by the server. // (The conditional below is for Flow) const { localID, ...rest } = messageInfo; if (rest.type === messageTypes.TEXT) { messageInfo = { ...rest }; } else if (rest.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...rest }: RawMediaMessageInfo); } else { messageInfo = ({ ...rest }: RawImagesMessageInfo); } } } else if ( currentMessageInfo && messageInfo.time > currentMessageInfo.time ) { // When thick threads will be introduced it will be possible for two // clients to create the same message (e.g. when they create the same // sidebar at the same time). We're going to use deterministic ids for // messages which should be unique within a thread and we have to find // a way for clients to agree which message to keep. We can't rely on // always choosing incoming messages nor messages from the store, // because a message that is in one user's store, will be send to // another user. One way to deal with it is to always choose a message // which is older, according to its timestamp. We can use this strategy // only for messages that can start a thread, because for other types // it might break the "contiguous" property of message ids (we can // consider selecting younger messages in that case, but for now we use // an invariant). invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR || messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.SIDEBAR_SOURCE, `Two different messages of type ${messageInfo.type} with the same ` + 'id found', ); return currentMessageInfo; } return _isEqual(messageInfo)(currentMessageInfo) ? currentMessageInfo : messageInfo; }), sortMessageInfoList, )(unshimmedNewMessagesOfWatchedThreads); const newMessageOps: MessageStoreOperation[] = []; const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos( orderedNewMessageInfos, ); const oldMessageInfosToCombine = []; const threadsThatNeedMessageIDsResorted = []; const local = {}; const updatedThreads = {}; const threads = _flow( _mapValuesWithKeys((messageIDs: string[], threadID: string) => { const oldThread = updatedMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (!oldThread) { updatedThreads[threadID] = { ...newThread(), messageIDs, startReached: truncate === messageTruncationStatus.EXHAUSTIVE, }; return updatedThreads[threadID]; } let oldMessageIDsUnchanged = true; const oldMessageIDs = oldThread.messageIDs.map(oldID => { const newID = localIDsToServerIDs.get(oldID); if (newID !== null && newID !== undefined) { oldMessageIDsUnchanged = false; return newID; } return oldID; }); if (truncate === messageTruncationStatus.TRUNCATED) { // If the result set in the payload isn't contiguous with what we have // now, that means we need to dump what we have in the state and replace // it with the result set. We do this to achieve our two goals for the // messageStore: currentness and contiguousness. newMessageOps.push({ type: 'remove_messages_for_threads', payload: { threadIDs: [threadID] }, }); updatedThreads[threadID] = { messageIDs, startReached: false, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; return updatedThreads[threadID]; } const oldNotInNew = _difference(oldMessageIDs)(messageIDs); for (const id of oldNotInNew) { const oldMessageInfo = updatedMessageStore.messages[id]; invariant(oldMessageInfo, `could not find ${id} in messageStore`); oldMessageInfosToCombine.push(oldMessageInfo); const localInfo = updatedMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } const startReached = oldThread.startReached || truncate === messageTruncationStatus.EXHAUSTIVE; if (_difference(messageIDs)(oldMessageIDs).length === 0) { if (startReached === oldThread.startReached && oldMessageIDsUnchanged) { return oldThread; } updatedThreads[threadID] = { messageIDs: oldMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; return updatedThreads[threadID]; } const mergedMessageIDs = [...messageIDs, ...oldNotInNew]; threadsThatNeedMessageIDsResorted.push(threadID); return { messageIDs: mergedMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; }), _pickBy(thread => !!thread), )(threadsToMessageIDs); for (const threadID in updatedMessageStore.threads) { if (threads[threadID]) { continue; } let thread = updatedMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (truncate === messageTruncationStatus.EXHAUSTIVE) { thread = { ...thread, startReached: true, }; } threads[threadID] = thread; updatedThreads[threadID] = thread; for (const id of thread.messageIDs) { const messageInfo = updatedMessageStore.messages[id]; if (messageInfo) { oldMessageInfosToCombine.push(messageInfo); } const localInfo = updatedMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } } const messages = _flow( sortMessageInfoList, _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); const newMessages = _keyBy(messageID)(orderedNewMessageInfos); for (const id in newMessages) { newMessageOps.push({ type: 'replace', payload: { id, messageInfo: newMessages[id] }, }); } if (localIDsToServerIDs.size > 0) { newMessageOps.push({ type: 'remove', payload: { ids: [...localIDsToServerIDs.keys()] }, }); } for (const threadID of threadsThatNeedMessageIDsResorted) { threads[threadID].messageIDs = sortMessageIDs(messages)( threads[threadID].messageIDs, ); updatedThreads[threadID] = threads[threadID]; } const currentAsOf = Math.max( orderedNewMessageInfos.length > 0 ? orderedNewMessageInfos[0].time : 0, updatedMessageStore.currentAsOf, ); newMessageOps.push({ type: 'replace_threads', payload: { threads: updatedThreads, }, }); const processedMessageStore = processMessageStoreOperations( updatedMessageStore, newMessageOps, ); const messageStore = { messages: processedMessageStore.messages, threads, local, currentAsOf, }; assertMessageStoreThreadsAreEqual( processedMessageStore, messageStore, `${actionType} | processed`, ); return { messageStoreOperations: [ ...updateWithLatestThreadInfosOps, ...newMessageOps, ], messageStore, }; } type UpdateMessageStoreWithLatestThreadInfosResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function updateMessageStoreWithLatestThreadInfos( messageStore: MessageStore, threadInfos: { +[id: string]: RawThreadInfo }, ): UpdateMessageStoreWithLatestThreadInfosResult { const messageStoreOperations: MessageStoreOperation[] = []; const { messageStore: reassignedMessageStore, messageStoreOperations: reassignMessagesOps, reassignedThreadIDs, } = reassignMessagesToRealizedThreads(messageStore, threadInfos); messageStoreOperations.push(...reassignMessagesOps); const watchedIDs = [...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs]; const watchedThreadInfos = _pickBy((threadInfo: RawThreadInfo) => isThreadWatched(threadInfo.id, threadInfo, watchedIDs), )(threadInfos); const filteredThreads = _pick(Object.keys(watchedThreadInfos))( reassignedMessageStore.threads, ); const messageIDsToRemove = []; const threadsToRemoveMessagesFrom = []; for (const threadID in reassignedMessageStore.threads) { if (watchedThreadInfos[threadID]) { continue; } threadsToRemoveMessagesFrom.push(threadID); for (const id of reassignedMessageStore.threads[threadID].messageIDs) { messageIDsToRemove.push(id); } } const updatedThreads = {}; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( isThreadWatched(threadID, threadInfo, watchedIDs) && !filteredThreads[threadID] ) { filteredThreads[threadID] = newThread(); updatedThreads[threadID] = filteredThreads[threadID]; } } messageStoreOperations.push({ type: 'remove_threads', payload: { ids: threadsToRemoveMessagesFrom }, }); messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads, }, }); messageStoreOperations.push({ type: 'remove_messages_for_threads', payload: { threadIDs: threadsToRemoveMessagesFrom }, }); return { messageStoreOperations, messageStore: { messages: _omit(messageIDsToRemove)(reassignedMessageStore.messages), threads: filteredThreads, local: _omit(messageIDsToRemove)(reassignedMessageStore.local), currentAsOf: reassignedMessageStore.currentAsOf, }, reassignedThreadIDs, }; } function ensureRealizedThreadIDIsUsedWhenPossible( payload: T, threadInfos: { +[id: string]: RawThreadInfo }, ): T { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(threadInfos); const realizedThreadID = pendingToRealizedThreadIDs.get(payload.threadID); return realizedThreadID ? { ...payload, threadID: realizedThreadID } : payload; } function processMessageStoreOperations( messageStore: MessageStore, messageStoreOperations: $ReadOnlyArray, ): MessageStore { if (messageStoreOperations.length === 0) { return messageStore; } let processedMessages = { ...messageStore.messages }; let processedThreads = { ...messageStore.threads }; for (const operation of messageStoreOperations) { if (operation.type === 'replace') { processedMessages[operation.payload.id] = operation.payload.messageInfo; } else if (operation.type === 'remove') { for (const id of operation.payload.ids) { delete processedMessages[id]; } } else if (operation.type === 'remove_messages_for_threads') { for (const msgID in processedMessages) { if ( operation.payload.threadIDs.includes( processedMessages[msgID].threadID, ) ) { delete processedMessages[msgID]; } } } else if (operation.type === 'rekey') { processedMessages[operation.payload.to] = processedMessages[operation.payload.from]; delete processedMessages[operation.payload.from]; } else if (operation.type === 'remove_all') { processedMessages = {}; } else if (operation.type === 'replace_threads') { for (const threadID in operation.payload.threads) { processedThreads[threadID] = operation.payload.threads[threadID]; } } else if (operation.type === 'remove_threads') { for (const id of operation.payload.ids) { delete processedThreads[id]; } } else if (operation.type === 'remove_all_threads') { processedThreads = {}; } } return { ...messageStore, threads: processedThreads, messages: processedMessages, }; } type ReduceMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function reduceMessageStore( messageStore: MessageStore, action: BaseAction, newThreadInfos: { +[id: string]: RawThreadInfo }, ): ReduceMessageStoreResult { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { const messagesResult = action.payload.messagesResult; const { messageStoreOperations, messageStore: freshStore } = freshMessageStore( messagesResult.messageInfos, messagesResult.truncationStatus, messagesResult.currentAsOf, newThreadInfos, ); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); assertMessageStoreThreadsAreEqual( processedMessageStore, freshStore, `${action.type} | fresh store`, ); return { messageStoreOperations, messageStore: { ...freshStore, messages: processedMessageStore.messages }, }; } else if (action.type === incrementalStateSyncActionType) { if ( action.payload.messagesResult.rawMessageInfos.length === 0 && action.payload.updatesResult.newUpdates.length === 0 ) { return { messageStoreOperations: [], messageStore }; } const messagesResult = mergeUpdatesWithMessageInfos( action.payload.messagesResult.rawMessageInfos, action.payload.updatesResult.newUpdates, action.payload.messagesResult.truncationStatuses, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === processUpdatesActionType) { if (action.payload.updatesResult.newUpdates.length === 0) { return { messageStoreOperations: [], messageStore }; } const messagesResult = mergeUpdatesWithMessageInfos( [], action.payload.updatesResult.newUpdates, ); const { messageStoreOperations, messageStore: newMessageStore } = mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); return { messageStoreOperations, messageStore: { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, currentAsOf: messageStore.currentAsOf, }, }; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType ) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === fetchSingleMostRecentMessagesFromThreadsActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, action.payload.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, { [action.payload.threadID]: action.payload.truncationStatus }, newThreadInfos, action.type, ); } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === setNewSessionActionType ) { const { messageStoreOperations, messageStore: filteredMessageStore } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); assertMessageStoreThreadsAreEqual( processedMessageStore, filteredMessageStore, action.type, ); return { messageStoreOperations, messageStore: { ...filteredMessageStore, messages: processedMessageStore.messages, }, }; } else if (action.type === newThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.newMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === sendMessageReportActionTypes.success) { return mergeNewMessages( messageStore, [action.payload.messageInfo], { [action.payload.messageInfo.threadID]: messageTruncationStatus.UNCHANGED, }, newThreadInfos, action.type, ); } else if (action.type === registerActionTypes.success) { const truncationStatuses = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } return mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success || action.type === restoreEntryActionTypes.success || action.type === toggleMessagePinActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === deleteEntryActionTypes.success) { const payload = action.payload; if (payload) { return mergeNewMessages( messageStore, payload.newMessageInfos, { [payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } } else if (action.type === joinThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.rawMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === sendEditMessageActionTypes.success) { const { newMessageInfos } = action.payload; const truncationStatuses = {}; for (const messageInfo of newMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } return mergeNewMessages( messageStore, newMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started || action.type === sendReactionMessageActionTypes.started ) { const payload = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); const { localID, threadID } = payload; invariant(localID, `localID should be set on ${action.type}`); const messageIDs = messageStore.threads[threadID]?.messageIDs ?? []; if (!messageStore.messages[localID]) { for (const existingMessageID of messageIDs) { const existingMessageInfo = messageStore.messages[existingMessageID]; if (existingMessageInfo && existingMessageInfo.localID === localID) { return { messageStoreOperations: [], messageStore }; } } } const messageStoreOperations = [ { type: 'replace', payload: { id: localID, messageInfo: payload }, }, ]; const now = Date.now(); let updatedThreads; let local = { ...messageStore.local }; if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [localID]: payload }; local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== localID, )(messageStore.local); const thread = messageStore.threads[threadID]; updatedThreads = { [threadID]: { messageIDs: sortMessageIDs(messages)(messageIDs), startReached: thread?.startReached ?? true, lastNavigatedTo: thread?.lastNavigatedTo ?? now, lastPruned: thread?.lastPruned ?? now, }, }; } else { updatedThreads = { [threadID]: messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, lastNavigatedTo: now, lastPruned: now, }, }; } const threads = { ...messageStore.threads, ...updatedThreads, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: { ...updatedThreads }, }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { messages: processedMessageStore.messages, threads, local, currentAsOf: messageStore.currentAsOf, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; return { messageStoreOperations: [], messageStore: { messages: messageStore.messages, threads: messageStore.threads, local: { ...messageStore.local, [localID]: { sendFailed: true }, }, currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === sendReactionMessageActionTypes.failed) { const { localID, threadID } = action.payload; const messageStoreOperations = []; messageStoreOperations.push({ type: 'remove', payload: { ids: [localID] }, }); const newMessageIDs = messageStore.threads[threadID].messageIDs.filter( id => id !== localID, ); const updatedThreads = { [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { ...processedMessageStore, threads: { ...messageStore.threads, ...updatedThreads, }, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if ( action.type === sendTextMessageActionTypes.success || action.type === sendMultimediaMessageActionTypes.success || action.type === sendReactionMessageActionTypes.success ) { const { payload } = action; invariant( !threadIsPending(payload.threadID), 'Successful message action should have realized thread id', ); const replaceMessageKey = (messageKey: string) => messageKey === payload.localID ? payload.serverID : messageKey; let newMessages; const messageStoreOperations = []; if (messageStore.messages[payload.serverID]) { // If somehow the serverID got in there already, we'll just update the // serverID message and scrub the localID one newMessages = _omitBy( (messageInfo: RawMessageInfo) => messageInfo.type === messageTypes.TEXT && !messageInfo.id && messageInfo.localID === payload.localID, )(messageStore.messages); messageStoreOperations.push({ type: 'remove', payload: { ids: [payload.localID] }, }); } else if (messageStore.messages[payload.localID]) { // The normal case, the localID message gets replaced by the serverID one newMessages = _mapKeys(replaceMessageKey)(messageStore.messages); messageStoreOperations.push({ type: 'rekey', payload: { from: payload.localID, to: payload.serverID }, }); } else { // Well this is weird, we probably got deauthorized between when the // action was dispatched and when we ran this reducer... return { messageStoreOperations, messageStore }; } const newMessage = { ...newMessages[payload.serverID], id: payload.serverID, localID: payload.localID, time: payload.time, }; newMessages[payload.serverID] = newMessage; messageStoreOperations.push({ type: 'replace', payload: { id: payload.serverID, messageInfo: newMessage }, }); const threadID = payload.threadID; const newMessageIDs = _flow( _uniq, sortMessageIDs(newMessages), )(messageStore.threads[threadID].messageIDs.map(replaceMessageKey)); const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== payload.localID, )(messageStore.local); const updatedThreads = { [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { ...messageStore, messages: processedMessageStore.messages, threads: { ...messageStore.threads, ...updatedThreads, }, local, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if (action.type === saveMessagesActionType) { const truncationStatuses = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const { messageStoreOperations, messageStore: newMessageStore } = mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); return { messageStoreOperations, messageStore: { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, // We avoid bumping currentAsOf because notifs may include a contracted // RawMessageInfo, so we want to make sure we still fetch it currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const messageIDsToPrune = []; const updatedThreads = {}; for (const threadID of action.payload.threadIDs) { let thread = messageStore.threads[threadID]; if (!thread) { continue; } thread = { ...thread, lastPruned: now }; const newMessageIDs = [...thread.messageIDs]; const removed = newMessageIDs.splice(defaultNumberPerThread); if (removed.length > 0) { thread = { ...thread, messageIDs: newMessageIDs, startReached: false, }; } for (const id of removed) { messageIDsToPrune.push(id); } updatedThreads[threadID] = thread; } const messageStoreOperations = [ { type: 'remove', payload: { ids: messageIDsToPrune }, }, { type: 'replace_threads', payload: { threads: updatedThreads, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { messages: processedMessageStore.messages, threads: { ...messageStore.threads, ...updatedThreads, }, local: _omit(messageIDsToPrune)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if (action.type === updateMultimediaMessageMediaActionType) { const { messageID: id, currentMediaID, mediaUpdate } = action.payload; const message = messageStore.messages[id]; invariant(message, `message with ID ${id} could not be found`); invariant( message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, `message with ID ${id} is not multimedia`, ); let updatedMessage; let replaced = false; if (message.type === messageTypes.IMAGES) { const media: Image[] = []; for (const singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else { let updatedMedia: Image = { id: mediaUpdate.id ?? singleMedia.id, type: 'photo', uri: mediaUpdate.uri ?? singleMedia.uri, dimensions: mediaUpdate.dimensions ?? singleMedia.dimensions, thumbHash: mediaUpdate.thumbHash ?? singleMedia.thumbHash, }; if ( 'localMediaSelection' in singleMedia && !('localMediaSelection' in mediaUpdate) ) { updatedMedia = { ...updatedMedia, localMediaSelection: singleMedia.localMediaSelection, }; } else if (mediaUpdate.localMediaSelection) { updatedMedia = { ...updatedMedia, localMediaSelection: mediaUpdate.localMediaSelection, }; } media.push(updatedMedia); replaced = true; } } updatedMessage = { ...message, media }; } else { const media: Media[] = []; for (const singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else if ( singleMedia.type === 'photo' && mediaUpdate.type === 'photo' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'video' && mediaUpdate.type === 'video' ) { media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'encrypted_photo' && mediaUpdate.type === 'encrypted_photo' ) { // TODO: Remove this $FlowFixMe after updating Flow to 0.202 // There's no way in Flow to refine both of these types appropriately // so they can be spread together. // $FlowFixMe media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'encrypted_video' && mediaUpdate.type === 'encrypted_video' ) { // TODO: Remove this $FlowFixMe after updating Flow to 0.202 // There's no way in Flow to refine both of these types appropriately // so they can be spread together. // $FlowFixMe media.push({ ...singleMedia, ...mediaUpdate }); replaced = true; } else if ( singleMedia.type === 'photo' && mediaUpdate.type === 'encrypted_photo' ) { // extract fields that are absent in encrypted_photo type const { uri, localMediaSelection, ...original } = singleMedia; const { holder: newHolder, blobURI: newBlobURI, encryptionKey, ...update } = mediaUpdate; const blobURI = newBlobURI ?? newHolder; invariant( blobURI && encryptionKey, 'holder and encryptionKey are required for encrypted_photo message', ); media.push({ ...original, ...update, type: 'encrypted_photo', blobURI, encryptionKey, }); replaced = true; } else if ( singleMedia.type === 'video' && mediaUpdate.type === 'encrypted_video' ) { const { uri, thumbnailURI, localMediaSelection, ...original } = singleMedia; const { holder: newHolder, blobURI: newBlobURI, encryptionKey, thumbnailHolder: newThumbnailHolder, thumbnailBlobURI: newThumbnailBlobURI, thumbnailEncryptionKey, ...update } = mediaUpdate; const blobURI = newBlobURI ?? newHolder; invariant( blobURI && encryptionKey, 'holder and encryptionKey are required for encrypted_video message', ); const thumbnailBlobURI = newThumbnailBlobURI ?? newThumbnailHolder; invariant( thumbnailBlobURI && thumbnailEncryptionKey, 'thumbnailHolder and thumbnailEncryptionKey are required for ' + 'encrypted_video message', ); media.push({ ...original, ...update, type: 'encrypted_video', blobURI, encryptionKey, thumbnailBlobURI, thumbnailEncryptionKey, }); replaced = true; } else if (mediaUpdate.id) { const { id: newID } = mediaUpdate; // branching for Flow if (singleMedia.type === 'photo') { media.push({ ...singleMedia, id: newID }); } else if (singleMedia.type === 'video') { media.push({ ...singleMedia, id: newID }); } else if (singleMedia.type === 'encrypted_photo') { // TODO: Try removing this branching after Flow 0.202 update if (singleMedia.blobURI) { media.push({ ...singleMedia, id: newID }); } else { invariant( singleMedia.holder, 'Encrypted media must have holder or blobURI', ); media.push({ ...singleMedia, id: newID }); } } else if (singleMedia.type === 'encrypted_video') { // TODO: Try removing this branching after Flow 0.202 update if (singleMedia.blobURI) { media.push({ ...singleMedia, id: newID }); } else { invariant( singleMedia.holder, 'Encrypted media must have holder or blobURI', ); media.push({ ...singleMedia, id: newID }); } } replaced = true; } } updatedMessage = { ...message, media }; } invariant( replaced, `message ${id} did not contain media with ID ${currentMediaID}`, ); const messageStoreOperations = [ { type: 'replace', payload: { id, messageInfo: updatedMessage, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, messages: processedMessageStore.messages, }, }; } else if (action.type === createLocalMessageActionType) { const messageInfo = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); const { localID, threadID } = messageInfo; const messageIDs = messageStore.threads[messageInfo.threadID]?.messageIDs ?? []; const now = Date.now(); const threadState: ThreadMessageInfo = messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, lastNavigatedTo: now, lastPruned: now, }; const messageStoreOperations = [ { type: 'replace', payload: { id: localID, messageInfo }, }, { type: 'replace_threads', payload: { threads: { [threadID]: threadState }, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { ...messageStore, threads: { ...messageStore.threads, [threadID]: threadState, }, messages: processedMessageStore.messages, }; assertMessageStoreThreadsAreEqual( processedMessageStore, newMessageStore, action.type, ); return { messageStoreOperations, messageStore: newMessageStore, }; } else if (action.type === processServerRequestsActionType) { const { messageStoreOperations, messageStore: messageStoreAfterReassignment, } = reassignMessagesToRealizedThreads(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); assertMessageStoreThreadsAreEqual( processedMessageStore, messageStoreAfterReassignment, action.type, ); return { messageStoreOperations, messageStore: { ...messageStoreAfterReassignment, messages: processedMessageStore.messages, }, }; } else if (action.type === setClientDBStoreActionType) { const payloadMessages = action.payload.messages; if (!payloadMessages) { return { messageStoreOperations: [], messageStore }; } const actionPayloadMessageStoreThreads = translateClientDBThreadMessageInfos( action.payload.messageStoreThreads ?? [], ); const newThreads = {}; for (const threadID in actionPayloadMessageStoreThreads) { newThreads[threadID] = { ...actionPayloadMessageStoreThreads[threadID], messageIDs: messageStore.threads[threadID]?.messageIDs ?? [], }; } assertMessageStoreThreadsAreEqual( { ...messageStore, threads: newThreads, }, messageStore, `${action.type} | comparing SQLite with redux-persist`, ); const { messageStoreOperations, messageStore: updatedMessageStore } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); let threads = { ...updatedMessageStore.threads }; let local = { ...updatedMessageStore.local }; // Store message IDs already contained within threads so that we // do not insert duplicates const existingMessageIDs = new Set(); for (const threadID in threads) { threads[threadID].messageIDs.forEach(msgID => { existingMessageIDs.add(msgID); }); } const threadsNeedMsgIDsResorting = new Set(); const actionPayloadMessages = translateClientDBMessageInfosToRawMessageInfos(payloadMessages); // When starting the app on native, we filter out any local-only multimedia // messages because the relevant context is no longer available const messageIDsToBeRemoved = []; const threadsToAdd = {}; for (const id in actionPayloadMessages) { const message = actionPayloadMessages[id]; const { threadID } = message; let existingThread = threads[threadID]; if (!existingThread) { existingThread = newThread(); threadsToAdd[threadID] = existingThread; } if ( (message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA) && !message.id ) { messageIDsToBeRemoved.push(id); threads = { ...threads, [threadID]: { ...existingThread, messageIDs: existingThread.messageIDs.filter( curMessageID => curMessageID !== id, ), }, }; local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== id, )(local); } else if (!existingMessageIDs.has(id)) { threads = { ...threads, [threadID]: { ...existingThread, messageIDs: [...existingThread.messageIDs, id], }, }; threadsNeedMsgIDsResorting.add(threadID); } else if (!threads[threadID]) { threads = { ...threads, [threadID]: existingThread }; } } for (const threadID of threadsNeedMsgIDsResorting) { threads[threadID].messageIDs = sortMessageIDs(actionPayloadMessages)( threads[threadID].messageIDs, ); } const newMessageStore = { ...updatedMessageStore, messages: actionPayloadMessages, threads: threads, local: local, }; if (messageIDsToBeRemoved.length > 0) { messageStoreOperations.push({ type: 'remove', payload: { ids: messageIDsToBeRemoved }, }); } const processedMessageStore = processMessageStoreOperations( newMessageStore, messageStoreOperations, ); messageStoreOperations.push({ type: 'replace_threads', payload: { threads: threadsToAdd }, }); return { messageStoreOperations, messageStore: processedMessageStore, }; } return { messageStoreOperations: [], messageStore }; } type MergedUpdatesWithMessages = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; function mergeUpdatesWithMessageInfos( messageInfos: $ReadOnlyArray, newUpdates: $ReadOnlyArray, truncationStatuses?: MessageTruncationStatuses, ): MergedUpdatesWithMessages { const messageIDs = new Set(messageInfos.map(messageInfo => messageInfo.id)); const mergedMessageInfos = [...messageInfos]; const mergedTruncationStatuses = { ...truncationStatuses }; for (const updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (const messageInfo of updateInfo.rawMessageInfos) { if (messageIDs.has(messageInfo.id)) { continue; } mergedMessageInfos.push(messageInfo); messageIDs.add(messageInfo.id); } mergedTruncationStatuses[updateInfo.threadInfo.id] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } return { rawMessageInfos: mergedMessageInfos, truncationStatuses: mergedTruncationStatuses, }; } export { freshMessageStore, reduceMessageStore }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index acf970c05..2cf9904f8 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,730 +1,660 @@ // @flow import invariant from 'invariant'; import t, { - type TUnion, type TDict, type TEnums, type TInterface, + type TUnion, } from 'tcomb'; import { type ClientDBMediaInfo } from './media-types.js'; -import { messageTypes, type MessageType } from './message-types-enum.js'; +import { type MessageType, messageTypes } from './message-types-enum.js'; import { type AddMembersMessageData, type AddMembersMessageInfo, type RawAddMembersMessageInfo, rawAddMembersMessageInfoValidator, } from './messages/add-members.js'; import { type ChangeRoleMessageData, type ChangeRoleMessageInfo, type RawChangeRoleMessageInfo, rawChangeRoleMessageInfoValidator, } from './messages/change-role.js'; import { type ChangeSettingsMessageData, type ChangeSettingsMessageInfo, type RawChangeSettingsMessageInfo, rawChangeSettingsMessageInfoValidator, } from './messages/change-settings.js'; import { type CreateEntryMessageData, type CreateEntryMessageInfo, type RawCreateEntryMessageInfo, rawCreateEntryMessageInfoValidator, } from './messages/create-entry.js'; import { type CreateSidebarMessageData, type CreateSidebarMessageInfo, type RawCreateSidebarMessageInfo, rawCreateSidebarMessageInfoValidator, } from './messages/create-sidebar.js'; import { type CreateSubthreadMessageData, type CreateSubthreadMessageInfo, type RawCreateSubthreadMessageInfo, rawCreateSubthreadMessageInfoValidator, } from './messages/create-subthread.js'; import { type CreateThreadMessageData, type CreateThreadMessageInfo, type RawCreateThreadMessageInfo, rawCreateThreadMessageInfoValidator, } from './messages/create-thread.js'; import { type DeleteEntryMessageData, type DeleteEntryMessageInfo, type RawDeleteEntryMessageInfo, rawDeleteEntryMessageInfoValidator, } from './messages/delete-entry.js'; import { type EditEntryMessageData, type EditEntryMessageInfo, type RawEditEntryMessageInfo, rawEditEntryMessageInfoValidator, } from './messages/edit-entry.js'; import { - type RawEditMessageInfo, - rawEditMessageInfoValidator, type EditMessageData, type EditMessageInfo, + type RawEditMessageInfo, + rawEditMessageInfoValidator, } from './messages/edit.js'; import { type ImagesMessageData, type ImagesMessageInfo, type RawImagesMessageInfo, rawImagesMessageInfoValidator, } from './messages/images.js'; import { type JoinThreadMessageData, type JoinThreadMessageInfo, type RawJoinThreadMessageInfo, rawJoinThreadMessageInfoValidator, } from './messages/join-thread.js'; import { type LeaveThreadMessageData, type LeaveThreadMessageInfo, type RawLeaveThreadMessageInfo, rawLeaveThreadMessageInfoValidator, } from './messages/leave-thread.js'; import { type MediaMessageData, type MediaMessageInfo, type MediaMessageServerDBContent, type RawMediaMessageInfo, rawMediaMessageInfoValidator, } from './messages/media.js'; import { - type ReactionMessageData, type RawReactionMessageInfo, rawReactionMessageInfoValidator, + type ReactionMessageData, type ReactionMessageInfo, } from './messages/reaction.js'; import { type RawRemoveMembersMessageInfo, rawRemoveMembersMessageInfoValidator, type RemoveMembersMessageData, type RemoveMembersMessageInfo, } from './messages/remove-members.js'; import { type RawRestoreEntryMessageInfo, rawRestoreEntryMessageInfoValidator, type RestoreEntryMessageData, type RestoreEntryMessageInfo, } from './messages/restore-entry.js'; import { type RawTextMessageInfo, rawTextMessageInfoValidator, type TextMessageData, type TextMessageInfo, } from './messages/text.js'; import { - type TogglePinMessageData, - type TogglePinMessageInfo, type RawTogglePinMessageInfo, rawTogglePinMessageInfoValidator, + type TogglePinMessageData, + type TogglePinMessageInfo, } from './messages/toggle-pin.js'; import { type RawUnsupportedMessageInfo, rawUnsupportedMessageInfoValidator, type UnsupportedMessageInfo, } from './messages/unsupported.js'; import { type RawUpdateRelationshipMessageInfo, rawUpdateRelationshipMessageInfoValidator, type UpdateRelationshipMessageData, type UpdateRelationshipMessageInfo, } from './messages/update-relationship.js'; import { type RelativeUserInfo, type UserInfos } from './user-types.js'; import type { CallServerEndpointResultInfoInterface } from '../utils/call-server-endpoint.js'; import { values } from '../utils/objects.js'; -import { tNumber, tShape, tID } from '../utils/validation-utils.js'; +import { tID, tNumber, tShape } from '../utils/validation-utils.js'; const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function assertComposableRawMessage( message: RawMessageInfo, ): RawComposableMessageInfo { invariant( message.type === messageTypes.TEXT || message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, 'Message is not composable', ); return message; } export function messageDataLocalID(messageData: MessageData): ?string { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA && messageData.type !== messageTypes.REACTION ) { return null; } return messageData.localID; } export function isMessageSidebarSourceReactionOrEdit( message: RawMessageInfo | MessageInfo, ): boolean %checks { return ( message.type === messageTypes.SIDEBAR_SOURCE || message.type === messageTypes.REACTION || message.type === messageTypes.EDIT_MESSAGE ); } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assertMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type SidebarSourceMessageData = { +type: 17, +threadID: string, +creatorID: string, +time: number, +sourceMessage?: RawComposableMessageInfo | RawRobotextMessageInfo, }; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | UpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData | ReactionMessageData | EditMessageData | TogglePinMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export const rawMultimediaMessageInfoValidator: TUnion = t.union([rawImagesMessageInfoValidator, rawMediaMessageInfoValidator]); export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; const rawComposableMessageInfoValidator = t.union([ rawTextMessageInfoValidator, rawMultimediaMessageInfoValidator, ]); export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo | RawTogglePinMessageInfo; const rawRobotextMessageInfoValidator = t.union([ rawCreateThreadMessageInfoValidator, rawAddMembersMessageInfoValidator, rawCreateSubthreadMessageInfoValidator, rawChangeSettingsMessageInfoValidator, rawRemoveMembersMessageInfoValidator, rawChangeRoleMessageInfoValidator, rawLeaveThreadMessageInfoValidator, rawJoinThreadMessageInfoValidator, rawCreateEntryMessageInfoValidator, rawEditEntryMessageInfoValidator, rawDeleteEntryMessageInfoValidator, rawRestoreEntryMessageInfoValidator, rawUpdateRelationshipMessageInfoValidator, rawCreateSidebarMessageInfoValidator, rawUnsupportedMessageInfoValidator, rawTogglePinMessageInfoValidator, ]); export type RawSidebarSourceMessageInfo = { ...SidebarSourceMessageData, id: string, }; export const rawSidebarSourceMessageInfoValidator: TInterface = tShape({ type: tNumber(messageTypes.SIDEBAR_SOURCE), threadID: tID, creatorID: t.String, time: t.Number, sourceMessage: t.maybe( t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, ]), ), id: tID, }); export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo | RawReactionMessageInfo | RawEditMessageInfo; export const rawMessageInfoValidator: TUnion = t.union([ rawComposableMessageInfoValidator, rawRobotextMessageInfoValidator, rawSidebarSourceMessageInfoValidator, rawReactionMessageInfoValidator, rawEditMessageInfoValidator, ]); export type LocallyComposedMessageInfo = | ({ ...RawImagesMessageInfo, +localID: string, } & RawImagesMessageInfo) | ({ ...RawMediaMessageInfo, +localID: string, } & RawMediaMessageInfo) | ({ ...RawTextMessageInfo, +localID: string, } & RawTextMessageInfo) | ({ ...RawReactionMessageInfo, +localID: string, } & RawReactionMessageInfo); export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | UnsupportedMessageInfo | UpdateRelationshipMessageInfo | CreateSidebarMessageInfo | TogglePinMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo | ReactionMessageInfo; export type SidebarSourceMessageInfo = { +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessage: ComposableMessageInfo | RobotextMessageInfo, }; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo | ReactionMessageInfo | EditMessageInfo; export type ThreadMessageInfo = { messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp }; const threadMessageInfoValidator: TInterface = tShape({ messageIDs: t.list(tID), startReached: t.Boolean, lastNavigatedTo: t.Number, lastPruned: t.Number, }); // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = { +sendFailed?: boolean, }; const localMessageInfoValidator: TInterface = tShape({ sendFailed: t.maybe(t.Boolean), }); export type MessageStoreThreads = { +[threadID: string]: ThreadMessageInfo, }; const messageStoreThreadsValidator: TDict = t.dict( tID, threadMessageInfoValidator, ); export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, +threads: MessageStoreThreads, +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, }; export const messageStoreValidator: TInterface = tShape({ messages: t.dict(tID, rawMessageInfoValidator), threads: messageStoreThreadsValidator, local: t.dict(t.String, localMessageInfoValidator), currentAsOf: t.Number, }); -// MessageStore messages ops -export type RemoveMessageOperation = { - +type: 'remove', - +payload: { +ids: $ReadOnlyArray }, -}; - -export type RemoveMessagesForThreadsOperation = { - +type: 'remove_messages_for_threads', - +payload: { +threadIDs: $ReadOnlyArray }, -}; - -export type ReplaceMessageOperation = { - +type: 'replace', - +payload: { +id: string, +messageInfo: RawMessageInfo }, -}; - -export type RekeyMessageOperation = { - +type: 'rekey', - +payload: { +from: string, +to: string }, -}; - -export type RemoveAllMessagesOperation = { - +type: 'remove_all', -}; - -// MessageStore threads ops -export type ReplaceMessageStoreThreadsOperation = { - +type: 'replace_threads', - +payload: { +threads: MessageStoreThreads }, -}; - -export type RemoveMessageStoreThreadsOperation = { - +type: 'remove_threads', - +payload: { +ids: $ReadOnlyArray }, -}; - -export type RemoveMessageStoreAllThreadsOperation = { - +type: 'remove_all_threads', -}; - // We were initially using `number`s` for `thread`, `type`, `future_type`, etc. // However, we ended up changing `thread` to `string` to account for thread IDs // including information about the keyserver (eg 'GENESIS|123') in the future. // // At that point we discussed whether we should switch the remaining `number` // fields to `string`s for consistency and flexibility. We researched whether // there was any performance cost to using `string`s instead of `number`s and // found the differences to be negligible. We also concluded using `string`s // may be safer after considering `jsi::Number` and the various C++ number // representations on the CommCoreModule side. export type ClientDBMessageInfo = { +id: string, +local_id: ?string, +thread: string, +user: string, +type: string, +future_type: ?string, +content: ?string, +time: string, +media_infos: ?$ReadOnlyArray, }; -export type ClientDBReplaceMessageOperation = { - +type: 'replace', - +payload: ClientDBMessageInfo, -}; - export type ClientDBThreadMessageInfo = { +id: string, +start_reached: string, +last_navigated_to: string, +last_pruned: string, }; -export type ClientDBReplaceThreadsOperation = { - +type: 'replace_threads', - +payload: { +threads: $ReadOnlyArray }, -}; - -export type MessageStoreOperation = - | RemoveMessageOperation - | ReplaceMessageOperation - | RekeyMessageOperation - | RemoveMessagesForThreadsOperation - | RemoveAllMessagesOperation - | ReplaceMessageStoreThreadsOperation - | RemoveMessageStoreThreadsOperation - | RemoveMessageStoreAllThreadsOperation; - -export type ClientDBMessageStoreOperation = - | RemoveMessageOperation - | ClientDBReplaceMessageOperation - | RekeyMessageOperation - | RemoveMessagesForThreadsOperation - | RemoveAllMessagesOperation - | ClientDBReplaceThreadsOperation - | RemoveMessageStoreThreadsOperation - | RemoveMessageStoreAllThreadsOperation; - export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: MessageSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export const messageTruncationStatusValidator: TEnums = t.enums.of( values(messageTruncationStatus), ); export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export const messageTruncationStatusesValidator: TDict = t.dict(tID, messageTruncationStatusValidator); export type ThreadCursors = { +[threadID: string]: ?string }; export type MessageSelectionCriteria = { +threadCursors?: ?ThreadCursors, +joinedThreads?: ?boolean, +newerThan?: ?number, }; export type FetchMessageInfosRequest = { +cursors: ThreadCursors, +numberPerThread?: ?number, }; export type FetchMessageInfosResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +userInfos: UserInfos, }; export type FetchMessageInfosResult = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export type FetchMessageInfosPayload = { +threadID: string, +rawMessageInfos: $ReadOnlyArray, +truncationStatus: MessageTruncationStatus, }; export type MessagesResponse = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, +currentAsOf: number, }; export const messagesResponseValidator: TInterface = tShape({ rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, currentAsOf: t.Number, }); export type SimpleMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +truncationStatuses: MessageTruncationStatuses, }; export const defaultNumberPerThread = 20; export const defaultMaxMessageAge = 14 * 24 * 60 * 60 * 1000; // 2 weeks export type SendMessageResponse = { +newMessageInfo: RawMessageInfo, }; export type SendMessageResult = { +id: string, +time: number, +interface: CallServerEndpointResultInfoInterface, }; export type SendMessagePayload = { +localID: string, +serverID: string, +threadID: string, +time: number, +interface: CallServerEndpointResultInfoInterface, }; export type SendTextMessageRequest = { +threadID: string, +localID?: string, +text: string, +sidebarCreation?: boolean, }; export type SendMultimediaMessageRequest = // This option is only used for messageTypes.IMAGES | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaIDs: $ReadOnlyArray, } | { +threadID: string, +localID: string, +sidebarCreation?: boolean, +mediaMessageContents: $ReadOnlyArray, }; export type SendReactionMessageRequest = { +threadID: string, +localID?: string, +targetMessageID: string, +reaction: string, +action: 'add_reaction' | 'remove_reaction', }; export type SendEditMessageRequest = { +targetMessageID: string, +text: string, }; export type SendEditMessageResponse = { +newMessageInfos: $ReadOnlyArray, }; export type EditMessagePayload = SendEditMessageResponse; export type SendEditMessageResult = SendEditMessageResponse; export type EditMessageContent = { +text: string, }; // Used for the message info included in log-in type actions export type GenericMessagesResult = { +messageInfos: RawMessageInfo[], +truncationStatus: MessageTruncationStatuses, +watchedIDsAtRequestTime: $ReadOnlyArray, +currentAsOf: number, }; export type SaveMessagesPayload = { +rawMessageInfos: $ReadOnlyArray, +updatesCurrentAsOf: number, }; export type NewMessagesPayload = { +messagesResult: MessagesResponse, }; export const newMessagesPayloadValidator: TInterface = tShape({ messagesResult: messagesResponseValidator, }); export type MessageStorePrunePayload = { +threadIDs: $ReadOnlyArray, }; export type FetchPinnedMessagesRequest = { +threadID: string, }; export type FetchPinnedMessagesResult = { +pinnedMessages: $ReadOnlyArray, }; export type SearchMessagesRequest = { +query: string, +threadID: string, +cursor?: string, }; export type SearchMessagesResponse = { +messages: $ReadOnlyArray, +endReached: boolean, }; diff --git a/lib/types/store-ops-types.js b/lib/types/store-ops-types.js index 136c4bd7f..7b7b47658 100644 --- a/lib/types/store-ops-types.js +++ b/lib/types/store-ops-types.js @@ -1,45 +1,47 @@ // @flow import type { DraftStoreOperation, ClientDBDraftStoreOperation, ClientDBDraftInfo, } from './draft-types.js'; import type { ClientDBMessageInfo, - ClientDBMessageStoreOperation, - MessageStoreOperation, ClientDBThreadMessageInfo, } from './message-types.js'; import type { ClientDBThreadInfo } from './thread-types.js'; +import type { + ClientDBMessageStoreOperation, + MessageStoreOperation, +} from '../ops/message-store-ops.js'; import type { ReportStoreOperation, ClientDBReport, ClientDBReportStoreOperation, } from '../ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation, ThreadStoreOperation, } from '../ops/thread-store-ops.js'; export type StoreOperations = { +draftStoreOperations: $ReadOnlyArray, +threadStoreOperations: $ReadOnlyArray, +messageStoreOperations: $ReadOnlyArray, +reportStoreOperations: $ReadOnlyArray, }; export type ClientDBStoreOperations = { +draftStoreOperations?: $ReadOnlyArray, +threadStoreOperations?: $ReadOnlyArray, +messageStoreOperations?: $ReadOnlyArray, +reportStoreOperations?: $ReadOnlyArray, }; export type ClientDBStore = { +messages: $ReadOnlyArray, +drafts: $ReadOnlyArray, +threads: $ReadOnlyArray, +messageStoreThreads: $ReadOnlyArray, +reports: $ReadOnlyArray, }; diff --git a/lib/utils/message-ops-utils.js b/lib/utils/message-ops-utils.js index 4895fb075..d4a03e82a 100644 --- a/lib/utils/message-ops-utils.js +++ b/lib/utils/message-ops-utils.js @@ -1,364 +1,366 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; import { contentStringForMediaArray, encryptedMediaBlobURI, encryptedVideoThumbnailBlobURI, } from '../media/media-utils.js'; +import type { + ClientDBMessageStoreOperation, + MessageStoreOperation, +} from '../ops/message-store-ops.js'; import { messageID } from '../shared/message-utils.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import type { EncryptedVideo, Media, ClientDBMediaInfo, Image, Video, } from '../types/media-types'; import { messageTypes, assertMessageType, } from '../types/message-types-enum.js'; import { type ClientDBMessageInfo, type RawMessageInfo, - type MessageStoreOperation, - type ClientDBMessageStoreOperation, type ClientDBThreadMessageInfo, type ThreadMessageInfo, type MessageStoreThreads, } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; function translateMediaToClientDBMediaInfos( media: $ReadOnlyArray, ): $ReadOnlyArray { const clientDBMediaInfos = []; for (const m of media) { const type = m.type === 'encrypted_photo' ? 'photo' : m.type === 'encrypted_video' ? 'video' : m.type; const mediaURI = m.type === 'encrypted_photo' || m.type === 'encrypted_video' ? encryptedMediaBlobURI(m) : m.uri; clientDBMediaInfos.push({ id: m.id, uri: mediaURI, type: type, extras: JSON.stringify({ dimensions: m.dimensions, loop: type === 'video' ? m.loop : false, local_media_selection: m.localMediaSelection, encryption_key: m.encryptionKey, thumb_hash: m.thumbHash ?? undefined, }), }); if (m.type === 'video' || m.type === 'encrypted_video') { const thumbnailURI = m.type === 'encrypted_video' ? encryptedVideoThumbnailBlobURI(m) : m.thumbnailURI; clientDBMediaInfos.push({ id: m.thumbnailID, uri: thumbnailURI, type: 'photo', extras: JSON.stringify({ dimensions: m.dimensions, loop: false, encryption_key: m.thumbnailEncryptionKey, thumb_hash: m.thumbnailThumbHash ?? undefined, }), }); } } return clientDBMediaInfos; } function translateClientDBMediaInfoToImage( clientDBMediaInfo: ClientDBMediaInfo, ): Image { const { dimensions, local_media_selection, thumb_hash } = JSON.parse( clientDBMediaInfo.extras, ); if (!local_media_selection) { return { id: clientDBMediaInfo.id, uri: clientDBMediaInfo.uri, type: 'photo', dimensions: dimensions, thumbHash: thumb_hash, }; } return { id: clientDBMediaInfo.id, uri: clientDBMediaInfo.uri, type: 'photo', dimensions: dimensions, localMediaSelection: local_media_selection, thumbHash: thumb_hash, }; } function translateClientDBMediaInfosToMedia( clientDBMessageInfo: ClientDBMessageInfo, ): $ReadOnlyArray { if (parseInt(clientDBMessageInfo.type) === messageTypes.IMAGES) { if (!clientDBMessageInfo.media_infos) { return []; } return clientDBMessageInfo.media_infos.map( translateClientDBMediaInfoToImage, ); } if ( !clientDBMessageInfo.media_infos || clientDBMessageInfo.media_infos.length === 0 ) { return []; } const mediaInfos: $ReadOnlyArray = clientDBMessageInfo.media_infos; const mediaMap = _keyBy('id')(mediaInfos); if (!clientDBMessageInfo.content) { return []; } const messageContent: $ReadOnlyArray = JSON.parse(clientDBMessageInfo.content); const translatedMedia: Media[] = []; for (const media of messageContent) { if (media.type === 'photo') { const extras = JSON.parse(mediaMap[media.uploadID].extras); const { dimensions, encryption_key: encryptionKey, thumb_hash: thumbHash, } = extras; let image; if (encryptionKey) { image = { id: media.uploadID, type: 'encrypted_photo', blobURI: mediaMap[media.uploadID].uri, dimensions, encryptionKey, thumbHash, }; } else { image = { id: media.uploadID, type: 'photo', uri: mediaMap[media.uploadID].uri, dimensions, thumbHash, }; } translatedMedia.push(image); } else if (media.type === 'video') { const extras = JSON.parse(mediaMap[media.uploadID].extras); const { dimensions, loop, local_media_selection: localMediaSelection, encryption_key: encryptionKey, } = extras; const { encryption_key: thumbnailEncryptionKey, thumb_hash: thumbnailThumbHash, } = JSON.parse(mediaMap[media.thumbnailUploadID].extras); if (encryptionKey) { const video: EncryptedVideo = { id: media.uploadID, type: 'encrypted_video', blobURI: mediaMap[media.uploadID].uri, dimensions, loop, encryptionKey, thumbnailID: media.thumbnailUploadID, thumbnailBlobURI: mediaMap[media.thumbnailUploadID].uri, thumbnailEncryptionKey, thumbnailThumbHash, }; translatedMedia.push(video); } else { const video: Video = { id: media.uploadID, uri: mediaMap[media.uploadID].uri, type: 'video', dimensions, loop, thumbnailID: media.thumbnailUploadID, thumbnailURI: mediaMap[media.thumbnailUploadID].uri, thumbnailThumbHash, }; translatedMedia.push( localMediaSelection ? { ...video, localMediaSelection } : video, ); } } } return translatedMedia; } function translateRawMessageInfoToClientDBMessageInfo( rawMessageInfo: RawMessageInfo, ): ClientDBMessageInfo { return { id: messageID(rawMessageInfo), local_id: rawMessageInfo.localID ? rawMessageInfo.localID : null, thread: rawMessageInfo.threadID, user: rawMessageInfo.creatorID, type: rawMessageInfo.type.toString(), future_type: rawMessageInfo.type === messageTypes.UNSUPPORTED ? rawMessageInfo.unsupportedMessageInfo.type.toString() : null, time: rawMessageInfo.time.toString(), content: messageSpecs[rawMessageInfo.type].messageContentForClientDB?.( rawMessageInfo, ), media_infos: rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ? translateMediaToClientDBMediaInfos(rawMessageInfo.media) : null, }; } function translateClientDBMessageInfoToRawMessageInfo( clientDBMessageInfo: ClientDBMessageInfo, ): RawMessageInfo { return messageSpecs[ assertMessageType(parseInt(clientDBMessageInfo.type)) ].rawMessageInfoFromClientDB(clientDBMessageInfo); } function translateClientDBMessageInfosToRawMessageInfos( clientDBMessageInfos: $ReadOnlyArray, ): { +[id: string]: RawMessageInfo } { return Object.fromEntries( clientDBMessageInfos.map((dbMessageInfo: ClientDBMessageInfo) => [ dbMessageInfo.id, translateClientDBMessageInfoToRawMessageInfo(dbMessageInfo), ]), ); } type TranslatedThreadMessageInfo = { +startReached: boolean, +lastNavigatedTo: number, +lastPruned: number, }; export type TranslatedThreadMessageInfos = { +[threadID: string]: TranslatedThreadMessageInfo, }; function translateClientDBThreadMessageInfos( clientDBThreadMessageInfo: $ReadOnlyArray, ): TranslatedThreadMessageInfos { return Object.fromEntries( clientDBThreadMessageInfo.map((threadInfo: ClientDBThreadMessageInfo) => [ threadInfo.id, { startReached: threadInfo.start_reached === '1', lastNavigatedTo: parseInt(threadInfo.last_navigated_to), lastPruned: parseInt(threadInfo.last_pruned), }, ]), ); } function translateThreadMessageInfoToClientDBThreadMessageInfo( id: string, threadMessageInfo: ThreadMessageInfo | TranslatedThreadMessageInfo, ): ClientDBThreadMessageInfo { const startReached = threadMessageInfo.startReached ? 1 : 0; const lastNavigatedTo = threadMessageInfo.lastNavigatedTo ?? 0; const lastPruned = threadMessageInfo.lastPruned ?? 0; return { id, start_reached: startReached.toString(), last_navigated_to: lastNavigatedTo.toString(), last_pruned: lastPruned.toString(), }; } function convertMessageStoreOperationsToClientDBOperations( messageStoreOperations: $ReadOnlyArray, ): $ReadOnlyArray { const convertedOperations = messageStoreOperations.map( messageStoreOperation => { if (messageStoreOperation.type === 'replace') { return { type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo( messageStoreOperation.payload.messageInfo, ), }; } if (messageStoreOperation.type !== 'replace_threads') { return messageStoreOperation; } const threadMessageInfo: MessageStoreThreads = messageStoreOperation.payload.threads; const dbThreadMessageInfos: ClientDBThreadMessageInfo[] = []; for (const threadID in threadMessageInfo) { dbThreadMessageInfos.push( translateThreadMessageInfoToClientDBThreadMessageInfo( threadID, threadMessageInfo[threadID], ), ); } if (dbThreadMessageInfos.length === 0) { return undefined; } return { type: 'replace_threads', payload: { threads: dbThreadMessageInfos, }, }; }, ); return convertedOperations.filter(Boolean); } function getPinnedContentFromClientDBMessageInfo( clientDBMessageInfo: ClientDBMessageInfo, ): string { const { media_infos } = clientDBMessageInfo; let pinnedContent; if (!media_infos || media_infos.length === 0) { pinnedContent = 'a message'; } else { const media = translateClientDBMediaInfosToMedia(clientDBMessageInfo); pinnedContent = contentStringForMediaArray(media); } return pinnedContent; } export { translateClientDBMediaInfoToImage, translateRawMessageInfoToClientDBMessageInfo, translateClientDBMessageInfoToRawMessageInfo, translateClientDBMessageInfosToRawMessageInfos, convertMessageStoreOperationsToClientDBOperations, translateClientDBMediaInfosToMedia, getPinnedContentFromClientDBMessageInfo, translateClientDBThreadMessageInfos, translateThreadMessageInfoToClientDBThreadMessageInfo, }; diff --git a/native/redux/client-db-utils.js b/native/redux/client-db-utils.js index 3a2f9655b..111d1cff3 100644 --- a/native/redux/client-db-utils.js +++ b/native/redux/client-db-utils.js @@ -1,162 +1,162 @@ // @flow +import type { ClientDBMessageStoreOperation } from 'lib/ops/message-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import type { - ClientDBMessageStoreOperation, RawMessageInfo, ClientDBMessageInfo, ClientDBThreadMessageInfo, } from 'lib/types/message-types.js'; import type { ClientDBThreadInfo, RawThreadInfo, ThreadStoreThreadInfos, } from 'lib/types/thread-types.js'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, translateClientDBThreadMessageInfos, translateThreadMessageInfoToClientDBThreadMessageInfo, type TranslatedThreadMessageInfos, } from 'lib/utils/message-ops-utils.js'; import { values, entries } from 'lib/utils/objects.js'; import { convertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, } from 'lib/utils/thread-ops-utils.js'; import type { AppState } from './state-types.js'; import { commCoreModule } from '../native-modules.js'; function updateClientDBThreadStoreThreadInfos( state: AppState, migrationFunc: ThreadStoreThreadInfos => ThreadStoreThreadInfos, ): AppState { // Get threads from SQLite `threads` table. const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const operations = createUpdateDBOpsForThreadStoreThreadInfos( clientDBThreadInfos, migrationFunc, ); // Try processing `ClientDBThreadStoreOperation`s and log out if // `processThreadStoreOperationsSync(...)` throws an exception. try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); const keyserverInfos = { ...state.keyserverStore.keyserverInfos }; for (const key in keyserverInfos) { keyserverInfos[key] = { ...keyserverInfos[key], cookie: null }; } const keyserverStore = { ...state.keyserverStore, keyserverInfos }; return { ...state, keyserverStore, }; } return state; } function createUpdateDBOpsForThreadStoreThreadInfos( clientDBThreadInfos: $ReadOnlyArray, migrationFunc: ThreadStoreThreadInfos => ThreadStoreThreadInfos, ): $ReadOnlyArray { // Translate `ClientDBThreadInfo`s to `RawThreadInfo`s. const rawThreadInfos = clientDBThreadInfos.map( convertClientDBThreadInfoToRawThreadInfo, ); // Convert `rawThreadInfo`s to a map of `threadID` => `threadInfo`. const threadIDToThreadInfo = rawThreadInfos.reduce((acc, threadInfo) => { acc[threadInfo.id] = threadInfo; return acc; }, {}); // Apply `migrationFunc` to `threadInfo`s. const updatedThreadIDToThreadInfo: ThreadStoreThreadInfos = migrationFunc(threadIDToThreadInfo); // Convert the updated `threadInfo`s back into an array. const updatedRawThreadInfos: $ReadOnlyArray = values( updatedThreadIDToThreadInfo, ); // Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. const convertedClientDBThreadInfos: $ReadOnlyArray = updatedRawThreadInfos.map(convertRawThreadInfoToClientDBThreadInfo); // Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` // table and repopulate with `ClientDBThreadInfo`s. return [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; } function createUpdateDBOpsForMessageStoreMessages( clientDBMessageInfos: $ReadOnlyArray, migrationFunc: ( $ReadOnlyArray, ) => $ReadOnlyArray, ): $ReadOnlyArray { const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); const convertedRawMessageInfos = migrationFunc(rawMessageInfos); const replaceMessagesOperations: $ReadOnlyArray = convertedRawMessageInfos.map(messageInfo => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messageInfo), })); return [ { type: 'remove_all', }, ...replaceMessagesOperations, ]; } function createUpdateDBOpsForMessageStoreThreads( messageStoreThreads: $ReadOnlyArray, migrationFunc: TranslatedThreadMessageInfos => TranslatedThreadMessageInfos, ): $ReadOnlyArray { const translatedMessageStoreThreads = translateClientDBThreadMessageInfos(messageStoreThreads); const convertedTranslatedMessageStoreThreads = migrationFunc( translatedMessageStoreThreads, ); return [ { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads: entries(convertedTranslatedMessageStoreThreads).map( ([id, thread]) => translateThreadMessageInfoToClientDBThreadMessageInfo(id, thread), ), }, }, ]; } export { updateClientDBThreadStoreThreadInfos, createUpdateDBOpsForThreadStoreThreadInfos, createUpdateDBOpsForMessageStoreMessages, createUpdateDBOpsForMessageStoreThreads, }; diff --git a/native/redux/persist.js b/native/redux/persist.js index 39c4860b9..f24f4b6b0 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,771 +1,771 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createTransform } from 'redux-persist'; import type { Transform } from 'redux-persist/es/types.js'; import { convertEntryStoreToNewIDSchema, convertInviteLinksStoreToNewIDSchema, convertMessageStoreToNewIDSchema, convertRawMessageInfoToNewIDSchema, convertCalendarFilterToNewIDSchema, convertConnectionInfoToNewIDSchema, } from 'lib/_generated/migration-utils.js'; +import type { ClientDBMessageStoreOperation } from 'lib/ops/message-store-ops.js'; import { type ReportStoreOperation, type ClientDBReportStoreOperation, convertReportStoreOperationToClientDBReportStoreOperation, convertReportsToReplaceReportOps, } from 'lib/ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors.js'; import { createAsyncMigrate } from 'lib/shared/create-async-migrate.js'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils.js'; import { getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { DEPRECATED_unshimMessageStore, unshimFunc, } from 'lib/shared/unshim-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type LocalMessageInfo, type MessageStore, - type ClientDBMessageStoreOperation, } from 'lib/types/message-types.js'; import type { ReportStore, ClientReportCreationRequest, } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import type { ClientDBThreadInfo } from 'lib/types/thread-types.js'; import { convertMessageStoreOperationsToClientDBOperations, translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { convertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, convertThreadStoreOperationsToClientDBOperations, } from 'lib/utils/thread-ops-utils.js'; import { getUUID } from 'lib/utils/uuid.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateClientDBThreadStoreThreadInfos, createUpdateDBOpsForThreadStoreThreadInfos, createUpdateDBOpsForMessageStoreMessages, createUpdateDBOpsForMessageStoreThreads, } from './client-db-utils.js'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js'; import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js'; import type { AppState } from './state-types.js'; import { unshimClientDB } from './unshim-utils.js'; import { updateRolesAndPermissions } from './update-roles-and-permissions.js'; import { commCoreModule } from '../native-modules.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { defaultGlobalThemeInfo } from '../types/themes.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: state => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: state => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: state => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: AppState) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: AppState) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.IMAGES, ]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: state => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: state => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: state => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: state => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: state => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: state => { for (const key in state.drafts) { const value = state.drafts[key]; try { commCoreModule.updateDraft(key, value); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: state => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: state => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: state => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: state => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: state => { const threadParentToChildren = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions( state.threadStore.threadInfos, ); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( convertThreadStoreOperationsToClientDBOperations(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [33]: (state: AppState) => unshimClientDB(state, [messageTypes.REACTION]), [34]: state => { const { threadIDsToNotifIDs, ...stateSansThreadIDsToNotifIDs } = state; return stateSansThreadIDsToNotifIDs; }, [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [36]: (state: AppState) => { // 1. Get threads and messages from SQLite `threads` and `messages` tables. const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and // `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawThreadInfos = clientDBThreadInfos.map( convertClientDBThreadInfoToRawThreadInfo, ); const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])), ); // 4. Filter out non-TOGGLE_PIN messages const filteredRawMessageInfos = unshimmedRawMessageInfos.filter( messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN, ); // 5. We want only the last TOGGLE_PIN message for each message ID, // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1. const lastMessageIDToRawMessageInfoMap = new Map(); for (const messageInfo of filteredRawMessageInfos) { const { targetMessageID } = messageInfo; lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo); } const lastMessageIDToRawMessageInfos = Array.from( lastMessageIDToRawMessageInfoMap.values(), ); // 6. Create a Map of threadIDs to pinnedCount const threadIDsToPinnedCount = new Map(); for (const messageInfo of lastMessageIDToRawMessageInfos) { const { threadID, type } = messageInfo; if (type === messageTypes.TOGGLE_PIN) { const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0; threadIDsToPinnedCount.set(threadID, pinnedCount + 1); } } // 7. Include a pinnedCount for each rawThreadInfo const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({ ...threadInfo, pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0, })); // 8. Convert rawThreadInfos to a map of threadID to threadInfo const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce( (acc, threadInfo) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); // 9. Add threadPermission to each threadInfo const rawThreadInfosWithThreadPermission = persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo); // 10. Convert the new threadInfos back into an array const rawThreadInfosWithCountAndPermission = Object.keys( rawThreadInfosWithThreadPermission, ).map(id => rawThreadInfosWithThreadPermission[id]); // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. const convertedClientDBThreadInfos = rawThreadInfosWithCountAndPermission.map( convertRawThreadInfoToClientDBThreadInfo, ); // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` // table and repopulate with `ClientDBThreadInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; // 13. Try processing `ClientDBThreadStoreOperation`s and log out if // `processThreadStoreOperationsSync(...)` throws an exception. try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, [37]: state => { const operations = convertMessageStoreOperationsToClientDBOperations([ { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads: state.messageStore.threads }, }, ]); try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.error(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [38]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [39]: (state: AppState) => unshimClientDB(state, [messageTypes.EDIT_MESSAGE]), [40]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [41]: (state: AppState) => { const queuedReports = state.reportStore.queuedReports.map(report => ({ ...report, id: getUUID(), })); return { ...state, reportStore: { ...state.reportStore, queuedReports }, }; }, [42]: (state: AppState) => { const reportStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_reports' }, ...convertReportsToReplaceReportOps(state.reportStore.queuedReports), ]; const dbOperations: $ReadOnlyArray = convertReportStoreOperationToClientDBReportStoreOperation( reportStoreOperations, ); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [43]: async (state: AppState) => { const { messages, drafts, threads, messageStoreThreads } = await commCoreModule.getClientDBStore(); const messageStoreThreadsOperations = createUpdateDBOpsForMessageStoreThreads( messageStoreThreads, convertMessageStoreThreadsToNewIDSchema, ); const messageStoreMessagesOperations = createUpdateDBOpsForMessageStoreMessages(messages, messageInfos => messageInfos.map(convertRawMessageInfoToNewIDSchema), ); const threadOperations = createUpdateDBOpsForThreadStoreThreadInfos( threads, convertThreadStoreThreadInfosToNewIDSchema, ); const draftOperations = generateIDSchemaMigrationOpsForDrafts(drafts); try { await Promise.all([ commCoreModule.processMessageStoreOperations([ ...messageStoreMessagesOperations, ...messageStoreThreadsOperations, ]), commCoreModule.processThreadStoreOperations(threadOperations), commCoreModule.processDraftStoreOperations(draftOperations), ]); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return { ...state, entryStore: convertEntryStoreToNewIDSchema(state.entryStore), messageStore: convertMessageStoreToNewIDSchema(state.messageStore), calendarFilters: state.calendarFilters.map( convertCalendarFilterToNewIDSchema, ), connection: convertConnectionInfoToNewIDSchema(state.connection), watchedThreadIDs: state.watchedThreadIDs.map( id => `${ashoatKeyserverID}|${id}`, ), inviteLinksStore: convertInviteLinksStoreToNewIDSchema( state.inviteLinksStore, ), }; }, [44]: async state => { const { cookie, ...rest } = state; return { ...rest, keyserverStore: { keyserverInfos: { [ashoatKeyserverID]: { cookie } } }, }; }, }; // After migration 31, we'll no longer want to persist `messageStore.messages` // via redux-persist. However, we DO want to continue persisting everything in // `messageStore` EXCEPT for `messages`. The `blacklist` property in // `persistConfig` allows us to specify top-level keys that shouldn't be // persisted. However, we aren't able to specify nested keys in `blacklist`. // As a result, if we want to prevent nested keys from being persisted we'll // need to use `createTransform(...)` to specify an `inbound` function that // allows us to modify the `state` object before it's passed through // `JSON.stringify(...)` and written to disk. We specify the keys for which // this transformation should be executed in the `whitelist` property of the // `config` object that's passed to `createTransform(...)`. // eslint-disable-next-line no-unused-vars type PersistedThreadMessageInfo = { +startReached: boolean, +lastNavigatedTo: number, +lastPruned: number, }; type PersistedMessageStore = { +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, +threads: { +[threadID: string]: PersistedThreadMessageInfo }, }; const messageStoreMessagesBlocklistTransform: Transform = createTransform( (state: MessageStore): PersistedMessageStore => { const { messages, threads, ...messageStoreSansMessages } = state; // We also do not want to persist `messageStore.threads[ID].messageIDs` // because they can be deterministically computed based on messages we have // from SQLite const threadsToPersist = {}; for (const threadID in threads) { const { messageIDs, ...threadsData } = threads[threadID]; threadsToPersist[threadID] = threadsData; } return { ...messageStoreSansMessages, threads: threadsToPersist }; }, (state: MessageStore): MessageStore => { const { threads: persistedThreads, ...messageStore } = state; const threads = {}; for (const threadID in persistedThreads) { threads[threadID] = { ...persistedThreads[threadID], messageIDs: [] }; } // We typically expect `messageStore.messages` to be `undefined` because // messages are persisted in the SQLite `messages` table rather than via // `redux-persist`. In this case we want to set `messageStore.messages` // to {} so we don't run into issues with `messageStore.messages` being // `undefined` (https://phab.comm.dev/D5545). // // However, in the case that a user is upgrading from a client where // `persistConfig.version` < 31, we expect `messageStore.messages` to // contain messages stored via `redux-persist` that we need in order // to correctly populate the SQLite `messages` table in migration 31 // (https://phab.comm.dev/D2600). // // However, because `messageStoreMessagesBlocklistTransform` modifies // `messageStore` before migrations are run, we need to make sure we aren't // inadvertently clearing `messageStore.messages` (by setting to {}) before // messages are stored in SQLite (https://linear.app/comm/issue/ENG-2377). return { ...messageStore, threads, messages: messageStore.messages ?? {} }; }, { whitelist: ['messageStore'] }, ); type PersistedReportStore = $Diff< ReportStore, { +queuedReports: $ReadOnlyArray }, >; const reportStoreTransform: Transform = createTransform( (state: ReportStore): PersistedReportStore => { return { enabledReports: state.enabledReports }; }, (state: PersistedReportStore): ReportStore => { return { ...state, queuedReports: [] }; }, { whitelist: ['reportStore'] }, ); const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'draftStore', 'connectivity', 'deviceOrientation', 'frozen', 'threadStore', 'storeLoaded', ], debug: __DEV__, version: 44, transforms: [messageStoreMessagesBlocklistTransform, reportStoreTransform], migrate: (createAsyncMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): empty { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/redux/unshim-utils.js b/native/redux/unshim-utils.js index 02127ff25..e793cab01 100644 --- a/native/redux/unshim-utils.js +++ b/native/redux/unshim-utils.js @@ -1,71 +1,69 @@ // @flow +import type { ClientDBMessageStoreOperation } from 'lib/ops/message-store-ops.js'; import { unshimFunc } from 'lib/shared/unshim-utils.js'; import { type MessageType } from 'lib/types/message-types-enum.js'; -import type { - ClientDBMessageStoreOperation, - ClientDBMessageInfo, -} from 'lib/types/message-types.js'; +import type { ClientDBMessageInfo } from 'lib/types/message-types.js'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils.js'; import type { AppState } from './state-types.js'; import { commCoreModule } from '../native-modules.js'; function unshimClientDB( state: AppState, unshimTypes: $ReadOnlyArray, ): AppState { // 1. Get messages from SQLite `messages` table. const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. "Unshim" translated `RawMessageInfo`s. const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set(unshimTypes)), ); // 4. Translate unshimmed `RawMessageInfo`s back to `ClientDBMessageInfo`s. const unshimmedClientDBMessageInfos = unshimmedRawMessageInfos.map( translateRawMessageInfoToClientDBMessageInfo, ); // 5. Construct `ClientDBMessageStoreOperation`s to clear SQLite `messages` // table and repopulate with unshimmed `ClientDBMessageInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...unshimmedClientDBMessageInfos.map((message: ClientDBMessageInfo) => ({ type: 'replace', payload: message, })), ]; // 6. Try processing `ClientDBMessageStoreOperation`s and log out if // `processMessageStoreOperationsSync(...)` throws an exception. try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); const keyserverInfos = { ...state.keyserverStore.keyserverInfos }; for (const key in keyserverInfos) { keyserverInfos[key] = { ...keyserverInfos[key], cookie: null }; } const keyserverStore = { ...state.keyserverStore, keyserverInfos }; return { ...state, keyserverStore, }; } return state; } export { unshimClientDB }; diff --git a/native/schema/CommCoreModuleSchema.js b/native/schema/CommCoreModuleSchema.js index 62b568a24..e512fc60b 100644 --- a/native/schema/CommCoreModuleSchema.js +++ b/native/schema/CommCoreModuleSchema.js @@ -1,126 +1,124 @@ // @flow 'use strict'; import { TurboModuleRegistry } from 'react-native'; import type { TurboModule } from 'react-native/Libraries/TurboModule/RCTExport.js'; +import type { ClientDBMessageStoreOperation } from 'lib/ops/message-store-ops.js'; import type { ClientDBReportStoreOperation } from 'lib/ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import type { OLMOneTimeKeys } from 'lib/types/crypto-types'; import type { ClientDBDraftStoreOperation } from 'lib/types/draft-types.js'; -import type { - ClientDBMessageInfo, - ClientDBMessageStoreOperation, -} from 'lib/types/message-types.js'; +import type { ClientDBMessageInfo } from 'lib/types/message-types.js'; import type { ClientDBStore } from 'lib/types/store-ops-types'; import type { ClientDBThreadInfo } from 'lib/types/thread-types.js'; type ClientPublicKeys = { +primaryIdentityPublicKeys: { +ed25519: string, +curve25519: string, }, +notificationIdentityPublicKeys: { +ed25519: string, +curve25519: string, }, +blobPayload: string, +signature: string, }; export interface Spec extends TurboModule { +getDraft: (key: string) => Promise; +updateDraft: (key: string, text: string) => Promise; +moveDraft: (oldKey: string, newKey: string) => Promise; +getClientDBStore: () => Promise; +removeAllDrafts: () => Promise; +getAllMessagesSync: () => $ReadOnlyArray; +processDraftStoreOperations: ( operations: $ReadOnlyArray, ) => Promise; +processMessageStoreOperations: ( operations: $ReadOnlyArray, ) => Promise; +processMessageStoreOperationsSync: ( operations: $ReadOnlyArray, ) => void; +getAllThreadsSync: () => $ReadOnlyArray; +processThreadStoreOperations: ( operations: $ReadOnlyArray, ) => Promise; +processReportStoreOperations: ( operations: $ReadOnlyArray, ) => Promise; +processReportStoreOperationsSync: ( operations: $ReadOnlyArray, ) => void; +processThreadStoreOperationsSync: ( operations: $ReadOnlyArray, ) => void; +initializeCryptoAccount: () => Promise; +getUserPublicKey: () => Promise; +getPrimaryOneTimeKeys: ( oneTimeKeysAmount: number, ) => Promise; +initializeNotificationsSession: ( identityKeys: string, prekey: string, prekeySignature: string, oneTimeKeys: string, ) => Promise; +isNotificationsSessionInitialized: () => Promise; +getCodeVersion: () => number; +terminate: () => void; +setNotifyToken: (token: string) => Promise; +clearNotifyToken: () => Promise; +setCurrentUserID: (userID: string) => Promise; +getCurrentUserID: () => Promise; +setDeviceID: (deviceType: string) => Promise; +getDeviceID: () => Promise; +clearSensitiveData: () => Promise; +checkIfDatabaseNeedsDeletion: () => boolean; +reportDBOperationsFailure: () => void; +generateNonce: () => Promise; +registerUser: ( username: string, password: string, keyPayload: string, keyPayloadSignature: string, contentPrekey: string, contentPrekeySignature: string, notifPrekey: string, notifPrekeySignature: string, contentOneTimeKeys: $ReadOnlyArray, notifOneTimeKeys: $ReadOnlyArray, ) => Promise; +loginPasswordUser: ( username: string, password: string, keyPayload: string, keyPayloadSignature: string, contentPrekey: string, contentPrekeySignature: string, notifPrekey: string, notifPrekeySignature: string, contentOneTimeKeys: $ReadOnlyArray, notifOneTimeKeys: $ReadOnlyArray, ) => Promise; +loginWalletUser: ( siweMessage: string, siweSignature: string, keyPayload: string, keyPayloadSignature: string, contentPrekey: string, contentPrekeySignature: string, notifPrekey: string, notifPrekeySignature: string, contentOneTimeKeys: $ReadOnlyArray, notifOneTimeKeys: $ReadOnlyArray, socialProof: string, ) => Promise; } export default (TurboModuleRegistry.getEnforcing( 'CommTurboModule', ): Spec);