{
const appURLFacts = getAppURLFactsFromRequestURL(req.originalUrl);
const { basePath, baseDomain } = appURLFacts;
const baseURL = basePath.replace(/\/$/, '');
const baseHref = baseDomain + baseURL;
const loadingPromise = getWebpackCompiledRootComponentForSSR();
const hasNotAcknowledgedPoliciesPromise = hasAnyNotAcknowledgedPolicies(
viewer.id,
baseLegalPolicies,
);
let initialNavInfo;
try {
initialNavInfo = navInfoFromURL(req.url, {
now: currentDateInTimeZone(viewer.timeZone),
});
} catch (e) {
throw new ServerError(e.message);
}
const calendarQuery = {
startDate: initialNavInfo.startDate,
endDate: initialNavInfo.endDate,
filters: defaultCalendarFilters,
};
const messageSelectionCriteria = { joinedThreads: true };
const initialTime = Date.now();
const assetInfoPromise = getAssetInfo();
const threadInfoPromise = fetchThreadInfos(viewer);
const messageInfoPromise = fetchMessageInfos(
viewer,
messageSelectionCriteria,
defaultNumberPerThread,
);
const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]);
const currentUserInfoPromise = fetchCurrentUserInfo(viewer);
const userInfoPromise = fetchKnownUserInfos(viewer);
const sessionIDPromise = (async () => {
if (viewer.loggedIn) {
await setNewSession(viewer, calendarQuery, initialTime);
}
return viewer.sessionID;
})();
const threadStorePromise = (async () => {
const [{ threadInfos }, hasNotAcknowledgedPolicies] = await Promise.all([
threadInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
return { threadInfos: hasNotAcknowledgedPolicies ? {} : threadInfos };
})();
const messageStorePromise = (async () => {
const [
{ threadInfos },
{ rawMessageInfos, truncationStatuses },
hasNotAcknowledgedPolicies,
] = await Promise.all([
threadInfoPromise,
messageInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
if (hasNotAcknowledgedPolicies) {
return {
messages: {},
threads: {},
local: {},
currentAsOf: 0,
};
}
const { messageStore: freshStore } = freshMessageStore(
rawMessageInfos,
truncationStatuses,
mostRecentMessageTimestamp(rawMessageInfos, initialTime),
threadInfos,
);
return freshStore;
})();
const entryStorePromise = (async () => {
const [{ rawEntryInfos }, hasNotAcknowledgedPolicies] = await Promise.all([
entryInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
if (hasNotAcknowledgedPolicies) {
return {
entryInfos: {},
daysToEntries: {},
lastUserInteractionCalendar: 0,
};
}
return {
entryInfos: _keyBy('id')(rawEntryInfos),
daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos),
lastUserInteractionCalendar: initialTime,
};
})();
const userStorePromise = (async () => {
const [userInfos, hasNotAcknowledgedPolicies] = await Promise.all([
userInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
return {
userInfos: hasNotAcknowledgedPolicies ? {} : userInfos,
inconsistencyReports: [],
};
})();
const navInfoPromise = (async () => {
const [{ threadInfos }, messageStore, currentUserInfo, userStore] =
await Promise.all([
threadInfoPromise,
messageStorePromise,
currentUserInfoPromise,
userStorePromise,
]);
const finalNavInfo = initialNavInfo;
const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID;
if (
requestedActiveChatThreadID &&
!threadIsPending(requestedActiveChatThreadID) &&
!threadHasPermission(
threadInfos[requestedActiveChatThreadID],
threadPermissions.VISIBLE,
)
) {
finalNavInfo.activeChatThreadID = null;
}
if (!finalNavInfo.activeChatThreadID) {
const mostRecentThread = mostRecentlyReadThread(
messageStore,
threadInfos,
);
if (mostRecentThread) {
finalNavInfo.activeChatThreadID = mostRecentThread;
}
}
if (
finalNavInfo.activeChatThreadID &&
threadIsPending(finalNavInfo.activeChatThreadID) &&
finalNavInfo.pendingThread?.id !== finalNavInfo.activeChatThreadID
) {
const pendingThreadData = parsePendingThreadID(
finalNavInfo.activeChatThreadID,
);
if (
pendingThreadData &&
pendingThreadData.threadType !== threadTypes.SIDEBAR &&
currentUserInfo.id
) {
const { userInfos } = userStore;
const members = [...pendingThreadData.memberIDs, currentUserInfo.id]
.map(id => {
const userInfo = userInfos[id];
if (!userInfo || !userInfo.username) {
return undefined;
}
const { username } = userInfo;
return { id, username };
})
.filter(Boolean);
const newPendingThread = createPendingThread({
viewerID: currentUserInfo.id,
threadType: pendingThreadData.threadType,
members,
});
finalNavInfo.activeChatThreadID = newPendingThread.id;
finalNavInfo.pendingThread = newPendingThread;
}
}
return finalNavInfo;
})();
const currentAsOfPromise = (async () => {
const hasNotAcknowledgedPolicies = await hasNotAcknowledgedPoliciesPromise;
return hasNotAcknowledgedPolicies ? 0 : initialTime;
})();
const pushApiPublicKeyPromise = (async () => {
const pushConfig = await getWebPushConfig();
if (!pushConfig) {
if (process.env.NODE_ENV !== 'development') {
console.warn('keyserver/secrets/web_push_config.json should exist');
}
return null;
}
return pushConfig.publicKey;
})();
const { jsURL, fontsURL, cssInclude } = await assetInfoPromise;
// prettier-ignore
res.write(html`
${getTitle(0)}
${cssInclude}
`);
const Loading = await loadingPromise;
const reactStream = renderToNodeStream();
reactStream.pipe(res, { end: false });
await waitForStream(reactStream);
res.write(html`
`);
}
export { websiteResponder };
diff --git a/keyserver/src/updaters/device-token-updaters.js b/keyserver/src/updaters/device-token-updaters.js
index 4b35d74eb..3350387aa 100644
--- a/keyserver/src/updaters/device-token-updaters.js
+++ b/keyserver/src/updaters/device-token-updaters.js
@@ -1,49 +1,46 @@
// @flow
-import {
- type DeviceTokenUpdateRequest,
- isDeviceType,
-} from 'lib/types/device-types.js';
+import { type DeviceTokenUpdateRequest } from 'lib/types/device-types.js';
import { ServerError } from 'lib/utils/errors.js';
import { dbQuery, SQL } from '../database/database.js';
import type { Viewer } from '../session/viewer.js';
async function deviceTokenUpdater(
viewer: Viewer,
update: DeviceTokenUpdateRequest,
): Promise {
const deviceType = update.platformDetails?.platform ?? update.deviceType;
- if (!isDeviceType(deviceType)) {
+ if (deviceType === undefined) {
throw new ServerError('invalid_parameters');
}
viewer.setDeviceToken(update.deviceToken);
await clearDeviceToken(update.deviceToken);
const setColumns = {};
setColumns.device_token = update.deviceToken;
setColumns.platform = deviceType;
if (update.platformDetails) {
const { platform, ...versions } = update.platformDetails;
if (Object.keys(versions).length > 0) {
setColumns.versions = JSON.stringify(versions);
}
}
const query = SQL`
UPDATE cookies SET ${setColumns} WHERE id = ${viewer.cookieID}
`;
await dbQuery(query);
}
async function clearDeviceToken(deviceToken: string): Promise {
const query = SQL`
UPDATE cookies
SET device_token = NULL
WHERE device_token = ${deviceToken}
`;
await dbQuery(query);
}
export { deviceTokenUpdater, clearDeviceToken };
diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js
index c53a05ffe..7930eb17e 100644
--- a/lib/reducers/master-reducer.js
+++ b/lib/reducers/master-reducer.js
@@ -1,117 +1,119 @@
// @flow
import reduceCalendarFilters from './calendar-filters-reducer.js';
import reduceConnectionInfo from './connection-reducer.js';
import reduceDataLoaded from './data-loaded-reducer.js';
+import { reduceDeviceToken } from './device-token-reducer.js';
import { reduceDraftStore } from './draft-reducer.js';
import reduceEnabledApps from './enabled-apps-reducer.js';
import { reduceEntryInfos } from './entry-reducer.js';
import reduceLifecycleState from './lifecycle-state-reducer.js';
import { reduceLoadingStatuses } from './loading-reducer.js';
import reduceNextLocalID from './local-id-reducer.js';
import { reduceMessageStore } from './message-reducer.js';
import reduceBaseNavInfo from './nav-reducer.js';
import policiesReducer from './policies-reducer.js';
import reduceReportStore from './report-store-reducer.js';
import { reduceThreadInfos } from './thread-reducer.js';
import reduceUpdatesCurrentAsOf from './updates-reducer.js';
import reduceURLPrefix from './url-prefix-reducer.js';
import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer.js';
import { siweAuthActionTypes } from '../actions/siwe-actions.js';
import {
registerActionTypes,
logInActionTypes,
} from '../actions/user-actions.js';
import type { BaseNavInfo } from '../types/nav-types.js';
import type { BaseAppState, BaseAction } from '../types/redux-types.js';
import {
fullStateSyncActionType,
incrementalStateSyncActionType,
} from '../types/socket-types.js';
import type { StoreOperations } from '../types/store-ops-types.js';
export default function baseReducer>(
state: T,
action: BaseAction,
): { state: T, storeOperations: StoreOperations } {
const { threadStore, newThreadInconsistencies, threadStoreOperations } =
reduceThreadInfos(state.threadStore, action);
const { threadInfos } = threadStore;
const [entryStore, newEntryInconsistencies] = reduceEntryInfos(
state.entryStore,
action,
threadInfos,
);
const newInconsistencies = [
...newEntryInconsistencies,
...newThreadInconsistencies,
];
// Only allow checkpoints to increase if we are connected
// or if the action is a STATE_SYNC
const { messageStoreOperations, messageStore: reducedMessageStore } =
reduceMessageStore(state.messageStore, action, threadInfos);
let messageStore = reducedMessageStore;
let updatesCurrentAsOf = reduceUpdatesCurrentAsOf(
state.updatesCurrentAsOf,
action,
);
const connection = reduceConnectionInfo(state.connection, action);
if (
connection.status !== 'connected' &&
action.type !== incrementalStateSyncActionType &&
action.type !== fullStateSyncActionType &&
action.type !== registerActionTypes.success &&
action.type !== logInActionTypes.success &&
action.type !== siweAuthActionTypes.success
) {
if (messageStore.currentAsOf !== state.messageStore.currentAsOf) {
messageStore = {
...messageStore,
currentAsOf: state.messageStore.currentAsOf,
};
}
if (updatesCurrentAsOf !== state.updatesCurrentAsOf) {
updatesCurrentAsOf = state.updatesCurrentAsOf;
}
}
const { draftStore, draftStoreOperations } = reduceDraftStore(
state.draftStore,
action,
);
return {
state: {
...state,
navInfo: reduceBaseNavInfo(state.navInfo, action),
draftStore,
entryStore,
loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action),
currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action),
threadStore,
userStore: reduceUserInfos(state.userStore, action),
messageStore,
updatesCurrentAsOf,
urlPrefix: reduceURLPrefix(state.urlPrefix, action),
calendarFilters: reduceCalendarFilters(state.calendarFilters, action),
connection,
lifecycleState: reduceLifecycleState(state.lifecycleState, action),
enabledApps: reduceEnabledApps(state.enabledApps, action),
reportStore: reduceReportStore(
state.reportStore,
action,
newInconsistencies,
),
nextLocalID: reduceNextLocalID(state.nextLocalID, action),
dataLoaded: reduceDataLoaded(state.dataLoaded, action),
userPolicies: policiesReducer(state.userPolicies, action),
+ deviceToken: reduceDeviceToken(state.deviceToken, action),
},
storeOperations: {
draftStoreOperations,
threadStoreOperations,
messageStoreOperations,
},
};
}
diff --git a/lib/selectors/account-selectors.js b/lib/selectors/account-selectors.js
index bcd39f181..339421a10 100644
--- a/lib/selectors/account-selectors.js
+++ b/lib/selectors/account-selectors.js
@@ -1,52 +1,49 @@
// @flow
import { createSelector } from 'reselect';
import { currentCalendarQuery } from './nav-selectors.js';
import type { LogInExtraInfo } from '../types/account-types.js';
-import { isDeviceType } from '../types/device-types.js';
import type { CalendarQuery } from '../types/entry-types.js';
import type { AppState } from '../types/redux-types.js';
import type { PreRequestUserState } from '../types/session-types.js';
import type { CurrentUserInfo } from '../types/user-types.js';
-import { getConfig } from '../utils/config.js';
const logInExtraInfoSelector: (
state: AppState,
) => (calendarActive: boolean) => LogInExtraInfo = createSelector(
(state: AppState) => state.deviceToken,
currentCalendarQuery,
(
deviceToken: ?string,
calendarQuery: (calendarActive: boolean) => CalendarQuery,
) => {
let deviceTokenUpdateRequest = null;
- const platform = getConfig().platformDetails.platform;
- if (deviceToken && isDeviceType(platform)) {
+ if (deviceToken) {
deviceTokenUpdateRequest = { deviceToken };
}
// Return a function since we depend on the time of evaluation
return (calendarActive: boolean): LogInExtraInfo => ({
calendarQuery: calendarQuery(calendarActive),
deviceTokenUpdateRequest,
});
},
);
const preRequestUserStateSelector: (state: AppState) => PreRequestUserState =
createSelector(
(state: AppState) => state.currentUserInfo,
(state: AppState) => state.cookie,
(state: AppState) => state.sessionID,
(
currentUserInfo: ?CurrentUserInfo,
cookie: ?string,
sessionID: ?string,
) => ({
currentUserInfo,
cookie,
sessionID,
}),
);
export { logInExtraInfoSelector, preRequestUserStateSelector };
diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js
index a5148a00d..1a8fe1b90 100644
--- a/lib/types/redux-types.js
+++ b/lib/types/redux-types.js
@@ -1,934 +1,932 @@
// @flow
import type {
LogOutResult,
LogInStartingPayload,
LogInResult,
RegisterResult,
DefaultNotificationPayload,
} from './account-types.js';
import type {
ActivityUpdateSuccessPayload,
QueueActivityUpdatesPayload,
SetThreadUnreadStatusPayload,
} from './activity-types.js';
import type { CryptoStore } from './crypto-types.js';
import type { ClientDBDraftInfo, DraftStore } from './draft-types.js';
import type { EnabledApps, SupportedApps } from './enabled-apps.js';
import type {
RawEntryInfo,
EntryStore,
SaveEntryPayload,
CreateEntryPayload,
DeleteEntryResult,
RestoreEntryPayload,
FetchEntryInfosResult,
CalendarQueryUpdateResult,
CalendarQueryUpdateStartingPayload,
} from './entry-types.js';
import type {
CalendarFilter,
CalendarThreadFilter,
SetCalendarDeletedFilterPayload,
} from './filter-types.js';
import type { LifecycleState } from './lifecycle-state-types.js';
import type { LoadingStatus, LoadingInfo } from './loading-types.js';
import type { UpdateMultimediaMessageMediaPayload } from './media-types.js';
import type { MessageReportCreationResult } from './message-report-types.js';
import type {
MessageStore,
RawMultimediaMessageInfo,
FetchMessageInfosPayload,
SendMessagePayload,
SaveMessagesPayload,
NewMessagesPayload,
MessageStorePrunePayload,
LocallyComposedMessageInfo,
ClientDBMessageInfo,
SimpleMessagesPayload,
} from './message-types.js';
import type { RawReactionMessageInfo } from './messages/reaction.js';
import type { RawTextMessageInfo } from './messages/text.js';
import type { BaseNavInfo } from './nav-types.js';
import {
type ForcePolicyAcknowledgmentPayload,
type PolicyAcknowledgmentPayload,
type UserPolicies,
} from './policy-types.js';
import type { RelationshipErrors } from './relationship-types.js';
import type {
EnabledReports,
ClearDeliveredReportsPayload,
QueueReportsPayload,
ReportStore,
} from './report-types.js';
import type { ProcessServerRequestsPayload } from './request-types.js';
import type { UserSearchResult } from './search-types.js';
import type { SetSessionPayload } from './session-types.js';
import type {
ConnectionInfo,
StateSyncFullActionPayload,
StateSyncIncrementalActionPayload,
UpdateConnectionStatusPayload,
SetLateResponsePayload,
UpdateDisconnectedBarPayload,
} from './socket-types.js';
import type { SubscriptionUpdateResult } from './subscription-types.js';
import type {
ThreadStore,
ChangeThreadSettingsPayload,
LeaveThreadPayload,
NewThreadResult,
ThreadJoinPayload,
} from './thread-types.js';
import type { ClientUpdatesResultWithUserInfos } from './update-types.js';
import type { CurrentUserInfo, UserStore } from './user-types.js';
import type { Shape } from '../types/core.js';
export type BaseAppState = {
navInfo: NavInfo,
currentUserInfo: ?CurrentUserInfo,
draftStore: DraftStore,
entryStore: EntryStore,
threadStore: ThreadStore,
userStore: UserStore,
messageStore: MessageStore,
updatesCurrentAsOf: number, // millisecond timestamp
loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } },
calendarFilters: $ReadOnlyArray,
urlPrefix: string,
connection: ConnectionInfo,
watchedThreadIDs: $ReadOnlyArray,
lifecycleState: LifecycleState,
enabledApps: EnabledApps,
reportStore: ReportStore,
nextLocalID: number,
dataLoaded: boolean,
userPolicies: UserPolicies,
+ deviceToken: ?string,
...
};
// Web JS runtime doesn't have access to the cookie for security reasons.
// Native JS doesn't have a sessionID because the cookieID is used instead.
-// Web JS doesn't have a device token because it's not a device...
export type NativeAppState = BaseAppState<*> & {
sessionID?: void,
- deviceToken: ?string,
cookie: ?string,
...
};
export type WebAppState = BaseAppState<*> & {
sessionID: ?string,
- deviceToken?: void,
cookie?: void,
cryptoStore: CryptoStore,
pushApiPublicKey: ?string,
...
};
export type AppState = NativeAppState | WebAppState;
export type BaseAction =
| {
+type: '@@redux/INIT',
+payload?: void,
}
| {
+type: 'FETCH_ENTRIES_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_ENTRIES_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_ENTRIES_SUCCESS',
+payload: FetchEntryInfosResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LOG_OUT_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LOG_OUT_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LOG_OUT_SUCCESS',
+payload: LogOutResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'DELETE_ACCOUNT_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'DELETE_ACCOUNT_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'DELETE_ACCOUNT_SUCCESS',
+payload: LogOutResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CREATE_LOCAL_ENTRY',
+payload: RawEntryInfo,
}
| {
+type: 'CREATE_ENTRY_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CREATE_ENTRY_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CREATE_ENTRY_SUCCESS',
+payload: CreateEntryPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SAVE_ENTRY_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SAVE_ENTRY_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SAVE_ENTRY_SUCCESS',
+payload: SaveEntryPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CONCURRENT_MODIFICATION_RESET',
+payload: {
+id: string,
+dbText: string,
},
}
| {
+type: 'DELETE_ENTRY_STARTED',
+loadingInfo: LoadingInfo,
+payload: {
+localID: ?string,
+serverID: ?string,
},
}
| {
+type: 'DELETE_ENTRY_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'DELETE_ENTRY_SUCCESS',
+payload: ?DeleteEntryResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LOG_IN_STARTED',
+loadingInfo: LoadingInfo,
+payload: LogInStartingPayload,
}
| {
+type: 'LOG_IN_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LOG_IN_SUCCESS',
+payload: LogInResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'REGISTER_STARTED',
+loadingInfo: LoadingInfo,
+payload: LogInStartingPayload,
}
| {
+type: 'REGISTER_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'REGISTER_SUCCESS',
+payload: RegisterResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_USER_PASSWORD_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_USER_PASSWORD_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_USER_PASSWORD_SUCCESS',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_THREAD_SETTINGS_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_THREAD_SETTINGS_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_THREAD_SETTINGS_SUCCESS',
+payload: ChangeThreadSettingsPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'DELETE_THREAD_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'DELETE_THREAD_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'DELETE_THREAD_SUCCESS',
+payload: LeaveThreadPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'NEW_THREAD_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'NEW_THREAD_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'NEW_THREAD_SUCCESS',
+payload: NewThreadResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'REMOVE_USERS_FROM_THREAD_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'REMOVE_USERS_FROM_THREAD_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'REMOVE_USERS_FROM_THREAD_SUCCESS',
+payload: ChangeThreadSettingsPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS',
+payload: ChangeThreadSettingsPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS',
+payload: {
+entryID: string,
+text: string,
+deleted: boolean,
},
+loadingInfo: LoadingInfo,
}
| {
+type: 'RESTORE_ENTRY_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'RESTORE_ENTRY_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'RESTORE_ENTRY_SUCCESS',
+payload: RestoreEntryPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'JOIN_THREAD_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'JOIN_THREAD_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'JOIN_THREAD_SUCCESS',
+payload: ThreadJoinPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LEAVE_THREAD_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LEAVE_THREAD_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'LEAVE_THREAD_SUCCESS',
+payload: LeaveThreadPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_NEW_SESSION',
+payload: SetSessionPayload,
}
| {
+type: 'persist/REHYDRATE',
+payload: ?BaseAppState<*>,
}
| {
+type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS',
+payload: FetchMessageInfosPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_MOST_RECENT_MESSAGES_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_MOST_RECENT_MESSAGES_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS',
+payload: FetchMessageInfosPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_SUCCESS',
+payload: SimpleMessagesPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_TEXT_MESSAGE_STARTED',
+loadingInfo?: LoadingInfo,
+payload: RawTextMessageInfo,
}
| {
+type: 'SEND_TEXT_MESSAGE_FAILED',
+error: true,
+payload: Error & {
+localID: string,
+threadID: string,
},
+loadingInfo?: LoadingInfo,
}
| {
+type: 'SEND_TEXT_MESSAGE_SUCCESS',
+payload: SendMessagePayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_MULTIMEDIA_MESSAGE_STARTED',
+loadingInfo?: LoadingInfo,
+payload: RawMultimediaMessageInfo,
}
| {
+type: 'SEND_MULTIMEDIA_MESSAGE_FAILED',
+error: true,
+payload: Error & {
+localID: string,
+threadID: string,
},
+loadingInfo?: LoadingInfo,
}
| {
+type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS',
+payload: SendMessagePayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REACTION_MESSAGE_STARTED',
+loadingInfo?: LoadingInfo,
+payload: RawReactionMessageInfo,
}
| {
+type: 'SEND_REACTION_MESSAGE_FAILED',
+error: true,
+payload: Error & {
+localID: string,
+threadID: string,
+targetMessageID: string,
+reaction: string,
+action: string,
},
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REACTION_MESSAGE_SUCCESS',
+payload: SendMessagePayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEARCH_USERS_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEARCH_USERS_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEARCH_USERS_SUCCESS',
+payload: UserSearchResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_DRAFT',
+payload: {
+key: string,
+text: string,
},
}
| {
+type: 'MOVE_DRAFT',
+payload: {
+oldKey: string,
+newKey: string,
},
}
| {
+type: 'SET_CLIENT_DB_STORE',
+payload: {
+currentUserID: ?string,
+drafts: $ReadOnlyArray,
+messages: $ReadOnlyArray,
+threadStore: ThreadStore,
},
}
| {
+type: 'UPDATE_ACTIVITY_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_ACTIVITY_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_ACTIVITY_SUCCESS',
+payload: ActivityUpdateSuccessPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_DEVICE_TOKEN_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_DEVICE_TOKEN_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_DEVICE_TOKEN_SUCCESS',
+payload: ?string,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REPORT_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REPORT_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REPORT_SUCCESS',
+payload?: ClearDeliveredReportsPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REPORTS_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REPORTS_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_REPORTS_SUCCESS',
+payload?: ClearDeliveredReportsPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'QUEUE_REPORTS',
+payload: QueueReportsPayload,
}
| {
+type: 'SET_URL_PREFIX',
+payload: string,
}
| {
+type: 'SAVE_MESSAGES',
+payload: SaveMessagesPayload,
}
| {
+type: 'UPDATE_CALENDAR_THREAD_FILTER',
+payload: CalendarThreadFilter,
}
| {
+type: 'CLEAR_CALENDAR_THREAD_FILTER',
+payload?: void,
}
| {
+type: 'SET_CALENDAR_DELETED_FILTER',
+payload: SetCalendarDeletedFilterPayload,
}
| {
+type: 'UPDATE_SUBSCRIPTION_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_SUBSCRIPTION_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_SUBSCRIPTION_SUCCESS',
+payload: SubscriptionUpdateResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_CALENDAR_QUERY_STARTED',
+loadingInfo: LoadingInfo,
+payload?: CalendarQueryUpdateStartingPayload,
}
| {
+type: 'UPDATE_CALENDAR_QUERY_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_CALENDAR_QUERY_SUCCESS',
+payload: CalendarQueryUpdateResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FULL_STATE_SYNC',
+payload: StateSyncFullActionPayload,
}
| {
+type: 'INCREMENTAL_STATE_SYNC',
+payload: StateSyncIncrementalActionPayload,
}
| {
+type: 'PROCESS_SERVER_REQUESTS',
+payload: ProcessServerRequestsPayload,
}
| {
+type: 'UPDATE_CONNECTION_STATUS',
+payload: UpdateConnectionStatusPayload,
}
| {
+type: 'QUEUE_ACTIVITY_UPDATES',
+payload: QueueActivityUpdatesPayload,
}
| {
+type: 'UNSUPERVISED_BACKGROUND',
+payload?: void,
}
| {
+type: 'UPDATE_LIFECYCLE_STATE',
+payload: LifecycleState,
}
| {
+type: 'ENABLE_APP',
+payload: SupportedApps,
}
| {
+type: 'DISABLE_APP',
+payload: SupportedApps,
}
| {
+type: 'UPDATE_REPORTS_ENABLED',
+payload: Shape,
}
| {
+type: 'PROCESS_UPDATES',
+payload: ClientUpdatesResultWithUserInfos,
}
| {
+type: 'PROCESS_MESSAGES',
+payload: NewMessagesPayload,
}
| {
+type: 'MESSAGE_STORE_PRUNE',
+payload: MessageStorePrunePayload,
}
| {
+type: 'SET_LATE_RESPONSE',
+payload: SetLateResponsePayload,
}
| {
+type: 'UPDATE_DISCONNECTED_BAR',
+payload: UpdateDisconnectedBarPayload,
}
| {
+type: 'REQUEST_ACCESS_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'REQUEST_ACCESS_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'REQUEST_ACCESS_SUCCESS',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA',
+payload: UpdateMultimediaMessageMediaPayload,
}
| {
+type: 'CREATE_LOCAL_MESSAGE',
+payload: LocallyComposedMessageInfo,
}
| {
+type: 'UPDATE_RELATIONSHIPS_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_RELATIONSHIPS_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'UPDATE_RELATIONSHIPS_SUCCESS',
+payload: RelationshipErrors,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_THREAD_UNREAD_STATUS_STARTED',
+payload: {
+threadID: string,
+unread: boolean,
},
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_THREAD_UNREAD_STATUS_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_THREAD_UNREAD_STATUS_SUCCESS',
+payload: SetThreadUnreadStatusPayload,
}
| {
+type: 'SET_USER_SETTINGS_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SET_USER_SETTINGS_SUCCESS',
+payload: DefaultNotificationPayload,
}
| {
+type: 'SET_USER_SETTINGS_FAILED',
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_MESSAGE_REPORT_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_MESSAGE_REPORT_SUCCESS',
+payload: MessageReportCreationResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SEND_MESSAGE_REPORT_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'FORCE_POLICY_ACKNOWLEDGMENT',
+payload: ForcePolicyAcknowledgmentPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'POLICY_ACKNOWLEDGMENT_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'POLICY_ACKNOWLEDGMENT_SUCCESS',
+payload: PolicyAcknowledgmentPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'POLICY_ACKNOWLEDGMENT_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'GET_SIWE_NONCE_STARTED',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'GET_SIWE_NONCE_SUCCESS',
+payload?: void,
+loadingInfo: LoadingInfo,
}
| {
+type: 'GET_SIWE_NONCE_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SIWE_AUTH_STARTED',
+payload: LogInStartingPayload,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SIWE_AUTH_SUCCESS',
+payload: LogInResult,
+loadingInfo: LoadingInfo,
}
| {
+type: 'SIWE_AUTH_FAILED',
+error: true,
+payload: Error,
+loadingInfo: LoadingInfo,
};
export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string);
export type SuperAction = {
type: string,
payload?: ActionPayload,
loadingInfo?: LoadingInfo,
error?: boolean,
};
type ThunkedAction = (dispatch: Dispatch) => void;
export type PromisedAction = (dispatch: Dispatch) => Promise;
export type Dispatch = ((promisedAction: PromisedAction) => Promise) &
((thunkedAction: ThunkedAction) => void) &
((action: SuperAction) => boolean);
// This is lifted from redux-persist/lib/constants.js
// I don't want to add redux-persist to the web/server bundles...
// import { REHYDRATE } from 'redux-persist';
export const rehydrateActionType = 'persist/REHYDRATE';
diff --git a/lib/utils/sanitization.js b/lib/utils/sanitization.js
index 0b6459e8c..2b4a3f8b7 100644
--- a/lib/utils/sanitization.js
+++ b/lib/utils/sanitization.js
@@ -1,316 +1,316 @@
// @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 type {
BaseAction,
NativeAppState,
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',
]);
// 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 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) {
return {
type: 'SET_DEVICE_TOKEN_SUCCESS',
payload: 'FAKE',
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 {
if (state.cookie !== undefined && state.cookie !== null) {
const oldState: NativeAppState = state;
state = { ...oldState, cookie: null };
}
if (state.deviceToken !== undefined && state.deviceToken !== null) {
- const oldState: NativeAppState = state;
+ const oldState: AppState = state;
state = { ...oldState, deviceToken: null };
}
const stateCopy = clone(state);
sanitizePII(stateCopy, redactionHelpers);
return stateCopy;
}
export {
sanitizeActionSecrets,
sanitizeAction,
sanitizeState,
sanitizeReduxReport,
};
diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js
index e40102f9f..7f3ede425 100644
--- a/native/redux/redux-setup.js
+++ b/native/redux/redux-setup.js
@@ -1,527 +1,522 @@
// @flow
import { AppState as NativeAppState, Platform, Alert } from 'react-native';
import Orientation from 'react-native-orientation-locker';
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,
deleteAccountActionTypes,
logInActionTypes,
} from 'lib/actions/user-actions.js';
-import { reduceDeviceToken } from 'lib/reducers/device-token-reducer.js';
import baseReducer from 'lib/reducers/master-reducer.js';
import { processThreadStoreOperations } from 'lib/reducers/thread-reducer.js';
import {
invalidSessionDowngrade,
invalidSessionRecovery,
} from 'lib/shared/account-utils.js';
import { isStaff } from 'lib/shared/user-utils.js';
import { defaultEnabledApps } from 'lib/types/enabled-apps.js';
import { defaultCalendarFilters } from 'lib/types/filter-types.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 { defaultConnectionInfo } from 'lib/types/socket-types.js';
import type { ThreadStoreOperation } from 'lib/types/thread-types.js';
import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js';
import { setNewSessionActionType } from 'lib/utils/action-utils.js';
import { convertMessageStoreOperationsToClientDBOperations } from 'lib/utils/message-ops-utils.js';
import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils.js';
import {
resetUserStateActionType,
recordNotifPermissionAlertActionType,
updateDimensionsActiveType,
updateConnectivityActiveType,
updateThemeInfoActionType,
updateDeviceCameraInfoActionType,
updateDeviceOrientationActionType,
updateThreadLastNavigatedActionType,
backgroundActionTypes,
setReduxStateActionType,
setStoreLoadedActionType,
type Action,
} from './action-types.js';
import { remoteReduxDevServerConfig } from './dev-tools.js';
import { defaultDimensionsInfo } from './dimensions-updater.react.js';
import { persistConfig, setPersistor } from './persist.js';
import type { AppState } from './state-types.js';
import { commCoreModule } from '../native-modules.js';
import { defaultNavInfo } from '../navigation/default-state.js';
import { getGlobalNavContext } from '../navigation/icky-global.js';
import { activeMessageListSelector } from '../navigation/nav-selectors.js';
import { defaultNotifPermissionAlertInfo } from '../push/alerts.js';
import reactotron from '../reactotron.js';
import { defaultDeviceCameraInfo } from '../types/camera.js';
import { defaultConnectivityInfo } from '../types/connectivity.js';
import { defaultGlobalThemeInfo } from '../types/themes.js';
import { isTaskCancelledError } from '../utils/error-handling.js';
import { isStaffRelease } from '../utils/staff-utils.js';
import {
defaultURLPrefix,
natNodeServer,
setCustomServer,
getDevServerHostname,
} from '../utils/url-utils.js';
const defaultState = ({
navInfo: defaultNavInfo,
currentUserInfo: null,
draftStore: { drafts: {} },
entryStore: {
entryInfos: {},
daysToEntries: {},
lastUserInteractionCalendar: 0,
},
threadStore: {
threadInfos: {},
},
userStore: {
userInfos: {},
inconsistencyReports: [],
},
messageStore: {
messages: {},
threads: {},
local: {},
currentAsOf: 0,
},
storeLoaded: false,
updatesCurrentAsOf: 0,
loadingStatuses: {},
calendarFilters: defaultCalendarFilters,
cookie: null,
deviceToken: null,
dataLoaded: false,
urlPrefix: defaultURLPrefix,
customServer: natNodeServer,
notifPermissionAlertInfo: defaultNotifPermissionAlertInfo,
connection: defaultConnectionInfo(Platform.OS),
watchedThreadIDs: [],
lifecycleState: 'active',
enabledApps: defaultEnabledApps,
reportStore: {
enabledReports: {
crashReports: __DEV__,
inconsistencyReports: __DEV__,
mediaReports: __DEV__,
},
queuedReports: [],
},
nextLocalID: 0,
_persist: null,
dimensions: defaultDimensionsInfo,
connectivity: defaultConnectivityInfo,
globalThemeInfo: defaultGlobalThemeInfo,
deviceCameraInfo: defaultDeviceCameraInfo,
deviceOrientation: Orientation.getInitialOrientation(),
frozen: false,
userPolicies: {},
}: AppState);
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 = 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 = 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.type === logOutActionTypes.success &&
invalidSessionDowngrade(
state,
action.payload.currentUserInfo,
action.payload.preRequestUserState,
)) ||
(action.type === deleteAccountActionTypes.success &&
invalidSessionDowngrade(
state,
action.payload.currentUserInfo,
action.payload.preRequestUserState,
))
) {
return state;
}
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 === setCustomServer) {
return {
...state,
customServer: action.payload,
};
} else if (action.type === recordNotifPermissionAlertActionType) {
return {
...state,
notifPermissionAlertInfo: {
totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1,
lastAlertTime: action.payload.time,
},
};
} else if (action.type === resetUserStateActionType) {
const cookie =
state.cookie && state.cookie.startsWith('anonymous=')
? state.cookie
: null;
const currentUserInfo =
state.currentUserInfo && state.currentUserInfo.anonymous
? state.currentUserInfo
: null;
return {
...state,
currentUserInfo,
cookie,
};
} else 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 === updateThemeInfoActionType) {
return {
...state,
globalThemeInfo: {
...state.globalThemeInfo,
...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 === updateThreadLastNavigatedActionType) {
const { threadID, time } = action.payload;
if (state.messageStore.threads[threadID]) {
state = {
...state,
messageStore: {
...state.messageStore,
threads: {
...state.messageStore.threads,
[threadID]: {
...state.messageStore.threads[threadID],
lastNavigatedTo: time,
},
},
},
};
}
return state;
}
if (action.type === setNewSessionActionType) {
sessionInvalidationAlert(action.payload);
state = {
...state,
cookie: action.payload.sessionChange.cookie,
};
}
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;
}
}
- state = {
- ...state,
- deviceToken: reduceDeviceToken(state.deviceToken, action),
- };
const baseReducerResult = baseReducer(state, (action: BaseAction));
state = baseReducerResult.state;
const { storeOperations } = baseReducerResult;
const {
draftStoreOperations,
threadStoreOperations,
messageStoreOperations,
} = storeOperations;
const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action);
state = fixUnreadActiveThreadResult.state;
const threadStoreOperationsWithUnreadFix = [
...threadStoreOperations,
...fixUnreadActiveThreadResult.threadStoreOperations,
];
const convertedThreadStoreOperations =
convertThreadStoreOperationsToClientDBOperations(
threadStoreOperationsWithUnreadFix,
);
const convertedMessageStoreOperations =
convertMessageStoreOperationsToClientDBOperations(messageStoreOperations);
(async () => {
try {
const promises = [];
if (convertedThreadStoreOperations.length > 0) {
promises.push(
commCoreModule.processThreadStoreOperations(
convertedThreadStoreOperations,
),
);
}
if (convertedMessageStoreOperations.length > 0) {
promises.push(
commCoreModule.processMessageStoreOperations(
convertedMessageStoreOperations,
),
);
}
if (draftStoreOperations.length > 0) {
promises.push(
commCoreModule.processDraftStoreOperations(draftStoreOperations),
);
}
await Promise.all(promises);
} catch (e) {
if (isTaskCancelledError(e)) {
return;
}
// this code will make an entry in SecureStore and cause re-creating
// database when user will open app again
commCoreModule.reportDBOperationsFailure();
commCoreModule.terminate();
}
})();
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') {
const app = Platform.select({
ios: 'App Store',
android: 'Play Store',
});
Alert.alert(
'App out of date',
'Your app version is pretty old, and the server doesn’t know how to ' +
`speak to it anymore. Please use the ${app} app to update!`,
[{ 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 updatedActiveThreadInfo = {
...state.threadStore.threadInfos[activeThread],
currentUser: {
...state.threadStore.threadInfos[activeThread].currentUser,
unread: false,
},
};
const threadStoreOperations = [
{
type: 'replace',
payload: {
id: activeThread,
threadInfo: updatedActiveThreadInfo,
},
},
];
const updatedThreadStore = processThreadStoreOperations(
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/redux-setup.js b/web/redux/redux-setup.js
index 84b098364..c457dbcd4 100644
--- a/web/redux/redux-setup.js
+++ b/web/redux/redux-setup.js
@@ -1,316 +1,316 @@
// @flow
import invariant from 'invariant';
import type { PersistState } from 'redux-persist/es/types.js';
import {
logOutActionTypes,
deleteAccountActionTypes,
} from 'lib/actions/user-actions.js';
import baseReducer from 'lib/reducers/master-reducer.js';
import { nonThreadCalendarFilters } from 'lib/selectors/calendar-filter-selectors.js';
import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import { invalidSessionDowngrade } from 'lib/shared/account-utils.js';
import type { Shape } from 'lib/types/core.js';
import type {
CryptoStore,
OLMIdentityKeys,
PickledOLMAccount,
} 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 } from 'lib/types/entry-types.js';
import {
type CalendarFilter,
calendarThreadFilterTypes,
} from 'lib/types/filter-types.js';
import type { LifecycleState } from 'lib/types/lifecycle-state-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 { ConnectionInfo } from 'lib/types/socket-types.js';
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 {
updateWindowActiveActionType,
setDeviceIDActionType,
updateNavInfoActionType,
updateWindowDimensionsActionType,
updateCalendarCommunityFilter,
clearCalendarCommunityFilter,
} from './action-types.js';
import {
reduceCryptoStore,
setPrimaryIdentityKeys,
setNotificationIdentityKeys,
setPickledNotificationAccount,
setPickledPrimaryAccount,
} from './crypto-store-reducer.js';
import { reduceDeviceID } from './device-id-reducer.js';
import reduceNavInfo from './nav-reducer.js';
import { getVisibility } from './visibility.js';
import { filterThreadIDsBelongingToCommunity } from '../selectors/calendar-selectors.js';
import { activeThreadSelector } from '../selectors/nav-selectors.js';
import { type NavInfo } from '../types/nav-types.js';
export type WindowDimensions = { width: number, height: number };
export type AppState = {
navInfo: NavInfo,
deviceID: ?string,
currentUserInfo: ?CurrentUserInfo,
draftStore: DraftStore,
sessionID: ?string,
entryStore: EntryStore,
threadStore: ThreadStore,
userStore: UserStore,
messageStore: MessageStore,
updatesCurrentAsOf: number,
loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } },
calendarFilters: $ReadOnlyArray,
calendarPickedCommunityID: ?string,
urlPrefix: string,
windowDimensions: WindowDimensions,
cookie?: void,
- deviceToken?: void,
+ deviceToken: ?string,
baseHref: string,
connection: ConnectionInfo,
watchedThreadIDs: $ReadOnlyArray,
lifecycleState: LifecycleState,
enabledApps: EnabledApps,
reportStore: ReportStore,
nextLocalID: number,
dataLoaded: boolean,
windowActive: boolean,
userPolicies: UserPolicies,
cryptoStore: CryptoStore,
pushApiPublicKey: ?string,
_persist: ?PersistState,
};
export type Action =
| BaseAction
| { type: 'UPDATE_NAV_INFO', payload: Shape }
| {
type: 'UPDATE_WINDOW_DIMENSIONS',
payload: WindowDimensions,
}
| {
type: 'UPDATE_WINDOW_ACTIVE',
payload: boolean,
}
| {
type: 'SET_DEVICE_ID',
payload: string,
}
| { +type: 'SET_PRIMARY_IDENTITY_KEYS', payload: ?OLMIdentityKeys }
| { +type: 'SET_NOTIFICATION_IDENTITY_KEYS', payload: ?OLMIdentityKeys }
| { +type: 'SET_PICKLED_PRIMARY_ACCOUNT', payload: ?PickledOLMAccount }
| { +type: 'SET_PICKLED_NOTIFICATION_ACCOUNT', payload: ?PickledOLMAccount }
| {
+type: 'UPDATE_CALENDAR_COMMUNITY_FILTER',
+payload: string,
}
| {
+type: 'CLEAR_CALENDAR_COMMUNITY_FILTER',
+payload: void,
};
export function reducer(oldState: AppState | void, action: Action): AppState {
invariant(oldState, 'should be set');
let state = oldState;
if (action.type === updateWindowDimensionsActionType) {
return validateState(oldState, {
...state,
windowDimensions: action.payload,
});
} else if (action.type === updateWindowActiveActionType) {
return validateState(oldState, {
...state,
windowActive: action.payload,
});
} else if (action.type === updateCalendarCommunityFilter) {
const nonThreadFilters = nonThreadCalendarFilters(state.calendarFilters);
const threadIDs = Array.from(
filterThreadIDsBelongingToCommunity(
action.payload,
state.threadStore.threadInfos,
),
);
return {
...state,
calendarFilters: [
...nonThreadFilters,
{
type: calendarThreadFilterTypes.THREAD_LIST,
threadIDs,
},
],
calendarPickedCommunityID: action.payload,
};
} else if (action.type === clearCalendarCommunityFilter) {
const nonThreadFilters = nonThreadCalendarFilters(state.calendarFilters);
return {
...state,
calendarFilters: nonThreadFilters,
calendarPickedCommunityID: null,
};
} else if (action.type === setNewSessionActionType) {
if (
invalidSessionDowngrade(
oldState,
action.payload.sessionChange.currentUserInfo,
action.payload.preRequestUserState,
)
) {
return oldState;
}
state = {
...state,
sessionID: action.payload.sessionChange.sessionID,
};
} else if (
(action.type === logOutActionTypes.success &&
invalidSessionDowngrade(
oldState,
action.payload.currentUserInfo,
action.payload.preRequestUserState,
)) ||
(action.type === deleteAccountActionTypes.success &&
invalidSessionDowngrade(
oldState,
action.payload.currentUserInfo,
action.payload.preRequestUserState,
))
) {
return oldState;
}
if (
action.type !== updateNavInfoActionType &&
action.type !== setDeviceIDActionType &&
action.type !== setPrimaryIdentityKeys &&
action.type !== setNotificationIdentityKeys &&
action.type !== setPickledPrimaryAccount &&
action.type !== setPickledNotificationAccount
) {
state = baseReducer(state, action).state;
}
state = {
...state,
navInfo: reduceNavInfo(
state.navInfo,
action,
state.threadStore.threadInfos,
),
deviceID: reduceDeviceID(state.deviceID, action),
cryptoStore: reduceCryptoStore(state.cryptoStore, action),
};
return validateState(oldState, state);
}
function validateState(oldState: AppState, state: AppState): AppState {
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
state = {
...state,
threadStore: {
...state.threadStore,
threadInfos: {
...state.threadStore.threadInfos,
[activeThread]: {
...state.threadStore.threadInfos[activeThread],
currentUser: {
...state.threadStore.threadInfos[activeThread].currentUser,
unread: false,
},
},
},
},
};
}
const oldActiveThread = activeThreadSelector(oldState);
if (
activeThread &&
oldActiveThread !== activeThread &&
state.messageStore.threads[activeThread]
) {
// Update messageStore.threads[activeThread].lastNavigatedTo
state = {
...state,
messageStore: {
...state.messageStore,
threads: {
...state.messageStore.threads,
[activeThread]: {
...state.messageStore.threads[activeThread],
lastNavigatedTo: Date.now(),
},
},
},
};
}
return state;
}