diff --git a/lib/keyserver-conn/keyserver-conn-types.js b/lib/keyserver-conn/keyserver-conn-types.js new file mode 100644 index 000000000..358680821 --- /dev/null +++ b/lib/keyserver-conn/keyserver-conn-types.js @@ -0,0 +1,51 @@ +// @flow + +import type { + LogInActionSource, + LogInStartingPayload, + LogInResult, +} from '../types/account-types.js'; +import type { Dispatch } from '../types/redux-types.js'; +import type { + ClientSessionChange, + PreRequestUserState, +} from '../types/session-types.js'; + +export type ActionTypes< + STARTED_ACTION_TYPE: string, + SUCCESS_ACTION_TYPE: string, + FAILED_ACTION_TYPE: string, +> = { + started: STARTED_ACTION_TYPE, + success: SUCCESS_ACTION_TYPE, + failed: FAILED_ACTION_TYPE, +}; + +export type DispatchRecoveryAttempt = ( + actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, + promise: Promise, + startingPayload: LogInStartingPayload, +) => Promise; + +const setNewSessionActionType = 'SET_NEW_SESSION'; +function setNewSession( + dispatch: Dispatch, + sessionChange: ClientSessionChange, + preRequestUserState: ?PreRequestUserState, + error: ?string, + logInActionSource: ?LogInActionSource, + keyserverID: string, +) { + dispatch({ + type: setNewSessionActionType, + payload: { + sessionChange, + preRequestUserState, + error, + logInActionSource, + keyserverID, + }, + }); +} + +export { setNewSessionActionType, setNewSession }; diff --git a/lib/reducers/calendar-filters-reducer.js b/lib/reducers/calendar-filters-reducer.js index b491b5aad..e8e953201 100644 --- a/lib/reducers/calendar-filters-reducer.js +++ b/lib/reducers/calendar-filters-reducer.js @@ -1,209 +1,209 @@ // @flow import { updateCalendarCommunityFilter, clearCalendarCommunityFilter, } from '../actions/community-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { newThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, deleteThreadActionTypes, } from '../actions/thread-actions.js'; import { keyserverAuthActionTypes, logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, registerActionTypes, tempIdentityLoginActionTypes, } from '../actions/user-actions.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { filteredThreadIDs, nonThreadCalendarFilters, nonExcludeDeletedCalendarFilters, } from '../selectors/calendar-filter-selectors.js'; import { threadInFilterList } from '../shared/thread-utils.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; import { type CalendarFilter, defaultCalendarFilters, updateCalendarThreadFilter, clearCalendarThreadFilter, setCalendarDeletedFilter, calendarThreadFilterTypes, } from '../types/filter-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import type { RawThreadInfos, ThreadStore } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { filterThreadIDsBelongingToCommunity } from '../utils/drawer-utils.react.js'; export default function reduceCalendarFilters( state: $ReadOnlyArray, action: BaseAction, threadStore: ThreadStore, ): $ReadOnlyArray { if ( action.type === tempIdentityLoginActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return defaultCalendarFilters; } 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 === fullStateSyncActionType) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } else if (action.type === updateCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); const threadIDs = Array.from( filterThreadIDsBelongingToCommunity( action.payload, threadStore.threadInfos, ), ); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs, }, ]; } else if (action.type === clearCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return nonThreadFilters; } return state; } function updateFilterListFromUpdateInfos( state: $ReadOnlyArray, updateInfos: $ReadOnlyArray, ): $ReadOnlyArray { const currentlyFilteredIDs: ?$ReadOnlySet = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const newFilteredThreadIDs = updateInfos.reduce( (reducedFilteredThreadIDs, update) => { const { reduceCalendarThreadFilters } = updateSpecs[update.type]; return reduceCalendarThreadFilters ? reduceCalendarThreadFilters(reducedFilteredThreadIDs, update) : reducedFilteredThreadIDs; }, currentlyFilteredIDs, ); if (currentlyFilteredIDs !== newFilteredThreadIDs) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: [...newFilteredThreadIDs] }, ]; } return state; } function filterThreadIDsInFilterList( state: $ReadOnlyArray, filterCondition: (threadID: string) => boolean, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const filtered = [...currentlyFilteredIDs].filter(filterCondition); if (filtered.length < currentlyFilteredIDs.size) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: filtered }, ]; } return state; } function removeDeletedThreadIDsFromFilterList( state: $ReadOnlyArray, threadInfos: RawThreadInfos, ): $ReadOnlyArray { const filterCondition = (threadID: string) => threadInFilterList(threadInfos[threadID]); return filterThreadIDsInFilterList(state, filterCondition); } function removeKeyserverThreadIDsFromFilterList( state: $ReadOnlyArray, keyserverIDs: $ReadOnlyArray, ): $ReadOnlyArray { const keyserverIDsSet = new Set(keyserverIDs); const filterCondition = (threadID: string) => !keyserverIDsSet.has(extractKeyserverIDFromID(threadID)); return filterThreadIDsInFilterList(state, filterCondition); } export { removeDeletedThreadIDsFromFilterList, removeKeyserverThreadIDsFromFilterList, }; diff --git a/lib/reducers/calendar-query-reducer.js b/lib/reducers/calendar-query-reducer.js index 11f35a6f7..32dde150d 100644 --- a/lib/reducers/calendar-query-reducer.js +++ b/lib/reducers/calendar-query-reducer.js @@ -1,49 +1,49 @@ // @flow import { updateCalendarQueryActionTypes } from '../actions/entry-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, registerActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { defaultCalendarQuery } from '../types/entry-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { type BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; function reduceCalendarQuery( state: CalendarQuery, action: BaseAction, ): CalendarQuery { if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return defaultCalendarQuery(getConfig().platformDetails.platform); } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { return action.payload.calendarResult.calendarQuery; } else if ( action.type === registerActionTypes.success || action.type === updateCalendarQueryActionTypes.success || action.type === fullStateSyncActionType || action.type === incrementalStateSyncActionType ) { return action.payload.calendarQuery; } return state; } export { reduceCalendarQuery }; diff --git a/lib/reducers/data-loaded-reducer.js b/lib/reducers/data-loaded-reducer.js index a0d229597..df3082c5d 100644 --- a/lib/reducers/data-loaded-reducer.js +++ b/lib/reducers/data-loaded-reducer.js @@ -1,40 +1,40 @@ // @flow import { setDataLoadedActionType } from '../actions/client-db-store-actions.js'; import { keyserverAuthActionTypes, logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { BaseAction } from '../types/redux-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; export default function reduceDataLoaded( state: boolean, action: BaseAction, ): boolean { if (action.type === setDataLoadedActionType) { return action.payload.dataLoaded; } else if (action.type === logInActionTypes.success) { return true; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && action.payload.sessionChange.currentUserInfo.anonymous ) { return false; } else if ( action.type === logOutActionTypes.started || action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success ) { return false; } else if (action.type === keyserverAuthActionTypes.success) { if (ashoatKeyserverID in action.payload.updatesCurrentAsOf) { return true; } } return state; } diff --git a/lib/reducers/draft-reducer.js b/lib/reducers/draft-reducer.js index 2028e04e0..2b82ee25f 100644 --- a/lib/reducers/draft-reducer.js +++ b/lib/reducers/draft-reducer.js @@ -1,91 +1,91 @@ // @flow import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { moveDraftActionType, updateDraftActionType, } from '../actions/draft-actions.js'; import { deleteKeyserverAccountActionTypes, logOutActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { DraftStore, DraftStoreOperation } from '../types/draft-types.js'; import type { BaseAction } from '../types/redux-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; type ReduceDraftStoreResult = { +draftStoreOperations: $ReadOnlyArray, +draftStore: DraftStore, }; function reduceDraftStore( draftStore: DraftStore, action: BaseAction, ): ReduceDraftStoreResult { if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { draftStoreOperations: [{ type: 'remove_all' }], draftStore: { drafts: {} }, }; } else if (action.type === updateDraftActionType) { const { key, text } = action.payload; const draftStoreOperations = [ { type: 'update', payload: { key, text }, }, ]; return { draftStoreOperations, draftStore: { ...draftStore, drafts: { ...draftStore.drafts, [key]: text, }, }, }; } else if (action.type === moveDraftActionType) { const { oldKey, newKey } = action.payload; const draftStoreOperations = [ { type: 'move', payload: { oldKey, newKey }, }, ]; const { [oldKey]: text, ...draftsWithoutOldKey } = draftStore.drafts; return { draftStoreOperations, draftStore: { ...draftStore, drafts: { ...draftsWithoutOldKey, [newKey]: text, }, }, }; } else if (action.type === setClientDBStoreActionType) { const drafts: { [string]: string } = {}; for (const dbDraftInfo of action.payload.drafts) { drafts[dbDraftInfo.key] = dbDraftInfo.text; } return { draftStoreOperations: [], draftStore: { ...draftStore, drafts: drafts, }, }; } return { draftStore, draftStoreOperations: [] }; } export { reduceDraftStore }; diff --git a/lib/reducers/enabled-apps-reducer.js b/lib/reducers/enabled-apps-reducer.js index f8c8b91dc..6ce15e45a 100644 --- a/lib/reducers/enabled-apps-reducer.js +++ b/lib/reducers/enabled-apps-reducer.js @@ -1,38 +1,38 @@ // @flow import { deleteKeyserverAccountActionTypes, logOutActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { EnabledApps } from '../types/enabled-apps.js'; import { defaultEnabledApps, defaultWebEnabledApps, } from '../types/enabled-apps.js'; import type { BaseAction } from '../types/redux-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; export const enableAppActionType = 'ENABLE_APP'; export const disableAppActionType = 'DISABLE_APP'; export default function reduceEnabledApps( state: EnabledApps, action: BaseAction, ): EnabledApps { if (action.type === enableAppActionType && action.payload === 'calendar') { return { ...state, calendar: true }; } else if ( action.type === disableAppActionType && action.payload === 'calendar' ) { return { ...state, calendar: false }; } else if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return process.env.BROWSER ? defaultWebEnabledApps : defaultEnabledApps; } return state; } diff --git a/lib/reducers/entry-reducer.js b/lib/reducers/entry-reducer.js index 371c4e411..aaa18f14c 100644 --- a/lib/reducers/entry-reducer.js +++ b/lib/reducers/entry-reducer.js @@ -1,675 +1,675 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; 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 _union from 'lodash/fp/union.js'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, createLocalEntryActionType, createEntryActionTypes, saveEntryActionTypes, concurrentModificationResetActionType, deleteEntryActionTypes, fetchRevisionsForEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { deleteThreadActionTypes, leaveThreadActionTypes, joinThreadActionTypes, changeThreadSettingsActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, newThreadActionTypes, } from '../actions/thread-actions.js'; import { keyserverAuthActionTypes, logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.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 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, } from '../types/socket-types.js'; import { type RawThreadInfos } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { dateString } from '../utils/date-utils.js'; import { values } from '../utils/objects.js'; 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 filterExistingDaysToEntriesWithNewEntryInfos( oldDaysToEntries: { +[id: string]: string[] }, newEntryInfos: { +[id: string]: RawEntryInfo }, ) { return _mapValues((entryIDs: string[]) => _filter((id: string) => newEntryInfos[id])(entryIDs), )(oldDaysToEntries); } function mergeNewEntryInfos( currentEntryInfos: { +[id: string]: RawEntryInfo }, currentDaysToEntries: ?{ +[day: string]: string[] }, newEntryInfos: $ReadOnlyArray, threadInfos: RawThreadInfos, ) { const mergedEntryInfos: { [string]: RawEntryInfo } = {}; let someEntryUpdated = false; 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 { mergedEntryInfos[serverID] = newEntryInfo; someEntryUpdated = true; } } for (const id in currentEntryInfos) { const newEntryInfo = mergedEntryInfos[id]; if (!newEntryInfo) { mergedEntryInfos[id] = currentEntryInfos[id]; } } for (const id in mergedEntryInfos) { const entryInfo = mergedEntryInfos[id]; if (!threadInFilterList(threadInfos[entryInfo.threadID])) { someEntryUpdated = true; delete mergedEntryInfos[id]; } } const daysToEntries = !currentDaysToEntries || someEntryUpdated ? daysToEntriesFromEntryInfos(values(mergedEntryInfos)) : currentDaysToEntries; const entryInfos = someEntryUpdated ? mergedEntryInfos : currentEntryInfos; return [entryInfos, daysToEntries]; } function reduceEntryInfos( entryStore: EntryStore, action: BaseAction, newThreadInfos: RawThreadInfos, ): [EntryStore, $ReadOnlyArray] { const { entryInfos, daysToEntries, lastUserInteractionCalendar } = entryStore; if ( action.type === logOutActionTypes.success || 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); const newLastUserInteractionCalendar = action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return [ { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } else if (action.type === setNewSessionActionType) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); const newLastUserInteractionCalendar = action.payload.sessionChange .cookieInvalidated ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return [ { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } else if (action.type === fetchEntriesActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if ( action.type === updateCalendarQueryActionTypes.started && action.payload && action.payload.calendarQuery ) { return [ { entryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if (action.type === updateCalendarQueryActionTypes.success) { const newLastUserInteractionCalendar = action.payload.calendarQuery ? Date.now() : lastUserInteractionCalendar; const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return [ { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, }, [], ]; } else if (action.type === createLocalEntryActionType) { const entryInfo = action.payload; const localID = entryInfo.localID; invariant(localID, 'localID should be set in CREATE_LOCAL_ENTRY'); const newEntryInfos = { ...entryInfos, [(localID: string)]: entryInfo, }; const dayString = dateString( entryInfo.year, entryInfo.month, entryInfo.day, ); const newDaysToEntries = { ...daysToEntries, [dayString]: _union([localID])(daysToEntries[dayString]), }; return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } 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, []]; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( rekeyedEntryInfos, null, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } 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, []]; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } 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, []]; } const newEntryInfos = { ...entryInfos, [payload.id]: { ...entryInfos[payload.id], text: payload.dbText, }, }; return [ { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, }, [], ]; } 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 newEntryInfos = { ...entryInfos, [(id: string)]: { ...entryInfos[id], deleted: true, }, }; return [ { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if (action.type === deleteEntryActionTypes.success) { const { payload } = action; if (payload) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } } 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, []]; } // Make sure the entry is in sync with its latest revision const newEntryInfos = { ...entryInfos, [id]: { ...entryInfos[id], text: action.payload.text, deleted: action.payload.deleted, }, }; return [ { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === restoreEntryActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), }, [], ]; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === keyserverAuthActionTypes.success ) { const { calendarResult } = action.payload; if (calendarResult) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, calendarResult.rawEntryInfos, newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } } else if (action.type === incrementalStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos( action.payload.deltaEntryInfos, action.payload.updatesResult.newUpdates, ), newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return [ { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if ( action.type === processUpdatesActionType || action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success ) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.newUpdates), newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === fullStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return [ { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, }, [], ]; } 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, []]; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return [ { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar, }, [], ]; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return [entryStore, []]; } const { rawEntryInfos, deleteEntryIDs } = checkStateRequest.stateChanges; if (!rawEntryInfos && !deleteEntryIDs) { return [entryStore, []]; } const updatedEntryInfos: { [string]: RawEntryInfo } = { ...entryInfos }; if (deleteEntryIDs) { for (const deleteEntryID of deleteEntryIDs) { delete updatedEntryInfos[deleteEntryID]; } } let mergedEntryInfos: { +[string]: RawEntryInfo }; let mergedDaysToEntries; if (rawEntryInfos) { [mergedEntryInfos, mergedDaysToEntries] = mergeNewEntryInfos( updatedEntryInfos, null, rawEntryInfos, newThreadInfos, ); } else { mergedEntryInfos = updatedEntryInfos; mergedDaysToEntries = daysToEntriesFromEntryInfos( values(updatedEntryInfos), ); } const newInconsistencies = stateSyncSpecs.entries.findStoreInconsistencies( action, entryInfos, mergedEntryInfos, ); return [ { entryInfos: mergedEntryInfos, daysToEntries: mergedDaysToEntries, lastUserInteractionCalendar, }, newInconsistencies, ]; } return [entryStore, []]; } 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, ): { +[id: string]: RawEntryInfo } { let result = entryInfos; for (const deletedEntryID of deletedEntryIDs) { const entryInfo = entryInfos[deletedEntryID]; if (!entryInfo || entryInfo.deleted) { continue; } result = { ...result, [deletedEntryID]: { ...entryInfo, deleted: true, }, }; } return result; } export { daysToEntriesFromEntryInfos, reduceEntryInfos }; diff --git a/lib/reducers/invite-links-reducer.js b/lib/reducers/invite-links-reducer.js index cdc5e0df9..4e46db3e2 100644 --- a/lib/reducers/invite-links-reducer.js +++ b/lib/reducers/invite-links-reducer.js @@ -1,74 +1,74 @@ // @flow import { createOrUpdatePublicLinkActionTypes, disableInviteLinkLinkActionTypes, fetchPrimaryInviteLinkActionTypes, } from '../actions/link-actions.js'; import { deleteKeyserverAccountActionTypes, logOutActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { InviteLinksStore, CommunityLinks } from '../types/link-types.js'; import type { BaseAction } from '../types/redux-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; function reduceInviteLinks( state: InviteLinksStore, action: BaseAction, ): InviteLinksStore { if (action.type === fetchPrimaryInviteLinkActionTypes.success) { const links: { [string]: CommunityLinks } = {}; for (const link of action.payload.links) { links[link.communityID] = { ...state.links[link.communityID], primaryLink: link, }; } return { links, }; } else if (action.type === createOrUpdatePublicLinkActionTypes.success) { const communityID = action.payload.communityID; return { ...state, links: { ...state.links, [communityID]: { ...state.links[communityID], primaryLink: action.payload, }, }, }; } else if (action.type === disableInviteLinkLinkActionTypes.success) { const communityID = action.payload.communityID; const currentPrimaryLink = state.links[communityID]?.primaryLink; if (currentPrimaryLink?.name !== action.payload.name) { return state; } const communityLinks = { ...state.links[communityID], primaryLink: null, }; return { ...state, links: { ...state.links, [communityID]: communityLinks, }, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { links: {}, }; } return state; } export default reduceInviteLinks; diff --git a/lib/reducers/keyserver-reducer.js b/lib/reducers/keyserver-reducer.js index ebca67a8b..416b00715 100644 --- a/lib/reducers/keyserver-reducer.js +++ b/lib/reducers/keyserver-reducer.js @@ -1,402 +1,402 @@ // @flow import { unsupervisedBackgroundActionType } from './lifecycle-state-reducer.js'; import { updateActivityActionTypes } from '../actions/activity-actions.js'; import { updateLastCommunicatedPlatformDetailsActionType, setDeviceTokenActionTypes, } from '../actions/device-actions.js'; import { addKeyserverActionType, removeKeyserverActionType, } from '../actions/keyserver-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { keyserverAuthActionTypes, logOutActionTypes, deleteKeyserverAccountActionTypes, registerActionTypes, logInActionTypes, resetUserStateActionType, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { queueActivityUpdatesActionType } from '../types/activity-types.js'; import type { KeyserverInfos, KeyserverStore } from '../types/keyserver-types'; import type { BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, setLateResponseActionType, updateDisconnectedBarActionType, setConnectionIssueActionType, } from '../types/socket-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { processUpdatesActionType } from '../types/update-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { setURLPrefix } from '../utils/url-utils.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; export default function reduceKeyserverStore( state: KeyserverStore, action: BaseAction, ): KeyserverStore { if (action.type === addKeyserverActionType) { return { ...state, keyserverInfos: { ...state.keyserverInfos, [action.payload.keyserverAdminUserID]: { ...action.payload.newKeyserverInfo, }, }, }; } else if (action.type === removeKeyserverActionType) { const { [action.payload.keyserverAdminUserID]: _, ...rest } = state.keyserverInfos; return { ...state, keyserverInfos: rest, }; } else if (action.type === resetUserStateActionType) { // this action is only dispatched on native const keyserverInfos = { ...state.keyserverInfos }; for (const keyserverID in keyserverInfos) { const stateCookie = state.keyserverInfos[keyserverID]?.cookie; const cookie = stateCookie && stateCookie.startsWith('anonymous=') ? stateCookie : null; keyserverInfos[keyserverID] = { ...keyserverInfos[keyserverID], cookie }; } return { ...state, keyserverInfos, }; } else if (action.type === setNewSessionActionType) { const { keyserverID, sessionChange } = action.payload; if (!state.keyserverInfos[keyserverID]) { if (sessionChange.cookie?.startsWith('user=')) { console.log( 'received sessionChange with user cookie, ' + `but keyserver ${keyserverID} is not in KeyserverStore!`, ); } return state; } if (sessionChange.cookie !== undefined) { state = { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], cookie: sessionChange.cookie, }, }, }; } if (sessionChange.cookieInvalidated) { state = { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, queuedActivityUpdates: [], }, }, }, }; } return state; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === keyserverAuthActionTypes.success ) { const { updatesCurrentAsOf } = action.payload; let keyserverInfos = { ...state.keyserverInfos }; for (const keyserverID in updatesCurrentAsOf) { keyserverInfos = { ...keyserverInfos, [keyserverID]: { ...keyserverInfos[keyserverID], updatesCurrentAsOf: updatesCurrentAsOf[keyserverID], lastCommunicatedPlatformDetails: getConfig().platformDetails, }, }; } return { ...state, keyserverInfos, }; } else if (action.type === fullStateSyncActionType) { const { keyserverID } = action.payload; return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], updatesCurrentAsOf: action.payload.updatesCurrentAsOf, }, }, }; } else if (action.type === incrementalStateSyncActionType) { const { keyserverID } = action.payload; let { deviceToken } = state.keyserverInfos[keyserverID]; for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.keyserverInfos[keyserverID].deviceToken ) { deviceToken = null; break; } } return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], updatesCurrentAsOf: action.payload.updatesResult.currentAsOf, deviceToken, }, }, }; } else if (action.type === processUpdatesActionType) { const { keyserverID } = action.payload; const updatesCurrentAsOf = Math.max( action.payload.updatesResult.currentAsOf, state.keyserverInfos[keyserverID].updatesCurrentAsOf, ); return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], updatesCurrentAsOf, }, }, }; } else if (action.type === setURLPrefix) { return { ...state, keyserverInfos: { ...state.keyserverInfos, [ashoatKeyserverID]: { ...state.keyserverInfos[ashoatKeyserverID], urlPrefix: action.payload, }, }, }; } else if (action.type === updateLastCommunicatedPlatformDetailsActionType) { const { keyserverID, platformDetails } = action.payload; return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], lastCommunicatedPlatformDetails: platformDetails, }, }, }; } else if (action.type === registerActionTypes.success) { state = { ...state, keyserverInfos: { ...state.keyserverInfos, [ashoatKeyserverID]: { ...state.keyserverInfos[ashoatKeyserverID], lastCommunicatedPlatformDetails: getConfig().platformDetails, }, }, }; } else if (action.type === updateConnectionStatusActionType) { const { keyserverID, status } = action.payload; return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, status, lateResponses: [], }, }, }, }; } else if (action.type === unsupervisedBackgroundActionType) { const { keyserverID } = action.payload; return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, status: 'disconnected', lateResponses: [], }, }, }, }; } else if (action.type === queueActivityUpdatesActionType) { const { activityUpdates, keyserverID } = action.payload; const oldConnection = state.keyserverInfos[keyserverID].connection; const connection = { ...oldConnection, queuedActivityUpdates: [ ...oldConnection.queuedActivityUpdates.filter(existingUpdate => { for (const activityUpdate of activityUpdates) { if ( ((existingUpdate.focus && activityUpdate.focus) || (existingUpdate.focus === false && activityUpdate.focus !== undefined)) && existingUpdate.threadID === activityUpdate.threadID ) { return false; } } return true; }), ...activityUpdates, ], }; return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], connection, }, }, }; } else if (action.type === updateActivityActionTypes.success) { const { activityUpdates } = action.payload; let keyserverInfos = { ...state.keyserverInfos }; for (const keyserverID in activityUpdates) { const oldConnection = keyserverInfos[keyserverID].connection; const queuedActivityUpdates = oldConnection.queuedActivityUpdates.filter( activityUpdate => !activityUpdates[keyserverID].includes(activityUpdate), ); keyserverInfos = { ...keyserverInfos, [keyserverID]: { ...keyserverInfos[keyserverID], connection: { ...oldConnection, queuedActivityUpdates }, }, }; } return { ...state, keyserverInfos, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success ) { // We want to remove all keyservers but Ashoat's keyserver const oldConnection = state.keyserverInfos[ashoatKeyserverID].connection; const keyserverInfos = { [ashoatKeyserverID]: { ...state.keyserverInfos[ashoatKeyserverID], connection: { ...oldConnection, connectionIssue: null, queuedActivityUpdates: [], }, cookie: null, }, }; return { ...state, keyserverInfos, }; } else if (action.type === setLateResponseActionType) { const { messageID, isLate, keyserverID } = action.payload; const lateResponsesSet = new Set( state.keyserverInfos[keyserverID].connection.lateResponses, ); if (isLate) { lateResponsesSet.add(messageID); } else { lateResponsesSet.delete(messageID); } return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, lateResponses: [...lateResponsesSet], }, }, }, }; } else if (action.type === updateDisconnectedBarActionType) { const { keyserverID } = action.payload; return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, showDisconnectedBar: action.payload.visible, }, }, }, }; } else if (action.type === setDeviceTokenActionTypes.success) { const { deviceTokens } = action.payload; const keyserverInfos: { ...KeyserverInfos } = { ...state.keyserverInfos }; for (const keyserverID in deviceTokens) { keyserverInfos[keyserverID] = { ...keyserverInfos[keyserverID], deviceToken: deviceTokens[keyserverID], }; } return { ...state, keyserverInfos, }; } else if (action.type === setConnectionIssueActionType) { const { connectionIssue, keyserverID } = action.payload; return { ...state, keyserverInfos: { ...state.keyserverInfos, [keyserverID]: { ...state.keyserverInfos[keyserverID], connection: { ...state.keyserverInfos[keyserverID].connection, connectionIssue, }, }, }, }; } return state; } diff --git a/lib/reducers/loading-reducer.js b/lib/reducers/loading-reducer.js index 4ba3c0056..42ea88b98 100644 --- a/lib/reducers/loading-reducer.js +++ b/lib/reducers/loading-reducer.js @@ -1,97 +1,97 @@ // @flow import _omit from 'lodash/fp/omit.js'; +import type { ActionTypes } from '../keyserver-conn/keyserver-conn-types.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { BaseAction } from '../types/redux-types.js'; -import type { ActionTypes } from '../utils/action-utils.js'; const fetchKeyRegistry: Set = new Set(); const registerFetchKey = (actionTypes: ActionTypes<*, *, *>) => { fetchKeyRegistry.add(actionTypes.started); fetchKeyRegistry.add(actionTypes.success); fetchKeyRegistry.add(actionTypes.failed); }; function reduceLoadingStatuses( state: { [key: string]: { [idx: number]: LoadingStatus } }, action: BaseAction, ): { [key: string]: { [idx: number]: LoadingStatus } } { const startMatch = action.type.match(/(.*)_STARTED/); if (startMatch && fetchKeyRegistry.has(action.type)) { if (!action.loadingInfo || typeof action.loadingInfo !== 'object') { return state; } const { loadingInfo } = action; if (typeof loadingInfo.fetchIndex !== 'number') { return state; } const keyName: string = loadingInfo.customKeyName && typeof loadingInfo.customKeyName === 'string' ? loadingInfo.customKeyName : startMatch[1]; if (loadingInfo.trackMultipleRequests) { return { ...state, [keyName]: { ...state[keyName], [loadingInfo.fetchIndex]: 'loading', }, }; } else { return { ...state, [keyName]: { [loadingInfo.fetchIndex]: 'loading', }, }; } } const failMatch = action.type.match(/(.*)_FAILED/); if (failMatch && fetchKeyRegistry.has(action.type)) { if (!action.loadingInfo || typeof action.loadingInfo !== 'object') { return state; } const { loadingInfo } = action; if (typeof loadingInfo.fetchIndex !== 'number') { return state; } const keyName: string = loadingInfo.customKeyName && typeof loadingInfo.customKeyName === 'string' ? loadingInfo.customKeyName : failMatch[1]; if (state[keyName] && state[keyName][loadingInfo.fetchIndex]) { return { ...state, [keyName]: { ...state[keyName], [loadingInfo.fetchIndex]: 'error', }, }; } return state; } const successMatch = action.type.match(/(.*)_SUCCESS/); if (successMatch && fetchKeyRegistry.has(action.type)) { if (!action.loadingInfo || typeof action.loadingInfo !== 'object') { return state; } const { loadingInfo } = action; if (typeof loadingInfo.fetchIndex !== 'number') { return state; } const keyName: string = loadingInfo.customKeyName && typeof loadingInfo.customKeyName === 'string' ? loadingInfo.customKeyName : successMatch[1]; if (state[keyName] && state[keyName][loadingInfo.fetchIndex]) { const newKeyState = _omit([loadingInfo.fetchIndex.toString()])( state[keyName], ); return { ...state, [keyName]: newKeyState }; } } return state; } export { reduceLoadingStatuses, registerFetchKey }; diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 565865cb9..075ae22d4 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,1668 +1,1668 @@ // @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 _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 { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, leaveThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, } from '../actions/thread-actions.js'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, registerActionTypes, } 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, } 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 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 BaseAction } from '../types/redux-types.js'; import { processServerRequestsActionType } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import type { RawThreadInfo, RawThreadInfos } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { translateClientDBThreadMessageInfos } from '../utils/message-ops-utils.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: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadIsPending(threadID) || (threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadID))) ); } 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: RawThreadInfos, ): FreshMessageStoreResult { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = sortMessageInfoList(unshimmed); const messages = _keyBy(messageID)(orderedMessageInfos); const messageStoreReplaceOperations = orderedMessageInfos.map( messageInfo => ({ type: 'replace', payload: { id: messageID(messageInfo), messageInfo }, }), ); const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos(orderedMessageInfos); const threads = _mapValuesWithKeys( (messageIDs: string[], threadID: string) => ({ ...newThread(), messageIDs, startReached: truncationStatus[threadID] === messageTruncationStatus.EXHAUSTIVE, }), )(threadsToMessageIDs); const watchedIDs = threadWatcher.getWatchedIDs(); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( threads[threadID] || !isThreadWatched(threadID, threadInfo, watchedIDs) ) { continue; } threads[threadID] = newThread(); } const messageStoreOperations = [ { type: 'remove_all' }, { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads }, }, ...messageStoreReplaceOperations, ]; return { messageStoreOperations, messageStore: { messages, threads, local: {}, currentAsOf, }, }; } type ReassignmentResult = { +messageStoreOperations: MessageStoreOperation[], +messageStore: MessageStore, +reassignedThreadIDs: string[], }; function reassignMessagesToRealizedThreads( messageStore: MessageStore, threadInfos: 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, }; const localIDsToServerIDs: Map = new Map(); const watchedThreadIDs = [ ...threadWatcher.getWatchedIDs(), ...reassignedThreadIDs, ]; const unshimmedNewMessages = unshimMessageInfos(newMessageInfos); const unshimmedNewMessagesOfWatchedThreads = unshimmedNewMessages.filter( msg => isThreadWatched( msg.threadID, threadInfos[msg.threadID], watchedThreadIDs, ), ); const orderedNewMessageInfos = _flow( _map((messageInfo: RawMessageInfo) => { const { id: inputID } = messageInfo; invariant(inputID, 'new messageInfos should have serverID'); invariant( !threadIsPending(messageInfo.threadID), 'new messageInfos should have realized thread id', ); const currentMessageInfo = updatedMessageStore.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? updatedMessageStore.messages[inputLocalID] : null; if (currentMessageInfo && currentMessageInfo.localID) { // If the client already has a RawMessageInfo with this serverID, keep // any localID associated with the existing one. This is because we // use localIDs as React keys and changing React keys leads to loss of // component state. (The conditional below is for Flow) if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawImagesMessageInfo); } } else if (currentLocalMessageInfo && currentLocalMessageInfo.localID) { // If the client has a RawMessageInfo with this localID, but not with // the serverID, that means the message creation succeeded but the // success action never got processed. We set a key in // localIDsToServerIDs here to fix the messageIDs for the rest of the // MessageStore too. (The conditional below is for Flow) invariant(inputLocalID, 'inputLocalID should be set'); localIDsToServerIDs.set(inputLocalID, inputID); if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentLocalMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawImagesMessageInfo); } } else { // If neither the serverID nor the localID from the delivered // RawMessageInfo exists in the client store, then this message is // brand new to us. Ignore any localID provided by the server. // (The conditional below is for Flow) const { localID, ...rest } = messageInfo; if (rest.type === messageTypes.TEXT) { messageInfo = { ...rest }; } else if (rest.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...rest }: RawMediaMessageInfo); } else { messageInfo = ({ ...rest }: RawImagesMessageInfo); } } } else if ( currentMessageInfo && messageInfo.time > currentMessageInfo.time ) { // When thick threads will be introduced it will be possible for two // clients to create the same message (e.g. when they create the same // sidebar at the same time). We're going to use deterministic ids for // messages which should be unique within a thread and we have to find // a way for clients to agree which message to keep. We can't rely on // always choosing incoming messages nor messages from the store, // because a message that is in one user's store, will be send to // another user. One way to deal with it is to always choose a message // which is older, according to its timestamp. We can use this strategy // only for messages that can start a thread, because for other types // it might break the "contiguous" property of message ids (we can // consider selecting younger messages in that case, but for now we use // an invariant). invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR || messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.SIDEBAR_SOURCE, `Two different messages of type ${messageInfo.type} with the same ` + 'id found', ); return currentMessageInfo; } return _isEqual(messageInfo)(currentMessageInfo) ? currentMessageInfo : messageInfo; }), sortMessageInfoList, )(unshimmedNewMessagesOfWatchedThreads); const newMessageOps: MessageStoreOperation[] = []; const threadsToMessageIDs = mapThreadsToMessageIDsFromOrderedMessageInfos( orderedNewMessageInfos, ); const oldMessageInfosToCombine = []; const threadsThatNeedMessageIDsResorted = []; const local: { [string]: LocalMessageInfo } = {}; 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) { local[id] = localInfo; } } const startReached = oldThread.startReached || truncate === messageTruncationStatus.EXHAUSTIVE; if (_difference(messageIDs)(oldMessageIDs).length === 0) { if (startReached === oldThread.startReached && oldMessageIDsUnchanged) { return oldThread; } updatedThreads[threadID] = { messageIDs: oldMessageIDs, startReached, }; 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) { local[id] = localInfo; } } } const messages = _flow( sortMessageInfoList, _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); const newMessages = _keyBy(messageID)(orderedNewMessageInfos); for (const id in newMessages) { newMessageOps.push({ type: 'replace', payload: { id, messageInfo: newMessages[id] }, }); } if (localIDsToServerIDs.size > 0) { newMessageOps.push({ type: 'remove', payload: { ids: [...localIDsToServerIDs.keys()] }, }); } for (const threadID of threadsThatNeedMessageIDsResorted) { threads[threadID].messageIDs = sortMessageIDs(messages)( threads[threadID].messageIDs, ); updatedThreads[threadID] = threads[threadID]; } 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], ); } const messageStore = { messages: processedMessageStore.messages, threads: processedMessageStore.threads, 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 }, }); 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, ): ReduceMessageStoreResult { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { const messagesResult = action.payload.messagesResult; const { messageStoreOperations, messageStore: freshStore } = freshMessageStore( messagesResult.messageInfos, messagesResult.truncationStatus, messagesResult.currentAsOf, newThreadInfos, ); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...freshStore, messages: processedMessageStore.messages, threads: processedMessageStore.threads, }, }; } else if (action.type === incrementalStateSyncActionType) { if ( action.payload.messagesResult.rawMessageInfos.length === 0 && action.payload.updatesResult.newUpdates.length === 0 ) { return { messageStoreOperations: [], messageStore }; } const messagesResult = mergeUpdatesWithMessageInfos( action.payload.messagesResult.rawMessageInfos, action.payload.updatesResult.newUpdates, action.payload.messagesResult.truncationStatuses, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, ); } 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 ) { 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 === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || 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, }, }; } 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 === registerActionTypes.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; let local = { ...messageStore.local }; if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [(localID: string)]: payload, }; local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== localID, )(messageStore.local); const thread = messageStore.threads[threadID]; updatedThreads = { [(threadID: string)]: { messageIDs: sortMessageIDs(messages)(messageIDs), startReached: thread?.startReached ?? true, }, }; } 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, currentAsOf: messageStore.currentAsOf, }; return { messageStoreOperations, messageStore: newMessageStore, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; return { messageStoreOperations: [], messageStore: { messages: messageStore.messages, threads: messageStore.threads, local: { ...messageStore.local, [(localID: string)]: { sendFailed: true }, }, 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)); const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== payload.localID, )(messageStore.local); const updatedThreads = { [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }; messageStoreOperations.push({ type: 'replace_threads', payload: { threads: updatedThreads }, }); const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); return { messageStoreOperations, messageStore: { ...messageStore, messages: processedMessageStore.messages, threads: processedMessageStore.threads, 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 messageStoreOperations = [ { type: 'remove', payload: { ids: messageIDsToPrune }, }, { type: 'replace_threads', payload: { threads: updatedThreads, }, }, ]; const processedMessageStore = processMessageStoreOperations( messageStore, messageStoreOperations, ); const newMessageStore = { messages: processedMessageStore.messages, threads: processedMessageStore.threads, local: _omit(messageIDsToPrune)(messageStore.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, }, }; } 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, }, }; } 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, }, }; } else if (action.type === setClientDBStoreActionType) { const actionPayloadMessageStoreThreads = translateClientDBThreadMessageInfos( action.payload.messageStoreThreads ?? [], ); 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, }; } 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/report-store-reducer.js b/lib/reducers/report-store-reducer.js index ce0652d6c..4cd874d59 100644 --- a/lib/reducers/report-store-reducer.js +++ b/lib/reducers/report-store-reducer.js @@ -1,195 +1,195 @@ // @flow import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { sendReportActionTypes, sendReportsActionTypes, queueReportsActionType, } from '../actions/report-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { ReportStoreOperation } from '../ops/report-store-ops.js'; import { convertReportsToRemoveReportsOperation, convertReportsToReplaceReportOps, reportStoreOpsHandlers, } from '../ops/report-store-ops.js'; import { isStaff } from '../shared/staff-utils.js'; import type { BaseAction } from '../types/redux-types.js'; import { type ReportStore, defaultEnabledReports, defaultDevEnabledReports, type ClientReportCreationRequest, } from '../types/report-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { isDev } from '../utils/dev-utils.js'; import { isReportEnabled } from '../utils/report-utils.js'; export const updateReportsEnabledActionType = 'UPDATE_REPORTS_ENABLED'; const { processStoreOperations: processReportStoreOperations } = reportStoreOpsHandlers; export default function reduceReportStore( state: ReportStore, action: BaseAction, newInconsistencies: $ReadOnlyArray, ): { reportStore: ReportStore, reportStoreOperations: $ReadOnlyArray, } { const newReports = newInconsistencies.filter(report => isReportEnabled(report, state.enabledReports), ); if (action.type === updateReportsEnabledActionType) { const newEnabledReports = { ...state.enabledReports, ...action.payload }; const newFilteredReports = newReports.filter(report => isReportEnabled(report, newEnabledReports), ); const reportsToRemove = state.queuedReports.filter( report => !isReportEnabled(report, newEnabledReports), ); const reportStoreOperations: $ReadOnlyArray = [ convertReportsToRemoveReportsOperation(reportsToRemove), ...convertReportsToReplaceReportOps(newFilteredReports), ]; const queuedReports = processReportStoreOperations( state.queuedReports, reportStoreOperations, ); return { reportStore: { queuedReports, enabledReports: newEnabledReports, }, reportStoreOperations, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { reportStore: { queuedReports: [], enabledReports: isDev ? defaultDevEnabledReports : defaultEnabledReports, }, reportStoreOperations: [{ type: 'remove_all_reports' }], }; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success ) { return { reportStore: { queuedReports: [], enabledReports: isStaff(action.payload.currentUserInfo.id) || isDev ? defaultDevEnabledReports : defaultEnabledReports, }, reportStoreOperations: [{ type: 'remove_all_reports' }], }; } else if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const sentReports = state.queuedReports.filter(response => payload.reports.includes(response), ); const reportStoreOperations: $ReadOnlyArray = [ convertReportsToRemoveReportsOperation(sentReports), ...convertReportsToReplaceReportOps(newReports), ]; const queuedReports = processReportStoreOperations( state.queuedReports, reportStoreOperations, ); return { reportStore: { ...state, queuedReports }, reportStoreOperations, }; } else if (action.type === queueReportsActionType) { const { reports } = action.payload; const filteredReports = reports.filter(report => isReportEnabled(report, state.enabledReports), ); const reportStoreOperations: $ReadOnlyArray = convertReportsToReplaceReportOps([...newReports, ...filteredReports]); const queuedReports = processReportStoreOperations( state.queuedReports, reportStoreOperations, ); return { reportStore: { ...state, queuedReports, }, reportStoreOperations, }; } else if (action.type === setClientDBStoreActionType) { const { reports } = action.payload; if (!reports) { return { reportStore: state, reportStoreOperations: [], }; } const reportStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_reports', }, ...convertReportsToReplaceReportOps(reports), ]; const queuedReports = processReportStoreOperations( state.queuedReports, reportStoreOperations, ); return { reportStore: { ...state, queuedReports }, reportStoreOperations: [], }; } if (newReports.length > 0) { const reportStoreOperations: $ReadOnlyArray = convertReportsToReplaceReportOps(newReports); const queuedReports = processReportStoreOperations( state.queuedReports, reportStoreOperations, ); return { reportStore: { ...state, queuedReports, }, reportStoreOperations, }; } return { reportStore: state, reportStoreOperations: [] }; } diff --git a/lib/reducers/services-access-token-reducer.js b/lib/reducers/services-access-token-reducer.js index 1b494c104..909325464 100644 --- a/lib/reducers/services-access-token-reducer.js +++ b/lib/reducers/services-access-token-reducer.js @@ -1,31 +1,31 @@ // @flow import { logOutActionTypes, deleteKeyserverAccountActionTypes, setAccessTokenActionType, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { BaseAction } from '../types/redux-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; export default function reduceServicesAccessToken( state: ?string, action: BaseAction, ): ?string { if (action.type === setAccessTokenActionType) { return action.payload; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && action.payload.sessionChange.currentUserInfo.anonymous ) { return null; } else if ( action.type === logOutActionTypes.started || action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success ) { return null; } return state; } diff --git a/lib/reducers/theme-reducer.js b/lib/reducers/theme-reducer.js index 1635b76df..d5c9328ff 100644 --- a/lib/reducers/theme-reducer.js +++ b/lib/reducers/theme-reducer.js @@ -1,49 +1,49 @@ // @flow import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { updateThemeInfoActionType } from '../actions/theme-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, registerActionTypes, tempIdentityLoginActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { defaultGlobalThemeInfo, type GlobalThemeInfo, } from '../types/theme-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; export default function reduceGlobalThemeInfo( state: GlobalThemeInfo, action: BaseAction, ): GlobalThemeInfo { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === tempIdentityLoginActionTypes.success ) { return defaultGlobalThemeInfo; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && action.payload.sessionChange.currentUserInfo.anonymous ) { return defaultGlobalThemeInfo; } else if ( action.type === logOutActionTypes.started || action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success ) { return defaultGlobalThemeInfo; } else if (action.type === updateThemeInfoActionType) { return { ...state, ...action.payload, }; } return state; } diff --git a/lib/reducers/thread-activity-reducer.js b/lib/reducers/thread-activity-reducer.js index 0529f230a..90d96c949 100644 --- a/lib/reducers/thread-activity-reducer.js +++ b/lib/reducers/thread-activity-reducer.js @@ -1,103 +1,103 @@ // @flow import invariant from 'invariant'; import { messageStorePruneActionType } from '../actions/message-actions.js'; import { changeThreadMemberRolesActionTypes, changeThreadSettingsActionTypes, deleteCommunityRoleActionTypes, deleteThreadActionTypes, joinThreadActionTypes, leaveThreadActionTypes, modifyCommunityRoleActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { BaseAction } from '../types/redux-types.js'; import { incrementalStateSyncActionType } 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 { setNewSessionActionType } from '../utils/action-utils.js'; function reduceThreadActivity( state: ThreadActivityStore, action: BaseAction, ): ThreadActivityStore { if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; const updatedThreadActivityStore = { ...state, [threadID]: { ...state[threadID], lastNavigatedTo: time, }, }; return updatedThreadActivityStore; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); let updatedThreadActivityStore = { ...state }; for (const threadID: string of action.payload.threadIDs) { updatedThreadActivityStore = { ...updatedThreadActivityStore, [threadID]: { ...updatedThreadActivityStore[threadID], lastPruned: now, }, }; } return updatedThreadActivityStore; } 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 state; } const deleteThreadUpdates = newUpdates.filter( (update: ClientUpdateInfo) => update.type === updateTypes.DELETE_THREAD, ); if (deleteThreadUpdates.length === 0) { return state; } let updatedState = { ...state }; for (const update: ClientUpdateInfo of deleteThreadUpdates) { invariant( update.type === updateTypes.DELETE_THREAD, 'update must be of type DELETE_THREAD', ); const { [update.threadID]: _, ...stateSansRemovedThread } = updatedState; updatedState = stateSansRemovedThread; } return updatedState; } else if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return {}; } return state; } export { reduceThreadActivity }; diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js index ae817408b..2f6d70a72 100644 --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -1,524 +1,524 @@ // @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 { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, leaveThreadActionTypes, modifyCommunityRoleActionTypes, deleteCommunityRoleActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, registerActionTypes, updateSubscriptionActionTypes, } from '../actions/user-actions.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 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, } from '../types/socket-types.js'; import type { RawThreadInfo, RawThreadInfos, ThreadStore, } from '../types/thread-types.js'; import { type ClientUpdateInfo, processUpdatesActionType, } from '../types/update-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; const { processStoreOperations: processThreadStoreOperations } = threadStoreOpsHandlers; function generateOpsForThreadUpdates( threadInfos: RawThreadInfos, payload: { +updatesResult: { +newUpdates: $ReadOnlyArray, ... }, ... }, ): $ReadOnlyArray { return payload.updatesResult.newUpdates .map(update => updateSpecs[update.type].generateOpsForThreadUpdates?.( threadInfos, update, ), ) .filter(Boolean) .flat(); } function reduceThreadInfos( state: ThreadStore, action: BaseAction, ): { threadStore: ThreadStore, newThreadInconsistencies: $ReadOnlyArray, threadStoreOperations: $ReadOnlyArray, } { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === fullStateSyncActionType ) { 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 === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.threadInfos).length === 0) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadStoreOperations = [ { type: 'remove_all', }, ]; 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, action.payload, ); 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]; // TODO (atul): Try to get rid of this ridiculous branching. if (threadInfo.minimallyEncoded) { 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 { 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; } const changedThreadInfo = state.threadInfos[threadID]; // TODO (atul): Try to get rid of this ridiculous branching. if (changedThreadInfo.minimallyEncoded) { changedThreadInfos[threadID] = { ...changedThreadInfo, currentUser: { ...changedThreadInfo.currentUser, unread: true, }, }; } else { changedThreadInfos[threadID] = { ...changedThreadInfo, currentUser: { ...changedThreadInfo.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]; // TODO (atul): Try to get rid of this ridiculous branching. if (threadInfo.minimallyEncoded) { if (threadInfo && !threadInfo.currentUser.unread) { updatedThreadInfos[setToUnread] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } } else { 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]; // TODO (atul): Try to get rid of this ridiculous branching. if (threadInfo.minimallyEncoded) { 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 { 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 currentUser = state.threadInfos[threadID].currentUser; if (!resetToUnread || currentUser.unread) { return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } const threadInfo = state.threadInfos[threadID]; // TODO (atul): Try to get rid of this ridiculous branching. if (threadInfo.minimallyEncoded) { const updatedThread = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: threadID, threadInfo: updatedThread, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state, threadStoreOperations, ); return { threadStore: updatedThreadStore, newThreadInconsistencies: [], threadStoreOperations, }; } else { const updatedThread = { ...threadInfo, currentUser: { ...threadInfo.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: [], }; } return { threadStore: state, newThreadInconsistencies: [], threadStoreOperations: [], }; } export { reduceThreadInfos }; diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 1d7718948..19017dc00 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,416 +1,416 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import { setClientDBStoreActionType } from '../actions/client-db-store-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { joinThreadActionTypes, newThreadActionTypes, } from '../actions/thread-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, registerActionTypes, setUserSettingsActionTypes, updateUserAvatarActionTypes, resetUserStateActionType, } from '../actions/user-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { convertUserInfosToReplaceUserOps, type UserStoreOperation, userStoreOpsHandlers, } from '../ops/user-store-ops.js'; import { stateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import { updateSpecs } from '../shared/updates/update-specs.js'; import type { BaseAction } from '../types/redux-types.js'; import type { ClientUserInconsistencyReportCreationRequest } from '../types/report-types.js'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import { updateTypes } from '../types/update-types-enum.js'; import { processUpdatesActionType } from '../types/update-types.js'; import type { ClientUpdateInfo } from '../types/update-types.js'; import type { CurrentUserInfo, UserInfos, UserStore, } from '../types/user-types.js'; -import { setNewSessionActionType } from '../utils/action-utils.js'; import { getMessageForException } from '../utils/errors.js'; import { assertObjectsAreEqual } from '../utils/objects.js'; function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo ) { const { sessionChange } = action.payload; if (!_isEqual(sessionChange.currentUserInfo)(state)) { return sessionChange.currentUserInfo; } } else if (action.type === fullStateSyncActionType) { const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { return action.payload.updatesResult.newUpdates.reduce( (reducedState, update) => { const { reduceCurrentUser } = updateSpecs[update.type]; return reduceCurrentUser ? reduceCurrentUser(reducedState, update) : reducedState; }, state, ); } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); const newCurrentUserInfo = checkStateRequest?.stateChanges?.currentUserInfo; if (newCurrentUserInfo && !_isEqual(newCurrentUserInfo)(state)) { return newCurrentUserInfo; } } else if ( action.type === updateUserAvatarActionTypes.success && state && !state.anonymous ) { const { viewerUpdates } = action.payload.updates; for (const update of viewerUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo.avatar)(state.avatar) ) { return { ...state, avatar: update.currentUserInfo.avatar, }; } } return state; } else if (action.type === setUserSettingsActionTypes.success) { if (state?.settings) { return { ...state, settings: { ...state.settings, ...action.payload, }, }; } } else if (action.type === resetUserStateActionType) { state = state && state.anonymous ? state : null; } return state; } function assertUserStoresAreEqual( processedUserStore: UserInfos, expectedUserStore: UserInfos, location: string, onStateDifference: (message: string) => mixed, ) { try { assertObjectsAreEqual( processedUserStore, expectedUserStore, `UserInfos - ${location}`, ); } catch (e) { console.log( 'Error processing UserStore ops', processedUserStore, expectedUserStore, ); const message = `Error processing UserStore ops ${ getMessageForException(e) ?? '{no exception message}' }`; onStateDifference(message); } } const { processStoreOperations: processUserStoreOps } = userStoreOpsHandlers; function generateOpsForUserUpdates(payload: { +updatesResult: { +newUpdates: $ReadOnlyArray, ... }, ... }): $ReadOnlyArray { return payload.updatesResult.newUpdates .map(update => updateSpecs[update.type].generateOpsForUserInfoUpdates?.(update), ) .filter(Boolean) .flat(); } function reduceUserInfos( state: UserStore, action: BaseAction, onStateDifference: (message: string) => mixed, ): [ UserStore, $ReadOnlyArray, $ReadOnlyArray, ] { if ( action.type === joinThreadActionTypes.success || action.type === newThreadActionTypes.success ) { const newUserInfos: UserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const userStoreOps: $ReadOnlyArray = convertUserInfosToReplaceUserOps(newUserInfos); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); const updated: UserInfos = { ...state.userInfos, ...newUserInfos }; assertUserStoresAreEqual( processedUserInfos, updated, action.type, onStateDifference, ); if (!_isEqual(state.userInfos)(updated)) { return [ { ...state, userInfos: updated, }, [], userStoreOps, ]; } } else if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { const userStoreOps: $ReadOnlyArray = [ { type: 'remove_all_users' }, ]; const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); assertUserStoresAreEqual( processedUserInfos, {}, action.type, onStateDifference, ); if (Object.keys(state.userInfos).length === 0) { return [state, [], []]; } return [ { userInfos: {}, }, [], userStoreOps, ]; } else if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || action.type === fullStateSyncActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const userStoreOps: $ReadOnlyArray = [ { type: 'remove_all_users' }, ...convertUserInfosToReplaceUserOps(newUserInfos), ]; const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); assertUserStoresAreEqual( processedUserInfos, newUserInfos, action.type, onStateDifference, ); if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success || !_isEqual(state.userInfos)(newUserInfos) ) { return [ { userInfos: newUserInfos, }, [], userStoreOps, ]; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); const userStoreOps: $ReadOnlyArray = [ ...convertUserInfosToReplaceUserOps(newUserInfos), ...generateOpsForUserUpdates(action.payload), ]; const updated = action.payload.updatesResult.newUpdates.reduce( (reducedState, update) => { const { reduceUserInfos: reduceUserInfosUpdate } = updateSpecs[update.type]; return reduceUserInfosUpdate ? reduceUserInfosUpdate(reducedState, update) : reducedState; }, { ...state.userInfos, ...newUserInfos }, ); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); assertUserStoresAreEqual( processedUserInfos, updated, action.type, onStateDifference, ); if (!_isEqual(state.userInfos)(updated)) { return [ { ...state, userInfos: updated, }, [], userStoreOps, ]; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return [state, [], []]; } const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; if (!userInfos && !deleteUserInfoIDs) { return [state, [], []]; } const userStoreOps: UserStoreOperation[] = []; const newUserInfos = { ...state.userInfos }; if (userInfos) { for (const userInfo of userInfos) { newUserInfos[userInfo.id] = userInfo; userStoreOps.push({ type: 'replace_user', payload: { ...userInfo } }); } } if (deleteUserInfoIDs) { for (const deleteUserInfoID of deleteUserInfoIDs) { delete newUserInfos[deleteUserInfoID]; } userStoreOps.push({ type: 'remove_users', payload: { ids: deleteUserInfoIDs }, }); } const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); assertUserStoresAreEqual( processedUserInfos, newUserInfos, action.type, onStateDifference, ); const newInconsistencies = stateSyncSpecs.users.findStoreInconsistencies( action, state.userInfos, newUserInfos, ); return [ { userInfos: newUserInfos, }, newInconsistencies, userStoreOps, ]; } else if (action.type === updateUserAvatarActionTypes.success) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.updates.userInfos, ); const userStoreOps: $ReadOnlyArray = convertUserInfosToReplaceUserOps(newUserInfos); const processedUserInfos: UserInfos = processUserStoreOps( state.userInfos, userStoreOps, ); const updated: UserInfos = { ...state.userInfos, ...newUserInfos }; assertUserStoresAreEqual( processedUserInfos, updated, action.type, onStateDifference, ); const newState = !_isEqual(state.userInfos)(updated) ? { ...state, userInfos: updated, } : state; return [newState, [], userStoreOps]; } else if (action.type === setClientDBStoreActionType) { if (!action.payload.users) { return [state, [], []]; } // Once the functionality is confirmed to work correctly, // we will proceed with returning the users from the payload. assertUserStoresAreEqual( action.payload.users ?? {}, state.userInfos, action.type, onStateDifference, ); return [state, [], []]; } return [state, [], []]; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/selectors/loading-selectors.js b/lib/selectors/loading-selectors.js index 536325aac..8f8849426 100644 --- a/lib/selectors/loading-selectors.js +++ b/lib/selectors/loading-selectors.js @@ -1,98 +1,98 @@ // @flow import invariant from 'invariant'; import _includes from 'lodash/fp/includes.js'; import _isEmpty from 'lodash/fp/isEmpty.js'; import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; +import type { ActionTypes } from '../keyserver-conn/keyserver-conn-types.js'; import { registerFetchKey } from '../reducers/loading-reducer.js'; import type { LoadingStatus } from '../types/loading-types.js'; import type { BaseAppState } from '../types/redux-types.js'; -import type { ActionTypes } from '../utils/action-utils.js'; import { values } from '../utils/objects.js'; function loadingStatusFromInfo(loadingStatusInfo: { [idx: number]: LoadingStatus, }): LoadingStatus { if (_isEmpty(loadingStatusInfo)) { return 'inactive'; } else if (_includes('error')(loadingStatusInfo)) { return 'error'; } else { return 'loading'; } } // This is the key used to store the Promise state in Redux function getTrackingKey( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) { if (overrideKey) { return overrideKey; } const startMatch = actionTypes.started.match(/(.*)_STARTED/); invariant( startMatch && startMatch[1], 'actionTypes.started should always end with _STARTED', ); return startMatch[1]; } const baseCreateLoadingStatusSelector = ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ): ((state: BaseAppState<>) => LoadingStatus) => { // This makes sure that reduceLoadingStatuses tracks this action registerFetchKey(actionTypes); const trackingKey = getTrackingKey(actionTypes, overrideKey); return createSelector( (state: BaseAppState<>) => state.loadingStatuses[trackingKey], (loadingStatusInfo: { [idx: number]: LoadingStatus }) => loadingStatusFromInfo(loadingStatusInfo), ); }; const createLoadingStatusSelector: ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) => (state: BaseAppState<>) => LoadingStatus = _memoize( baseCreateLoadingStatusSelector, getTrackingKey, ); function combineLoadingStatuses( ...loadingStatuses: $ReadOnlyArray ): LoadingStatus { let errorExists = false; for (const loadingStatus of loadingStatuses) { if (loadingStatus === 'loading') { return 'loading'; } if (loadingStatus === 'error') { errorExists = true; } } return errorExists ? 'error' : 'inactive'; } const globalLoadingStatusSelector: (state: BaseAppState<>) => LoadingStatus = createSelector( (state: BaseAppState<>) => state.loadingStatuses, (loadingStatusInfos: { [key: string]: { [idx: number]: LoadingStatus }, }): LoadingStatus => { const loadingStatusInfoValues = values(loadingStatusInfos); const loadingStatuses = loadingStatusInfoValues.map( loadingStatusFromInfo, ); return combineLoadingStatuses(...loadingStatuses); }, ); export { createLoadingStatusSelector, globalLoadingStatusSelector, combineLoadingStatuses, }; diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index 07a41fd1b..cef542073 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,819 +1,817 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import ActivityHandler from './activity-handler.react.js'; import APIRequestHandler from './api-request-handler.react.js'; import CalendarQueryHandler from './calendar-query-handler.react.js'; import { InflightRequests } from './inflight-requests.js'; import MessageHandler from './message-handler.react.js'; import ReportHandler from './report-handler.react.js'; import RequestResponseHandler from './request-response-handler.react.js'; import UpdateHandler from './update-handler.react.js'; import { updateActivityActionTypes } from '../actions/activity-actions.js'; import { updateLastCommunicatedPlatformDetailsActionType } from '../actions/device-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import { unsupervisedBackgroundActionType } from '../reducers/lifecycle-state-reducer.js'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts.js'; import { logInActionSources } from '../types/account-types.js'; import type { CompressedData } from '../types/compression-types.js'; import { type PlatformDetails } from '../types/device-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import { forcePolicyAcknowledgmentActionType } from '../types/policy-types.js'; import type { Dispatch } from '../types/redux-types.js'; import { serverRequestTypes, type ClientClientResponse, type ClientServerRequest, } from '../types/request-types.js'; import { type SessionState, type SessionIdentification, type PreRequestUserState, } from '../types/session-types.js'; import { setConnectionIssueActionType, clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ClientServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, type CommTransportLayer, type ActivityUpdateResponseServerSocketMessage, type ClientStateSyncServerSocketMessage, type PongServerSocketMessage, } from '../types/socket-types.js'; import { actionLogger } from '../utils/action-logger.js'; import type { DispatchActionPromise } from '../utils/action-utils.js'; -import { - setNewSessionActionType, - resolveKeyserverSessionInvalidation, -} from '../utils/action-utils.js'; +import { resolveKeyserverSessionInvalidation } from '../utils/action-utils.js'; import { getConfig } from '../utils/config.js'; import { ServerError, SocketTimeout, SocketOffline } from '../utils/errors.js'; import { promiseAll } from '../utils/promises.js'; import sleep from '../utils/sleep.js'; import { ashoatKeyserverID } from '../utils/validation-utils.js'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = { +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, }; type Props = { ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => CommTransportLayer, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => Promise<$ReadOnlyArray>, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, +noDataAfterPolicyAcknowledgment?: boolean, +lastCommunicatedPlatformDetails: ?PlatformDetails, +decompressSocketMessage: CompressedData => string, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +socketCrashLoopRecovery?: () => Promise, // keyserver olm sessions specific props +getInitialNotificationsEncryptedMessage?: () => Promise, }; type State = { +inflightRequests: ?InflightRequests, }; class Socket extends React.PureComponent { state: State = { inflightRequests: null, }; socket: ?CommTransportLayer; nextClientMessageID: number = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; reopenConnectionAfterClosing: boolean = false; invalidationRecoveryInProgress: boolean = false; initializedWithUserState: ?PreRequestUserState; failuresAfterPolicyAcknowledgment: number = 0; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || !this.props.cookie || !this.props.cookie.startsWith('user=') ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus, keyserverID: ashoatKeyserverID }, }); const socket = this.props.openSocket(); const openObject: { initializeMessageSent?: true } = {}; socket.onopen = () => { if (this.socket === socket) { void this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; void (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected', keyserverID: ashoatKeyserverID }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting', keyserverID: ashoatKeyserverID }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting', keyserverID: ashoatKeyserverID, }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected', keyserverID: ashoatKeyserverID }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect: $Call void, number> = _throttle( () => this.openSocket('reconnecting'), 2000, ); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render(): React.Node { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } sendMessageWithoutID: (message: ClientSocketMessageWithoutID) => number = message => { const id = this.nextClientMessageID++; // These conditions all do the same thing and the runtime checks are only // necessary for Flow if (message.type === clientSocketMessageTypes.INITIAL) { this.sendMessage( ({ ...message, id }: ClientInitialClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.sendMessage( ({ ...message, id }: ClientResponsesClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.PING) { this.sendMessage(({ ...message, id }: PingClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); } return id; }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } messageFromEvent(event: MessageEvent): ?ClientServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } let rawMessage; try { rawMessage = JSON.parse(event.data); } catch (e) { console.log(e); return null; } if (rawMessage.type !== serverSocketMessageTypes.COMPRESSED_MESSAGE) { return rawMessage; } const result = this.props.decompressSocketMessage(rawMessage.payload); try { return JSON.parse(result); } catch (e) { console.log(e); return null; } } receiveMessage: (event: MessageEvent) => Promise = async event => { const message = this.messageFromEvent(event); if (!message) { return; } this.failuresAfterPolicyAcknowledgment = 0; const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } for (const listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } if (errorMessage === 'policies_not_accepted' && this.props.active) { this.props.dispatch({ type: forcePolicyAcknowledgmentActionType, payload, }); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await resolveKeyserverSessionInvalidation( this.props.dispatch, cookie, this.props.urlPrefix, logInActionSources.socketAuthErrorResolutionAttempt, ashoatKeyserverID, this.props.getInitialNotificationsEncryptedMessage, ); if (!recoverySessionChange) { const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, logInActionSource: logInActionSources.socketAuthErrorResolutionAttempt, keyserverID: ashoatKeyserverID, }, }); } this.invalidationRecoveryInProgress = false; } }; addListener: (listener: SocketListener) => void = listener => { this.listeners.add(listener); }; removeListener: (listener: SocketListener) => void = listener => { this.listeners.delete(listener); }; onClose: () => void = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected', keyserverID: ashoatKeyserverID }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const shouldSendInitialPlatformDetails = !_isEqual( this.props.lastCommunicatedPlatformDetails, )(getConfig().platformDetails); const clientResponses: ClientClientResponse[] = []; if (shouldSendInitialPlatformDetails) { clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } let activityUpdatePromise; const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); activityUpdatePromise = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); const stateSyncPromise = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); // https://flow.org/try/#1N4Igxg9gdgZglgcxALlAJwKYEMwBcD6aArlLnALYYrgA2WAzvXGCADQgYAeOBARgJ74AJhhhYiNXClzEM7DFCLl602QF92kEdQb8oYAAQwSeONAMAHNBHJx6GAII0aAHgAqyA8AMBqANoA1hj8nvQycFAIALqetpwYQgZqAHwAFAA6UAYGERZEuJ4AJABK2EIA8lA0-O7JrJkAlJ4ACta29i6F5bwAVgCyWBburAa4-BYYEDAGhVgA7lhwuMnJXpnZkFBhlm12GPQGALwGflEA3OsGm9tB-AfH3T0YeAB0t-SpufkNF1lGEGgDKkaBhcDkjgYAAxncEuAzvF4gyK4AAWMLgPh8DTWfw20BuwQh7z8cHOlzxWzBVhsewhX1wgWCZNxOxp9noLzy9BRqWp7QwP0uaku1zBmHoElw9wM80WYNabIwLywzl5u3Zgr+ooMAgAclhKJ5gH4wmgItFYnB4kI1BDgGpftkYACgSCwXAIdDYfDghykQhUejMdjgOSrviwbcib6Sczstk9QaMIz+FEIeLJfRY46kpdMLgiGgsonKL9hVBMrp9EYTGRzPYoEIAJJQJZwFV9fb0LAIDCpEOXN2jfa4BX8nNwaYZEAojDOCDpEAvMJYNBSgDqSx5i4Ci4aA5ZuBHY9pxxP9he4ogNAAbn2ZEQBTny5dZUtWfynDRUt4j2FzxgSSamobAgHeaBMNA1A3pCLwAEwAIwACwvJCIBqEAA // $FlowFixMe fixed in Flow 0.214 const { stateSyncMessage, activityUpdateMessage } = await promiseAll({ activityUpdateMessage: activityUpdatePromise, stateSyncMessage: stateSyncPromise, }); if (shouldSendInitialPlatformDetails) { this.props.dispatch({ type: updateLastCommunicatedPlatformDetailsActionType, payload: { platformDetails: getConfig().platformDetails, keyserverID: ashoatKeyserverID, }, }); } if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: { [ashoatKeyserverID]: queuedActivityUpdates }, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, keyserverID: ashoatKeyserverID, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, logInActionSource: undefined, keyserverID: ashoatKeyserverID, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, keyserverID: ashoatKeyserverID, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket: (retriesLeft?: number) => Promise = async ( retriesLeft = 1, ) => { try { await this.sendInitialMessage(); } catch (e) { if (this.props.noDataAfterPolicyAcknowledgment) { this.failuresAfterPolicyAcknowledgment++; } else { this.failuresAfterPolicyAcknowledgment = 0; } if ( this.failuresAfterPolicyAcknowledgment >= 2 && this.props.socketCrashLoopRecovery ) { this.failuresAfterPolicyAcknowledgment = 0; try { await this.props.socketCrashLoopRecovery(); } catch (error) { console.log(error); this.props.dispatch({ type: setConnectionIssueActionType, payload: { keyserverID: ashoatKeyserverID, connectionIssue: 'policy_acknowledgement_socket_crash_loop', }, }); } return; } console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatch({ type: setConnectionIssueActionType, payload: { keyserverID: ashoatKeyserverID, connectionIssue: 'not_logged_in_error', }, }); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { void this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse: (messageID: number, isLate: boolean) => void = ( messageID, isLate, ) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate, keyserverID: ashoatKeyserverID }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean = alreadyClosed => { // On native, sometimes the app is backgrounded without the proper // callbacks getting triggered. This leaves us in an incorrect state for // two reasons: // (1) The connection is still considered to be active, causing API // requests to be processed via socket and failing. // (2) We rely on flipping foreground state in Redux to detect activity // changes, and thus won't think we need to update activity. if ( this.props.connection.status !== 'connected' || !this.messageLastReceived || this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || (actionLogger.mostRecentActionTime && actionLogger.mostRecentActionTime + 3000 < Date.now()) ) { return false; } if (!alreadyClosed) { this.cleanUpServerTerminatedSocket(); } this.props.dispatch({ type: unsupervisedBackgroundActionType, payload: { keyserverID: ashoatKeyserverID }, }); return true; }; } export default Socket; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index ba9c15f0d..eb17a5529 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,546 +1,509 @@ // @flow import invariant from 'invariant'; import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { createSelector } from 'reselect'; import callServerEndpoint from './call-server-endpoint.js'; import type { CallServerEndpoint, CallServerEndpointOptions, } from './call-server-endpoint.js'; import { getConfig } from './config.js'; import { promiseAll } from './promises.js'; import { useSelector, useDispatch } from './redux-utils.js'; import { usingCommServicesAccessToken } from './services-utils.js'; import { ashoatKeyserverID } from './validation-utils.js'; +import { + type ActionTypes, + setNewSession, +} from '../keyserver-conn/keyserver-conn-types.js'; import { serverCallStateSelector } from '../selectors/server-calls.js'; import { logInActionSources, type LogInActionSource, type LogInStartingPayload, type LogInResult, } from '../types/account-types.js'; import type { PlatformDetails } from '../types/device-types.js'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints.js'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types.js'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types.js'; -import type { - ClientSessionChange, - PreRequestUserState, -} from '../types/session-types.js'; +import type { ClientSessionChange } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; let nextPromiseIndex = 0; -export type ActionTypes< - STARTED_ACTION_TYPE: string, - SUCCESS_ACTION_TYPE: string, - FAILED_ACTION_TYPE: string, -> = { - started: STARTED_ACTION_TYPE, - success: SUCCESS_ACTION_TYPE, - failed: FAILED_ACTION_TYPE, -}; - function wrapActionPromise< STARTED_ACTION_TYPE: string, STARTED_PAYLOAD: ActionPayload, SUCCESS_ACTION_TYPE: string, SUCCESS_PAYLOAD: ActionPayload, FAILED_ACTION_TYPE: string, >( actionTypes: ActionTypes< STARTED_ACTION_TYPE, SUCCESS_ACTION_TYPE, FAILED_ACTION_TYPE, >, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?STARTED_PAYLOAD, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!loadingOptions?.trackMultipleRequests, customKeyName: loadingOptions?.customKeyName || null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, payload: (startingPayload: STARTED_PAYLOAD), } : { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: SUCCESS_ACTION_TYPE), payload: (result: SUCCESS_PAYLOAD), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: FAILED_ACTION_TYPE), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPromise = < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise(): DispatchActionPromise { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = { +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, }; let currentlyWaitingForNewCookie = false; let serverEndpointCallsWaitingForNewCookie: (( callServerEndpoint: ?CallServerEndpoint, ) => void)[] = []; -export type DispatchRecoveryAttempt = ( - actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, - promise: Promise, - startingPayload: LogInStartingPayload, -) => Promise; - -const setNewSessionActionType = 'SET_NEW_SESSION'; -function setNewSession( - dispatch: Dispatch, - sessionChange: ClientSessionChange, - preRequestUserState: ?PreRequestUserState, - error: ?string, - logInActionSource: ?LogInActionSource, - keyserverID: string, -) { - dispatch({ - type: setNewSessionActionType, - payload: { - sessionChange, - preRequestUserState, - error, - logInActionSource, - keyserverID, - }, - }); -} - // This function is a shortcut that tells us whether it's worth even trying to // call resolveKeyserverSessionInvalidation function canResolveKeyserverSessionInvalidation() { if (usingCommServicesAccessToken) { // We can always try to resolve a keyserver session invalidation // automatically using the Olm auth responder return true; } const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); // If we can't use the Olm auth responder, then we can only resolve a // keyserver session invalidation on native, where we have access to the // user's native credentials. Note that we can't do this for ETH users, but we // don't know if the user is an ETH user from this function return !!resolveKeyserverSessionInvalidationUsingNativeCredentials; } // This function attempts to resolve an invalid keyserver session. A session can // become invalid when a keyserver invalidates it, or due to inconsistent client // state. If the client is usingCommServicesAccessToken, then the invalidation // recovery will try to go through the keyserver's Olm auth responder. // Otherwise, it will attempt to use the user's credentials to log in with the // legacy auth responder, which won't work on web and won't work for ETH users. async function resolveKeyserverSessionInvalidation( dispatch: Dispatch, cookie: ?string, urlPrefix: string, logInActionSource: LogInActionSource, keyserverID: string, getInitialNotificationsEncryptedMessage?: () => Promise, ): Promise { const { resolveKeyserverSessionInvalidationUsingNativeCredentials } = getConfig(); if (!resolveKeyserverSessionInvalidationUsingNativeCredentials) { return null; } let newSessionChange = null; let callServerEndpointCallback = null; const boundCallServerEndpoint = async ( endpoint: Endpoint, data: { +[key: string]: mixed }, options?: ?CallServerEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession( dispatch, sessionChange, null, error, logInActionSource, keyserverID, ); }; try { const result = await callServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, false, null, null, endpoint, data, dispatch, options, false, keyserverID, ); if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } return result; } catch (e) { if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } throw e; } }; const boundCallKeyserverEndpoint = ( endpoint: Endpoint, requests: { +[keyserverID: string]: ?{ +[string]: mixed } }, options?: ?CallServerEndpointOptions, ) => { if (requests[keyserverID]) { const promises = { [keyserverID]: boundCallServerEndpoint( endpoint, requests[keyserverID], options, ), }; return promiseAll(promises); } return Promise.resolve({}); }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, logInActionSource }; void dispatch( wrapActionPromise(actionTypes, promise, null, startingPayload), ); return new Promise(r => (callServerEndpointCallback = r)); }; await resolveKeyserverSessionInvalidationUsingNativeCredentials( boundCallServerEndpoint, boundCallKeyserverEndpoint, dispatchRecoveryAttempt, logInActionSource, keyserverID, getInitialNotificationsEncryptedMessage, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoCallServerEndpoint( params: BindServerCallsParams, ): CallServerEndpoint { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, isSocketConnected, lastCommunicatedPlatformDetails, keyserverID, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookiesAndSessions: { [keyserverID]: { cookie, sessionID } }, }, error, undefined, keyserverID, ); const canResolveInvalidation = canResolveKeyserverSessionInvalidation(); // This function gets called before callServerEndpoint sends a request, // to make sure that we're not in the middle of trying to recover // an invalidated cookie const waitIfCookieInvalidated = () => { if (!canResolveInvalidation) { // If there is no way to resolve the session invalidation, // just let the caller callServerEndpoint instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r), ); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await resolveKeyserverSessionInvalidation( dispatch, newAnonymousCookie, urlPrefix, logInActionSources.cookieInvalidationResolutionAttempt, keyserverID, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = serverEndpointCallsWaitingForNewCookie; serverEndpointCallsWaitingForNewCookie = []; const newCallServerEndpoint = newSessionChange ? bindCookieAndUtilsIntoCallServerEndpoint({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newCallServerEndpoint); } return newCallServerEndpoint; }; // If this function is called, callServerEndpoint got a response invalidating // its cookie, and is wondering if it should just like... give up? // Or if there's a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!canResolveInvalidation) { // If there is no way to resolve the session invalidation, // just let the caller callServerEndpoint instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r), ); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return ( endpoint: Endpoint, data: Object, options?: ?CallServerEndpointOptions, ) => callServerEndpoint( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, isSocketConnected, lastCommunicatedPlatformDetails, socketAPIHandler, endpoint, data, dispatch, options, loggedIn, keyserverID, ); } export type ActionFunc = (callServerEndpoint: CallServerEndpoint) => F; export type BindServerCall = (serverCall: ActionFunc) => F; export type BindServerCallsParams = { +dispatch: Dispatch, +cookie: ?string, +urlPrefix: string, +sessionID: ?string, +currentUserInfo: ?CurrentUserInfo, +isSocketConnected: boolean, +lastCommunicatedPlatformDetails: ?PlatformDetails, +keyserverID: string, }; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where callServerEndpoint is called. We don't want to bother // propagating the cookie (and any future config info that callServerEndpoint // needs) through to the server calls so they can pass it to callServerEndpoint. // Instead, we "curry" the cookie onto callServerEndpoint within react-redux's // connect's mapStateToProps function, and then pass that "bound" // callServerEndpoint that no longer needs the cookie as a parameter on to // the server call. const baseCreateBoundServerCallsSelector = ( actionFunc: ActionFunc, ): (BindServerCallsParams => F) => createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.isSocketConnected, (state: BindServerCallsParams) => state.lastCommunicatedPlatformDetails, (state: BindServerCallsParams) => state.keyserverID, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, isSocketConnected: boolean, lastCommunicatedPlatformDetails: ?PlatformDetails, keyserverID: string, ) => { const boundCallServerEndpoint = bindCookieAndUtilsIntoCallServerEndpoint({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, isSocketConnected, lastCommunicatedPlatformDetails, keyserverID, }); return actionFunc(boundCallServerEndpoint); }, ); type CreateBoundServerCallsSelectorType = ( ActionFunc, ) => BindServerCallsParams => F; const createBoundServerCallsSelector: CreateBoundServerCallsSelectorType = (_memoize(baseCreateBoundServerCallsSelector): any); function useServerCall( serverCall: ActionFunc, paramOverride?: ?Partial, ): F { const dispatch = useDispatch(); const serverCallState = useSelector( serverCallStateSelector(ashoatKeyserverID), ); return React.useMemo(() => { const { urlPrefix, isSocketConnected } = serverCallState; invariant( !!urlPrefix && isSocketConnected !== undefined && isSocketConnected !== null, 'keyserver missing from keyserverStore', ); return createBoundServerCallsSelector(serverCall)({ ...serverCallState, urlPrefix, isSocketConnected, dispatch, ...paramOverride, keyserverID: ashoatKeyserverID, }); }, [serverCall, serverCallState, dispatch, paramOverride]); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, - setNewSessionActionType, resolveKeyserverSessionInvalidation, createBoundServerCallsSelector, registerActiveSocket, useServerCall, bindCookieAndUtilsIntoCallServerEndpoint, }; diff --git a/lib/utils/config.js b/lib/utils/config.js index 3b7de405d..ab389b3cf 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,36 +1,36 @@ // @flow import invariant from 'invariant'; -import type { DispatchRecoveryAttempt } from './action-utils.js'; import type { CallServerEndpoint } from './call-server-endpoint.js'; import type { CallKeyserverEndpoint } from './keyserver-call.js'; +import type { DispatchRecoveryAttempt } from '../keyserver-conn/keyserver-conn-types.js'; import type { LogInActionSource } from '../types/account-types.js'; import type { PlatformDetails } from '../types/device-types.js'; export type Config = { +resolveKeyserverSessionInvalidationUsingNativeCredentials: ?( callServerEndpoint: CallServerEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, logInActionSource: LogInActionSource, keyserverID: string, getInitialNotificationsEncryptedMessage?: () => Promise, ) => Promise, +setSessionIDOnRequest: boolean, +calendarRangeInactivityLimit: ?number, +platformDetails: PlatformDetails, }; let registeredConfig: ?Config = null; const registerConfig = (config: Config) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; export { registerConfig, getConfig }; diff --git a/lib/utils/sanitization.js b/lib/utils/sanitization.js index efa418795..9ea2a48f7 100644 --- a/lib/utils/sanitization.js +++ b/lib/utils/sanitization.js @@ -1,338 +1,338 @@ // @flow import clone from 'just-clone'; import stringHash from 'string-hash'; -import { setNewSessionActionType } from './action-utils.js'; import { setDeviceTokenActionTypes } from '../actions/device-actions.js'; +import { setNewSessionActionType } from '../keyserver-conn/keyserver-conn-types.js'; import type { BaseAction, AppState } from '../types/redux-types.js'; export type ReduxCrashReport = { +preloadedState: AppState, +currentState: AppState, +actions: $ReadOnlyArray, }; export type RedactionHelpers = { +redactString: string => string, +redactColor: string => string, }; // eg {"email":"squad@bot.com"} => {"email":"[redacted]"} const keysWithStringsToBeRedacted = new Set([ 'source', 'value', 'targetID', 'sourceMessageAuthorID', 'content', 'cookie', 'creatorID', 'currentMediaID', 'currentUser', 'childThreadID', 'dbText', 'description', 'draft', 'email', 'entryID', 'extras', 'filename', 'first255Chars', 'id', 'inputFilename', 'latestMessage', 'local_id', 'localID', 'mediaLocalID', 'mediaNativeID', 'messageID', 'messageLocalID', 'messageServerID', 'name', 'newThreadID', 'parentThreadID', 'path', 'serverID', 'sessionID', 'sourceMessageID', 'thread', 'threadID', 'thumbnailID', 'uploadLocalID', 'uploadServerID', 'uiName', 'uri', 'user', 'username', 'deletedUserID', 'deviceToken', 'updatedUserID', 'role', 'ed25519', 'curve25519', 'picklingKey', 'pickledAccount', ]); // eg {"memberIDs":["123", "456"]} => {"memberIDs":["redacted", "redacted"]} const keysWithArraysToBeRedacted = new Set([ 'memberIDs', 'messageIDs', 'already_friends', 'invalid_user', 'user_blocked', 'deletedEntryIDs', 'addedUserIDs', ]); // eg "userInfos":{"1":[Object]} => "userInfos":{"redacted":[Object]} const keysWithObjectsWithKeysToBeRedacted = new Set([ 'userInfos', 'threadInfos', 'threads', 'messages', 'entryInfos', 'roles', ]); // eg {"text":"hello world"} => {"text":"z6lgz waac5"} const keysWithStringsToBeScrambled = new Set(['text', 'robotext']); // eg {"uri":"https://comm.app/1234/5678"} // => {"uri":"https://comm.app/images/placeholder.png"} const keysWithImageURIsToBeReplaced = new Set([ 'uri', 'localURI', 'inputURI', 'outputURI', 'newURI', 'thumbnailURI', ]); // (special case that redacts triply-linked [] to handle `daysToEntries` ) // eg "daysToEntries":{"2020-12-29":["123"]} // => "daysToEntries":{"2020-12-29":["redacted"]} const keysWithObjectsWithArraysToBeRedacted = new Set(['daysToEntries']); function generateSaltedRedactionFn(): string => string { const salt = Math.random().toString(36); return (str: string) => { return `[redacted-${stringHash(str.concat(salt))}]`; }; } function generateColorRedactionFn(): string => string { const salt = Math.random().toString(16); return (oldColor: string) => { return `${stringHash(oldColor.concat(salt)).toString(16).slice(0, 6)}`; }; } function placeholderImageURI(): string { return 'https://comm.app/images/placeholder.png'; } function scrambleText(str: string): string { const arr = []; for (const char of new String(str)) { if (char === ' ') { arr.push(' '); continue; } const randomChar = Math.random().toString(36)[2]; arr.push(randomChar); } return arr.join(''); } function sanitizeReduxReport(reduxReport: ReduxCrashReport): ReduxCrashReport { const redactionHelpers: RedactionHelpers = { redactString: generateSaltedRedactionFn(), redactColor: generateColorRedactionFn(), }; return { preloadedState: sanitizeState(reduxReport.preloadedState, redactionHelpers), currentState: sanitizeState(reduxReport.currentState, redactionHelpers), actions: reduxReport.actions.map(x => sanitizeAction(sanitizeActionSecrets(x), redactionHelpers), ), }; } const MessageListRouteName = 'MessageList'; const ThreadSettingsRouteName = 'ThreadSettings'; function potentiallyRedactReactNavigationKey( key: string, redactionFn: string => string, ): string { if (key.startsWith(MessageListRouteName)) { return `${MessageListRouteName}${redactionFn( key.substring(MessageListRouteName.length), )}`; } else if (key.startsWith(ThreadSettingsRouteName)) { return `${ThreadSettingsRouteName}${redactionFn( key.substring(ThreadSettingsRouteName.length), )}`; } return key; } function sanitizeNavState( obj: Object, redactionHelpers: RedactionHelpers, ): void { for (const k in obj) { if (k === 'params') { sanitizePII(obj[k], redactionHelpers); } else if (k === 'key') { obj[k] = potentiallyRedactReactNavigationKey( obj[k], redactionHelpers.redactString, ); } else if (typeof obj[k] === 'object') { sanitizeNavState(obj[k], redactionHelpers); } } } function sanitizePII(obj: Object, redactionHelpers: RedactionHelpers): void { for (const k in obj) { if (k === 'navState') { sanitizeNavState(obj[k], redactionHelpers); continue; } if (keysWithObjectsWithKeysToBeRedacted.has(k)) { for (const keyToBeRedacted in obj[k]) { obj[k][redactionHelpers.redactString(keyToBeRedacted)] = obj[k][keyToBeRedacted]; delete obj[k][keyToBeRedacted]; } } if (keysWithObjectsWithArraysToBeRedacted.has(k)) { for (const arrayToBeRedacted in obj[k]) { obj[k][arrayToBeRedacted] = obj[k][arrayToBeRedacted].map( redactionHelpers.redactString, ); } } if (keysWithStringsToBeRedacted.has(k) && typeof obj[k] === 'string') { obj[k] = redactionHelpers.redactString(obj[k]); } else if (k === 'key') { obj[k] = potentiallyRedactReactNavigationKey( obj[k], redactionHelpers.redactString, ); } else if (k === 'color') { obj[k] = redactionHelpers.redactColor(obj[k]); } else if (keysWithStringsToBeScrambled.has(k)) { obj[k] = scrambleText(obj[k]); } else if (keysWithImageURIsToBeReplaced.has(k)) { obj[k] = placeholderImageURI(); } else if (keysWithArraysToBeRedacted.has(k)) { obj[k] = obj[k].map(redactionHelpers.redactString); } else if (typeof obj[k] === 'object') { sanitizePII(obj[k], redactionHelpers); } } } function sanitizeActionSecrets(action: BaseAction): BaseAction { if (action.type === setNewSessionActionType) { const { sessionChange } = action.payload; if (sessionChange.cookieInvalidated) { const { cookie, ...rest } = sessionChange; return { type: 'SET_NEW_SESSION', payload: { ...action.payload, sessionChange: { cookieInvalidated: true, ...rest }, }, }; } else { const { cookie, ...rest } = sessionChange; return { type: 'SET_NEW_SESSION', payload: { ...action.payload, sessionChange: { cookieInvalidated: false, ...rest }, }, }; } } else if ( action.type === setDeviceTokenActionTypes.started && action.payload ) { return ({ type: 'SET_DEVICE_TOKEN_STARTED', payload: 'FAKE', loadingInfo: action.loadingInfo, }: any); } else if (action.type === setDeviceTokenActionTypes.success) { const deviceTokens: { [keyserverID: string]: ?string } = {}; for (const keyserverID in action.payload.deviceTokens) { deviceTokens[keyserverID] = 'FAKE'; } return { type: 'SET_DEVICE_TOKEN_SUCCESS', payload: { deviceTokens, }, loadingInfo: action.loadingInfo, }; } return action; } function sanitizeAction( action: BaseAction, redactionHelpers: RedactionHelpers, ): BaseAction { const actionCopy = clone(action); sanitizePII(actionCopy, redactionHelpers); return actionCopy; } function sanitizeState( state: AppState, redactionHelpers: RedactionHelpers, ): AppState { const { keyserverInfos } = state.keyserverStore; const keyserverInfosCopy = Object.fromEntries( Object.keys(keyserverInfos).map(key => { const cookie = keyserverInfos[key].cookie === undefined ? undefined : null; const deviceToken = keyserverInfos[key].deviceToken === undefined ? undefined : null; return [ key, { ...keyserverInfos[key], cookie, deviceToken, }, ]; }), ); const keyserverStore = { ...state.keyserverStore, keyserverInfos: keyserverInfosCopy, }; state = { ...state, keyserverStore }; const stateCopy = clone(state); sanitizePII(stateCopy, redactionHelpers); return stateCopy; } export { sanitizeActionSecrets, sanitizeAction, sanitizeState, sanitizeReduxReport, }; diff --git a/native/account/resolve-invalidated-cookie.js b/native/account/resolve-invalidated-cookie.js index 83f07aff1..c762c20c6 100644 --- a/native/account/resolve-invalidated-cookie.js +++ b/native/account/resolve-invalidated-cookie.js @@ -1,49 +1,49 @@ // @flow import { logInActionTypes, logInRawAction } from 'lib/actions/user-actions.js'; +import type { DispatchRecoveryAttempt } from 'lib/keyserver-conn/keyserver-conn-types.js'; import type { InitialNotifMessageOptions } from 'lib/shared/crypto-utils.js'; import type { LogInActionSource } from 'lib/types/account-types.js'; -import type { DispatchRecoveryAttempt } from 'lib/utils/action-utils.js'; import type { CallServerEndpoint } from 'lib/utils/call-server-endpoint.js'; import type { CallKeyserverEndpoint } from 'lib/utils/keyserver-call.js'; import { fetchNativeKeychainCredentials } from './native-credentials.js'; import { store } from '../redux/redux-setup.js'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors.js'; async function resolveKeyserverSessionInvalidationUsingNativeCredentials( callServerEndpoint: CallServerEndpoint, callKeyserverEndpoint: CallKeyserverEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, logInActionSource: LogInActionSource, keyserverID: string, getInitialNotificationsEncryptedMessage?: ( ?InitialNotifMessageOptions, ) => Promise, ) { const keychainCredentials = await fetchNativeKeychainCredentials(); if (!keychainCredentials) { return; } let extraInfo = await nativeLogInExtraInfoSelector(store.getState())(); if (getInitialNotificationsEncryptedMessage) { const initialNotificationsEncryptedMessage = await getInitialNotificationsEncryptedMessage({ callServerEndpoint }); extraInfo = { ...extraInfo, initialNotificationsEncryptedMessage }; } const { calendarQuery } = extraInfo; await dispatchRecoveryAttempt( logInActionTypes, logInRawAction(callKeyserverEndpoint)({ ...keychainCredentials, ...extraInfo, logInActionSource, keyserverIDs: [keyserverID], }), { calendarQuery }, ); } export { resolveKeyserverSessionInvalidationUsingNativeCredentials }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index dedea589a..2c73b0cd3 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,423 +1,423 @@ // @flow import { AppState as NativeAppState, Alert } from 'react-native'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import thunk from 'redux-thunk'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import { siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions.js'; +import { setNewSessionActionType } from 'lib/keyserver-conn/keyserver-conn-types.js'; import type { ThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { reduceLoadingStatuses } from 'lib/reducers/loading-reducer.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/session-utils.js'; import { isStaff } from 'lib/shared/staff-utils.js'; import type { Dispatch, BaseAction } from 'lib/types/redux-types.js'; import { rehydrateActionType } from 'lib/types/redux-types.js'; import type { SetSessionPayload } from 'lib/types/session-types.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; -import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateDimensionsActiveType, updateConnectivityActiveType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, backgroundActionTypes, setReduxStateActionType, setStoreLoadedActionType, type Action, setLocalSettingsActionType, } from './action-types.js'; import { defaultState } from './default-state.js'; import { remoteReduxDevServerConfig } from './dev-tools.js'; import { persistConfig, setPersistor } from './persist.js'; import { onStateDifference } from './redux-debug-utils.js'; import { processDBStoreOperations } from './redux-utils.js'; import type { AppState } from './state-types.js'; import { getGlobalNavContext } from '../navigation/icky-global.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import reactotron from '../reactotron.js'; import { AppOutOfDateAlertDetails } from '../utils/alert-messages.js'; import { isStaffRelease } from '../utils/staff-utils.js'; import { getDevServerHostname } from '../utils/url-utils.js'; function reducer(state: AppState = defaultState, action: Action) { if (action.type === setReduxStateActionType) { return action.payload.state; } // We want to alert staff/developers if there's a difference between the keys // we expect to see REHYDRATED and the keys that are actually REHYDRATED. // Context: https://linear.app/comm/issue/ENG-2127/ if ( action.type === rehydrateActionType && (__DEV__ || isStaffRelease || (state.currentUserInfo && state.currentUserInfo.id && isStaff(state.currentUserInfo.id))) ) { // 1. Construct set of keys expected to be REHYDRATED const defaultKeys: $ReadOnlyArray = Object.keys(defaultState); const expectedKeys = defaultKeys.filter( each => !persistConfig.blacklist.includes(each), ); const expectedKeysSet = new Set(expectedKeys); // 2. Construct set of keys actually REHYDRATED const rehydratedKeys: $ReadOnlyArray = Object.keys( action.payload ?? {}, ); const rehydratedKeysSet = new Set(rehydratedKeys); // 3. Determine the difference between the two sets const expectedKeysNotRehydrated = expectedKeys.filter( each => !rehydratedKeysSet.has(each), ); const rehydratedKeysNotExpected = rehydratedKeys.filter( each => !expectedKeysSet.has(each), ); // 4. Display alerts with the differences between the two sets if (expectedKeysNotRehydrated.length > 0) { Alert.alert( `EXPECTED KEYS NOT REHYDRATED: ${JSON.stringify( expectedKeysNotRehydrated, )}`, ); } if (rehydratedKeysNotExpected.length > 0) { Alert.alert( `REHYDRATED KEYS NOT EXPECTED: ${JSON.stringify( rehydratedKeysNotExpected, )}`, ); } } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, action.payload.keyserverID, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, ashoatKeyserverID, )) || (action.type === deleteKeyserverAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, ashoatKeyserverID, )) ) { return { ...state, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } if ( (action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.logInActionSource, )) || ((action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success) && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.logInActionSource, )) ) { return state; } if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setLocalSettingsActionType) { return { ...state, localSettings: { ...state.localSettings, ...action.payload }, }; } else if ( action.type === logOutActionTypes.started || action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success ) { state = { ...state, localSettings: { isBackupEnabled: false, }, }; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); } if (action.type === setStoreLoadedActionType) { return { ...state, storeLoaded: true, }; } if (action.type === setClientDBStoreActionType) { state = { ...state, storeLoaded: true, }; const currentLoggedInUserID = state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id; const actionCurrentLoggedInUserID = action.payload.currentUserID; if ( !currentLoggedInUserID || !actionCurrentLoggedInUserID || actionCurrentLoggedInUserID !== currentLoggedInUserID ) { // If user is logged out now, was logged out at the time action was // dispatched or their ID changed between action dispatch and a // call to reducer we ignore the SQLite data since it is not valid return state; } } const baseReducerResult = baseReducer( state, (action: BaseAction), onStateDifference, ); state = baseReducerResult.state; const { storeOperations } = baseReducerResult; const { draftStoreOperations, threadStoreOperations, messageStoreOperations, reportStoreOperations, userStoreOperations, } = storeOperations; const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action); state = fixUnreadActiveThreadResult.state; const threadStoreOperationsWithUnreadFix = [ ...threadStoreOperations, ...fixUnreadActiveThreadResult.threadStoreOperations, ]; void processDBStoreOperations({ draftStoreOperations, messageStoreOperations, threadStoreOperations: threadStoreOperationsWithUnreadFix, reportStoreOperations, userStoreOperations, }); return state; } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, [{ text: 'OK' }], { cancelable: true, }, ); } else { Alert.alert( 'Session invalidated', 'We’re sorry, but your session was invalidated by the server. ' + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. type FixUnreadActiveThreadResult = { +state: AppState, +threadStoreOperations: $ReadOnlyArray, }; function fixUnreadActiveThread( state: AppState, action: *, ): FixUnreadActiveThreadResult { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( !activeThread || !state.threadStore.threadInfos[activeThread]?.currentUser.unread || (NativeAppState.currentState !== 'active' && (appLastBecameInactive + 10000 >= Date.now() || backgroundActionTypes.has(action.type))) ) { return { state, threadStoreOperations: [] }; } const activeThreadInfo = state.threadStore.threadInfos[activeThread]; // TODO (atul): Try to get rid of this ridiculous branching. if (activeThreadInfo.minimallyEncoded) { const updatedActiveThreadInfo = { ...activeThreadInfo, currentUser: { ...activeThreadInfo.currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; const updatedThreadStore = threadStoreOpsHandlers.processStoreOperations( state.threadStore, threadStoreOperations, ); return { state: { ...state, threadStore: updatedThreadStore }, threadStoreOperations, }; } else { const updatedActiveThreadInfo = { ...activeThreadInfo, currentUser: { ...activeThreadInfo.currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; const updatedThreadStore = threadStoreOpsHandlers.processStoreOperations( state.threadStore, threadStoreOperations, ); return { state: { ...state, threadStore: updatedThreadStore }, threadStoreOperations, }; } } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); let composeFunc = compose; if (__DEV__ && global.HermesInternal) { const { composeWithDevTools } = require('remote-redux-devtools/src/index.js'); composeFunc = composeWithDevTools({ name: 'Redux', hostname: getDevServerHostname(), ...remoteReduxDevServerConfig, }); } else if (global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeFunc = global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux', }); } let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/web/redux/crypto-store-reducer.js b/web/redux/crypto-store-reducer.js index a5b52c98f..e2a6a03a2 100644 --- a/web/redux/crypto-store-reducer.js +++ b/web/redux/crypto-store-reducer.js @@ -1,28 +1,28 @@ // @flow import { logOutActionTypes, deleteKeyserverAccountActionTypes, } from 'lib/actions/user-actions.js'; +import { setNewSessionActionType } from 'lib/keyserver-conn/keyserver-conn-types.js'; import type { CryptoStore } from 'lib/types/crypto-types.js'; -import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import type { Action } from './redux-setup.js'; const setCryptoStore = 'SET_CRYPTO_STORE'; function reduceCryptoStore(state: ?CryptoStore, action: Action): ?CryptoStore { if (action.type === setCryptoStore) { return action.payload; } else if ( action.type === logOutActionTypes.success || action.type === deleteKeyserverAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return null; } return state; } export { setCryptoStore, reduceCryptoStore }; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 20898e303..0a0e17950 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,389 +1,389 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/es/types.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, } from 'lib/actions/user-actions.js'; +import { setNewSessionActionType } from 'lib/keyserver-conn/keyserver-conn-types.js'; import { type ThreadStoreOperation, threadStoreOpsHandlers, } from 'lib/ops/thread-store-ops.js'; import { reduceLoadingStatuses } from 'lib/reducers/loading-reducer.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { invalidSessionDowngrade } from 'lib/shared/session-utils.js'; import type { CryptoStore } from 'lib/types/crypto-types.js'; import type { DraftStore } from 'lib/types/draft-types.js'; import type { EnabledApps } from 'lib/types/enabled-apps.js'; import type { EntryStore, CalendarQuery } from 'lib/types/entry-types.js'; import { type CalendarFilter } from 'lib/types/filter-types.js'; import type { IntegrityStore } from 'lib/types/integrity-types.js'; import type { KeyserverStore } from 'lib/types/keyserver-types.js'; import type { LifecycleState } from 'lib/types/lifecycle-state-types.js'; import type { InviteLinksStore } from 'lib/types/link-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MessageStore } from 'lib/types/message-types.js'; import type { UserPolicies } from 'lib/types/policy-types.js'; import type { BaseAction } from 'lib/types/redux-types.js'; import type { ReportStore } from 'lib/types/report-types.js'; import type { StoreOperations } from 'lib/types/store-ops-types.js'; import type { GlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ThreadActivityStore } from 'lib/types/thread-activity-types'; import type { ThreadStore } from 'lib/types/thread-types.js'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types.js'; -import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import type { NotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateWindowActiveActionType, updateNavInfoActionType, updateWindowDimensionsActionType, setInitialReduxState, } from './action-types.js'; import { reduceCommunityPickerStore } from './community-picker-reducer.js'; import { reduceCryptoStore, setCryptoStore } from './crypto-store-reducer.js'; import reduceNavInfo from './nav-reducer.js'; import { onStateDifference } from './redux-debug-utils.js'; import { getVisibility } from './visibility.js'; import { processDBStoreOperations } from '../database/utils/store.js'; import { activeThreadSelector } from '../selectors/nav-selectors.js'; import { type NavInfo } from '../types/nav-types.js'; import type { InitialReduxState } from '../types/redux-types.js'; export type WindowDimensions = { width: number, height: number }; export type CommunityPickerStore = { +chat: ?string, +calendar: ?string, }; export type AppState = { +navInfo: NavInfo, +currentUserInfo: ?CurrentUserInfo, +draftStore: DraftStore, +entryStore: EntryStore, +threadStore: ThreadStore, +userStore: UserStore, +messageStore: MessageStore, +loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, +calendarFilters: $ReadOnlyArray, +communityPickerStore: CommunityPickerStore, +windowDimensions: WindowDimensions, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +actualizedCalendarQuery: CalendarQuery, +watchedThreadIDs: $ReadOnlyArray, +lifecycleState: LifecycleState, +enabledApps: EnabledApps, +reportStore: ReportStore, +nextLocalID: number, +dataLoaded: boolean, +windowActive: boolean, +userPolicies: UserPolicies, +cryptoStore: ?CryptoStore, +pushApiPublicKey: ?string, +_persist: ?PersistState, +commServicesAccessToken: ?string, +inviteLinksStore: InviteLinksStore, +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, +initialStateLoaded: boolean, +integrityStore: IntegrityStore, +globalThemeInfo: GlobalThemeInfo, +customServer: ?string, }; export type Action = | BaseAction | { type: 'UPDATE_NAV_INFO', payload: Partial } | { type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, } | { type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, } | { +type: 'SET_CRYPTO_STORE', payload: CryptoStore } | { +type: 'SET_INITIAL_REDUX_STATE', payload: InitialReduxState }; export function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; let storeOperations: StoreOperations = { draftStoreOperations: [], threadStoreOperations: [], messageStoreOperations: [], reportStoreOperations: [], userStoreOperations: [], }; if (action.type === setInitialReduxState) { const { userInfos, keyserverInfos, ...rest } = action.payload; const newKeyserverInfos = { ...state.keyserverStore.keyserverInfos }; for (const keyserverID in keyserverInfos) { newKeyserverInfos[keyserverID] = { ...newKeyserverInfos[keyserverID], ...keyserverInfos[keyserverID], }; } return validateStateAndProcessDBOperations( oldState, { ...state, ...rest, userStore: { userInfos }, keyserverStore: { ...state.keyserverStore, keyserverInfos: newKeyserverInfos, }, initialStateLoaded: true, }, storeOperations, ); } else if (action.type === updateWindowDimensionsActionType) { return validateStateAndProcessDBOperations( oldState, { ...state, windowDimensions: action.payload, }, storeOperations, ); } else if (action.type === updateWindowActiveActionType) { return validateStateAndProcessDBOperations( oldState, { ...state, windowActive: action.payload, }, storeOperations, ); } else if (action.type === setNewSessionActionType) { const { keyserverID, sessionChange } = action.payload; if (!state.keyserverStore.keyserverInfos[keyserverID]) { if (sessionChange.cookie?.startsWith('user=')) { console.log( 'received sessionChange with user cookie, ' + `but keyserver ${keyserverID} is not in KeyserverStore!`, ); } return state; } if ( invalidSessionDowngrade( oldState, sessionChange.currentUserInfo, action.payload.preRequestUserState, keyserverID, ) ) { return { ...oldState, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } state = { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, [keyserverID]: { ...state.keyserverStore.keyserverInfos[keyserverID], sessionID: sessionChange.sessionID, }, }, }, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, ashoatKeyserverID, )) || (action.type === deleteKeyserverAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, ashoatKeyserverID, )) ) { return { ...oldState, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } if ( action.type !== updateNavInfoActionType && action.type !== setCryptoStore ) { const baseReducerResult = baseReducer(state, action, onStateDifference); state = baseReducerResult.state; storeOperations = baseReducerResult.storeOperations; } const communityPickerStore = reduceCommunityPickerStore( state.communityPickerStore, action, ); state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), cryptoStore: reduceCryptoStore(state.cryptoStore, action), communityPickerStore, }; return validateStateAndProcessDBOperations(oldState, state, storeOperations); } function validateStateAndProcessDBOperations( oldState: AppState, state: AppState, storeOperations: StoreOperations, ): AppState { const updateActiveThreadOps: ThreadStoreOperation[] = []; if ( (state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID]) || (!state.navInfo.activeChatThreadID && isLoggedIn(state)) ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentlyReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && getVisibility().hidden() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but visibilityjs reports the window is not visible', ); } if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && typeof document !== 'undefined' && document && 'hasFocus' in document && !document.hasFocus() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but document.hasFocus() is false', ); } if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && 'hasFocus' in document && document.hasFocus() && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread const activeThreadInfo = state.threadStore.threadInfos[activeThread]; // TODO (atul): Try to get rid of this ridiculous branching. if (activeThreadInfo.minimallyEncoded) { updateActiveThreadOps.push({ type: 'replace', payload: { id: activeThread, threadInfo: { ...activeThreadInfo, currentUser: { ...activeThreadInfo.currentUser, unread: false, }, }, }, }); } else { updateActiveThreadOps.push({ type: 'replace', payload: { id: activeThread, threadInfo: { ...activeThreadInfo, currentUser: { ...activeThreadInfo.currentUser, unread: false, }, }, }, }); } } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { const now = Date.now(); state = { ...state, threadActivityStore: { ...state.threadActivityStore, [(activeThread: string)]: { ...state.threadActivityStore[activeThread], lastNavigatedTo: now, }, }, }; } if (updateActiveThreadOps.length > 0) { state = { ...state, threadStore: threadStoreOpsHandlers.processStoreOperations( state.threadStore, updateActiveThreadOps, ), }; storeOperations = { ...storeOperations, threadStoreOperations: [ ...storeOperations.threadStoreOperations, ...updateActiveThreadOps, ], }; } void processDBStoreOperations( storeOperations, state.currentUserInfo?.id ?? null, ); return state; }