diff --git a/lib/reducers/calendar-filters-reducer.js b/lib/reducers/calendar-filters-reducer.js index ab098afe8..484bd4fcd 100644 --- a/lib/reducers/calendar-filters-reducer.js +++ b/lib/reducers/calendar-filters-reducer.js @@ -1,235 +1,238 @@ // @flow import { updateCalendarCommunityFilter, clearCalendarCommunityFilter, } from '../actions/community-actions.js'; import { legacySiweAuthActionTypes } from '../actions/siwe-actions.js'; import { newThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, deleteThreadActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { keyserverAuthActionTypes, deleteKeyserverAccountActionTypes, legacyLogInActionTypes, legacyKeyserverRegisterActionTypes, } from '../actions/user-actions.js'; import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { filteredThreadIDs, nonThreadCalendarFilters, nonExcludeDeletedCalendarFilters, } from '../selectors/calendar-filter-selectors.js'; import { threadInFilterList } from '../shared/thread-utils.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; +import { processDMOpsActionType } from '../types/dm-ops.js'; import { type CalendarFilter, defaultCalendarFilters, updateCalendarThreadFilter, clearCalendarThreadFilter, setCalendarDeletedFilter, calendarThreadFilterTypes, } from '../types/filter-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, stateSyncPayloadTypes, } from '../types/socket-types.js'; import type { RawThreadInfos, ThreadStore } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { filterThreadIDsBelongingToCommunity } from '../utils/drawer-utils.react.js'; export default function reduceCalendarFilters( state: $ReadOnlyArray, action: BaseAction, threadStore: ThreadStore, ): $ReadOnlyArray { if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success || action.type === legacyKeyserverRegisterActionTypes.success ) { return defaultCalendarFilters; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated ) { return removeKeyserverThreadIDsFromFilterList(state, [ action.payload.keyserverID, ]); } else if (action.type === deleteKeyserverAccountActionTypes.success) { return removeKeyserverThreadIDsFromFilterList( state, action.payload.keyserverIDs, ); } else if (action.type === keyserverAuthActionTypes.success) { const keyserverIDs = Object.keys(action.payload.updatesCurrentAsOf); return removeKeyserverThreadIDsFromFilterList(state, keyserverIDs); } else if (action.type === updateCalendarThreadFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: action.payload.threadIDs, }, ]; } else if (action.type === clearCalendarThreadFilter) { return nonThreadCalendarFilters(state); } else if (action.type === setCalendarDeletedFilter) { const otherFilters = nonExcludeDeletedCalendarFilters(state); if (action.payload.includeDeleted && otherFilters.length === state.length) { // Attempting to remove NOT_DELETED filter, but it doesn't exist return state; } else if (action.payload.includeDeleted) { // Removing NOT_DELETED filter return otherFilters; } else if (otherFilters.length < state.length) { // Attempting to add NOT_DELETED filter, but it already exists return state; } else { // Adding NOT_DELETED filter return [...state, { type: calendarThreadFilterTypes.NOT_DELETED }]; } } else if ( action.type === newThreadActionTypes.success || action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === processUpdatesActionType ) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === incrementalStateSyncActionType) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.INCREMENTAL ) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === fullStateSyncActionType) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.FULL ) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } else if (action.type === updateCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); const threadIDs = Array.from( filterThreadIDsBelongingToCommunity( action.payload, threadStore.threadInfos, ), ); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs, }, ]; } else if (action.type === clearCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return nonThreadFilters; + } else if (action.type === processDMOpsActionType) { + return updateFilterListFromUpdateInfos(state, action.payload.updateInfos); } return state; } function updateFilterListFromUpdateInfos( state: $ReadOnlyArray, updateInfos: $ReadOnlyArray, ): $ReadOnlyArray { const currentlyFilteredIDs: ?$ReadOnlySet = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const newFilteredThreadIDs = updateInfos.reduce( (reducedFilteredThreadIDs, update) => { const { reduceCalendarThreadFilters } = updateSpecs[update.type]; return reduceCalendarThreadFilters ? reduceCalendarThreadFilters(reducedFilteredThreadIDs, update) : reducedFilteredThreadIDs; }, currentlyFilteredIDs, ); if (currentlyFilteredIDs !== newFilteredThreadIDs) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: [...newFilteredThreadIDs] }, ]; } return state; } function filterThreadIDsInFilterList( state: $ReadOnlyArray, filterCondition: (threadID: string) => boolean, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const filtered = [...currentlyFilteredIDs].filter(filterCondition); if (filtered.length < currentlyFilteredIDs.size) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: filtered }, ]; } return state; } function removeDeletedThreadIDsFromFilterList( state: $ReadOnlyArray, threadInfos: RawThreadInfos, ): $ReadOnlyArray { const filterCondition = (threadID: string) => threadInFilterList(threadInfos[threadID]); return filterThreadIDsInFilterList(state, filterCondition); } function removeKeyserverThreadIDsFromFilterList( state: $ReadOnlyArray, keyserverIDs: $ReadOnlyArray, ): $ReadOnlyArray { const keyserverIDsSet = new Set(keyserverIDs); const filterCondition = (threadID: string) => { const keyserverID = extractKeyserverIDFromIDOptional(threadID); return !keyserverID || !keyserverIDsSet.has(keyserverID); }; return filterThreadIDsInFilterList(state, filterCondition); } export { filterThreadIDsInFilterList, removeDeletedThreadIDsFromFilterList, removeKeyserverThreadIDsFromFilterList, }; diff --git a/lib/reducers/entry-reducer.js b/lib/reducers/entry-reducer.js index 1740f90ea..cc80d94fb 100644 --- a/lib/reducers/entry-reducer.js +++ b/lib/reducers/entry-reducer.js @@ -1,827 +1,840 @@ // @flow import invariant from 'invariant'; import _flow from 'lodash/fp/flow.js'; import _groupBy from 'lodash/fp/groupBy.js'; import _isEqual from 'lodash/fp/isEqual.js'; import _map from 'lodash/fp/map.js'; import _mapKeys from 'lodash/fp/mapKeys.js'; import _mapValues from 'lodash/fp/mapValues.js'; import _omitBy from 'lodash/fp/omitBy.js'; import _pickBy from 'lodash/fp/pickBy.js'; import _sortBy from 'lodash/fp/sortBy.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, createLocalEntryActionType, createEntryActionTypes, saveEntryActionTypes, concurrentModificationResetActionType, deleteEntryActionTypes, fetchRevisionsForEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions.js'; import { legacySiweAuthActionTypes } from '../actions/siwe-actions.js'; import { deleteThreadActionTypes, leaveThreadActionTypes, joinThreadActionTypes, changeThreadSettingsActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, newThreadActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { keyserverAuthActionTypes, deleteKeyserverAccountActionTypes, legacyLogInActionTypes, } from '../actions/user-actions.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { EntryStoreOperation } from '../ops/entries-store-ops.js'; import { entryStoreOpsHandlers } from '../ops/entries-store-ops.js'; import { entryID } from '../shared/entry-utils.js'; import { stateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import { threadInFilterList } from '../shared/thread-utils.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; import { isWebPlatform } from '../types/device-types.js'; +import { processDMOpsActionType } from '../types/dm-ops.js'; import type { RawEntryInfo, EntryStore } from '../types/entry-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { type ClientEntryInconsistencyReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, stateSyncPayloadTypes, type ClientStateSyncIncrementalSocketResult, type StateSyncIncrementalActionPayload, } from '../types/socket-types.js'; import type { RawThreadInfos } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { authoritativeKeyserverID } from '../utils/authoritative-keyserver.js'; import { getConfig } from '../utils/config.js'; import { dateString } from '../utils/date-utils.js'; import { getMessageForException } from '../utils/errors.js'; import { assertObjectsAreEqual, values } from '../utils/objects.js'; import { relyingOnAuthoritativeKeyserver } from '../utils/services-utils.js'; function assertEntryStoresAreEqual( processedEntryStore: EntryStore, expectedEntryStore: EntryStore, location: string, onStateDifference?: (message: string) => mixed, ) { try { assertObjectsAreEqual( processedEntryStore, expectedEntryStore, `EntryStore - ${location}`, ); } catch (e) { console.log( 'Error processing EntryStore ops', processedEntryStore, expectedEntryStore, ); const message = `Error processing EntryStore ops ${ getMessageForException(e) ?? '{no exception message}' }`; onStateDifference?.(message); } } function daysToEntriesFromEntryInfos( entryInfos: $ReadOnlyArray, ): { [day: string]: string[] } { return _flow( _sortBy((['id', 'localID']: $ReadOnlyArray)), _groupBy((entryInfo: RawEntryInfo) => dateString(entryInfo.year, entryInfo.month, entryInfo.day), ), _mapValues((entryInfoGroup: $ReadOnlyArray) => _map(entryID)(entryInfoGroup), ), )([...entryInfos]); } function mergeNewEntryInfosOps( currentEntryInfos: { +[id: string]: RawEntryInfo }, currentDaysToEntries: ?{ +[day: string]: string[] }, newEntryInfos: $ReadOnlyArray, threadInfos: RawThreadInfos, ): $ReadOnlyArray { const mergedEntryInfos: { [string]: RawEntryInfo } = {}; const ops: Array = []; for (const rawEntryInfo of newEntryInfos) { const serverID = rawEntryInfo.id; invariant(serverID, 'new entryInfos should have serverID'); const currentEntryInfo = currentEntryInfos[serverID]; let newEntryInfo; if (currentEntryInfo && currentEntryInfo.localID) { newEntryInfo = { id: serverID, // Try to preserve localIDs. This is because we use them as React // keys and changing React keys leads to loss of component state. localID: currentEntryInfo.localID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } else { newEntryInfo = { id: serverID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } if (_isEqual(currentEntryInfo)(newEntryInfo)) { mergedEntryInfos[serverID] = currentEntryInfo; } else { ops.push({ type: 'replace_entry', payload: { id: serverID, entry: newEntryInfo, }, }); mergedEntryInfos[serverID] = newEntryInfo; } } for (const id in currentEntryInfos) { const newEntryInfo = mergedEntryInfos[id]; if (!newEntryInfo) { mergedEntryInfos[id] = currentEntryInfos[id]; } } const entriesFromOutsideFilterList = Object.entries(mergedEntryInfos) .filter(([, entry]) => !threadInFilterList(threadInfos[entry.threadID])) .map(([id]) => id); if (entriesFromOutsideFilterList.length > 0) { ops.push({ type: 'remove_entries', payload: { ids: entriesFromOutsideFilterList, }, }); } return ops; } type ReduceEntryInfosResult = { +entryStore: EntryStore, +entryStoreOperations: $ReadOnlyArray, +reportCreationRequests: $ReadOnlyArray, }; function handleIncrementalStateSync( entryStore: EntryStore, newThreadInfos: RawThreadInfos, payload: | ClientStateSyncIncrementalSocketResult | StateSyncIncrementalActionPayload, ): ReduceEntryInfosResult { const { entryInfos, daysToEntries } = entryStore; const { deletedEntryIDs, deltaEntryInfos, updatesResult } = payload; const mergeEntriesOps = mergeNewEntryInfosOps( entryInfos, daysToEntries, mergeUpdateEntryInfos(deltaEntryInfos, updatesResult.newUpdates), newThreadInfos, ); const updatedEntryInfos = entryStoreOpsHandlers.processStoreOperations( entryStore, mergeEntriesOps, ).entryInfos; const markAsDeletedOps = markDeletedEntries( updatedEntryInfos, deletedEntryIDs, ); const ops = [...mergeEntriesOps, ...markAsDeletedOps]; return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } function reduceEntryInfos( entryStore: EntryStore, action: BaseAction, newThreadInfos: RawThreadInfos, onStateDifference?: (message: string) => mixed, ): ReduceEntryInfosResult { const { entryInfos, daysToEntries, lastUserInteractionCalendar } = entryStore; if ( action.type === deleteKeyserverAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } const ops = [ { type: 'remove_all_entries' }, ...Object.entries(newEntryInfos).map(([id, entry]) => ({ type: 'replace_entry', payload: { id, entry, }, })), ]; return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === setNewSessionActionType) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); let newLastUserInteractionCalendar = lastUserInteractionCalendar; if ( action.payload.sessionChange.cookieInvalidated && action.payload.keyserverID === authoritativeKeyserverID() && relyingOnAuthoritativeKeyserver ) { newLastUserInteractionCalendar = 0; } if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return { entryStore: { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, entryStoreOperations: [], reportCreationRequests: [], }; } const ops = [ { type: 'remove_all_entries' }, ...Object.entries(newEntryInfos).map(([id, entry]) => ({ type: 'replace_entry', payload: { id, entry, }, })), ]; return { entryStore: { ...entryStoreOpsHandlers.processStoreOperations(entryStore, ops), lastUserInteractionCalendar: newLastUserInteractionCalendar, }, entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === fetchEntriesActionTypes.success) { const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } else if ( action.type === updateCalendarQueryActionTypes.started && action.payload && action.payload.calendarQuery ) { return { entryStore: { entryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), }, entryStoreOperations: [], reportCreationRequests: [], }; } else if (action.type === updateCalendarQueryActionTypes.success) { const newLastUserInteractionCalendar = action.payload.calendarQuery ? Date.now() : lastUserInteractionCalendar; const mergeEntriesOps = mergeNewEntryInfosOps( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); const updatedEntryInfos = entryStoreOpsHandlers.processStoreOperations( entryStore, mergeEntriesOps, ).entryInfos; const markAsDeletedOps = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); const ops = [...mergeEntriesOps, ...markAsDeletedOps]; return { entryStore: { ...entryStoreOpsHandlers.processStoreOperations(entryStore, ops), lastUserInteractionCalendar: newLastUserInteractionCalendar, }, entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === createLocalEntryActionType) { const entryInfo = action.payload; const localID = entryInfo.localID; invariant(localID, 'localID should be set in CREATE_LOCAL_ENTRY'); const ops = [ { type: 'replace_entry', payload: { id: localID, entry: entryInfo, }, }, ]; return { entryStore: { ...entryStoreOpsHandlers.processStoreOperations(entryStore, ops), lastUserInteractionCalendar: Date.now(), }, entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === createEntryActionTypes.success) { const localID = action.payload.localID; const serverID = action.payload.entryID; // If an entry with this serverID already got into the store somehow // (likely through an unrelated request), we need to dedup them. let rekeyedEntryInfos; if (entryInfos[serverID]) { // It's fair to assume the serverID entry is newer than the localID // entry, and this probably won't happen often, so for now we can just // keep the serverID entry. rekeyedEntryInfos = _omitBy( (candidate: RawEntryInfo) => !candidate.id && candidate.localID === localID, )(entryInfos); } else if (entryInfos[localID]) { rekeyedEntryInfos = _mapKeys((oldKey: string) => entryInfos[oldKey].localID === localID ? serverID : oldKey, )(entryInfos); } else { // This happens if the entry is deauthorized before it's saved return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } const ops = [ ...mergeNewEntryInfosOps( rekeyedEntryInfos, null, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ), { type: 'remove_entries', payload: { ids: [localID], }, }, ]; return { entryStore: { ...entryStoreOpsHandlers.processStoreOperations(entryStore, ops), lastUserInteractionCalendar: Date.now(), }, entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === saveEntryActionTypes.success) { const serverID = action.payload.entryID; if ( !entryInfos[serverID] || !threadInFilterList(newThreadInfos[entryInfos[serverID].threadID]) ) { // This happens if the entry is deauthorized before it's saved return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryStore: { ...entryStoreOpsHandlers.processStoreOperations(entryStore, ops), lastUserInteractionCalendar: Date.now(), }, entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === concurrentModificationResetActionType) { const { payload } = action; if ( !entryInfos[payload.id] || !threadInFilterList(newThreadInfos[entryInfos[payload.id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } const ops = [ { type: 'replace_entry', payload: { id: payload.id, entry: { ...entryInfos[payload.id], text: payload.dbText, }, }, }, ]; return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === deleteEntryActionTypes.started) { const payload = action.payload; const id = payload.serverID && entryInfos[payload.serverID] ? payload.serverID : payload.localID; invariant(id, 'either serverID or localID should be set'); const ops = [ { type: 'replace_entry', payload: { id, entry: { ...entryInfos[id], deleted: true, }, }, }, ]; return { entryStore: { ...entryStoreOpsHandlers.processStoreOperations(entryStore, ops), lastUserInteractionCalendar: Date.now(), }, entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === deleteEntryActionTypes.success) { const { payload } = action; if (payload) { const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, mergeUpdateEntryInfos([], payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryStore: entryStoreOpsHandlers.processStoreOperations( entryStore, ops, ), entryStoreOperations: ops, reportCreationRequests: [], }; } } else if (action.type === fetchRevisionsForEntryActionTypes.success) { const id = action.payload.entryID; if ( !entryInfos[id] || !threadInFilterList(newThreadInfos[entryInfos[id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } // Make sure the entry is in sync with its latest revision const ops = [ { type: 'replace_entry', payload: { id, entry: { ...entryInfos[id], text: action.payload.text, deleted: action.payload.deleted, }, }, }, ]; return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === restoreEntryActionTypes.success) { const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryStore: { ...entryStoreOpsHandlers.processStoreOperations(entryStore, ops), lastUserInteractionCalendar: Date.now(), }, entryStoreOperations: ops, reportCreationRequests: [], }; } else if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success || action.type === keyserverAuthActionTypes.success ) { const { calendarResult } = action.payload; if (calendarResult) { const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, calendarResult.rawEntryInfos, newThreadInfos, ); return { entryStore: entryStoreOpsHandlers.processStoreOperations( entryStore, ops, ), entryStoreOperations: ops, reportCreationRequests: [], }; } } else if (action.type === incrementalStateSyncActionType) { return handleIncrementalStateSync( entryStore, newThreadInfos, action.payload, ); } else if (action.type === fetchPendingUpdatesActionTypes.success) { const { payload } = action; if (payload.type === stateSyncPayloadTypes.INCREMENTAL) { return handleIncrementalStateSync(entryStore, newThreadInfos, payload); } else { const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, payload.rawEntryInfos, newThreadInfos, ); return { entryStore: entryStoreOpsHandlers.processStoreOperations( entryStore, ops, ), entryStoreOperations: ops, reportCreationRequests: [], }; } } else if ( action.type === processUpdatesActionType || action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success ) { const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.newUpdates), newThreadInfos, ); return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === fullStateSyncActionType) { const ops = mergeNewEntryInfosOps( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } const ops = [ { type: 'remove_all_entries', }, ...Object.entries(newEntryInfos).map(([id, entry]) => ({ type: 'replace_entry', payload: { id, entry, }, })), ]; return { entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), entryStoreOperations: ops, reportCreationRequests: [], }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } const { rawEntryInfos, deleteEntryIDs } = checkStateRequest.stateChanges; if (!rawEntryInfos && !deleteEntryIDs) { return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } const ops: Array = []; let updatedEntryInfos = entryInfos; if (deleteEntryIDs) { ops.push({ type: 'remove_entries', payload: { ids: deleteEntryIDs, }, }); updatedEntryInfos = entryStoreOpsHandlers.processStoreOperations( entryStore, ops, ).entryInfos; } if (rawEntryInfos) { ops.push( ...mergeNewEntryInfosOps( updatedEntryInfos, null, rawEntryInfos, newThreadInfos, ), ); } const newStore = entryStoreOpsHandlers.processStoreOperations( entryStore, ops, ); const newInconsistencies = stateSyncSpecs.entries.findStoreInconsistencies( action, entryInfos, newStore.entryInfos, ); return { entryStore: newStore, entryStoreOperations: ops, reportCreationRequests: newInconsistencies, }; } else if (action.type === setClientDBStoreActionType) { const entryInfosFromDB = action.payload.entries ?? {}; const newStore = { entryInfos: entryInfosFromDB, daysToEntries: daysToEntriesFromEntryInfos(values(entryInfosFromDB)), lastUserInteractionCalendar, }; if (!isWebPlatform(getConfig().platformDetails.platform)) { assertEntryStoresAreEqual( newStore, entryStore, action.type, onStateDifference, ); } return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; + } else if (action.type === processDMOpsActionType) { + const ops = mergeNewEntryInfosOps( + entryInfos, + daysToEntries, + mergeUpdateEntryInfos([], action.payload.updateInfos), + newThreadInfos, + ); + return { + entryStore: entryStoreOpsHandlers.processStoreOperations(entryStore, ops), + entryStoreOperations: ops, + reportCreationRequests: [], + }; } return { entryStore, entryStoreOperations: [], reportCreationRequests: [], }; } function mergeUpdateEntryInfos( entryInfos: $ReadOnlyArray, newUpdates: $ReadOnlyArray, ): RawEntryInfo[] { const entryIDs = new Set( entryInfos.map(entryInfo => entryInfo.id).filter(Boolean), ); const mergedEntryInfos = [...entryInfos]; for (const updateInfo of newUpdates) { updateSpecs[updateInfo.type].mergeEntryInfos?.( entryIDs, mergedEntryInfos, updateInfo, ); } return mergedEntryInfos; } function markDeletedEntries( entryInfos: { +[id: string]: RawEntryInfo }, deletedEntryIDs: $ReadOnlyArray, ): $ReadOnlyArray { const ops = []; for (const deletedEntryID of deletedEntryIDs) { const entryInfo = entryInfos[deletedEntryID]; if (!entryInfo || entryInfo.deleted) { continue; } ops.push({ type: 'replace_entry', payload: { id: deletedEntryID, entry: { ...entryInfo, deleted: true, }, }, }); } return ops; } export { daysToEntriesFromEntryInfos, reduceEntryInfos }; diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index d2f618f71..5043c91e8 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,1854 +1,1873 @@ // @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 { toggleMessagePinActionTypes, 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 { legacySiweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, leaveThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions.js'; import { keyserverAuthActionTypes, deleteKeyserverAccountActionTypes, legacyLogInActionTypes, legacyKeyserverRegisterActionTypes, } from '../actions/user-actions.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { messageStoreOpsHandlers, type MessageStoreOperation, type ReplaceMessageOperation, } from '../ops/message-store-ops.js'; import { pendingToRealizedThreadIDsSelector } from '../selectors/thread-selectors.js'; import { messageID, sortMessageInfoList, sortMessageIDs, mergeThreadMessageInfos, findNewestMessageTimePerKeyserver, localIDPrefix, } 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 { updateSpecs } from '../shared/updates/update-specs.js'; import { recoveryFromReduxActionSources } from '../types/account-types.js'; +import { processDMOpsActionType } from '../types/dm-ops.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 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 { RawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { type BaseAction } from '../types/redux-types.js'; import { processServerRequestsActionType } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, stateSyncPayloadTypes, } from '../types/socket-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import type { LegacyRawThreadInfo, RawThreadInfos, MixedRawThreadInfos, } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; import { getMessageForException } from '../utils/errors.js'; import { translateClientDBThreadMessageInfos } from '../utils/message-ops-utils.js'; import { assertObjectsAreEqual } from '../utils/objects.js'; 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: ?LegacyRawThreadInfo | ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadIsPending(threadID) || (threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadID))) ); } function assertMessageStoreLocalMessageInfosAreEqual( processedMessageStore: MessageStore, expectedMessageStore: MessageStore, location: string, onStateDifference?: (message: string) => mixed, ) { try { assertObjectsAreEqual( processedMessageStore.local, expectedMessageStore.local, `MessageStore.local - ${location}`, ); } catch (e) { console.log( 'Error processing MessageStore local ops', processedMessageStore.local, expectedMessageStore.local, ); const message = `Error processing MessageStore local ops ${ getMessageForException(e) ?? '{no exception message}' }`; onStateDifference?.(message); } } const newThread = (): ThreadMessageInfo => ({ messageIDs: [], startReached: false, }); type FreshMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function freshMessageStore( messageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: { +[keyserverID: string]: number }, threadInfos: MixedRawThreadInfos, ): 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: 'remove_all_local_message_infos', }, { 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: RawThreadInfos, ): ReassignmentResult { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(threadInfos); const messageStoreOperations: MessageStoreOperation[] = []; const messages: { [string]: RawMessageInfo } = {}; 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: { [string]: ThreadMessageInfo } = {}; const reassignedThreadIDs = []; const updatedThreads: { [string]: ThreadMessageInfo } = {}; 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: RawThreadInfos, ): MergeNewMessagesResult { const { messageStoreOperations: updateWithLatestThreadInfosOps, messageStore: messageStoreUpdatedWithLatestThreadInfos, reassignedThreadIDs, } = updateMessageStoreWithLatestThreadInfos(oldMessageStore, threadInfos); const messageStoreAfterUpdateOps = processMessageStoreOperations( oldMessageStore, updateWithLatestThreadInfosOps, ); const updatedMessageStore = { ...messageStoreUpdatedWithLatestThreadInfos, messages: messageStoreAfterUpdateOps.messages, threads: messageStoreAfterUpdateOps.threads, local: messageStoreAfterUpdateOps.local, }; 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 updatedThreads: { [string]: ThreadMessageInfo } = {}; 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, }; 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) { newMessageOps.push({ type: 'replace_local_message_info', payload: { id, localMessageInfo: 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, }; return updatedThreads[threadID]; } const mergedMessageIDs = [...messageIDs, ...oldNotInNew]; threadsThatNeedMessageIDsResorted.push(threadID); return { messageIDs: mergedMessageIDs, startReached, }; }), _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) { newMessageOps.push({ type: 'replace_local_message_info', payload: { id, localMessageInfo: 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]; } newMessageOps.push({ type: 'replace_threads', payload: { threads: updatedThreads, }, }); const processedMessageStore = processMessageStoreOperations( updatedMessageStore, newMessageOps, ); const currentAsOf: { [keyserverID: string]: number } = {}; const newestMessageTimePerKeyserver = findNewestMessageTimePerKeyserver( orderedNewMessageInfos, ); for (const keyserverID in newestMessageTimePerKeyserver) { currentAsOf[keyserverID] = Math.max( newestMessageTimePerKeyserver[keyserverID], processedMessageStore.currentAsOf[keyserverID] ?? 0, ); } const messageStore = { messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, currentAsOf: { ...processedMessageStore.currentAsOf, ...currentAsOf, }, }; return { messageStoreOperations: [ ...updateWithLatestThreadInfosOps, ...newMessageOps, ], messageStore, }; } type UpdateMessageStoreWithLatestThreadInfosResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function updateMessageStoreWithLatestThreadInfos( messageStore: MessageStore, threadInfos: RawThreadInfos, ): UpdateMessageStoreWithLatestThreadInfosResult { const messageStoreOperations: MessageStoreOperation[] = []; const { messageStore: reassignedMessageStore, messageStoreOperations: reassignMessagesOps, reassignedThreadIDs, } = reassignMessagesToRealizedThreads(messageStore, threadInfos); messageStoreOperations.push(...reassignMessagesOps); const watchedIDs = [...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs]; const filteredThreads: { [string]: ThreadMessageInfo } = {}; const threadsToRemoveMessagesFrom = []; const messageIDsToRemove: string[] = []; for (const threadID in reassignedMessageStore.threads) { const threadMessageInfo = reassignedMessageStore.threads[threadID]; const threadInfo = threadInfos[threadID]; if (isThreadWatched(threadID, threadInfo, watchedIDs)) { filteredThreads[threadID] = threadMessageInfo; } else { threadsToRemoveMessagesFrom.push(threadID); messageIDsToRemove.push(...threadMessageInfo.messageIDs); } } const updatedThreads: { [string]: ThreadMessageInfo } = {}; 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 }, }); const localMessagesToRemove = _pick(messageIDsToRemove)( reassignedMessageStore.local, ); messageStoreOperations.push({ type: 'remove_local_message_infos', payload: { ids: Object.keys(localMessagesToRemove) }, }); return { messageStoreOperations, messageStore: { messages: _omit(messageIDsToRemove)(reassignedMessageStore.messages), threads: filteredThreads, local: _omit(messageIDsToRemove)(reassignedMessageStore.local), currentAsOf: reassignedMessageStore.currentAsOf, }, reassignedThreadIDs, }; } function ensureRealizedThreadIDIsUsedWhenPossible( payload: T, threadInfos: RawThreadInfos, ): T { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector(threadInfos); const realizedThreadID = pendingToRealizedThreadIDs.get(payload.threadID); return realizedThreadID ? { ...payload, threadID: realizedThreadID } : payload; } const { processStoreOperations: processMessageStoreOperations } = messageStoreOpsHandlers; type ReduceMessageStoreResult = { +messageStoreOperations: $ReadOnlyArray, +messageStore: MessageStore, }; function reduceMessageStore( messageStore: MessageStore, action: BaseAction, newThreadInfos: RawThreadInfos, onStateDifference?: (message: string) => mixed, ): ReduceMessageStoreResult { if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success ) { const { messagesResult } = action.payload; let { messageInfos } = messagesResult; // If it's a resolution attempt and the userID doesn't change, // then we should keep all local messages in the store // TODO we can't check if the userID changed until ENG-6126 if ( action.payload.authActionSource === recoveryFromReduxActionSources.cookieInvalidationResolutionAttempt || action.payload.authActionSource === recoveryFromReduxActionSources.socketAuthErrorResolutionAttempt ) { const localMessages = Object.values(messageStore.messages).filter( rawMessageInfo => messageID(rawMessageInfo).startsWith(localIDPrefix), ); messageInfos = [...messageInfos, ...localMessages]; } const { messageStoreOperations, messageStore: freshStore } = freshMessageStore( messageInfos, messagesResult.truncationStatus, messagesResult.currentAsOf, newThreadInfos, ); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...freshStore, messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, }, }; } else if (action.type === keyserverAuthActionTypes.success) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.messageInfos, messagesResult.truncationStatus, newThreadInfos, ); } else if (action.type === incrementalStateSyncActionType) { const { messagesResult, updatesResult } = action.payload; if ( messagesResult.rawMessageInfos.length === 0 && updatesResult.newUpdates.length === 0 ) { return { messageStoreOperations: [], messageStore }; } const newMessagesResult = mergeUpdatesWithMessageInfos( messagesResult.rawMessageInfos, updatesResult.newUpdates, messagesResult.truncationStatuses, ); return mergeNewMessages( messageStore, newMessagesResult.rawMessageInfos, newMessagesResult.truncationStatuses, newThreadInfos, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.INCREMENTAL ) { const { messagesResult, updatesResult } = action.payload; if ( messagesResult.rawMessageInfos.length === 0 && updatesResult.newUpdates.length === 0 ) { return { messageStoreOperations: [], messageStore }; } const newMessagesResult = mergeUpdatesWithMessageInfos( messagesResult.rawMessageInfos, updatesResult.newUpdates, messagesResult.truncationStatuses, ); return mergeNewMessages( messageStore, newMessagesResult.rawMessageInfos, newMessagesResult.truncationStatuses, newThreadInfos, ); } 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, ); return { messageStoreOperations, messageStore: { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, currentAsOf: messageStore.currentAsOf, }, }; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType || (action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.FULL) ) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } else if ( action.type === fetchSingleMostRecentMessagesFromThreadsActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, action.payload.truncationStatuses, newThreadInfos, ); } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, { [action.payload.threadID]: action.payload.truncationStatus }, newThreadInfos, ); } else if (action.type === deleteKeyserverAccountActionTypes.success) { const { messageStoreOperations, messageStore: filteredMessageStore } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); let currentAsOf = {}; if (action.payload.keyserverIDs) { currentAsOf = _omit(action.payload.keyserverIDs)( filteredMessageStore.currentAsOf, ); } return { messageStoreOperations, messageStore: { ...filteredMessageStore, currentAsOf, messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, }, }; } else if ( action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === setNewSessionActionType ) { const { messageStoreOperations, messageStore: filteredMessageStore } = updateMessageStoreWithLatestThreadInfos(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...filteredMessageStore, messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, }, }; } else if (action.type === newThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.newMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } else if (action.type === sendMessageReportActionTypes.success) { return mergeNewMessages( messageStore, [action.payload.messageInfo], { [(action.payload.messageInfo.threadID: string)]: messageTruncationStatus.UNCHANGED, }, newThreadInfos, ); } else if (action.type === legacyKeyserverRegisterActionTypes.success) { const truncationStatuses: { [string]: MessageTruncationStatus } = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } return mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, ); } 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: string)]: messageTruncationStatus.UNCHANGED, }, newThreadInfos, ); } else if (action.type === deleteEntryActionTypes.success) { const payload = action.payload; if (payload) { return mergeNewMessages( messageStore, payload.newMessageInfos, { [payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, ); } } else if (action.type === joinThreadActionTypes.success) { const messagesResult = mergeUpdatesWithMessageInfos( action.payload.rawMessageInfos, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } else if (action.type === sendEditMessageActionTypes.success) { const { newMessageInfos } = action.payload; const truncationStatuses: { [string]: MessageTruncationStatus } = {}; for (const messageInfo of newMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } return mergeNewMessages( messageStore, newMessageInfos, truncationStatuses, newThreadInfos, ); } 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: MessageStoreOperation[] = [ { type: 'replace', payload: { id: localID, messageInfo: payload }, }, ]; let updatedThreads; if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [(localID: string)]: payload, }; const thread = messageStore.threads[threadID]; updatedThreads = { [(threadID: string)]: { messageIDs: sortMessageIDs(messages)(messageIDs), startReached: thread?.startReached ?? true, }, }; messageStoreOperations.push({ type: 'remove_local_message_infos', payload: { ids: [localID], }, }); } else { updatedThreads = { [(threadID: string)]: messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, }, }; } messageStoreOperations.push({ type: 'replace_threads', payload: { threads: { ...updatedThreads }, }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, currentAsOf: messageStore.currentAsOf, }; return { messageStoreOperations, messageStore: newMessageStore, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; const messageStoreOperations = [ { type: 'replace_local_message_info', payload: { id: localID, localMessageInfo: { sendFailed: true, }, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, currentAsOf: messageStore.currentAsOf, }, }; } else if (action.type === sendReactionMessageActionTypes.failed) { const { localID, threadID } = action.payload; const messageStoreOperations: MessageStoreOperation[] = []; 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, ); return { messageStoreOperations, messageStore: processedMessageStore, }; } 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: MessageStoreOperation[] = []; 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)); messageStoreOperations.push({ type: 'remove_local_message_infos', payload: { ids: [payload.localID], }, }); const updatedThreads = { [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, }, }; } else if (action.type === saveMessagesActionType) { const truncationStatuses: { [string]: MessageTruncationStatus } = {}; for (const messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const { messageStoreOperations, messageStore: newMessageStore } = mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, ); 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 messageIDsToPrune = []; const updatedThreads: { [string]: ThreadMessageInfo } = {}; for (const threadID of action.payload.threadIDs) { let thread = messageStore.threads[threadID]; if (!thread) { continue; } 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 localMessageIDsToRemove = _pick(messageIDsToPrune)( messageStore.local, ); const messageStoreOperations = [ { type: 'remove', payload: { ids: messageIDsToPrune }, }, { type: 'replace_threads', payload: { threads: updatedThreads, }, }, { type: 'remove_local_message_infos', payload: { ids: Object.keys(localMessageIDsToRemove) }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, currentAsOf: messageStore.currentAsOf, }; 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' ) { if (singleMedia.blobURI) { const { holder, ...rest } = mediaUpdate; if (holder) { console.log( `mediaUpdate contained holder for media ${singleMedia.id} ` + 'that has blobURI', ); } media.push({ ...singleMedia, ...rest }); } else { invariant( singleMedia.holder, 'Encrypted media must have holder or blobURI', ); const { blobURI, ...rest } = mediaUpdate; if (blobURI) { console.log( `mediaUpdate contained blobURI for media ${singleMedia.id} ` + 'that has holder', ); } media.push({ ...singleMedia, ...rest }); } replaced = true; } else if ( singleMedia.type === 'encrypted_video' && mediaUpdate.type === 'encrypted_video' ) { if (singleMedia.blobURI) { const { holder, thumbnailHolder, ...rest } = mediaUpdate; if (holder || thumbnailHolder) { console.log( 'mediaUpdate contained holder or thumbnailHolder for media ' + `${singleMedia.id} that has blobURI`, ); } media.push({ ...singleMedia, ...rest }); } else { invariant( singleMedia.holder, 'Encrypted media must have holder or blobURI', ); const { blobURI, thumbnailBlobURI, ...rest } = mediaUpdate; if (blobURI || thumbnailBlobURI) { console.log( 'mediaUpdate contained blobURI or thumbnailBlobURI for media ' + `${singleMedia.id} that has holder`, ); } media.push({ ...singleMedia, ...rest }); } 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; 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, threads: processedMessageStore.threads, local: processedMessageStore.local, }, }; } else if (action.type === createLocalMessageActionType) { const messageInfo = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); const { localID, threadID } = messageInfo; const messageIDs = messageStore.threads[messageInfo.threadID]?.messageIDs ?? []; const threadState: ThreadMessageInfo = messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageIDs], } : { messageIDs: [localID], startReached: true, }; const messageStoreOperations = [ { type: 'replace', payload: { id: localID, messageInfo }, }, { type: 'replace_threads', payload: { threads: { [(threadID: string)]: threadState }, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, threads: processedMessageStore.threads, messages: processedMessageStore.messages, local: processedMessageStore.local, }, }; } else if (action.type === processServerRequestsActionType) { const { messageStoreOperations, messageStore: messageStoreAfterReassignment, } = reassignMessagesToRealizedThreads(messageStore, newThreadInfos); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStoreAfterReassignment, messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: processedMessageStore.local, }, }; } else if (action.type === setClientDBStoreActionType) { const actionPayloadMessageStoreThreads = translateClientDBThreadMessageInfos( action.payload.messageStoreThreads ?? [], ); const actionPayloadMessageStoreLocalMessageInfos = action.payload.messageStoreLocalMessageInfos ?? {}; assertMessageStoreLocalMessageInfosAreEqual( { ...messageStore, local: actionPayloadMessageStoreLocalMessageInfos, }, messageStore, action.type, onStateDifference, ); const newThreads: { [threadID: string]: ThreadMessageInfo, } = { ...messageStore.threads }; for (const threadID in actionPayloadMessageStoreThreads) { newThreads[threadID] = { ...actionPayloadMessageStoreThreads[threadID], messageIDs: messageStore.threads?.[threadID]?.messageIDs ?? [], }; } const payloadMessages = action.payload.messages; if (!payloadMessages) { return { messageStoreOperations: [], messageStore: { ...messageStore, threads: newThreads }, }; } const { messageStoreOperations, messageStore: updatedMessageStore } = updateMessageStoreWithLatestThreadInfos( { ...messageStore, threads: newThreads }, 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 = messageStoreOpsHandlers.translateClientDBData(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: { [string]: ThreadMessageInfo } = {}; 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: string)]: { ...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: string)]: { ...existingThread, messageIDs: [...existingThread.messageIDs, id], }, }; threadsNeedMsgIDsResorting.add(threadID); } else if (!threads[threadID]) { threads = { ...threads, [(threadID: string)]: 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, }; + } else if (action.type === processDMOpsActionType) { + const { rawMessageInfos, updateInfos } = action.payload; + if (rawMessageInfos.length === 0 && updateInfos.length === 0) { + return { messageStoreOperations: [], messageStore }; + } + + const messagesResult = mergeUpdatesWithMessageInfos( + rawMessageInfos, + updateInfos, + {}, + ); + + return mergeNewMessages( + messageStore, + messagesResult.rawMessageInfos, + messagesResult.truncationStatuses, + newThreadInfos, + ); } 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).filter(Boolean), ); const mergedMessageInfos = [...messageInfos]; const mergedTruncationStatuses = { ...truncationStatuses }; for (const update of newUpdates) { const { mergeMessageInfosAndTruncationStatuses } = updateSpecs[update.type]; if (!mergeMessageInfosAndTruncationStatuses) { continue; } mergeMessageInfosAndTruncationStatuses( messageIDs, mergedMessageInfos, mergedTruncationStatuses, update, ); } return { rawMessageInfos: mergedMessageInfos, truncationStatuses: mergedTruncationStatuses, }; } export { freshMessageStore, reduceMessageStore }; diff --git a/lib/reducers/thread-activity-reducer.js b/lib/reducers/thread-activity-reducer.js index 2836a6192..c72db684f 100644 --- a/lib/reducers/thread-activity-reducer.js +++ b/lib/reducers/thread-activity-reducer.js @@ -1,250 +1,253 @@ // @flow import invariant from 'invariant'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { messageStorePruneActionType } from '../actions/message-actions.js'; import { changeThreadMemberRolesActionTypes, changeThreadSettingsActionTypes, deleteCommunityRoleActionTypes, deleteThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, modifyCommunityRoleActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { deleteKeyserverAccountActionTypes } from '../actions/user-actions.js'; import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { threadActivityStoreOpsHandlers, type ThreadActivityStoreOperation, } from '../ops/thread-activity-store-ops.js'; import { isWebPlatform } from '../types/device-types.js'; +import { processDMOpsActionType } from '../types/dm-ops.js'; import type { BaseAction } from '../types/redux-types.js'; import { incrementalStateSyncActionType, stateSyncPayloadTypes, } from '../types/socket-types.js'; import type { ThreadActivityStore } from '../types/thread-activity-types.js'; import { updateThreadLastNavigatedActionType } from '../types/thread-activity-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import type { ClientUpdateInfo } from '../types/update-types.js'; import { processUpdatesActionType } from '../types/update-types.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { assertObjectsAreEqual } from '../utils/objects.js'; function assertThreadActivityStoresAreEqual( processedThreadActivityStore: ThreadActivityStore, expectedThreadActivityStore: ThreadActivityStore, location: string, onStateDifference?: (message: string) => mixed, ) { try { assertObjectsAreEqual( processedThreadActivityStore, expectedThreadActivityStore, `ThreadActivityStore - ${location}`, ); } catch (e) { console.log( 'Error processing ThreadActivityStore ops', processedThreadActivityStore, expectedThreadActivityStore, ); const message = `Error processing ThreadActivityStore ops ${ getMessageForException(e) ?? '{no exception message}' }`; onStateDifference?.(message); } } type ReduceThreadActivityResult = { +threadActivityStore: ThreadActivityStore, +threadActivityStoreOperations: $ReadOnlyArray, }; function handleThreadDeletionUpdates( state: ThreadActivityStore, newUpdates: $ReadOnlyArray, ): ReduceThreadActivityResult { if (newUpdates.length === 0) { return { threadActivityStore: state, threadActivityStoreOperations: [], }; } const deleteThreadUpdates = newUpdates.filter( (update: ClientUpdateInfo) => update.type === updateTypes.DELETE_THREAD, ); if (deleteThreadUpdates.length === 0) { return { threadActivityStore: state, threadActivityStoreOperations: [], }; } const threadIDsToRemove = []; for (const update: ClientUpdateInfo of deleteThreadUpdates) { invariant( update.type === updateTypes.DELETE_THREAD, 'update must be of type DELETE_THREAD', ); threadIDsToRemove.push(update.threadID); } const removeOperation = { type: 'remove_thread_activity_entries', payload: { ids: threadIDsToRemove, }, }; return { threadActivityStore: processStoreOps(state, [removeOperation]), threadActivityStoreOperations: [removeOperation], }; } const { processStoreOperations: processStoreOps } = threadActivityStoreOpsHandlers; function reduceThreadActivity( state: ThreadActivityStore, action: BaseAction, onStateDifference?: (message: string) => mixed, ): ReduceThreadActivityResult { if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; const replaceOperation = { type: 'replace_thread_activity_entry', payload: { id: threadID, threadActivityStoreEntry: { ...state[threadID], lastNavigatedTo: time, }, }, }; return { threadActivityStore: processStoreOps(state, [replaceOperation]), threadActivityStoreOperations: [replaceOperation], }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const replaceOperations = []; for (const threadID: string of action.payload.threadIDs) { const replaceOperation = { type: 'replace_thread_activity_entry', payload: { id: threadID, threadActivityStoreEntry: { ...state[threadID], lastPruned: now, }, }, }; replaceOperations.push(replaceOperation); } return { threadActivityStore: processStoreOps(state, replaceOperations), threadActivityStoreOperations: replaceOperations, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success || action.type === modifyCommunityRoleActionTypes.success || action.type === deleteCommunityRoleActionTypes.success ) { return handleThreadDeletionUpdates( state, action.payload.updatesResult.newUpdates, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.INCREMENTAL ) { return handleThreadDeletionUpdates( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === deleteKeyserverAccountActionTypes.success) { const threadIDsToRemove = []; const keyserverIDsSet = new Set(action.payload.keyserverIDs); for (const threadID in state) { const keyserverID = extractKeyserverIDFromIDOptional(threadID); if (!keyserverID || !keyserverIDsSet.has(keyserverID)) { continue; } threadIDsToRemove.push(threadID); } const removeOperation = { type: 'remove_thread_activity_entries', payload: { ids: threadIDsToRemove, }, }; return { threadActivityStore: processStoreOps(state, [removeOperation]), threadActivityStoreOperations: [removeOperation], }; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated ) { const threadIDsToRemove = []; const { keyserverID } = action.payload; for (const threadID in state) { if (extractKeyserverIDFromIDOptional(threadID) !== keyserverID) { continue; } threadIDsToRemove.push(threadID); } const removeOperation = { type: 'remove_thread_activity_entries', payload: { ids: threadIDsToRemove, }, }; return { threadActivityStore: processStoreOps(state, [removeOperation]), threadActivityStoreOperations: [removeOperation], }; } else if (action.type === setClientDBStoreActionType) { if (!isWebPlatform(getConfig().platformDetails.platform)) { assertThreadActivityStoresAreEqual( action.payload.threadActivityStore ?? {}, state, action.type, onStateDifference, ); } return { threadActivityStore: state, threadActivityStoreOperations: [], }; + } else if (action.type === processDMOpsActionType) { + return handleThreadDeletionUpdates(state, action.payload.updateInfos); } return { threadActivityStore: state, threadActivityStoreOperations: [], }; } export { reduceThreadActivity }; diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js index 951398d4b..695d5086e 100644 --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -1,545 +1,568 @@ // @flow import { setThreadUnreadStatusActionTypes, updateActivityActionTypes, } from '../actions/activity-actions.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { saveMessagesActionType } from '../actions/message-actions.js'; import { legacySiweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, leaveThreadActionTypes, modifyCommunityRoleActionTypes, deleteCommunityRoleActionTypes, } from '../actions/thread-actions.js'; import { fetchPendingUpdatesActionTypes } from '../actions/update-actions.js'; import { keyserverAuthActionTypes, deleteKeyserverAccountActionTypes, legacyLogInActionTypes, legacyKeyserverRegisterActionTypes, updateSubscriptionActionTypes, } from '../actions/user-actions.js'; import { getThreadIDsForKeyservers, extractKeyserverIDFromID, } from '../keyserver-conn/keyserver-call-utils.js'; import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { type ThreadStoreOperation, threadStoreOpsHandlers, } from '../ops/thread-store-ops.js'; import { stateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; +import { processDMOpsActionType } from '../types/dm-ops.js'; import type { RawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { type ClientThreadInconsistencyReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, stateSyncPayloadTypes, } from '../types/socket-types.js'; import type { RawThreadInfos, ThreadStore } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; const { processStoreOperations: processThreadStoreOperations } = threadStoreOpsHandlers; function generateOpsForThreadUpdates( threadInfos: RawThreadInfos, newUpdates: $ReadOnlyArray, ): $ReadOnlyArray { return newUpdates .map(update => updateSpecs[update.type].generateOpsForThreadUpdates?.( threadInfos, update, ), ) .filter(Boolean) .flat(); } type ReduceThreadInfosResult = { threadStore: ThreadStore, newThreadInconsistencies: $ReadOnlyArray, threadStoreOperations: $ReadOnlyArray, }; function handleFullStateSync( state: ThreadStore, keyserverID: string, newThreadInfos: RawThreadInfos, ): ReduceThreadInfosResult { const threadsToRemove = Object.keys(state.threadInfos).filter( key => extractKeyserverIDFromID(key) === keyserverID, ); const threadStoreOperations = [ { type: 'remove', payload: { ids: threadsToRemove }, }, ...Object.keys(newThreadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: newThreadInfos[id] }, })), ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } function reduceThreadInfos( state: ThreadStore, action: BaseAction, ): ReduceThreadInfosResult { if (action.type === fullStateSyncActionType) { return handleFullStateSync( state, action.payload.keyserverID, action.payload.threadInfos, ); } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.FULL ) { return handleFullStateSync( state, action.payload.keyserverID, action.payload.threadInfos, ); } else if ( action.type === legacyLogInActionTypes.success || action.type === legacySiweAuthActionTypes.success || action.type === legacyKeyserverRegisterActionTypes.success ) { const newThreadInfos = action.payload.threadInfos; const threadStoreOperations = [ { type: 'remove_all', }, ...Object.keys(newThreadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: newThreadInfos[id] }, })), ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === keyserverAuthActionTypes.success) { const keyserverIDs = Object.keys(action.payload.updatesCurrentAsOf); const threadIDsToRemove = getThreadIDsForKeyservers( Object.keys(state.threadInfos), keyserverIDs, ); const newThreadInfos = action.payload.threadInfos; const threadStoreOperations = [ { type: 'remove', payload: { ids: threadIDsToRemove }, }, ...Object.keys(newThreadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: newThreadInfos[id] }, })), ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === deleteKeyserverAccountActionTypes.success) { const threadIDsToRemove = getThreadIDsForKeyservers( Object.keys(state.threadInfos), action.payload.keyserverIDs, ); if (threadIDsToRemove.length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = [ { type: 'remove', payload: { ids: threadIDsToRemove }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated ) { const threadIDsToRemove = getThreadIDsForKeyservers( Object.keys(state.threadInfos), [action.payload.keyserverID], ); if (threadIDsToRemove.length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = [ { type: 'remove', payload: { ids: threadIDsToRemove }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success || action.type === modifyCommunityRoleActionTypes.success || action.type === deleteCommunityRoleActionTypes.success ) { const { newUpdates } = action.payload.updatesResult; if (newUpdates.length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = generateOpsForThreadUpdates( state.threadInfos, newUpdates, ); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if ( action.type === fetchPendingUpdatesActionTypes.success && action.payload.type === stateSyncPayloadTypes.INCREMENTAL ) { const { newUpdates } = action.payload.updatesResult; if (newUpdates.length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = generateOpsForThreadUpdates( state.threadInfos, newUpdates, ); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === updateSubscriptionActionTypes.success) { const { threadID, subscription } = action.payload; const threadInfo = state.threadInfos[threadID]; const newThreadInfo = { ...threadInfo, currentUser: { ...threadInfo.currentUser, subscription, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: newThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === saveMessagesActionType) { const threadIDToMostRecentTime = new Map(); for (const messageInfo of action.payload.rawMessageInfos) { const current = threadIDToMostRecentTime.get(messageInfo.threadID); if (!current || current < messageInfo.time) { threadIDToMostRecentTime.set(messageInfo.threadID, messageInfo.time); } } const changedThreadInfos: { [string]: RawThreadInfo } = {}; for (const [threadID, mostRecentTime] of threadIDToMostRecentTime) { const threadInfo = state.threadInfos[threadID]; if ( !threadInfo || threadInfo.currentUser.unread || action.payload.updatesCurrentAsOf > mostRecentTime ) { continue; } changedThreadInfos[threadID] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } if (Object.keys(changedThreadInfos).length !== 0) { const threadStoreOperations = Object.keys(changedThreadInfos).map(id => ({ type: 'replace', payload: { id, threadInfo: changedThreadInfos[id], }, })); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const { rawThreadInfos, deleteThreadIDs } = checkStateRequest.stateChanges; if (!rawThreadInfos && !deleteThreadIDs) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations: ThreadStoreOperation[] = []; if (rawThreadInfos) { for (const rawThreadInfo of rawThreadInfos) { threadStoreOperations.push({ type: 'replace', payload: { id: rawThreadInfo.id, threadInfo: rawThreadInfo, }, }); } } if (deleteThreadIDs) { threadStoreOperations.push({ type: 'remove', payload: { ids: deleteThreadIDs, }, }); } const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); const newThreadInconsistencies = stateSyncSpecs.threads.findStoreInconsistencies( action, state.threadInfos, updatedThreadStore.threadInfos, ); return { threadStore: updatedThreadStore, newThreadInconsistencies, threadStoreOperations, }; } else if (action.type === updateActivityActionTypes.success) { const updatedThreadInfos: { [string]: RawThreadInfo } = {}; for (const setToUnread of action.payload.result.unfocusedToUnread) { const threadInfo = state.threadInfos[setToUnread]; if (threadInfo && !threadInfo.currentUser.unread) { updatedThreadInfos[setToUnread] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } } if (Object.keys(updatedThreadInfos).length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = Object.keys(updatedThreadInfos).map(id => ({ type: 'replace', payload: { id, threadInfo: updatedThreadInfos[id], }, })); const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setThreadUnreadStatusActionTypes.started) { const { threadID, unread } = action.payload; const threadInfo = state.threadInfos[threadID]; const updatedThreadInfo = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: updatedThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setThreadUnreadStatusActionTypes.success) { const { threadID, resetToUnread } = action.payload; const threadInfo = state.threadInfos[threadID]; const { currentUser } = threadInfo; if (!resetToUnread || currentUser.unread) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const updatedThread = { ...threadInfo, currentUser: { ...currentUser, unread: true }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: updatedThread, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else if (action.type === setClientDBStoreActionType) { return { threadStore: action.payload.threadStore ?? state, newThreadInconsistencies: [], threadStoreOperations: [], }; + } else if (action.type === processDMOpsActionType) { + const { updateInfos } = action.payload; + if (updateInfos.length === 0) { + return { + threadStore: state, + newThreadInconsistencies: [], + threadStoreOperations: [], + }; + } + const threadStoreOperations = generateOpsForThreadUpdates( + state.threadInfos, + updateInfos, + ); + const updatedThreadStore = processThreadStoreOperations( + state, + threadStoreOperations, + ); + return { + threadStore: updatedThreadStore, + newThreadInconsistencies: [], + threadStoreOperations, + }; } return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } export { reduceThreadInfos };