Page MenuHomePhabricator

No OneTemporary

diff --git a/lib/components/socket.react.js b/lib/components/socket.react.js
index 197e2511e..96a1f69ef 100644
--- a/lib/components/socket.react.js
+++ b/lib/components/socket.react.js
@@ -1,255 +1,381 @@
// @flow
import {
+ type ServerRequest,
serverRequestTypes,
type ClientResponse,
- clientResponsePropType,
+ clearDeliveredClientResponsesActionType,
+ processServerRequestsActionType,
} from '../types/request-types';
import {
type SessionState,
type SessionIdentification,
sessionIdentificationPropType,
} from '../types/session-types';
import {
clientSocketMessageTypes,
type ClientSocketMessage,
serverSocketMessageTypes,
type ServerSocketMessage,
stateSyncPayloadTypes,
} from '../types/socket-types';
import type { Dispatch } from '../types/redux-types';
import type { DispatchActionPayload } from '../utils/action-utils';
import type { LogInExtraInfo } from '../types/account-types';
import * as React from 'react';
import PropTypes from 'prop-types';
import invariant from 'invariant';
import { getConfig } from '../utils/config';
import {
registerActiveWebSocket,
setNewSessionActionType,
fetchNewCookieFromNativeCredentials,
} from '../utils/action-utils';
import { socketAuthErrorResolutionAttempt } from '../actions/user-actions';
const fullStateSyncActionPayload = "FULL_STATE_SYNC";
const incrementalStateSyncActionPayload = "INCREMENTAL_STATE_SYNC";
+type SentClientResponse = { clientResponse: ClientResponse, messageID: number };
type Props = {|
active: bool,
// Redux state
openSocket: () => WebSocket,
- clientResponses: $ReadOnlyArray<ClientResponse>,
+ getClientResponses: (
+ activeServerRequests?: $ReadOnlyArray<ServerRequest>,
+ ) => $ReadOnlyArray<ClientResponse>,
activeThread: ?string,
sessionStateFunc: () => SessionState,
sessionIdentification: SessionIdentification,
cookie: ?string,
urlPrefix: string,
logInExtraInfo: () => LogInExtraInfo,
// Redux dispatch functions
dispatch: Dispatch,
dispatchActionPayload: DispatchActionPayload,
|};
class Socket extends React.PureComponent<Props> {
static propTypes = {
openSocket: PropTypes.func.isRequired,
active: PropTypes.bool.isRequired,
- clientResponses: PropTypes.arrayOf(clientResponsePropType).isRequired,
+ getClientResponses: PropTypes.func.isRequired,
activeThread: PropTypes.string,
sessionStateFunc: PropTypes.func.isRequired,
sessionIdentification: sessionIdentificationPropType.isRequired,
cookie: PropTypes.string,
urlPrefix: PropTypes.string.isRequired,
logInExtraInfo: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
dispatchActionPayload: PropTypes.func.isRequired,
};
socket: ?WebSocket;
initialPlatformDetailsSent = false;
nextClientMessageID = 0;
+ socketInitializedAndActive = false;
+ clientResponsesInTransit: SentClientResponse[] = [];
openSocket() {
if (this.socket) {
this.socket.close();
}
const socket = this.props.openSocket();
socket.onopen = this.sendInitialMessage;
socket.onmessage = this.receiveMessage;
socket.onclose = this.onClose;
this.socket = socket;
+ this.clientResponsesInTransit = [];
}
closeSocket() {
+ this.socketInitializedAndActive = false;
registerActiveWebSocket(null);
if (this.socket) {
if (this.socket.readyState < 2) {
// If it's not closing already, close it
this.socket.close();
}
this.socket = null;
}
}
+ markSocketInitialized() {
+ this.socketInitializedAndActive = true;
+ registerActiveWebSocket(this.socket);
+ // In case any ClientResponses have accumulated in Redux while we were
+ // initializing
+ this.possiblySendReduxClientResponses();
+ }
+
componentDidMount() {
if (this.props.active) {
this.openSocket();
}
}
componentWillUnmount() {
this.closeSocket();
}
componentDidUpdate(prevProps: Props) {
if (this.props.active && !prevProps.active) {
this.openSocket();
} else if (!this.props.active && prevProps.active) {
this.closeSocket();
} else if (
this.props.active &&
prevProps.openSocket !== this.props.openSocket
) {
// This case happens when the baseURL/urlPrefix is changed. Not sure if
// the closeSocket() call is entirely necessary. Long-term we will update
// this logic to retry in-flight requests anyways, so we can figure out
// then.
this.closeSocket();
this.openSocket();
}
+
+ if (
+ this.socketInitializedAndActive &&
+ this.props.getClientResponses !== prevProps.getClientResponses
+ ) {
+ this.possiblySendReduxClientResponses();
+ }
}
render() {
return null;
}
sendMessage(message: ClientSocketMessage) {
const socket = this.socket;
invariant(socket, "should be set");
socket.send(JSON.stringify(message));
}
messageFromEvent(event: MessageEvent): ?ServerSocketMessage {
if (typeof event.data !== "string") {
console.warn('socket received a non-string message');
return null;
}
try {
return JSON.parse(event.data);
} catch (e) {
console.warn(e);
return null;
}
}
receiveMessage = async (event: MessageEvent) => {
const message = this.messageFromEvent(event);
if (!message) {
return;
}
if (message.type === serverSocketMessageTypes.STATE_SYNC) {
+ this.clearDeliveredClientResponses(message.responseTo);
if (message.payload.type === stateSyncPayloadTypes.FULL) {
const { sessionID, type, ...actionPayload } = message.payload;
this.props.dispatchActionPayload(
fullStateSyncActionPayload,
actionPayload,
);
if (sessionID !== null && sessionID !== undefined) {
this.props.dispatchActionPayload(
setNewSessionActionType,
{
sessionChange: { cookieInvalidated: false, sessionID },
error: null,
},
);
}
} else {
const { type, ...actionPayload } = message.payload;
this.props.dispatchActionPayload(
incrementalStateSyncActionPayload,
actionPayload,
);
}
- // Once we receive the STATE_SYNC, the socket is ready for use
- registerActiveWebSocket(this.socket);
+ this.markSocketInitialized();
} else if (message.type === serverSocketMessageTypes.REQUESTS) {
+ this.clearDeliveredClientResponses(message.responseTo);
+ const { serverRequests } = message.payload;
+ this.processServerRequests(serverRequests);
+ const clientResponses = this.props.getClientResponses(serverRequests);
+ this.sendClientResponses(clientResponses);
} else if (message.type === serverSocketMessageTypes.ERROR) {
- const { message: errorMessage, payload } = message;
+ const { message: errorMessage, payload, responseTo } = message;
+ if (responseTo !== null && responseTo !== undefined) {
+ this.clearDeliveredClientResponses(responseTo);
+ }
if (payload) {
console.warn(`socket sent error ${errorMessage} with payload`, payload);
} else {
console.warn(`socket sent error ${errorMessage}`);
}
} else if (message.type === serverSocketMessageTypes.AUTH_ERROR) {
const { sessionChange } = message;
const cookie = sessionChange ? sessionChange.cookie : this.props.cookie;
const recoverySessionChange = await fetchNewCookieFromNativeCredentials(
this.props.dispatch,
cookie,
this.props.urlPrefix,
socketAuthErrorResolutionAttempt,
this.props.logInExtraInfo,
);
if (!recoverySessionChange && sessionChange) {
// This should only happen in the cookieSources.BODY (native) case when
// the resolution attempt failed
const { cookie, currentUserInfo } = sessionChange;
this.props.dispatchActionPayload(
setNewSessionActionType,
{
sessionChange: { cookieInvalidated: true, currentUserInfo, cookie },
error: null,
},
);
}
}
}
onClose = (event: CloseEvent) => {
this.closeSocket();
}
sendInitialMessage = () => {
- const clientResponses = [ ...this.props.clientResponses ];
+ const baseClientResponses = this.props.getClientResponses();
+ const clientResponses = [ ...baseClientResponses ];
if (this.props.activeThread) {
clientResponses.push({
type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES,
activityUpdates: [{
focus: true,
threadID: this.props.activeThread,
}],
});
}
const responsesIncludePlatformDetails = clientResponses.some(
response => response.type === serverRequestTypes.PLATFORM_DETAILS,
);
if (!this.initialPlatformDetailsSent) {
this.initialPlatformDetailsSent = true;
if (!responsesIncludePlatformDetails) {
clientResponses.push({
type: serverRequestTypes.PLATFORM_DETAILS,
platformDetails: getConfig().platformDetails,
});
}
}
+ const messageID = this.nextClientMessageID++;
+ this.markClientResponsesAsInTransit(clientResponses, messageID);
+
const sessionState = this.props.sessionStateFunc();
const { sessionIdentification } = this.props;
const initialMessage = {
type: clientSocketMessageTypes.INITIAL,
- id: this.nextClientMessageID++,
+ id: messageID,
payload: {
clientResponses,
sessionState,
sessionIdentification,
},
};
this.sendMessage(initialMessage);
}
+ possiblySendReduxClientResponses() {
+ const clientResponses = this.props.getClientResponses();
+ this.sendClientResponses(clientResponses);
+ }
+
+ sendClientResponses(clientResponses: $ReadOnlyArray<ClientResponse>) {
+ if (clientResponses.length === 0) {
+ return;
+ }
+ const filtered = this.filterInTransitClientResponses(clientResponses);
+ if (filtered.length === 0) {
+ return;
+ }
+ const messageID = this.nextClientMessageID++;
+ this.markClientResponsesAsInTransit(filtered, messageID);
+ this.sendMessage({
+ type: clientSocketMessageTypes.RESPONSES,
+ id: messageID,
+ payload: { clientResponses: filtered },
+ });
+ }
+
+ markClientResponsesAsInTransit(
+ clientResponses: $ReadOnlyArray<ClientResponse>,
+ messageID: number,
+ ) {
+ // We want to avoid double-sending the ClientResponses we cache in Redux
+ // (namely, the inconsistency responses), so we mark them as in-transit once
+ // they're sent
+ for (let clientResponse of clientResponses) {
+ this.clientResponsesInTransit.push({ clientResponse, messageID });
+ }
+ }
+
+ filterInTransitClientResponses(
+ clientResponses: $ReadOnlyArray<ClientResponse>,
+ ): ClientResponse[] {
+ const filtered = [];
+ for (let clientResponse of clientResponses) {
+ let inTransit = false;
+ for (let sentClientResponse of this.clientResponsesInTransit) {
+ const { clientResponse: inTransitClientResponse } = sentClientResponse;
+ if (inTransitClientResponse === clientResponse) {
+ inTransit = true;
+ break;
+ }
+ }
+ if (!inTransit) {
+ filtered.push(clientResponse);
+ }
+ }
+ return filtered;
+ }
+
+ clearDeliveredClientResponses(messageID: number) {
+ const deliveredClientResponses = [], clientResponsesStillInTransit = [];
+ for (let sentClientResponse of this.clientResponsesInTransit) {
+ if (sentClientResponse.messageID === messageID) {
+ deliveredClientResponses.push(sentClientResponse.clientResponse);
+ } else {
+ clientResponsesStillInTransit.push(sentClientResponse);
+ }
+ }
+ if (deliveredClientResponses.length === 0) {
+ return;
+ }
+ // Note: it's hypothetically possible for something to call
+ // possiblySendReduxClientResponses after we update
+ // this.clientResponsesInTransit below but before the Redux action below
+ // propagates to this component. In that case, we could double-send some
+ // Redux-cached ClientResponses. Since right now only inconsistency
+ // responses are Redux-cached, and the server knows how to dedup those so
+ // that the transaction is idempotent, we're not going to worry about it.
+ this.props.dispatchActionPayload(
+ clearDeliveredClientResponsesActionType,
+ { clientResponses: deliveredClientResponses },
+ );
+ this.clientResponsesInTransit = clientResponsesStillInTransit;
+ }
+
+ processServerRequests(serverRequests: $ReadOnlyArray<ServerRequest>) {
+ if (serverRequests.length === 0) {
+ return;
+ }
+ this.props.dispatchActionPayload(
+ processServerRequestsActionType,
+ { serverRequests },
+ );
+ }
+
}
export default Socket;
diff --git a/lib/reducers/entry-reducer.js b/lib/reducers/entry-reducer.js
index e572f749a..74fed96d7 100644
--- a/lib/reducers/entry-reducer.js
+++ b/lib/reducers/entry-reducer.js
@@ -1,818 +1,834 @@
// @flow
import type { BaseAction } from '../types/redux-types';
import type {
RawEntryInfo,
EntryStore,
CalendarQuery,
CalendarResult,
} from '../types/entry-types';
import { type RawThreadInfo } from '../types/thread-types';
import { updateTypes, type UpdateInfo } from '../types/update-types';
import {
type EntryInconsistencyClientResponse,
serverRequestTypes,
+ clearDeliveredClientResponsesActionType,
} from '../types/request-types';
import { pingResponseTypes } from '../types/ping-types';
import _flow from 'lodash/fp/flow';
import _map from 'lodash/fp/map';
import _pickBy from 'lodash/fp/pickBy';
import _omitBy from 'lodash/fp/omitBy';
import _mapValues from 'lodash/fp/mapValues';
import _filter from 'lodash/fp/filter';
import _union from 'lodash/fp/union';
import _mapKeys from 'lodash/fp/mapKeys';
import _groupBy from 'lodash/fp/groupBy';
import _isEqual from 'lodash/fp/isEqual';
import _isEmpty from 'lodash/fp/isEmpty';
import invariant from 'invariant';
import { dateString } from '../utils/date-utils';
import { setHighestLocalID } from '../utils/local-ids';
import { setNewSessionActionType } from '../utils/action-utils';
import {
fetchEntriesActionTypes,
updateCalendarQueryActionTypes,
createLocalEntryActionType,
createEntryActionTypes,
saveEntryActionTypes,
concurrentModificationResetActionType,
deleteEntryActionTypes,
fetchRevisionsForEntryActionTypes,
restoreEntryActionTypes,
} from '../actions/entry-actions';
import {
logOutActionTypes,
deleteAccountActionTypes,
logInActionTypes,
resetPasswordActionTypes,
registerActionTypes,
} from '../actions/user-actions';
import {
deleteThreadActionTypes,
leaveThreadActionTypes,
joinThreadActionTypes,
changeThreadSettingsActionTypes,
removeUsersFromThreadActionTypes,
changeThreadMemberRolesActionTypes,
} from '../actions/thread-actions';
import { pingActionTypes } from '../actions/ping-actions';
import { rehydrateActionType } from '../types/redux-types';
import {
entryID,
rawEntryInfoWithinCalendarQuery,
filterRawEntryInfosByCalendarQuery,
} from '../shared/entry-utils';
import { threadInFilterList } from '../shared/thread-utils';
import { getConfig } from '../utils/config';
import { reduxLogger } from '../utils/redux-logger';
import { values } from '../utils/objects';
import { sanitizeAction } from '../utils/sanitization';
function daysToEntriesFromEntryInfos(entryInfos: $ReadOnlyArray<RawEntryInfo>) {
return _flow(
_groupBy(
(entryInfo: RawEntryInfo) =>
dateString(entryInfo.year, entryInfo.month, entryInfo.day),
),
_mapValues(
(entryInfoGroup: RawEntryInfo[]) => _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},
newEntryInfos: $ReadOnlyArray<RawEntryInfo>,
threadInfos: {[id: string]: RawThreadInfo},
pingInfo?: {|
prevEntryInfos: {[id: string]: RawEntryInfo},
calendarQuery: CalendarQuery,
|},
) {
const mergedEntryInfos = {};
for (let 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;
continue;
}
if (pingInfo) {
const prevEntryInfo = pingInfo.prevEntryInfos[serverID];
// If the entry at the time of the start of the ping is the same as what
// was returned, but the current state is different, then it's likely that
// an action mutated the state after the ping result was fetched on the
// server. We should keep the mutated (current) state.
if (_isEqual(prevEntryInfo)(newEntryInfo)) {
if (currentEntryInfo) {
mergedEntryInfos[serverID] = currentEntryInfo;
}
continue;
}
}
mergedEntryInfos[serverID] = newEntryInfo;
}
for (let id in currentEntryInfos) {
const newEntryInfo = mergedEntryInfos[id];
if (newEntryInfo) {
continue;
}
const currentEntryInfo = currentEntryInfos[id];
if (pingInfo) {
const prevEntryInfo = pingInfo.prevEntryInfos[id];
// If an EntryInfo was present at the start of the ping, and is currently
// present, but did not appear in the ping result, then there are three
// possibilities:
// - It is outside the scope of the CalendarQuery.
// - It is a local entry that has not been committed to the server yet, in
// which case we should keep it.
// - It has been deleted on the server side.
// We should delete it only in the third case.
if (prevEntryInfo && prevEntryInfo.id) {
const withinCalendarQueryRange = rawEntryInfoWithinCalendarQuery(
currentEntryInfo,
pingInfo.calendarQuery,
);
if (withinCalendarQueryRange) {
continue;
}
}
}
mergedEntryInfos[id] = currentEntryInfo;
}
for (let entryID in mergedEntryInfos) {
const entryInfo = mergedEntryInfos[entryID];
if (!threadInFilterList(threadInfos[entryInfo.threadID])) {
delete mergedEntryInfos[entryID];
}
}
const daysToEntries = daysToEntriesFromEntryInfos(values(mergedEntryInfos));
return [mergedEntryInfos, daysToEntries];
}
function reduceEntryInfos(
entryStore: EntryStore,
action: BaseAction,
newThreadInfos: {[id: string]: RawThreadInfo},
): EntryStore {
const {
entryInfos,
daysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses,
} = entryStore;
if (
action.type === logOutActionTypes.success ||
action.type === deleteAccountActionTypes.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 === deleteAccountActionTypes.success
? 0
: lastUserInteractionCalendar;
if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) {
return {
entryInfos,
daysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: newLastUserInteractionCalendar,
inconsistencyResponses,
};
}
const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos(
daysToEntries,
newEntryInfos,
);
return {
entryInfos: newEntryInfos,
daysToEntries: newDaysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: newLastUserInteractionCalendar,
inconsistencyResponses,
};
} 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,
actualizedCalendarQuery,
lastUserInteractionCalendar: newLastUserInteractionCalendar,
inconsistencyResponses,
};
}
return {
entryInfos: newEntryInfos,
daysToEntries: newDaysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: newLastUserInteractionCalendar,
inconsistencyResponses,
};
} else if (action.type === fetchEntriesActionTypes.success) {
const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos(
entryInfos,
action.payload.rawEntryInfos,
newThreadInfos,
);
return {
entryInfos: updatedEntryInfos,
daysToEntries: updatedDaysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses,
};
} else if (
action.type === updateCalendarQueryActionTypes.started &&
action.payload &&
action.payload.calendarQuery
) {
return {
entryInfos,
daysToEntries,
actualizedCalendarQuery: action.payload.calendarQuery,
lastUserInteractionCalendar: Date.now(),
inconsistencyResponses,
};
} else if (
action.type === logInActionTypes.started ||
action.type === resetPasswordActionTypes.started ||
action.type === registerActionTypes.started ||
action.type === pingActionTypes.started
) {
return {
entryInfos,
daysToEntries,
actualizedCalendarQuery: action.payload.calendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses,
};
} else if (action.type === updateCalendarQueryActionTypes.success) {
const newActualizedCalendarQuery = action.payload.calendarQuery
? action.payload.calendarQuery
: actualizedCalendarQuery;
const newLastUserInteractionCalendar = action.payload.calendarQuery
? Date.now()
: lastUserInteractionCalendar;
const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos(
entryInfos,
action.payload.rawEntryInfos,
newThreadInfos,
);
return {
entryInfos: updatedEntryInfos,
daysToEntries: updatedDaysToEntries,
actualizedCalendarQuery: newActualizedCalendarQuery,
lastUserInteractionCalendar: newLastUserInteractionCalendar,
inconsistencyResponses,
};
} 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]: entryInfo,
};
const dayString =
dateString(entryInfo.year, entryInfo.month, entryInfo.day);
const newDaysToEntries = {
...daysToEntries,
[dayString]: _union([localID])(daysToEntries[dayString]),
};
return {
entryInfos: newEntryInfos,
daysToEntries: newDaysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: Date.now(),
inconsistencyResponses,
};
} 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.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 = {
...rekeyedEntryInfos,
[serverID]: {
...rekeyedEntryInfos[serverID],
id: serverID,
localID,
text: action.payload.text,
},
};
const newDaysToEntries = daysToEntriesFromEntryInfos(
values(updatedEntryInfos),
);
const updateEntryInfos = mergeUpdateEntryInfos(
[],
action.payload.updatesResult.viewerUpdates,
);
const [ newUpdatedEntryInfos ] = mergeNewEntryInfos(
rekeyedEntryInfos,
updateEntryInfos,
newThreadInfos,
);
const newInconsistencies = findInconsistencies(
entryInfos,
action,
updatedEntryInfos,
newUpdatedEntryInfos,
action.payload.calendarQuery,
);
return {
entryInfos: updatedEntryInfos,
daysToEntries: newDaysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: Date.now(),
inconsistencyResponses: [
...inconsistencyResponses,
...newInconsistencies,
],
};
} 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 = {
...entryInfos,
[serverID]: {
...entryInfos[serverID],
text: action.payload.text,
},
};
const updateEntryInfos = mergeUpdateEntryInfos(
[],
action.payload.updatesResult.viewerUpdates,
);
const [ newUpdatedEntryInfos ] = mergeNewEntryInfos(
entryInfos,
updateEntryInfos,
newThreadInfos,
);
const newInconsistencies = findInconsistencies(
entryInfos,
action,
updatedEntryInfos,
newUpdatedEntryInfos,
action.payload.calendarQuery,
);
return {
entryInfos: updatedEntryInfos,
daysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: Date.now(),
inconsistencyResponses: [
...inconsistencyResponses,
...newInconsistencies,
],
};
} else if (action.type === concurrentModificationResetActionType) {
const payload = action.payload;
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,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses,
};
} 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]: {
...entryInfos[id],
deleted: true,
},
};
return {
entryInfos: newEntryInfos,
daysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: Date.now(),
inconsistencyResponses,
};
} else if (action.type === deleteEntryActionTypes.success && action.payload) {
const { payload } = action;
const updateEntryInfos = mergeUpdateEntryInfos(
[],
payload.updatesResult.viewerUpdates,
);
const [ newUpdatedEntryInfos ] = mergeNewEntryInfos(
entryInfos,
updateEntryInfos,
newThreadInfos,
);
const newInconsistencies = findInconsistencies(
entryInfos,
action,
entryInfos,
newUpdatedEntryInfos,
payload.calendarQuery,
);
return {
entryInfos,
daysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses: [
...inconsistencyResponses,
...newInconsistencies,
],
};
} 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,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses,
};
} else if (action.type === restoreEntryActionTypes.success) {
const entryInfo = action.payload.entryInfo;
const key = entryID(entryInfo);
const updatedEntryInfos = {
...entryInfos,
[key]: entryInfo,
};
const updateEntryInfos = mergeUpdateEntryInfos(
[],
action.payload.updatesResult.viewerUpdates,
);
const [ newUpdatedEntryInfos ] = mergeNewEntryInfos(
entryInfos,
updateEntryInfos,
newThreadInfos,
);
const newInconsistencies = findInconsistencies(
entryInfos,
action,
updatedEntryInfos,
newUpdatedEntryInfos,
action.payload.calendarQuery,
);
return {
entryInfos: updatedEntryInfos,
daysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar: Date.now(),
inconsistencyResponses: [
...inconsistencyResponses,
...newInconsistencies,
],
};
} else if (
action.type === logInActionTypes.success ||
action.type === resetPasswordActionTypes.success ||
action.type === joinThreadActionTypes.success
) {
const calendarResult = action.payload.calendarResult;
if (calendarResult) {
const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos(
entryInfos,
calendarResult.rawEntryInfos,
newThreadInfos,
);
return {
entryInfos: updatedEntryInfos,
daysToEntries: updatedDaysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses,
};
}
} else if (action.type === pingActionTypes.success) {
const { payload } = action;
let newInconsistencies = [], updatedEntryInfos, updatedDaysToEntries;
if (payload.type === pingResponseTypes.FULL) {
[ updatedEntryInfos, updatedDaysToEntries ] = mergeNewEntryInfos(
entryInfos,
payload.calendarResult.rawEntryInfos,
newThreadInfos,
{
prevEntryInfos: payload.prevState.entryInfos,
calendarQuery: payload.calendarResult.calendarQuery,
},
);
} else {
const { calendarQuery, updatesResult, deltaEntryInfos } = payload;
const updateEntryInfos = mergeUpdateEntryInfos(
deltaEntryInfos,
updatesResult.newUpdates,
);
const updateResult = mergeNewEntryInfos(
entryInfos,
updateEntryInfos,
newThreadInfos,
);
const checkStateRequest = payload.requests.serverRequests.find(
candidate => candidate.type === serverRequestTypes.CHECK_STATE,
);
if (
!checkStateRequest ||
!checkStateRequest.stateChanges ||
!checkStateRequest.stateChanges.rawEntryInfos
) {
[ updatedEntryInfos, updatedDaysToEntries ] = updateResult;
} else {
const { rawEntryInfos } = checkStateRequest.stateChanges;
[ updatedEntryInfos, updatedDaysToEntries ] = mergeNewEntryInfos(
entryInfos,
[ ...updateEntryInfos, ...rawEntryInfos ],
newThreadInfos,
);
newInconsistencies = findInconsistencies(
entryInfos,
action,
updateResult[0],
updatedEntryInfos,
calendarQuery,
);
}
}
return {
entryInfos: updatedEntryInfos,
daysToEntries: updatedDaysToEntries,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses: [
...inconsistencyResponses.filter(
response =>
!payload.requests.deliveredClientResponses.includes(response),
),
...newInconsistencies,
],
};
} 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,
actualizedCalendarQuery,
lastUserInteractionCalendar,
inconsistencyResponses,
};
} else if (action.type === rehydrateActionType) {
if (!action.payload || !action.payload.entryStore) {
return entryStore;
}
let highestLocalIDFound = -1;
for (let entryKey in action.payload.entryStore.entryInfos) {
const localID = action.payload.entryStore.entryInfos[entryKey].localID;
if (localID) {
const matches = localID.match(/^local([0-9]+)$/);
invariant(
matches && matches[1],
`${localID} doesn't look like a localID`,
);
const thisLocalID = parseInt(matches[1]);
if (thisLocalID > highestLocalIDFound) {
highestLocalIDFound = thisLocalID;
}
}
}
setHighestLocalID(highestLocalIDFound + 1);
+ } else if (action.type === clearDeliveredClientResponsesActionType) {
+ const { payload } = action;
+ const updatedResponses = inconsistencyResponses.filter(
+ response => !payload.clientResponses.includes(response),
+ );
+ if (updatedResponses.length === inconsistencyResponses.length) {
+ return entryStore;
+ }
+ return {
+ entryInfos,
+ daysToEntries,
+ actualizedCalendarQuery,
+ lastUserInteractionCalendar,
+ inconsistencyResponses: updatedResponses,
+ };
}
return entryStore;
}
function mergeUpdateEntryInfos(
entryInfos: $ReadOnlyArray<RawEntryInfo>,
newUpdates: $ReadOnlyArray<UpdateInfo>,
): RawEntryInfo[] {
const entryIDs = new Set(entryInfos.map(entryInfo => entryInfo.id));
const mergedEntryInfos = [...entryInfos];
for (let updateInfo of newUpdates) {
if (updateInfo.type === updateTypes.JOIN_THREAD) {
for (let entryInfo of updateInfo.rawEntryInfos) {
if (entryIDs.has(entryInfo.id)) {
continue;
}
mergedEntryInfos.push(entryInfo);
entryIDs.add(entryInfo.id);
}
} else if (updateInfo.type === updateTypes.UPDATE_ENTRY) {
const { entryInfo } = updateInfo;
if (entryIDs.has(entryInfo.id)) {
continue;
}
mergedEntryInfos.push(entryInfo);
entryIDs.add(entryInfo.id);
}
}
return mergedEntryInfos;
}
const emptyArray = [];
function findInconsistencies(
beforeAction: {[id: string]: RawEntryInfo},
action: BaseAction,
oldResult: {[id: string]: RawEntryInfo},
newResult: {[id: string]: RawEntryInfo},
calendarQuery: CalendarQuery,
): EntryInconsistencyClientResponse[] {
const filteredPollResult = filterRawEntryInfosByCalendarQuery(
oldResult,
calendarQuery,
);
const filteredPushResult = filterRawEntryInfosByCalendarQuery(
newResult,
calendarQuery,
);
if (_isEqual(filteredPollResult)(filteredPushResult)) {
return emptyArray;
}
if (action.type === pingActionTypes.success) {
if (action.payload.type === pingResponseTypes.FULL) {
// We can get a memory leak if we include a previous
// EntryInconsistencyClientResponse in this one
action = {
type: "PING_SUCCESS",
loadingInfo: action.loadingInfo,
payload: {
...action.payload,
requests: {
...action.payload.requests,
deliveredClientResponses: [],
},
},
};
} else {
// This is a separate condition because of Flow
action = {
type: "PING_SUCCESS",
loadingInfo: action.loadingInfo,
payload: {
...action.payload,
requests: {
...action.payload.requests,
deliveredClientResponses: [],
},
},
};
}
}
return [{
type: serverRequestTypes.ENTRY_INCONSISTENCY,
platformDetails: getConfig().platformDetails,
beforeAction,
action: sanitizeAction(action),
calendarQuery,
pollResult: oldResult,
pushResult: newResult,
lastActionTypes: reduxLogger.interestingActionTypes,
time: Date.now(),
}];
}
export {
daysToEntriesFromEntryInfos,
reduceEntryInfos,
};
diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js
index 9f72c8ad2..f5a9df5c8 100644
--- a/lib/reducers/master-reducer.js
+++ b/lib/reducers/master-reducer.js
@@ -1,62 +1,57 @@
// @flow
import type { BaseAppState, BaseAction } from '../types/redux-types';
import type { BaseNavInfo } from '../types/nav-types';
import invariant from 'invariant';
import { reduceLoadingStatuses } from './loading-reducer';
import { reduceEntryInfos } from './entry-reducer';
import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer';
import reduceThreadInfos from './thread-reducer';
import reduceBaseNavInfo from './nav-reducer';
import { reduceMessageStore } from './message-reducer';
import reduceUpdatesCurrentAsOf from './updates-reducer';
import { reduceDrafts } from './draft-reducer';
import reduceURLPrefix from './url-prefix-reducer';
-import reduceServerRequests from './server-requests-reducer';
import reduceCalendarFilters from './calendar-filters-reducer';
export default function baseReducer<N: BaseNavInfo, T: BaseAppState<N>>(
state: T,
action: BaseAction,
): T {
const threadStore = reduceThreadInfos(state.threadStore, action);
const { threadInfos } = threadStore;
// NavInfo has to be handled differently because of the covariance
// (see comment about "+" in redux-types.js)
const baseNavInfo = reduceBaseNavInfo(state.navInfo, action);
const navInfo = baseNavInfo === state.navInfo
? state.navInfo
: { ...state.navInfo, ...baseNavInfo };
return {
...state,
navInfo,
entryStore: reduceEntryInfos(
state.entryStore,
action,
threadInfos,
),
loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action),
currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action),
threadStore,
userInfos: reduceUserInfos(state.userInfos, action),
messageStore: reduceMessageStore(state.messageStore, action, threadInfos),
drafts: reduceDrafts(state.drafts, action),
updatesCurrentAsOf: reduceUpdatesCurrentAsOf(
state.updatesCurrentAsOf,
action,
),
urlPrefix: reduceURLPrefix(state.urlPrefix, action),
- activeServerRequests: reduceServerRequests(
- state.activeServerRequests,
- action,
- ),
calendarFilters: reduceCalendarFilters(
state.calendarFilters,
action,
),
};
}
diff --git a/lib/reducers/server-requests-reducer.js b/lib/reducers/server-requests-reducer.js
deleted file mode 100644
index d3ea600f9..000000000
--- a/lib/reducers/server-requests-reducer.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// @flow
-
-import type { ServerRequest } from '../types/request-types';
-import type { BaseAction } from '../types/redux-types';
-
-import { pingActionTypes } from '../actions/ping-actions';
-
-export default function reduceServerRequests(
- state: $ReadOnlyArray<ServerRequest>,
- action: BaseAction,
-): $ReadOnlyArray<ServerRequest> {
- if (
- action.type === pingActionTypes.success &&
- action.payload.requests.serverRequests.length > 0
- ) {
- return [
- ...state,
- ...action.payload.requests.serverRequests,
- ];
- } else if (action.type === pingActionTypes.started && state.length > 0) {
- // For now, we assume that each ping responds to every server request
- // present at the time. The result of the ping will include all server
- // requests that weren't responded to, so it's safe to clear them here.
- return [];
- }
- return state;
-}
diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js
index 8fa4da39a..70b69aeb9 100644
--- a/lib/reducers/thread-reducer.js
+++ b/lib/reducers/thread-reducer.js
@@ -1,420 +1,433 @@
// @flow
import type { BaseAction } from '../types/redux-types';
import type { RawThreadInfo, ThreadStore } from '../types/thread-types';
import { type PingResult, pingResponseTypes } from '../types/ping-types';
import { updateTypes, type UpdateInfo } from '../types/update-types';
import {
type ThreadInconsistencyClientResponse,
serverRequestTypes,
+ clearDeliveredClientResponsesActionType,
} from '../types/request-types';
import invariant from 'invariant';
import _isEqual from 'lodash/fp/isEqual';
import { setNewSessionActionType } from '../utils/action-utils';
import {
logOutActionTypes,
deleteAccountActionTypes,
logInActionTypes,
resetPasswordActionTypes,
registerActionTypes,
updateSubscriptionActionTypes,
} from '../actions/user-actions';
import {
changeThreadSettingsActionTypes,
deleteThreadActionTypes,
newThreadActionTypes,
removeUsersFromThreadActionTypes,
changeThreadMemberRolesActionTypes,
joinThreadActionTypes,
leaveThreadActionTypes,
} from '../actions/thread-actions';
import {
pingActionTypes,
updateActivityActionTypes,
} from '../actions/ping-actions';
import { saveMessagesActionType } from '../actions/message-actions';
import { getConfig } from '../utils/config';
import { reduxLogger } from '../utils/redux-logger';
import { sanitizeAction } from '../utils/sanitization';
// If the user goes away for a while and the server stops keeping track of their
// session, the server will send down the full current set of threadInfos
function reduceFullState(
threadInfos: {[id: string]: RawThreadInfo},
payload: PingResult,
): {[id: string]: RawThreadInfo} {
if (
payload.type !== pingResponseTypes.FULL ||
_isEqual(threadInfos)(payload.threadInfos)
) {
return threadInfos;
}
const newThreadInfos = {};
let threadIDsWithNewMessages: ?Set<string> = null;
const getThreadIDsWithNewMessages = () => {
if (threadIDsWithNewMessages) {
return threadIDsWithNewMessages;
}
threadIDsWithNewMessages = new Set();
for (let rawMessageInfo of payload.messagesResult.messageInfos) {
threadIDsWithNewMessages.add(rawMessageInfo.threadID);
}
return threadIDsWithNewMessages;
};
for (let threadID in payload.threadInfos) {
const newThreadInfo = payload.threadInfos[threadID];
const currentThreadInfo = threadInfos[threadID];
const prevThreadInfo = payload.prevState.threadInfos[threadID];
if (_isEqual(currentThreadInfo)(newThreadInfo)) {
newThreadInfos[threadID] = currentThreadInfo;
} else if (_isEqual(prevThreadInfo)(newThreadInfo)) {
// If the thread at the time of the start of the ping is the same as
// what was returned, but the current state is different, then it's
// likely that an action mutated the state after the ping result was
// fetched on the server. We should keep the mutated (current) state.
if (currentThreadInfo) {
newThreadInfos[threadID] = currentThreadInfo;
}
} else if (
newThreadInfo.currentUser.unread &&
currentThreadInfo &&
!currentThreadInfo.currentUser.unread &&
!getThreadIDsWithNewMessages().has(threadID)
) {
// To make sure the unread status doesn't update from a stale ping
// result, we check if there actually are any new messages for this
// threadInfo.
const potentiallyNewThreadInfo = {
...newThreadInfo,
currentUser: {
...newThreadInfo.currentUser,
unread: false,
},
};
if (_isEqual(currentThreadInfo)(potentiallyNewThreadInfo)) {
newThreadInfos[threadID] = currentThreadInfo;
} else {
newThreadInfos[threadID] = potentiallyNewThreadInfo;
}
} else {
newThreadInfos[threadID] = newThreadInfo;
}
}
for (let threadID in threadInfos) {
const newThreadInfo = payload.threadInfos[threadID];
if (newThreadInfo) {
continue;
}
const prevThreadInfo = payload.prevState.threadInfos[threadID];
if (prevThreadInfo) {
continue;
}
// If a thread was not present at the start of the ping, it's possible
// that an action added it in between the start and the end of the ping.
const currentThreadInfo = threadInfos[threadID];
newThreadInfos[threadID] = currentThreadInfo;
}
if (_isEqual(threadInfos)(newThreadInfos)) {
return threadInfos;
}
return newThreadInfos;
}
function reduceThreadUpdates(
threadInfos: {[id: string]: RawThreadInfo},
payload: { +updatesResult: { newUpdates: $ReadOnlyArray<UpdateInfo> } },
): {[id: string]: RawThreadInfo} {
const newState = { ...threadInfos };
let someThreadUpdated = false;
for (let update of payload.updatesResult.newUpdates) {
if (
(update.type === updateTypes.UPDATE_THREAD ||
update.type === updateTypes.JOIN_THREAD) &&
!_isEqual(threadInfos[update.threadInfo.id])(update.threadInfo)
) {
someThreadUpdated = true;
newState[update.threadInfo.id] = update.threadInfo;
} else if (
update.type === updateTypes.UPDATE_THREAD_READ_STATUS &&
threadInfos[update.threadID] &&
threadInfos[update.threadID].currentUser.unread !== update.unread
) {
someThreadUpdated = true;
newState[update.threadID] = {
...threadInfos[update.threadID],
currentUser: {
...threadInfos[update.threadID].currentUser,
unread: update.unread,
},
};
} else if (
update.type === updateTypes.DELETE_THREAD &&
threadInfos[update.threadID]
) {
someThreadUpdated = true;
delete newState[update.threadID];
} else if (update.type === updateTypes.DELETE_ACCOUNT) {
for (let threadID in threadInfos) {
const threadInfo = threadInfos[threadID];
const newMembers = threadInfo.members.filter(
member => member.id !== update.deletedUserID,
);
if (newMembers.length < threadInfo.members.length) {
someThreadUpdated = true;
newState[threadID] = {
...threadInfo,
members: newMembers,
};
}
}
}
}
if (!someThreadUpdated) {
return threadInfos;
}
return newState;
}
const emptyArray = [];
function findInconsistencies(
beforeAction: {[id: string]: RawThreadInfo},
action: BaseAction,
oldResult: {[id: string]: RawThreadInfo},
newResult: {[id: string]: RawThreadInfo},
): ThreadInconsistencyClientResponse[] {
if (_isEqual(oldResult)(newResult)) {
return emptyArray;
}
if (action.type === pingActionTypes.success) {
if (action.payload.type === pingResponseTypes.FULL) {
// We can get a memory leak if we include a previous
// EntryInconsistencyClientResponse in this one
action = {
type: "PING_SUCCESS",
loadingInfo: action.loadingInfo,
payload: {
...action.payload,
requests: {
...action.payload.requests,
deliveredClientResponses: [],
},
},
};
} else {
// This is a separate condition because of Flow
action = {
type: "PING_SUCCESS",
loadingInfo: action.loadingInfo,
payload: {
...action.payload,
requests: {
...action.payload.requests,
deliveredClientResponses: [],
},
},
};
}
}
return [{
type: serverRequestTypes.THREAD_INCONSISTENCY,
platformDetails: getConfig().platformDetails,
beforeAction,
action: sanitizeAction(action),
pollResult: oldResult,
pushResult: newResult,
lastActionTypes: reduxLogger.interestingActionTypes,
time: Date.now(),
}];
}
export default function reduceThreadInfos(
state: ThreadStore,
action: BaseAction,
): ThreadStore {
if (
action.type === logInActionTypes.success ||
action.type === registerActionTypes.success ||
action.type === resetPasswordActionTypes.success
) {
if (_isEqual(state.threadInfos)(action.payload.threadInfos)) {
return state;
}
return {
threadInfos: action.payload.threadInfos,
inconsistencyResponses: state.inconsistencyResponses,
};
} else if (
action.type === logOutActionTypes.success ||
action.type === deleteAccountActionTypes.success ||
(action.type === setNewSessionActionType &&
action.payload.sessionChange.cookieInvalidated)
) {
if (Object.keys(state.threadInfos).length === 0) {
return state;
}
return {
threadInfos: {},
inconsistencyResponses: state.inconsistencyResponses,
};
} 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
) {
if (
_isEqual(state.threadInfos)(action.payload.threadInfos) &&
action.payload.updatesResult.newUpdates.length === 0
) {
return state;
}
const oldResult = action.payload.threadInfos;
const newResult = reduceThreadUpdates(state.threadInfos, action.payload);
return {
threadInfos: oldResult,
inconsistencyResponses: [
...state.inconsistencyResponses,
...findInconsistencies(
state.threadInfos,
action,
oldResult,
newResult,
),
],
};
} else if (action.type === pingActionTypes.success) {
const payload = action.payload;
let newInconsistencies = [], newThreadInfos;
if (payload.type === pingResponseTypes.FULL) {
newThreadInfos = reduceFullState(state.threadInfos, payload);
} else {
const updateResult = reduceThreadUpdates(state.threadInfos, payload);
const checkStateRequest = payload.requests.serverRequests.find(
candidate => candidate.type === serverRequestTypes.CHECK_STATE,
);
if (
!checkStateRequest ||
!checkStateRequest.stateChanges ||
!checkStateRequest.stateChanges.rawThreadInfos
) {
newThreadInfos = updateResult;
} else {
const { rawThreadInfos } = checkStateRequest.stateChanges;
newThreadInfos = { ...updateResult };
for (let rawThreadInfo of rawThreadInfos) {
newThreadInfos[rawThreadInfo.id] = rawThreadInfo;
}
newInconsistencies = findInconsistencies(
state.threadInfos,
action,
updateResult,
newThreadInfos,
);
}
}
return {
threadInfos: newThreadInfos,
inconsistencyResponses: [
...state.inconsistencyResponses.filter(
response =>
!payload.requests.deliveredClientResponses.includes(response),
),
...newInconsistencies,
],
};
} else if (action.type === newThreadActionTypes.success) {
const newThreadInfo = action.payload.newThreadInfo;
if (
_isEqual(state.threadInfos[newThreadInfo.id])(newThreadInfo) &&
action.payload.updatesResult.newUpdates.length === 0
) {
return state;
}
const oldResult = {
...state.threadInfos,
[newThreadInfo.id]: newThreadInfo,
};
const newResult = reduceThreadUpdates(state.threadInfos, action.payload);
return {
threadInfos: oldResult,
inconsistencyResponses: [
...state.inconsistencyResponses,
...findInconsistencies(
state.threadInfos,
action,
oldResult,
newResult,
),
],
};
} else if (action.type === updateActivityActionTypes.success) {
const newThreadInfos = { ...state.threadInfos };
for (let setToUnread of action.payload.unfocusedToUnread) {
const threadInfo = newThreadInfos[setToUnread];
if (threadInfo) {
threadInfo.currentUser.unread = true;
}
}
return {
threadInfos: newThreadInfos,
inconsistencyResponses: state.inconsistencyResponses,
};
} else if (action.type === updateSubscriptionActionTypes.success) {
const newThreadInfos = {
...state.threadInfos,
[action.payload.threadID]: {
...state.threadInfos[action.payload.threadID],
currentUser: {
...state.threadInfos[action.payload.threadID].currentUser,
subscription: action.payload.subscription,
},
},
};
return {
threadInfos: newThreadInfos,
inconsistencyResponses: state.inconsistencyResponses,
};
} else if (action.type === saveMessagesActionType) {
const threadIDToMostRecentTime = new Map();
for (let messageInfo of action.payload.rawMessageInfos) {
const current = threadIDToMostRecentTime.get(messageInfo.threadID);
if (!current || current < messageInfo.time) {
threadIDToMostRecentTime.set(messageInfo.threadID, messageInfo.time);
}
}
const changedThreadInfos = {};
for (let [threadID, mostRecentTime] of threadIDToMostRecentTime) {
const threadInfo = state.threadInfos[threadID];
if (
!threadInfo ||
threadInfo.currentUser.unread ||
action.payload.updatesCurrentAsOf > mostRecentTime
) {
continue;
}
changedThreadInfos[threadID] = {
...state.threadInfos[threadID],
currentUser: {
...state.threadInfos[threadID].currentUser,
unread: true,
},
};
}
if (Object.keys(changedThreadInfos).length !== 0) {
return {
threadInfos: {
...state.threadInfos,
...changedThreadInfos,
},
inconsistencyResponses: state.inconsistencyResponses,
};
}
+ } else if (action.type === clearDeliveredClientResponsesActionType) {
+ const { payload } = action;
+ const updatedResponses = state.inconsistencyResponses.filter(
+ response => !payload.clientResponses.includes(response),
+ );
+ if (updatedResponses.length === state.inconsistencyResponses.length) {
+ return state;
+ }
+ return {
+ threadInfos: state.threadInfos,
+ inconsistencyResponses: updatedResponses,
+ };
}
return state;
}
diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js
index 5fb322887..ab110b44e 100644
--- a/lib/selectors/socket-selectors.js
+++ b/lib/selectors/socket-selectors.js
@@ -1,135 +1,128 @@
// @flow
import type { AppState } from '../types/redux-types';
import {
serverRequestTypes,
type ServerRequest,
type ClientResponse,
type ThreadInconsistencyClientResponse,
type EntryInconsistencyClientResponse,
} from '../types/request-types';
import type { RawEntryInfo, CalendarQuery } from '../types/entry-types';
import type { CurrentUserInfo } from '../types/user-types';
import type { RawThreadInfo } from '../types/thread-types';
import type { SessionState } from '../types/session-types';
import { createSelector } from 'reselect';
import { getConfig } from '../utils/config';
import {
serverEntryInfo,
serverEntryInfosObject,
filterRawEntryInfosByCalendarQuery,
} from '../shared/entry-utils';
import { values, hash } from '../utils/objects';
import { currentCalendarQuery } from './nav-selectors';
import threadWatcher from '../shared/thread-watcher';
const clientResponsesSelector = createSelector(
- (state: AppState) => state.activeServerRequests,
(state: AppState) => state.threadStore.inconsistencyResponses,
(state: AppState) => state.entryStore.inconsistencyResponses,
(state: AppState) => state.threadStore.threadInfos,
(state: AppState) => state.entryStore.entryInfos,
(state: AppState) => state.currentUserInfo,
- (state: AppState) => state.deviceToken,
currentCalendarQuery,
(
- activeServerRequests: $ReadOnlyArray<ServerRequest>,
threadInconsistencyResponses:
$ReadOnlyArray<ThreadInconsistencyClientResponse>,
entryInconsistencyResponses:
$ReadOnlyArray<EntryInconsistencyClientResponse>,
threadInfos: {[id: string]: RawThreadInfo},
entryInfos: {[id: string]: RawEntryInfo},
currentUserInfo: ?CurrentUserInfo,
- deviceToken: ?string,
calendarQuery: () => CalendarQuery,
+ ) => (
+ serverRequests?: $ReadOnlyArray<ServerRequest>,
): $ReadOnlyArray<ClientResponse> => {
const clientResponses = [
...threadInconsistencyResponses,
...entryInconsistencyResponses,
];
- const serverRequestedPlatformDetails = activeServerRequests.some(
+ if (!serverRequests) {
+ return clientResponses;
+ }
+ const serverRequestedPlatformDetails = serverRequests.some(
request => request.type === serverRequestTypes.PLATFORM_DETAILS,
);
- for (let serverRequest of activeServerRequests) {
+ for (let serverRequest of serverRequests) {
if (
serverRequest.type === serverRequestTypes.PLATFORM &&
!serverRequestedPlatformDetails
) {
clientResponses.push({
type: serverRequestTypes.PLATFORM,
platform: getConfig().platformDetails.platform,
});
- } else if (
- serverRequest.type === serverRequestTypes.DEVICE_TOKEN &&
- deviceToken !== null && deviceToken !== undefined
- ) {
- clientResponses.push({
- type: serverRequestTypes.DEVICE_TOKEN,
- deviceToken,
- });
} else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) {
clientResponses.push({
type: serverRequestTypes.PLATFORM_DETAILS,
platformDetails: getConfig().platformDetails,
});
} else if (serverRequest.type === serverRequestTypes.CHECK_STATE) {
const hashResults = {};
for (let key in serverRequest.hashesToCheck) {
const expectedHashValue = serverRequest.hashesToCheck[key];
let hashValue;
if (key === "threadInfos") {
hashValue = hash(threadInfos);
} else if (key === "entryInfos") {
const filteredEntryInfos = filterRawEntryInfosByCalendarQuery(
serverEntryInfosObject(values(entryInfos)),
calendarQuery(),
);
hashValue = hash(filteredEntryInfos);
} else if (key === "currentUserInfo") {
hashValue = hash(currentUserInfo);
} else if (key.startsWith("threadInfo|")) {
const [ ignore, threadID ] = key.split('|');
hashValue = hash(threadInfos[threadID]);
} else if (key.startsWith("entryInfo|")) {
const [ ignore, entryID ] = key.split('|');
let rawEntryInfo = entryInfos[entryID];
if (rawEntryInfo) {
rawEntryInfo = serverEntryInfo(rawEntryInfo);
}
hashValue = hash(rawEntryInfo);
}
hashResults[key] = expectedHashValue === hashValue;
}
clientResponses.push({
type: serverRequestTypes.CHECK_STATE,
hashResults,
});
}
}
return clientResponses;
},
);
const sessionStateFuncSelector = createSelector(
(state: AppState) => state.messageStore.currentAsOf,
(state: AppState) => state.updatesCurrentAsOf,
currentCalendarQuery,
(
messagesCurrentAsOf: number,
updatesCurrentAsOf: number,
calendarQuery: () => CalendarQuery,
): () => SessionState => () => ({
calendarQuery: calendarQuery(),
messagesCurrentAsOf,
updatesCurrentAsOf,
watchedIDs: threadWatcher.getWatchedIDs(),
}),
);
export {
clientResponsesSelector,
sessionStateFuncSelector,
};
diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js
index 7bdcf2ba3..a23806535 100644
--- a/lib/types/redux-types.js
+++ b/lib/types/redux-types.js
@@ -1,563 +1,571 @@
// @flow
import type {
ThreadStore,
ChangeThreadSettingsResult,
LeaveThreadPayload,
NewThreadResult,
ThreadJoinPayload,
} from './thread-types';
import type {
RawEntryInfo,
EntryStore,
CalendarQuery,
SaveEntryPayload,
CreateEntryPayload,
DeleteEntryPayload,
RestoreEntryPayload,
FetchEntryInfosResult,
CalendarResult,
CalendarQueryUpdateResult,
} from './entry-types';
import type { LoadingStatus, LoadingInfo } from './loading-types';
import type { BaseNavInfo } from './nav-types';
import type {
CurrentUserInfo,
UserInfo,
} from './user-types';
import type {
LogOutResult,
LogInStartingPayload,
LogInResult,
RegisterResult,
} from './account-types';
import type { UserSearchResult } from '../types/search-types';
import type {
PingStartingPayload,
PingResult,
} from './ping-types';
import type {
MessageStore,
RawTextMessageInfo,
RawMessageInfo,
FetchMessageInfosPayload,
SendTextMessagePayload,
SaveMessagesPayload,
} from './message-types';
import type { SetSessionPayload } from './session-types';
import type { UpdateActivityResult } from './activity-types';
import type { ReportCreationResponse } from './report-types';
-import type { ServerRequest } from './request-types';
+import type {
+ ClearDeliveredClientResponsesPayload,
+ ProcessServerRequestsPayload,
+} from './request-types';
import type {
CalendarFilter,
CalendarThreadFilter,
SetCalendarDeletedFilterPayload,
} from './filter-types';
import type { SubscriptionUpdateResult } from '../types/subscription-types';
import type {
ConnectionInfo,
StateSyncFullActionPayload,
StateSyncIncrementalActionPayload,
} from '../types/socket-types';
export type BaseAppState<NavInfo: BaseNavInfo> = {
navInfo: NavInfo,
currentUserInfo: ?CurrentUserInfo,
entryStore: EntryStore,
threadStore: ThreadStore,
userInfos: {[id: string]: UserInfo},
messageStore: MessageStore,
drafts: {[key: string]: string},
updatesCurrentAsOf: number, // millisecond timestamp
loadingStatuses: {[key: string]: {[idx: number]: LoadingStatus}},
- activeServerRequests: $ReadOnlyArray<ServerRequest>,
calendarFilters: $ReadOnlyArray<CalendarFilter>,
urlPrefix: string,
connection: ConnectionInfo,
watchedThreadIDs: $ReadOnlyArray<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 };
export type AppState = NativeAppState | WebAppState;
export type BaseAction =
{| type: "@@redux/INIT" |} | {|
type: "FETCH_ENTRIES_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "FETCH_ENTRIES_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "FETCH_ENTRIES_SUCCESS",
payload: FetchEntryInfosResult,
loadingInfo: LoadingInfo,
|} | {|
type: "LOG_OUT_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "LOG_OUT_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "LOG_OUT_SUCCESS",
payload: LogOutResult,
loadingInfo: LoadingInfo,
|} | {|
type: "DELETE_ACCOUNT_STARTED",
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",
loadingInfo: LoadingInfo,
|} | {|
type: "CREATE_ENTRY_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "CREATE_ENTRY_SUCCESS",
payload: CreateEntryPayload,
loadingInfo: LoadingInfo,
|} | {|
type: "SAVE_ENTRY_STARTED",
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: ?DeleteEntryPayload,
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: "RESET_PASSWORD_STARTED",
payload: {| calendarQuery: CalendarQuery |},
loadingInfo: LoadingInfo,
|} | {|
type: "RESET_PASSWORD_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "RESET_PASSWORD_SUCCESS",
payload: LogInResult,
loadingInfo: LoadingInfo,
|} | {|
type: "FORGOT_PASSWORD_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "FORGOT_PASSWORD_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "FORGOT_PASSWORD_SUCCESS",
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_USER_SETTINGS_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_USER_SETTINGS_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_USER_SETTINGS_SUCCESS",
payload: {|
email: string,
|},
loadingInfo: LoadingInfo,
|} | {|
type: "RESEND_VERIFICATION_EMAIL_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "RESEND_VERIFICATION_EMAIL_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "RESEND_VERIFICATION_EMAIL_SUCCESS",
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_THREAD_SETTINGS_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_THREAD_SETTINGS_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_THREAD_SETTINGS_SUCCESS",
payload: ChangeThreadSettingsResult,
loadingInfo: LoadingInfo,
|} | {|
type: "DELETE_THREAD_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "DELETE_THREAD_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "DELETE_THREAD_SUCCESS",
payload: LeaveThreadPayload,
loadingInfo: LoadingInfo,
|} | {|
type: "NEW_THREAD_STARTED",
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",
loadingInfo: LoadingInfo,
|} | {|
type: "REMOVE_USERS_FROM_THREAD_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "REMOVE_USERS_FROM_THREAD_SUCCESS",
payload: ChangeThreadSettingsResult,
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_THREAD_MEMBER_ROLES_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_THREAD_MEMBER_ROLES_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "CHANGE_THREAD_MEMBER_ROLES_SUCCESS",
payload: ChangeThreadSettingsResult,
loadingInfo: LoadingInfo,
|} | {|
type: "FETCH_REVISIONS_FOR_ENTRY_STARTED",
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: bool,
|},
loadingInfo: LoadingInfo,
|} | {|
type: "RESTORE_ENTRY_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "RESTORE_ENTRY_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "RESTORE_ENTRY_SUCCESS",
payload: RestoreEntryPayload,
loadingInfo: LoadingInfo,
|} | {|
type: "JOIN_THREAD_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "JOIN_THREAD_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "JOIN_THREAD_SUCCESS",
payload: ThreadJoinPayload,
loadingInfo: LoadingInfo,
|} | {|
type: "LEAVE_THREAD_STARTED",
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: "PING_STARTED",
payload: PingStartingPayload,
loadingInfo: LoadingInfo,
|} | {|
type: "PING_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "PING_SUCCESS",
payload: PingResult,
loadingInfo: LoadingInfo,
|} | {|
type: "FETCH_ENTRIES_AND_APPEND_RANGE_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "FETCH_ENTRIES_AND_APPEND_RANGE_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "FETCH_ENTRIES_AND_APPEND_RANGE_SUCCESS",
payload: CalendarResult,
loadingInfo: LoadingInfo,
|} | {|
type: "persist/REHYDRATE",
payload: BaseAppState<*>,
|} | {|
type: "FETCH_MESSAGES_BEFORE_CURSOR_STARTED",
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",
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: "SEND_MESSAGE_STARTED",
loadingInfo: LoadingInfo,
payload: RawTextMessageInfo,
|} | {|
type: "SEND_MESSAGE_FAILED",
error: true,
payload: Error & {
localID: string,
threadID: string,
},
loadingInfo: LoadingInfo,
|} | {|
type: "SEND_MESSAGE_SUCCESS",
payload: SendTextMessagePayload,
loadingInfo: LoadingInfo,
|} | {|
type: "SEARCH_USERS_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "SEARCH_USERS_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "SEARCH_USERS_SUCCESS",
payload: UserSearchResult,
loadingInfo: LoadingInfo,
|} | {|
type: "SAVE_DRAFT",
payload: {
key: string,
draft: string,
},
|} | {|
type: "UPDATE_ACTIVITY_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "UPDATE_ACTIVITY_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "UPDATE_ACTIVITY_SUCCESS",
payload: UpdateActivityResult,
loadingInfo: LoadingInfo,
|} | {|
type: "SET_DEVICE_TOKEN_STARTED",
payload: string,
loadingInfo: LoadingInfo,
|} | {|
type: "SET_DEVICE_TOKEN_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "SET_DEVICE_TOKEN_SUCCESS",
payload: string,
loadingInfo: LoadingInfo,
|} | {|
type: "HANDLE_VERIFICATION_CODE_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "HANDLE_VERIFICATION_CODE_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "HANDLE_VERIFICATION_CODE_SUCCESS",
loadingInfo: LoadingInfo,
|} | {|
type: "SEND_REPORT_STARTED",
loadingInfo: LoadingInfo,
|} | {|
type: "SEND_REPORT_FAILED",
error: true,
payload: Error,
loadingInfo: LoadingInfo,
|} | {|
type: "SEND_REPORT_SUCCESS",
payload: ReportCreationResponse,
loadingInfo: LoadingInfo,
|} | {|
type: "SET_URL_PREFIX",
payload: string,
|} | {|
type: "SAVE_MESSAGES",
payload: SaveMessagesPayload,
|} | {|
type: "UPDATE_CALENDAR_THREAD_FILTER",
payload: CalendarThreadFilter,
|} | {|
type: "CLEAR_CALENDAR_THREAD_FILTER",
|} | {|
type: "SET_CALENDAR_DELETED_FILTER",
payload: SetCalendarDeletedFilterPayload,
|} | {|
type: "UPDATE_SUBSCRIPTION_STARTED",
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?: {| calendarQuery?: CalendarQuery |},
|} | {|
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: "CLEAR_DELIVERED_CLIENT_RESPONSES",
+ payload: ClearDeliveredClientResponsesPayload,
+ |} | {|
+ type: "PROCESS_SERVER_REQUESTS",
+ payload: ProcessServerRequestsPayload,
|};
export type ActionPayload
= ?(Object | Array<*> | $ReadOnlyArray<*> | string);
export type SuperAction = {
type: $Subtype<string>,
payload?: ActionPayload,
loadingInfo?: LoadingInfo,
error?: bool,
};
type ThunkedAction = (dispatch: Dispatch) => void;
export type PromisedAction = (dispatch: Dispatch) => Promise<void>;
export type Dispatch =
((promisedAction: PromisedAction) => Promise<void>) &
((thunkedAction: ThunkedAction) => void) &
((action: SuperAction) => bool);
export const rehydrateActionType = "persist/REHYDRATE";
diff --git a/lib/types/request-types.js b/lib/types/request-types.js
index e9fa0ca72..d647f17a8 100644
--- a/lib/types/request-types.js
+++ b/lib/types/request-types.js
@@ -1,213 +1,224 @@
// @flow
import {
type Platform,
type PlatformDetails,
platformPropType,
platformDetailsPropType,
} from './device-types';
import { type RawThreadInfo, rawThreadInfoPropType } from './thread-types';
import {
type RawEntryInfo,
type CalendarQuery,
rawEntryInfoPropType,
calendarQueryPropType,
} from './entry-types';
import type { BaseAction } from './redux-types';
import type { ActivityUpdate } from './activity-types';
import {
type CurrentUserInfo,
type AccountUserInfo,
accountUserInfoPropType,
currentUserPropType,
} from './user-types';
import invariant from 'invariant';
import PropTypes from 'prop-types';
// "Server requests" are requests for information that the server delivers to
// clients. Clients then respond to those requests with a "client response".
export const serverRequestTypes = Object.freeze({
PLATFORM: 0,
DEVICE_TOKEN: 1,
THREAD_INCONSISTENCY: 2,
PLATFORM_DETAILS: 3,
INITIAL_ACTIVITY_UPDATE: 4,
ENTRY_INCONSISTENCY: 5,
CHECK_STATE: 6,
INITIAL_ACTIVITY_UPDATES: 7,
});
type ServerRequestType = $Values<typeof serverRequestTypes>;
function assertServerRequestType(
serverRequestType: number,
): ServerRequestType {
invariant(
serverRequestType === 0 ||
serverRequestType === 1 ||
serverRequestType === 2 ||
serverRequestType === 3 ||
serverRequestType === 4 ||
serverRequestType === 5 ||
serverRequestType === 6 ||
serverRequestType === 7,
"number is not ServerRequestType enum",
);
return serverRequestType;
}
type PlatformServerRequest = {|
type: 0,
|};
type PlatformClientResponse = {|
type: 0,
platform: Platform,
|};
type DeviceTokenServerRequest = {|
type: 1,
|};
type DeviceTokenClientResponse = {|
type: 1,
deviceToken: string,
|};
export type ThreadInconsistencyClientResponse = {|
type: 2,
platformDetails: PlatformDetails,
beforeAction: {[id: string]: RawThreadInfo},
action: BaseAction,
pollResult: {[id: string]: RawThreadInfo},
pushResult: {[id: string]: RawThreadInfo},
lastActionTypes?: $ReadOnlyArray<$PropertyType<BaseAction, 'type'>>,
time?: number,
|};
type PlatformDetailsServerRequest = {|
type: 3,
|};
type PlatformDetailsClientResponse = {|
type: 3,
platformDetails: PlatformDetails,
|};
type InitialActivityUpdateClientResponse = {|
type: 4,
threadID: string,
|};
export type EntryInconsistencyClientResponse = {|
type: 5,
platformDetails: PlatformDetails,
beforeAction: {[id: string]: RawEntryInfo},
action: BaseAction,
calendarQuery: CalendarQuery,
pollResult: {[id: string]: RawEntryInfo},
pushResult: {[id: string]: RawEntryInfo},
lastActionTypes: $ReadOnlyArray<$PropertyType<BaseAction, 'type'>>,
time: number,
|};
export type CheckStateServerRequest = {|
type: 6,
hashesToCheck: {[key: string]: number},
stateChanges?: $Shape<{|
rawThreadInfos: RawThreadInfo[],
rawEntryInfos: RawEntryInfo[],
currentUserInfo: CurrentUserInfo,
userInfos: AccountUserInfo[],
|}>,
|};
type CheckStateClientResponse = {|
type: 6,
hashResults: {[key: string]: bool},
|};
type InitialActivityUpdatesClientResponse = {|
type: 7,
activityUpdates: $ReadOnlyArray<ActivityUpdate>,
|};
export const serverRequestPropType = PropTypes.oneOfType([
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.PLATFORM ]).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.DEVICE_TOKEN ]).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.PLATFORM_DETAILS ]).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.CHECK_STATE ]).isRequired,
hashesToCheck: PropTypes.objectOf(PropTypes.number).isRequired,
stateChanges: PropTypes.shape({
rawThreadInfos: PropTypes.arrayOf(rawThreadInfoPropType),
rawEntryInfos: PropTypes.arrayOf(rawEntryInfoPropType),
currentUserInfo: currentUserPropType,
userInfos: PropTypes.arrayOf(accountUserInfoPropType),
}),
}),
]);
export const clientResponsePropType = PropTypes.oneOfType([
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.PLATFORM ]).isRequired,
platform: platformPropType.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.DEVICE_TOKEN ]).isRequired,
deviceToken: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([
serverRequestTypes.THREAD_INCONSISTENCY,
]).isRequired,
platformDetails: platformDetailsPropType.isRequired,
beforeAction: PropTypes.objectOf(rawThreadInfoPropType).isRequired,
action: PropTypes.object.isRequired,
pollResult: PropTypes.objectOf(rawThreadInfoPropType).isRequired,
pushResult: PropTypes.objectOf(rawThreadInfoPropType).isRequired,
lastActionTypes: PropTypes.arrayOf(PropTypes.string),
time: PropTypes.number,
}),
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.PLATFORM_DETAILS ]).isRequired,
platformDetails: platformDetailsPropType.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.INITIAL_ACTIVITY_UPDATE ]).isRequired,
threadID: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([
serverRequestTypes.ENTRY_INCONSISTENCY,
]).isRequired,
platformDetails: platformDetailsPropType.isRequired,
beforeAction: PropTypes.objectOf(rawEntryInfoPropType).isRequired,
action: PropTypes.object.isRequired,
calendarQuery: calendarQueryPropType.isRequired,
pollResult: PropTypes.objectOf(rawEntryInfoPropType).isRequired,
pushResult: PropTypes.objectOf(rawEntryInfoPropType).isRequired,
lastActionTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
time: PropTypes.number.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([ serverRequestTypes.CHECK_STATE ]).isRequired,
hashResults: PropTypes.objectOf(PropTypes.bool).isRequired,
}),
]);
export type ServerRequest =
| PlatformServerRequest
| DeviceTokenServerRequest
| PlatformDetailsServerRequest
| CheckStateServerRequest;
export type ClientResponse =
| PlatformClientResponse
| DeviceTokenClientResponse
| ThreadInconsistencyClientResponse
| PlatformDetailsClientResponse
| InitialActivityUpdateClientResponse
| EntryInconsistencyClientResponse
| CheckStateClientResponse
| InitialActivityUpdatesClientResponse;
+
+export const clearDeliveredClientResponsesActionType =
+ "CLEAR_DELIVERED_CLIENT_RESPONSES";
+export type ClearDeliveredClientResponsesPayload = {|
+ clientResponses: $ReadOnlyArray<ClientResponse>,
+|};
+
+export const processServerRequestsActionType = "PROCESS_SERVER_REQUESTS";
+export type ProcessServerRequestsPayload = {|
+ serverRequests: $ReadOnlyArray<ServerRequest>,
+|};
diff --git a/lib/types/socket-types.js b/lib/types/socket-types.js
index b236741ef..3449e8ec2 100644
--- a/lib/types/socket-types.js
+++ b/lib/types/socket-types.js
@@ -1,139 +1,150 @@
// @flow
import type { SessionState, SessionIdentification } from './session-types';
import type { ServerRequest, ClientResponse } from './request-types';
import type { RawThreadInfo } from './thread-types';
import type { MessagesPingResponse } from './message-types';
import type { UpdatesResult } from './update-types';
import type {
UserInfo,
CurrentUserInfo,
LoggedOutUserInfo,
} from './user-types';
import type { RawEntryInfo } from './entry-types';
import invariant from 'invariant';
import { pingResponseTypes } from './ping-types';
// The types of messages that the client sends across the socket
export const clientSocketMessageTypes = Object.freeze({
INITIAL: 0,
+ RESPONSES: 1,
});
export type ClientSocketMessageType = $Values<typeof clientSocketMessageTypes>;
export function assertClientSocketMessageType(
ourClientSocketMessageType: number,
): ClientSocketMessageType {
invariant(
- ourClientSocketMessageType === 0,
+ ourClientSocketMessageType === 0 ||
+ ourClientSocketMessageType === 1,
"number is not ClientSocketMessageType enum",
);
return ourClientSocketMessageType;
}
+export type InitialClientSocketMessage = {|
+ type: 0,
+ id: number,
+ payload: {|
+ sessionIdentification: SessionIdentification,
+ sessionState: SessionState,
+ clientResponses: $ReadOnlyArray<ClientResponse>,
+ |},
+|};
+export type ResponsesClientSocketMessage = {|
+ type: 1,
+ id: number,
+ payload: {|
+ clientResponses: $ReadOnlyArray<ClientResponse>,
+ |},
+|};
+export type ClientSocketMessage =
+ | InitialClientSocketMessage
+ | ResponsesClientSocketMessage;
+
// The types of messages that the server sends across the socket
export const serverSocketMessageTypes = Object.freeze({
STATE_SYNC: 0,
REQUESTS: 1,
ERROR: 2,
AUTH_ERROR: 3,
});
export type ServerSocketMessageType = $Values<typeof serverSocketMessageTypes>;
export function assertServerSocketMessageType(
ourServerSocketMessageType: number,
): ServerSocketMessageType {
invariant(
ourServerSocketMessageType === 0 ||
ourServerSocketMessageType === 1 ||
ourServerSocketMessageType === 2 ||
ourServerSocketMessageType === 3,
"number is not ServerSocketMessageType enum",
);
return ourServerSocketMessageType;
}
-export type InitialClientSocketMessage = {|
- type: 0,
- id: number,
- payload: {|
- sessionIdentification: SessionIdentification,
- sessionState: SessionState,
- clientResponses: $ReadOnlyArray<ClientResponse>,
- |},
-|};
-export type ClientSocketMessage =
- | InitialClientSocketMessage;
-
export const stateSyncPayloadTypes = pingResponseTypes;
export type StateSyncFullActionPayload = {|
messagesResult: MessagesPingResponse,
threadInfos: {[id: string]: RawThreadInfo},
currentUserInfo: CurrentUserInfo,
rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
userInfos: $ReadOnlyArray<UserInfo>,
|};
export type StateSyncFullSocketPayload = {|
...StateSyncFullActionPayload,
type: 0,
// Included iff client is using sessionIdentifierTypes.BODY_SESSION_ID
sessionID?: string,
|};
export type StateSyncIncrementalActionPayload = {|
messagesResult: MessagesPingResponse,
updatesResult: UpdatesResult,
deltaEntryInfos: $ReadOnlyArray<RawEntryInfo>,
userInfos: $ReadOnlyArray<UserInfo>,
|};
type StateSyncIncrementalSocketPayload = {|
type: 1,
...StateSyncIncrementalActionPayload,
|};
export type StateSyncSocketPayload =
| StateSyncFullSocketPayload
| StateSyncIncrementalSocketPayload;
export type StateSyncServerSocketMessage = {|
type: 0,
responseTo: number,
payload: StateSyncSocketPayload,
|};
export type RequestsServerSocketMessage = {|
type: 1,
+ responseTo: number,
payload: {|
serverRequests: $ReadOnlyArray<ServerRequest>,
|},
|};
export type ErrorServerSocketMessage = {|
type: 2,
responseTo?: number,
message: string,
payload?: Object,
|};
export type AuthErrorServerSocketMessage = {|
type: 3,
responseTo: number,
message: string,
// If unspecified, it is because the client is using cookieSources.HEADER,
// which means the server can't update the cookie from a socket message.
sessionChange?: {
cookie: string,
currentUserInfo: LoggedOutUserInfo,
},
|};
export type ServerSocketMessage =
| StateSyncServerSocketMessage
| RequestsServerSocketMessage
| ErrorServerSocketMessage
| AuthErrorServerSocketMessage;
export type ConnectionStatus =
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export type ConnectionInfo = {|
status: ConnectionStatus,
|};
export const defaultConnectionInfo = {
status: "connecting",
};
diff --git a/native/app.react.js b/native/app.react.js
index b970d4924..c3f28c704 100644
--- a/native/app.react.js
+++ b/native/app.react.js
@@ -1,804 +1,780 @@
// @flow
import type {
NavigationState,
NavigationAction,
} from 'react-navigation';
import type { Dispatch } from 'lib/types/redux-types';
import type { AppState } from './redux-setup';
import type { Action } from './navigation/navigation-setup';
import type {
DispatchActionPayload,
DispatchActionPromise,
} from 'lib/utils/action-utils';
import type {
ActivityUpdate,
UpdateActivityResult,
} from 'lib/types/activity-types';
import type { RawThreadInfo } from 'lib/types/thread-types';
import { rawThreadInfoPropType } from 'lib/types/thread-types';
import type { DeviceType } from 'lib/types/device-types';
import {
type NotifPermissionAlertInfo,
notifPermissionAlertInfoPropType,
} from './push/alerts';
import type { RawMessageInfo } from 'lib/types/message-types';
-import {
- type ServerRequest,
- serverRequestPropType,
- serverRequestTypes,
-} from 'lib/types/request-types';
import React from 'react';
import { Provider } from 'react-redux';
import {
AppRegistry,
Platform,
UIManager,
AppState as NativeAppState,
Linking,
View,
StyleSheet,
Alert,
DeviceInfo,
} from 'react-native';
import { reduxifyNavigator } from 'react-navigation-redux-helpers';
import invariant from 'invariant';
import PropTypes from 'prop-types';
import NotificationsIOS from 'react-native-notifications';
import InAppNotification from 'react-native-in-app-notification';
import FCM, { FCMEvent } from 'react-native-fcm';
import SplashScreen from 'react-native-splash-screen';
import { registerConfig } from 'lib/utils/config';
import { connect } from 'lib/utils/redux-utils';
import {
updateActivityActionTypes,
updateActivity,
} from 'lib/actions/ping-actions';
import {
setDeviceTokenActionTypes,
setDeviceToken,
} from 'lib/actions/device-actions';
import { unreadCount } from 'lib/selectors/thread-selectors';
import { notificationPressActionType } from 'lib/shared/notif-utils';
import { saveMessagesActionType } from 'lib/actions/message-actions';
import {
RootNavigator,
} from './navigation/navigation-setup';
import {
handleURLActionType,
backgroundActionType,
foregroundActionType,
recordNotifPermissionAlertActionType,
recordAndroidNotificationActionType,
clearAndroidNotificationActionType,
} from './navigation/action-types';
import { store, appBecameInactive } from './redux-setup';
import { resolveInvalidatedCookie } from './account/native-credentials';
import ConnectedStatusBar from './connected-status-bar.react';
import {
activeThreadSelector,
appLoggedInSelector,
} from './selectors/nav-selectors';
import {
requestIOSPushPermissions,
iosPushPermissionResponseReceived,
} from './push/ios';
import {
requestAndroidPushPermissions,
} from './push/android';
import NotificationBody from './push/notification-body.react';
import ErrorBoundary from './error-boundary.react';
import { persistConfig, codeVersion } from './persist';
import { AppRouteName } from './navigation/route-names';
import Socket from './socket.react';
registerConfig({
resolveInvalidatedCookie,
setCookieOnRequest: true,
setSessionIDOnRequest: false,
calendarRangeInactivityLimit: 15 * 60 * 1000,
platformDetails: {
platform: Platform.OS,
codeVersion,
stateVersion: persistConfig.version,
},
});
const msInDay = 24 * 60 * 60 * 1000;
const ReduxifiedRootNavigator = reduxifyNavigator(RootNavigator, "root");
type NativeDispatch = Dispatch & ((action: NavigationAction) => boolean);
type Props = {
// Redux state
navigationState: NavigationState,
activeThread: ?string,
appLoggedIn: bool,
loggedIn: bool,
activeThreadLatestMessage: ?string,
deviceToken: ?string,
unreadCount: number,
rawThreadInfos: {[id: string]: RawThreadInfo},
notifPermissionAlertInfo: NotifPermissionAlertInfo,
- activeServerRequests: $ReadOnlyArray<ServerRequest>,
updatesCurrentAsOf: number,
// Redux dispatch functions
dispatch: NativeDispatch,
dispatchActionPayload: DispatchActionPayload,
dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
updateActivity: (
activityUpdates: $ReadOnlyArray<ActivityUpdate>,
) => Promise<UpdateActivityResult>,
setDeviceToken: (
deviceToken: string,
deviceType: DeviceType,
) => Promise<string>,
};
type State = {|
foreground: bool,
|};
class AppWithNavigationState extends React.PureComponent<Props, State> {
static propTypes = {
navigationState: PropTypes.object.isRequired,
activeThread: PropTypes.string,
appLoggedIn: PropTypes.bool.isRequired,
loggedIn: PropTypes.bool.isRequired,
activeThreadLatestMessage: PropTypes.string,
deviceToken: PropTypes.string,
unreadCount: PropTypes.number.isRequired,
rawThreadInfos: PropTypes.objectOf(rawThreadInfoPropType).isRequired,
notifPermissionAlertInfo: notifPermissionAlertInfoPropType.isRequired,
- activeServerRequests: PropTypes.arrayOf(serverRequestPropType).isRequired,
updatesCurrentAsOf: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired,
dispatchActionPayload: PropTypes.func.isRequired,
dispatchActionPromise: PropTypes.func.isRequired,
updateActivity: PropTypes.func.isRequired,
setDeviceToken: PropTypes.func.isRequired,
};
state = {
foreground: true,
};
currentState: ?string = NativeAppState.currentState;
inAppNotification: ?InAppNotification = null;
androidNotifListener: ?Object = null;
androidRefreshTokenListener: ?Object = null;
initialAndroidNotifHandled = false;
openThreadOnceReceived: Set<string> = new Set();
appStarted = 0;
componentDidMount() {
this.appStarted = Date.now();
if (Platform.OS === "android") {
setTimeout(SplashScreen.hide, 350);
} else {
SplashScreen.hide();
}
NativeAppState.addEventListener('change', this.handleAppStateChange);
this.handleInitialURL();
Linking.addEventListener('url', this.handleURLChange);
if (Platform.OS === "ios") {
NotificationsIOS.addEventListener(
"remoteNotificationsRegistered",
this.registerPushPermissions,
);
NotificationsIOS.addEventListener(
"remoteNotificationsRegistrationFailed",
this.failedToRegisterPushPermissions,
);
NotificationsIOS.addEventListener(
"notificationReceivedForeground",
this.iosForegroundNotificationReceived,
);
NotificationsIOS.addEventListener(
"notificationOpened",
this.iosNotificationOpened,
);
} else if (Platform.OS === "android") {
FCM.createNotificationChannel({
id: "default",
name: "Default",
description: "SquadCal notifications channel",
priority: "high",
});
this.androidNotifListener = FCM.on(
FCMEvent.Notification,
this.androidNotificationReceived,
);
this.androidRefreshTokenListener = FCM.on(
FCMEvent.RefreshToken,
this.registerPushPermissionsAndHandleInitialNotif,
);
}
this.onForeground();
}
onForeground() {
if (this.props.appLoggedIn) {
this.ensurePushNotifsEnabled();
} else if (this.props.deviceToken) {
// We do this in case there was a crash, so we can clear deviceToken from
// any other cookies it might be set for
this.setDeviceToken(this.props.deviceToken);
}
}
static updateBadgeCount(unreadCount: number) {
if (Platform.OS === "ios") {
NotificationsIOS.setBadgesCount(unreadCount);
} else if (Platform.OS === "android") {
FCM.setBadgeNumber(unreadCount);
}
}
static clearNotifsOfThread(props: Props) {
const activeThread = props.activeThread;
invariant(activeThread, "activeThread should be set");
if (Platform.OS === "ios") {
NotificationsIOS.getDeliveredNotifications(
(notifications) =>
AppWithNavigationState.clearDeliveredIOSNotificationsForThread(
activeThread,
notifications,
),
);
} else if (Platform.OS === "android") {
props.dispatchActionPayload(
clearAndroidNotificationActionType,
{ threadID: activeThread },
);
}
}
static clearDeliveredIOSNotificationsForThread(
threadID: string,
notifications: Object[],
) {
const identifiersToClear = [];
for (let notification of notifications) {
if (notification["thread-id"] === threadID) {
identifiersToClear.push(notification.identifier);
}
}
if (identifiersToClear) {
NotificationsIOS.removeDeliveredNotifications(identifiersToClear);
}
}
async handleInitialURL() {
const url = await Linking.getInitialURL();
if (url) {
this.dispatchActionForURL(url);
}
}
componentWillUnmount() {
NativeAppState.removeEventListener('change', this.handleAppStateChange);
Linking.removeEventListener('url', this.handleURLChange);
if (Platform.OS === "ios") {
NotificationsIOS.removeEventListener(
"remoteNotificationsRegistered",
this.registerPushPermissions,
);
NotificationsIOS.removeEventListener(
"remoteNotificationsRegistrationFailed",
this.failedToRegisterPushPermissions,
);
NotificationsIOS.removeEventListener(
"notificationReceivedForeground",
this.iosForegroundNotificationReceived,
);
NotificationsIOS.removeEventListener(
"notificationOpened",
this.iosNotificationOpened,
);
} else if (Platform.OS === "android") {
if (this.androidNotifListener) {
this.androidNotifListener.remove();
this.androidNotifListener = null;
}
if (this.androidRefreshTokenListener) {
this.androidRefreshTokenListener.remove();
this.androidRefreshTokenListener = null;
}
}
}
handleURLChange = (event: { url: string }) => {
this.dispatchActionForURL(event.url);
}
dispatchActionForURL(url: string) {
if (!url.startsWith("http")) {
return;
}
this.props.dispatchActionPayload(handleURLActionType, url);
}
handleAppStateChange = (nextAppState: ?string) => {
const lastState = this.currentState;
this.currentState = nextAppState;
this.setState({ foreground: this.currentState === "active" });
if (
lastState &&
lastState.match(/inactive|background/) &&
this.currentState === "active"
) {
this.props.dispatchActionPayload(foregroundActionType, null);
this.onForeground();
if (this.props.activeThread) {
AppWithNavigationState.clearNotifsOfThread(this.props);
}
} else if (
lastState === "active" &&
this.currentState &&
this.currentState.match(/inactive|background/)
) {
this.props.dispatchActionPayload(backgroundActionType, null);
this.closingApp();
}
}
componentWillReceiveProps(nextProps: Props) {
const justLoggedIn = nextProps.loggedIn && !this.props.loggedIn;
if (
!justLoggedIn &&
nextProps.activeThread !== this.props.activeThread
) {
this.updateFocusedThreads(
nextProps,
this.props.activeThread,
this.props.activeThreadLatestMessage,
);
}
const nextActiveThread = nextProps.activeThread;
if (nextActiveThread && nextActiveThread !== this.props.activeThread) {
AppWithNavigationState.clearNotifsOfThread(nextProps);
}
if (nextProps.unreadCount !== this.props.unreadCount) {
AppWithNavigationState.updateBadgeCount(nextProps.unreadCount);
}
for (let threadID of this.openThreadOnceReceived) {
const rawThreadInfo = nextProps.rawThreadInfos[threadID];
if (rawThreadInfo) {
this.navigateToThread(rawThreadInfo, false);
this.openThreadOnceReceived.clear();
break;
}
}
}
componentDidUpdate(prevProps: Props) {
if (
- (this.props.loggedIn && !prevProps.loggedIn) ||
- (!this.props.deviceToken && prevProps.deviceToken) ||
- (
- AppWithNavigationState.serverRequestsHasDeviceTokenRequest(
- this.props.activeServerRequests,
- ) &&
- !AppWithNavigationState.serverRequestsHasDeviceTokenRequest(
- prevProps.activeServerRequests,
- )
- )
+ (this.props.appLoggedIn && !prevProps.appLoggedIn) ||
+ (!this.props.deviceToken && prevProps.deviceToken)
) {
this.ensurePushNotifsEnabled();
}
}
- static serverRequestsHasDeviceTokenRequest(
- requests: $ReadOnlyArray<ServerRequest>,
- ) {
- return requests.some(
- request => request.type === serverRequestTypes.DEVICE_TOKEN,
- );
- }
-
async ensurePushNotifsEnabled() {
if (!this.props.appLoggedIn) {
return;
}
if (Platform.OS === "ios") {
const missingDeviceToken = this.props.deviceToken === null
|| this.props.deviceToken === undefined;
await requestIOSPushPermissions(missingDeviceToken);
} else if (Platform.OS === "android") {
await this.ensureAndroidPushNotifsEnabled();
}
}
async ensureAndroidPushNotifsEnabled() {
const missingDeviceToken = this.props.deviceToken === null
|| this.props.deviceToken === undefined;
let token = await this.getAndroidFCMToken();
if (token) {
await this.registerPushPermissionsAndHandleInitialNotif(token);
return;
}
try {
await FCM.deleteInstanceId();
} catch (e) {
this.failedToRegisterPushPermissions(e);
return null;
}
token = await this.getAndroidFCMToken();
if (token) {
await this.registerPushPermissionsAndHandleInitialNotif(token);
} else if (missingDeviceToken) {
this.failedToRegisterPushPermissions();
}
}
async getAndroidFCMToken() {
try {
return await requestAndroidPushPermissions();
} catch (e) {
this.failedToRegisterPushPermissions(e);
return null;
}
}
registerPushPermissionsAndHandleInitialNotif = async (
deviceToken: string,
) => {
this.registerPushPermissions(deviceToken);
await this.handleInitialAndroidNotification();
}
async handleInitialAndroidNotification() {
if (this.initialAndroidNotifHandled) {
return;
}
this.initialAndroidNotifHandled = true;
const initialNotif = await FCM.getInitialNotification();
if (initialNotif) {
await this.androidNotificationReceived(initialNotif, true);
}
}
registerPushPermissions = (deviceToken: string) => {
const deviceType = Platform.OS;
if (deviceType !== "android" && deviceType !== "ios") {
return;
}
if (deviceType === "ios") {
iosPushPermissionResponseReceived();
}
if (deviceToken !== this.props.deviceToken) {
this.setDeviceToken(deviceToken);
}
}
setDeviceToken(deviceToken: string) {
this.props.dispatchActionPromise(
setDeviceTokenActionTypes,
this.props.setDeviceToken(deviceToken, Platform.OS),
undefined,
deviceToken,
);
}
failedToRegisterPushPermissions = (error) => {
if (!this.props.appLoggedIn) {
return;
}
const deviceType = Platform.OS;
if (deviceType === "ios") {
iosPushPermissionResponseReceived();
if (__DEV__) {
// iOS simulator can't handle notifs
return;
}
}
const alertInfo = this.props.notifPermissionAlertInfo;
if (
(alertInfo.totalAlerts > 3 &&
alertInfo.lastAlertTime > (Date.now() - msInDay)) ||
(alertInfo.totalAlerts > 6 &&
alertInfo.lastAlertTime > (Date.now() - msInDay * 3)) ||
(alertInfo.totalAlerts > 9 &&
alertInfo.lastAlertTime > (Date.now() - msInDay * 7))
) {
return;
}
this.props.dispatchActionPayload(
recordNotifPermissionAlertActionType,
{ time: Date.now() },
);
if (deviceType === "ios") {
Alert.alert(
"Need notif permissions",
"SquadCal needs notification permissions to keep you in the loop! " +
"Please enable in Settings App -> Notifications -> SquadCal.",
[ { text: 'OK' } ],
);
} else if (deviceType === "android") {
Alert.alert(
"Unable to initialize notifs!",
"Please check your network connection, make sure Google Play " +
"services are installed and enabled, and confirm that your Google " +
"Play credentials are valid in the Google Play Store.",
);
}
}
navigateToThread(rawThreadInfo: RawThreadInfo, clearChatRoutes: bool) {
this.props.dispatchActionPayload(
notificationPressActionType,
{
rawThreadInfo,
clearChatRoutes,
},
);
}
onPressNotificationForThread(threadID: string, clearChatRoutes: bool) {
const rawThreadInfo = this.props.rawThreadInfos[threadID];
if (rawThreadInfo) {
this.navigateToThread(rawThreadInfo, clearChatRoutes);
} else {
this.openThreadOnceReceived.add(threadID);
}
}
saveMessageInfos(messageInfosString: string) {
const messageInfos: $ReadOnlyArray<RawMessageInfo> =
JSON.parse(messageInfosString);
const { updatesCurrentAsOf } = this.props;
this.props.dispatchActionPayload(
saveMessagesActionType,
{ rawMessageInfos: messageInfos, updatesCurrentAsOf },
);
}
iosForegroundNotificationReceived = (notification) => {
if (
notification.getData() &&
notification.getData().managedAps &&
notification.getData().managedAps.action === "CLEAR"
) {
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
if (Date.now() < this.appStarted + 1500) {
// On iOS, when the app is opened from a notif press, for some reason this
// callback gets triggered before iosNotificationOpened. In fact this
// callback shouldn't be triggered at all. To avoid weirdness we are
// ignoring any foreground notification received within the first second
// of the app being started, since they are most likely to be erroneous.
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
const threadID = notification.getData().threadID;
if (!threadID) {
console.log("Notification with missing threadID received!");
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
const messageInfos = notification.getData().messageInfos;
if (messageInfos) {
this.saveMessageInfos(messageInfos);
}
this.showInAppNotification(threadID, notification.getMessage());
notification.finish(NotificationsIOS.FetchResult.NewData);
}
iosNotificationOpened = (notification) => {
const threadID = notification.getData().threadID;
if (!threadID) {
console.log("Notification with missing threadID received!");
notification.finish(NotificationsIOS.FetchResult.NoData);
return;
}
const messageInfos = notification.getData().messageInfos;
if (messageInfos) {
this.saveMessageInfos(messageInfos);
}
this.onPressNotificationForThread(threadID, true),
notification.finish(NotificationsIOS.FetchResult.NewData);
}
showInAppNotification(threadID: string, message: string) {
if (threadID === this.props.activeThread) {
return;
}
invariant(this.inAppNotification, "should be set");
this.inAppNotification.show({
message,
onPress: () => this.onPressNotificationForThread(threadID, false),
});
}
// This function gets called when:
// - The app is open (either foreground or background) and a notif is
// received. In this case, notification has a custom_notification property.
// custom_notification can have either a new notif or a payload indicating a
// notif should be rescinded. In both cases, the native side will handle
// presenting or rescinding the notif.
// - The app is open and a notif is pressed. In this case, notification has a
// body property.
// - The app is closed and a notif is pressed. This is possible because when
// the app is closed and a notif is recevied, the native side will boot up
// to process it. However, in this case, this function does not get
// triggered when the notif is received - only when it is pressed.
androidNotificationReceived = async (
notification,
appOpenedFromNotif = false,
) => {
if (appOpenedFromNotif && notification.messageInfos) {
// This indicates that while the app was closed (not backgrounded), a
// notif was delivered to the native side, which presented a local notif.
// The local notif was then pressed, opening the app and triggering here.
// Normally, this callback is called initially when the local notif is
// generated, and at that point the MessageInfos get saved. But in the
// case of a notif press opening the app, that doesn't happen, so we'll
// save the notifs here.
this.saveMessageInfos(notification.messageInfos);
}
if (notification.body) {
// This indicates that we're being called because a notif was pressed
this.onPressNotificationForThread(notification.threadID, true);
return;
}
if (notification.custom_notification) {
const customNotification = JSON.parse(notification.custom_notification);
if (customNotification.rescind === "true") {
// We have nothing to do on the JS thread in the case of a rescind
return;
}
const threadID = customNotification.threadID;
if (!threadID) {
console.log("Server notification with missing threadID received!");
return;
}
// We are here because notif was received, but hasn't been pressed yet
this.saveMessageInfos(customNotification.messageInfos);
if (this.currentState === "active") {
// In the case where the app is in the foreground, we will show an
// in-app notif
this.showInAppNotification(threadID, customNotification.body);
} else {
// We keep track of what notifs have been rendered for a given thread so
// that we can clear them immediately (without waiting for the rescind)
// when the user navigates to that thread. Since we can't do this while
// the app is closed, we rely on the rescind notif in that case.
this.props.dispatchActionPayload(
recordAndroidNotificationActionType,
{
threadID,
notifID: customNotification.id,
},
);
}
}
}
updateFocusedThreads(
props: Props,
oldActiveThread: ?string,
oldActiveThreadLatestMessage: ?string,
) {
if (!props.appLoggedIn || this.currentState !== "active") {
// If the app isn't logged in, the server isn't tracking our activity
// anyways. If the currentState isn't active, we can expect that when it
// becomes active, the socket initialization will include any activity
// update that it needs to update the server. We want to avoid any races
// between update_activity and socket initialization, so we return here.
return;
}
const updates = [];
if (props.activeThread) {
updates.push({
focus: true,
threadID: props.activeThread,
});
}
if (oldActiveThread && oldActiveThread !== props.activeThread) {
updates.push({
focus: false,
threadID: oldActiveThread,
latestMessage: oldActiveThreadLatestMessage,
});
}
if (updates.length === 0) {
return;
}
props.dispatchActionPromise(
updateActivityActionTypes,
props.updateActivity(updates),
);
}
closingApp() {
appBecameInactive();
if (!this.props.appLoggedIn || !this.props.activeThread) {
return;
}
const updates = [{
focus: false,
threadID: this.props.activeThread,
latestMessage: this.props.activeThreadLatestMessage,
}];
this.props.dispatchActionPromise(
updateActivityActionTypes,
this.props.updateActivity(updates),
);
}
render() {
const inAppNotificationHeight = DeviceInfo.isIPhoneX_deprecated ? 104 : 80;
return (
<View style={styles.app}>
<Socket active={this.props.appLoggedIn && this.state.foreground} />
<ReduxifiedRootNavigator
state={this.props.navigationState}
dispatch={this.props.dispatch}
/>
<ConnectedStatusBar />
<InAppNotification
height={inAppNotificationHeight}
notificationBodyComponent={NotificationBody}
ref={this.inAppNotificationRef}
/>
</View>
);
}
inAppNotificationRef = (inAppNotification: InAppNotification) => {
this.inAppNotification = inAppNotification;
}
}
const styles = StyleSheet.create({
app: {
flex: 1,
},
});
const ConnectedAppWithNavigationState = connect(
(state: AppState) => {
const activeThread = activeThreadSelector(state);
const appLoggedIn = appLoggedInSelector(state);
return {
navigationState: state.navInfo.navigationState,
activeThread,
appLoggedIn,
loggedIn: appLoggedIn &&
!!(state.currentUserInfo && !state.currentUserInfo.anonymous && true),
activeThreadLatestMessage:
activeThread && state.messageStore.threads[activeThread]
? state.messageStore.threads[activeThread].messageIDs[0]
: null,
deviceToken: state.deviceToken,
unreadCount: unreadCount(state),
rawThreadInfos: state.threadStore.threadInfos,
notifPermissionAlertInfo: state.notifPermissionAlertInfo,
- activeServerRequests: state.activeServerRequests,
updatesCurrentAsOf: state.updatesCurrentAsOf,
};
},
{ updateActivity, setDeviceToken },
)(AppWithNavigationState);
const App = (props: {}) =>
<Provider store={store}>
<ErrorBoundary>
<ConnectedAppWithNavigationState />
</ErrorBoundary>
</Provider>;
AppRegistry.registerComponent('SquadCal', () => App);
diff --git a/native/persist.js b/native/persist.js
index 748320e31..7b9be8478 100644
--- a/native/persist.js
+++ b/native/persist.js
@@ -1,112 +1,113 @@
// @flow
import type { AppState } from './redux-setup';
import { defaultCalendarFilters } from 'lib/types/filter-types';
import { defaultConnectionInfo } from 'lib/types/socket-types';
import storage from 'redux-persist/lib/storage';
import { createMigrate } from 'redux-persist';
import invariant from 'invariant';
import { currentCalendarQuery } from 'lib/selectors/nav-selectors';
import version from 'lib/facts/version';
import { defaultNotifPermissionAlertInfo } from './push/alerts';
const blacklist = __DEV__
? [ 'loadingStatuses' ]
: [ 'loadingStatuses', 'navInfo' ];
const migrations = {
[1]: (state: AppState) => ({
...state,
notifPermissionAlertInfo: defaultNotifPermissionAlertInfo,
}),
[2]: (state: AppState) => ({
...state,
messageSentFromRoute: [],
}),
[3]: (state) => ({
currentUserInfo: state.currentUserInfo,
entryStore: state.entryStore,
threadInfos: state.threadInfos,
userInfos: state.userInfos,
messageStore: {
...state.messageStore,
currentAsOf: state.currentAsOf,
},
drafts: state.drafts,
updatesCurrentAsOf: state.currentAsOf,
cookie: state.cookie,
deviceToken: state.deviceToken,
urlPrefix: state.urlPrefix,
customServer: state.customServer,
threadIDsToNotifIDs: state.threadIDsToNotifIDs,
notifPermissionAlertInfo: state.notifPermissionAlertInfo,
messageSentFromRoute: state.messageSentFromRoute,
_persist: state._persist,
}),
[4]: (state: AppState) => ({
...state,
pingTimestamps: undefined,
- activeServerRequests: [],
+ activeServerRequests: undefined,
}),
[5]: (state: AppState) => ({
...state,
calendarFilters: defaultCalendarFilters,
}),
[6]: (state) => ({
...state,
threadInfos: undefined,
threadStore: {
threadInfos: state.threadInfos,
inconsistencyResponses: [],
},
}),
[7]: (state) => ({
...state,
lastUserInteraction: undefined,
sessionID: undefined,
entryStore: {
...state.entryStore,
inconsistencyResponses: [],
actualizedCalendarQuery: currentCalendarQuery(state)(),
},
}),
[8]: (state: AppState) => ({
...state,
pingTimestamps: undefined,
+ activeServerRequests: undefined,
connection: defaultConnectionInfo,
watchedThreadIDs: [],
}),
};
const persistConfig = {
key: 'root',
storage,
blacklist,
debug: __DEV__,
version: 8,
migrate: createMigrate(migrations, { debug: __DEV__ }),
};
const codeVersion = version.currentCodeVersion;
// This local exists to avoid a circular dependency where redux-setup needs to
// import all the navigation and screen stuff, but some of those screens want to
// access the persistor to purge its state.
let storedPersistor = null;
function setPersistor(persistor: *) {
storedPersistor = persistor;
}
function getPersistor() {
invariant(storedPersistor, "should be set");
return storedPersistor;
}
export {
persistConfig,
codeVersion,
setPersistor,
getPersistor,
};
diff --git a/native/redux-setup.js b/native/redux-setup.js
index 2d4a1d223..64fdae9e8 100644
--- a/native/redux-setup.js
+++ b/native/redux-setup.js
@@ -1,391 +1,388 @@
// @flow
import type { ThreadStore } from 'lib/types/thread-types';
import type { EntryStore } from 'lib/types/entry-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import type { CurrentUserInfo, UserInfo } from 'lib/types/user-types';
import type { MessageStore } from 'lib/types/message-types';
import type { NavInfo } from './navigation/navigation-setup';
import type { PersistState } from 'redux-persist/src/types';
import {
type NotifPermissionAlertInfo,
defaultNotifPermissionAlertInfo,
} from './push/alerts';
import type { NavigationStateRoute, NavigationRoute } from 'react-navigation';
-import type { ServerRequest } from 'lib/types/request-types';
import {
type CalendarFilter,
defaultCalendarFilters,
} from 'lib/types/filter-types';
import { setNewSessionActionType } from 'lib/utils/action-utils';
import { updateTypes } from 'lib/types/update-types';
import { setDeviceTokenActionTypes } from 'lib/actions/device-actions';
import {
type ConnectionInfo,
defaultConnectionInfo,
} from 'lib/types/socket-types';
import React from 'react';
import invariant from 'invariant';
import thunk from 'redux-thunk';
import { createStore as defaultCreateStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import { persistStore, persistReducer } from 'redux-persist';
import PropTypes from 'prop-types';
import { NavigationActions, StackActions } from 'react-navigation';
import {
createReactNavigationReduxMiddleware,
} from 'react-navigation-redux-helpers';
import { AppState as NativeAppState } from 'react-native';
import baseReducer from 'lib/reducers/master-reducer';
import { notificationPressActionType } from 'lib/shared/notif-utils';
import {
sendMessageActionTypes,
saveMessagesActionType,
} from 'lib/actions/message-actions';
import { pingActionTypes } from 'lib/actions/ping-actions';
import { reduxLoggerMiddleware } from 'lib/utils/redux-logger';
import { defaultCalendarQuery } from 'lib/selectors/nav-selectors';
import { activeThreadSelector } from './selectors/nav-selectors';
import {
handleURLActionType,
navigateToAppActionType,
resetUserStateActionType,
recordNotifPermissionAlertActionType,
recordAndroidNotificationActionType,
clearAndroidNotificationActionType,
} from './navigation/action-types';
import {
defaultNavInfo,
reduceNavInfo,
removeScreensFromStack,
} from './navigation/navigation-setup';
import {
reduceThreadIDsToNotifIDs,
} from './push/android';
import { persistConfig, setPersistor } from './persist';
import {
defaultURLPrefix,
natServer,
setCustomServer,
} from './utils/url-utils';
import {
assertNavigationRouteNotLeafNode,
currentLeafRoute,
findRouteIndexWithKey,
} from './utils/navigation-utils';
import {
ComposeThreadRouteName,
MessageListRouteName,
} from './navigation/route-names';
import reactotron from './reactotron';
const createStore = reactotron
? reactotron.createStore
: defaultCreateStore;
export type AppState = {|
navInfo: NavInfo,
currentUserInfo: ?CurrentUserInfo,
entryStore: EntryStore,
threadStore: ThreadStore,
userInfos: {[id: string]: UserInfo},
messageStore: MessageStore,
drafts: {[key: string]: string},
updatesCurrentAsOf: number,
loadingStatuses: {[key: string]: {[idx: number]: LoadingStatus}},
- activeServerRequests: $ReadOnlyArray<ServerRequest>,
calendarFilters: $ReadOnlyArray<CalendarFilter>,
cookie: ?string,
deviceToken: ?string,
urlPrefix: string,
customServer: ?string,
threadIDsToNotifIDs: {[threadID: string]: string[]},
notifPermissionAlertInfo: NotifPermissionAlertInfo,
messageSentFromRoute: $ReadOnlyArray<string>,
connection: ConnectionInfo,
watchedThreadIDs: $ReadOnlyArray<string>,
_persist: ?PersistState,
sessionID?: void,
|};
const defaultState = ({
navInfo: defaultNavInfo,
currentUserInfo: null,
entryStore: {
entryInfos: {},
daysToEntries: {},
actualizedCalendarQuery: defaultCalendarQuery(),
lastUserInteractionCalendar: 0,
inconsistencyResponses: [],
},
threadStore: {
threadInfos: {},
inconsistencyResponses: [],
},
userInfos: {},
messageStore: {
messages: {},
threads: {},
currentAsOf: 0,
},
drafts: {},
updatesCurrentAsOf: 0,
loadingStatuses: {},
- activeServerRequests: [],
calendarFilters: defaultCalendarFilters,
cookie: null,
deviceToken: null,
urlPrefix: defaultURLPrefix(),
customServer: natServer,
threadIDsToNotifIDs: {},
notifPermissionAlertInfo: defaultNotifPermissionAlertInfo,
messageSentFromRoute: [],
connection: defaultConnectionInfo,
watchedThreadIDs: [],
_persist: null,
}: AppState);
function chatRouteFromNavInfo(navInfo: NavInfo): NavigationStateRoute {
const navState = navInfo.navigationState;
const appRoute = assertNavigationRouteNotLeafNode(navState.routes[0]);
return assertNavigationRouteNotLeafNode(appRoute.routes[1]);
}
function reducer(state: AppState = defaultState, action: *) {
if (
action.type === recordAndroidNotificationActionType ||
action.type === clearAndroidNotificationActionType
) {
return {
...state,
threadIDsToNotifIDs: reduceThreadIDsToNotifIDs(
state.threadIDsToNotifIDs,
action,
),
};
} else 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,
};
}
const oldState = state;
if (action.type === sendMessageActionTypes.started) {
const chatRoute = chatRouteFromNavInfo(state.navInfo);
const currentChatSubroute = currentLeafRoute(chatRoute);
const messageSentFromRoute =
state.messageSentFromRoute.includes(currentChatSubroute.key)
? state.messageSentFromRoute
: [ ...state.messageSentFromRoute, currentChatSubroute.key];
state = {
...state,
messageSentFromRoute,
};
}
if (action.type === setNewSessionActionType) {
state = {
...state,
cookie: action.payload.sessionChange.cookie,
};
} else if (action.type === setDeviceTokenActionTypes.started) {
state = {
...state,
deviceToken: action.payload,
};
} else if (action.type === pingActionTypes.success) {
let wipeDeviceToken = false;
for (let update of action.payload.updatesResult.newUpdates) {
if (
update.type === updateTypes.BAD_DEVICE_TOKEN &&
update.deviceToken === state.deviceToken
) {
wipeDeviceToken = true;
break;
}
}
if (wipeDeviceToken) {
state = {
...state,
deviceToken: null,
};
}
}
state = baseReducer(state, action);
let navInfo = reduceNavInfo(state, action, state.threadStore.threadInfos);
if (navInfo && navInfo !== state.navInfo) {
const chatRoute = chatRouteFromNavInfo(navInfo);
const currentChatSubroute = currentLeafRoute(chatRoute);
if (currentChatSubroute.routeName === ComposeThreadRouteName) {
const oldChatRoute = chatRouteFromNavInfo(state.navInfo);
const oldRouteIndex = findRouteIndexWithKey(
oldChatRoute,
currentChatSubroute.key,
);
const oldNextRoute = oldChatRoute.routes[oldRouteIndex + 1];
if (
oldNextRoute &&
state.messageSentFromRoute.includes(oldNextRoute.key)
) {
// This indicates that the user went to the compose thread screen, then
// saw that a thread already existed for the people they wanted to
// contact, and sent a message to that thread. We are now about to
// navigate back to that compose thread screen, but instead, since the
// user's intent has ostensibly already been satisfied, we will pop up
// to the screen right before that one.
const newChatRoute = removeScreensFromStack(
chatRoute,
(route: NavigationRoute) => route.key === currentChatSubroute.key
? "remove"
: "keep",
);
const appRoute =
assertNavigationRouteNotLeafNode(navInfo.navigationState.routes[0]);
const newAppSubRoutes = [ ...appRoute.routes ];
newAppSubRoutes[1] = newChatRoute;
const newRootSubRoutes = [ ...navInfo.navigationState.routes ];
newRootSubRoutes[0] = { ...appRoute, routes: newAppSubRoutes };
navInfo = {
startDate: navInfo.startDate,
endDate: navInfo.endDate,
navigationState: {
...navInfo.navigationState,
routes: newRootSubRoutes,
},
};
}
}
state = { ...state, navInfo };
}
return validateState(oldState, state, action);
}
function validateState(
oldState: AppState,
state: AppState,
action: *,
): AppState {
const activeThread = activeThreadSelector(state);
if (
activeThread &&
(NativeAppState.currentState === "active" ||
(appLastBecameInactive + 10000 < Date.now() &&
action.type !== saveMessagesActionType)) &&
state.threadStore.threadInfos[activeThread].currentUser.unread
) {
// 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 saveMessagesActionType, since
// that action can be expected to happen while the app is backgrounded.
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(),
},
},
},
};
}
const chatRoute = chatRouteFromNavInfo(state.navInfo);
const chatSubrouteKeys = new Set(chatRoute.routes.map(route => route.key));
const messageSentFromRoute = state.messageSentFromRoute.filter(
key => chatSubrouteKeys.has(key),
);
if (messageSentFromRoute.length !== state.messageSentFromRoute.length) {
state = {
...state,
messageSentFromRoute,
};
}
return state;
}
let appLastBecameInactive = 0;
function appBecameInactive() {
appLastBecameInactive = Date.now();
}
const reactNavigationMiddleware = createReactNavigationReduxMiddleware(
"root",
(state: AppState) => state.navInfo.navigationState,
);
const store = createStore(
persistReducer(persistConfig, reducer),
defaultState,
composeWithDevTools(
applyMiddleware(thunk, reactNavigationMiddleware, reduxLoggerMiddleware),
),
);
const persistor = persistStore(store);
setPersistor(persistor);
export {
store,
appBecameInactive,
};
diff --git a/native/socket.react.js b/native/socket.react.js
index 0ecaf3a22..846200ec5 100644
--- a/native/socket.react.js
+++ b/native/socket.react.js
@@ -1,32 +1,32 @@
// @flow
import type { AppState } from './redux-setup';
import { connect } from 'lib/utils/redux-utils';
import {
clientResponsesSelector,
sessionStateFuncSelector,
} from 'lib/selectors/socket-selectors';
import { logInExtraInfoSelector } from 'lib/selectors/account-selectors';
import Socket from 'lib/components/socket.react';
import {
openSocketSelector,
sessionIdentificationSelector,
} from './selectors/socket-selectors';
import { activeThreadSelector } from './selectors/nav-selectors';
export default connect(
(state: AppState) => ({
openSocket: openSocketSelector(state),
- clientResponses: clientResponsesSelector(state),
+ getClientResponses: clientResponsesSelector(state),
activeThread: activeThreadSelector(state),
sessionStateFunc: sessionStateFuncSelector(state),
sessionIdentification: sessionIdentificationSelector(state),
cookie: state.cookie,
urlPrefix: state.urlPrefix,
logInExtraInfo: logInExtraInfoSelector(state),
}),
null,
true,
)(Socket);
diff --git a/server/src/responders/website-responders.js b/server/src/responders/website-responders.js
index 89160fbad..6d3c20e2f 100644
--- a/server/src/responders/website-responders.js
+++ b/server/src/responders/website-responders.js
@@ -1,247 +1,246 @@
// @flow
import type { $Response, $Request } from 'express';
import type { AppState, Action } from 'web/redux-setup';
import type { Store } from 'redux';
import { defaultCalendarFilters } from 'lib/types/filter-types';
import { threadPermissions } from 'lib/types/thread-types';
import { defaultConnectionInfo } from 'lib/types/socket-types';
import html from 'common-tags/lib/html';
import { createStore } from 'redux';
import ReactDOMServer from 'react-dom/server';
import ReactHotLoader from 'react-hot-loader';
import ReactRedux from 'react-redux';
import { Route, StaticRouter } from 'react-router';
import React from 'react';
import _keyBy from 'lodash/fp/keyBy';
import { ServerError } from 'lib/utils/errors';
import {
startDateForYearAndMonth,
endDateForYearAndMonth,
} from 'lib/utils/date-utils';
import { defaultNumberPerThread } from 'lib/types/message-types';
import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer';
import { freshMessageStore } from 'lib/reducers/message-reducer';
import { verifyField } from 'lib/types/verify-types';
import { mostRecentMessageTimestamp } from 'lib/shared/message-utils';
import { mostRecentReadThread } from 'lib/selectors/thread-selectors';
import { threadHasPermission } from 'lib/shared/thread-utils';
import 'web/server-rendering';
import * as ReduxSetup from 'web/redux-setup';
import App from 'web/dist/app.build';
import { navInfoFromURL } from 'web/url-utils';
import { activeThreadFromNavInfo } from 'web/selectors/nav-selectors';
import { Viewer } from '../session/viewer';
import { handleCodeVerificationRequest } from '../models/verification';
import { fetchMessageInfos } from '../fetchers/message-fetchers';
import { fetchThreadInfos } from '../fetchers/thread-fetchers';
import { fetchEntryInfos } from '../fetchers/entry-fetchers';
import { fetchCurrentUserInfo } from '../fetchers/user-fetchers';
import { setNewSession } from '../session/cookies';
import { activityUpdater } from '../updaters/activity-updaters';
import urlFacts from '../../facts/url';
import assets from '../../compiled/assets';
const { basePath, baseDomain } = urlFacts;
const { renderToString } = ReactDOMServer;
const { AppContainer } = ReactHotLoader;
const { Provider } = ReactRedux;
const { reducer } = ReduxSetup;
async function websiteResponder(viewer: Viewer, url: string): Promise<string> {
let navInfo;
try {
navInfo = navInfoFromURL(url);
} catch (e) {
throw new ServerError(e.message);
}
const calendarQuery = {
startDate: navInfo.startDate,
endDate: navInfo.endDate,
filters: defaultCalendarFilters,
};
const threadSelectionCriteria = { joinedThreads: true };
const initialTime = Date.now();
const [
{ threadInfos, userInfos: threadUserInfos },
currentUserInfo,
{ rawEntryInfos, userInfos: entryUserInfos },
{ rawMessageInfos, truncationStatuses, userInfos: messageUserInfos },
verificationResult,
] = await Promise.all([
fetchThreadInfos(viewer),
fetchCurrentUserInfo(viewer),
fetchEntryInfos(viewer, [ calendarQuery ]),
fetchMessageInfos(
viewer,
threadSelectionCriteria,
defaultNumberPerThread,
),
navInfo.verify
? handleCodeVerificationRequest(viewer, navInfo.verify)
: null,
viewer.loggedIn ? setNewSession(viewer, calendarQuery, initialTime) : null,
]);
const messageStore = freshMessageStore(
rawMessageInfos,
truncationStatuses,
mostRecentMessageTimestamp(rawMessageInfos, initialTime),
threadInfos,
);
const threadID = navInfo.activeChatThreadID;
if (
threadID &&
!threadHasPermission(threadInfos[threadID], threadPermissions.VISIBLE)
) {
navInfo.activeChatThreadID = null;
}
if (!navInfo.activeChatThreadID) {
const mostRecentThread = mostRecentReadThread(messageStore, threadInfos);
if (mostRecentThread) {
navInfo.activeChatThreadID = mostRecentThread;
}
}
const activeThread = activeThreadFromNavInfo(navInfo);
if (activeThread) {
await activityUpdater(
viewer,
{ updates: [ { focus: true, threadID: activeThread } ] },
);
}
const baseURL = basePath.replace(/\/$/, '');
const store: Store<AppState, Action> = createStore(
reducer,
({
navInfo,
currentUserInfo,
sessionID: viewer.sessionID,
verifyField: verificationResult && verificationResult.field,
resetPasswordUsername:
verificationResult && verificationResult.resetPasswordUsername
? verificationResult.resetPasswordUsername
: "",
entryStore: {
entryInfos: _keyBy('id')(rawEntryInfos),
daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos),
actualizedCalendarQuery: calendarQuery,
lastUserInteractionCalendar: initialTime,
inconsistencyResponses: [],
},
threadStore: {
threadInfos,
inconsistencyResponses: [],
},
userInfos: {
...messageUserInfos,
...entryUserInfos,
...threadUserInfos,
},
messageStore,
drafts: {},
updatesCurrentAsOf: initialTime,
loadingStatuses: {},
- activeServerRequests: [],
calendarFilters: defaultCalendarFilters,
// We can use paths local to the <base href> on web
urlPrefix: "",
windowDimensions: { width: 0, height: 0 },
baseHref: baseDomain + baseURL,
connection: defaultConnectionInfo,
watchedThreadIDs: [],
}: AppState),
);
const routerContext = {};
const rendered = renderToString(
<AppContainer>
<Provider store={store}>
<StaticRouter
location={url}
basename={baseURL}
context={routerContext}
>
<Route path="*" component={App.default} />
</StaticRouter>
</Provider>
</AppContainer>,
);
if (routerContext.url) {
throw new ServerError("URL modified during server render!");
}
const state = store.getState();
const stringifiedState = JSON.stringify(state).replace(/</g, '\\u003c');
const fontsURL = process.env.NODE_ENV === "dev"
? "fonts/local-fonts.css"
: "https://fonts.googleapis.com/css?family=Open+Sans:300,600%7CAnaheim";
const jsURL = process.env.NODE_ENV === "dev"
? "compiled/dev.build.js"
: `compiled/${assets.browser.js}`;
const cssInclude = process.env.NODE_ENV === "dev"
? ""
: html`<link
rel="stylesheet"
type="text/css"
href="compiled/${assets.browser.css}"
/>`;
let result = html`
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SquadCal</title>
<base href="${basePath}" />
<link rel="stylesheet" type="text/css" href="${fontsURL}" />
${cssInclude}
<link
rel="apple-touch-icon"
sizes="180x180"
href="apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="favicon-16x16.png"
/>
<link rel="manifest" href="site.webmanifest" />
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#b91d47" />
<meta name="apple-mobile-web-app-title" content="SquadCal" />
<meta name="application-name" content="SquadCal" />
<meta name="msapplication-TileColor" content="#b91d47" />
<meta name="theme-color" content="#b91d47" />
<script>
var preloadedState = ${stringifiedState};
var baseURL = "${baseURL}";
</script>
</head>
<body>
<div id="react-root">
`;
result += rendered;
result += html`
</div>
<script src="${jsURL}"></script>
</body>
</html>
`;
return result;
}
export {
websiteResponder,
};
diff --git a/server/src/socket.js b/server/src/socket.js
index bb9142a36..261eae21a 100644
--- a/server/src/socket.js
+++ b/server/src/socket.js
@@ -1,392 +1,446 @@
// @flow
import type { WebSocket } from 'ws';
import type { $Request } from 'express';
import {
type ClientSocketMessage,
type InitialClientSocketMessage,
+ type ResponsesClientSocketMessage,
type StateSyncFullSocketPayload,
type ServerSocketMessage,
type ErrorServerSocketMessage,
type AuthErrorServerSocketMessage,
clientSocketMessageTypes,
stateSyncPayloadTypes,
serverSocketMessageTypes,
} from 'lib/types/socket-types';
import { cookieSources } from 'lib/types/session-types';
import { defaultNumberPerThread } from 'lib/types/message-types';
+import { serverRequestTypes } from 'lib/types/request-types';
import t from 'tcomb';
import invariant from 'invariant';
import { ServerError } from 'lib/utils/errors';
import { mostRecentMessageTimestamp } from 'lib/shared/message-utils';
import { mostRecentUpdateTimestamp } from 'lib/shared/update-utils';
import { promiseAll } from 'lib/utils/promises';
import { values } from 'lib/utils/objects';
import { Viewer } from './session/viewer';
import {
checkInputValidator,
checkClientSupported,
tShape,
} from './utils/validation-utils';
import {
newEntryQueryInputValidator,
verifyCalendarQueryThreadIDs,
} from './responders/entry-responders';
import {
clientResponseInputValidator,
processClientResponses,
initializeSession,
checkState,
} from './responders/ping-responders';
import { assertSecureRequest } from './utils/security-utils';
import { fetchViewerForSocket, extendCookieLifespan } from './session/cookies';
import { fetchMessageInfosSince } from './fetchers/message-fetchers';
import { fetchThreadInfos } from './fetchers/thread-fetchers';
import { fetchEntryInfos } from './fetchers/entry-fetchers';
import { fetchCurrentUserInfo } from './fetchers/user-fetchers';
import { updateActivityTime } from './updaters/activity-updaters';
import {
deleteUpdatesBeforeTimeTargettingSession,
} from './deleters/update-deleters';
import { fetchUpdateInfos } from './fetchers/update-fetchers';
import { commitSessionUpdate } from './updaters/session-updaters';
import { handleAsyncPromise } from './responders/handlers';
import { deleteCookie } from './deleters/cookie-deleters';
import { createNewAnonymousCookie } from './session/cookies';
-const clientSocketMessageInputValidator = tShape({
- type: t.irreducible(
- 'clientSocketMessageTypes.INITIAL',
- x => x === clientSocketMessageTypes.INITIAL,
- ),
- id: t.Number,
- payload: tShape({
- sessionIdentification: tShape({
- cookie: t.maybe(t.String),
- sessionID: t.maybe(t.String),
+const clientSocketMessageInputValidator = t.union([
+ tShape({
+ type: t.irreducible(
+ 'clientSocketMessageTypes.INITIAL',
+ x => x === clientSocketMessageTypes.INITIAL,
+ ),
+ id: t.Number,
+ payload: tShape({
+ sessionIdentification: tShape({
+ cookie: t.maybe(t.String),
+ sessionID: t.maybe(t.String),
+ }),
+ sessionState: tShape({
+ calendarQuery: newEntryQueryInputValidator,
+ messagesCurrentAsOf: t.Number,
+ updatesCurrentAsOf: t.Number,
+ watchedIDs: t.list(t.String),
+ }),
+ clientResponses: t.list(clientResponseInputValidator),
}),
- sessionState: tShape({
- calendarQuery: newEntryQueryInputValidator,
- messagesCurrentAsOf: t.Number,
- updatesCurrentAsOf: t.Number,
- watchedIDs: t.list(t.String),
+ }),
+ tShape({
+ type: t.irreducible(
+ 'clientSocketMessageTypes.RESPONSES',
+ x => x === clientSocketMessageTypes.RESPONSES,
+ ),
+ id: t.Number,
+ payload: tShape({
+ clientResponses: t.list(clientResponseInputValidator),
}),
- clientResponses: t.list(clientResponseInputValidator),
}),
-});
+]);
type SendMessageFunc = (message: ServerSocketMessage) => void;
function onConnection(ws: WebSocket, req: $Request) {
assertSecureRequest(req);
let viewer;
const sendMessage = (message: ServerSocketMessage) => {
ws.send(JSON.stringify(message));
};
ws.on('message', async messageString => {
let clientSocketMessage: ?ClientSocketMessage;
try {
const message = JSON.parse(messageString);
checkInputValidator(clientSocketMessageInputValidator, message);
clientSocketMessage = message;
if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) {
if (viewer) {
// This indicates that the user sent multiple INITIAL messages.
throw new ServerError('socket_already_initialized');
}
viewer = await fetchViewerForSocket(req, clientSocketMessage);
if (!viewer) {
// This indicates that the cookie was invalid, but the client is using
// cookieSources.HEADER and thus can't accept a new cookie over
// WebSockets. See comment under catch block for socket_deauthorized.
throw new ServerError('socket_deauthorized');
}
}
if (!viewer) {
// This indicates a non-INITIAL message was sent by the client before
// the INITIAL message.
throw new ServerError('socket_uninitialized');
}
if (viewer.sessionChanged) {
// This indicates that the cookie was invalid, and we've assigned a new
// anonymous one.
throw new ServerError('socket_deauthorized');
}
if (!viewer.loggedIn) {
// This indicates that the specified cookie was an anonymous one.
throw new ServerError('not_logged_in');
}
await checkClientSupported(
viewer,
clientSocketMessageInputValidator,
clientSocketMessage,
);
const serverResponses = await handleClientSocketMessage(
viewer,
clientSocketMessage,
);
if (viewer.sessionChanged) {
// This indicates that something has caused the session to change, which
// shouldn't happen from inside a WebSocket since we can't handle cookie
// invalidation.
throw new ServerError('session_mutated_from_socket');
}
handleAsyncPromise(extendCookieLifespan(viewer.cookieID));
for (let response of serverResponses) {
sendMessage(response);
}
} catch (error) {
console.warn(error);
if (!(error instanceof ServerError)) {
const errorMessage: ErrorServerSocketMessage = {
type: serverSocketMessageTypes.ERROR,
message: error.message,
};
const responseTo = clientSocketMessage ? clientSocketMessage.id : null;
if (responseTo !== null) {
errorMessage.responseTo = responseTo;
}
sendMessage(errorMessage);
return;
}
invariant(clientSocketMessage, "should be set");
const responseTo = clientSocketMessage.id;
if (error.message === "socket_deauthorized") {
const authErrorMessage: AuthErrorServerSocketMessage = {
type: serverSocketMessageTypes.AUTH_ERROR,
responseTo,
message: error.message,
}
if (viewer) {
// viewer should only be falsey for cookieSources.HEADER (web)
// clients. Usually if the cookie is invalid we construct a new
// anonymous Viewer with a new cookie, and then pass the cookie down
// in the error. But we can't pass HTTP cookies in WebSocket messages.
authErrorMessage.sessionChange = {
cookie: viewer.cookiePairString,
currentUserInfo: {
id: viewer.cookieID,
anonymous: true,
},
};
}
sendMessage(authErrorMessage);
ws.close(4100, error.message);
return;
} else if (error.message === "client_version_unsupported") {
invariant(viewer, "should be set");
const promises = {};
promises.deleteCookie = deleteCookie(viewer.cookieID);
if (viewer.cookieSource !== cookieSources.BODY) {
promises.anonymousViewerData = createNewAnonymousCookie({
platformDetails: error.platformDetails,
deviceToken: viewer.deviceToken,
});
}
const { anonymousViewerData } = await promiseAll(promises);
const authErrorMessage: AuthErrorServerSocketMessage = {
type: serverSocketMessageTypes.AUTH_ERROR,
responseTo,
message: error.message,
}
if (anonymousViewerData) {
const anonViewer = new Viewer(anonymousViewerData);
authErrorMessage.sessionChange = {
cookie: anonViewer.cookiePairString,
currentUserInfo: {
id: anonViewer.cookieID,
anonymous: true,
},
};
}
sendMessage(authErrorMessage);
ws.close(4101, error.message);
return;
}
sendMessage({
type: serverSocketMessageTypes.ERROR,
responseTo,
message: error.message,
});
if (error.message === "not_logged_in") {
ws.close(4101, error.message);
} else if (error.message === "session_mutated_from_socket") {
ws.close(4102, error.message);
}
}
});
ws.on('close', () => {
console.log('connection closed');
});
}
async function handleClientSocketMessage(
viewer: Viewer,
message: ClientSocketMessage,
): Promise<ServerSocketMessage[]> {
if (message.type === clientSocketMessageTypes.INITIAL) {
return await handleInitialClientSocketMessage(viewer, message);
+ } else if (message.type === clientSocketMessageTypes.RESPONSES) {
+ return await handleResponsesClientSocketMessage(viewer, message);
}
return [];
}
async function handleInitialClientSocketMessage(
viewer: Viewer,
message: InitialClientSocketMessage,
): Promise<ServerSocketMessage[]> {
const responses = [];
const { sessionState, clientResponses } = message.payload;
const {
calendarQuery,
updatesCurrentAsOf: oldUpdatesCurrentAsOf,
messagesCurrentAsOf: oldMessagesCurrentAsOf,
watchedIDs,
} = sessionState;
await verifyCalendarQueryThreadIDs(calendarQuery);
const sessionInitializationResult = await initializeSession(
viewer,
calendarQuery,
oldUpdatesCurrentAsOf,
);
const threadCursors = {};
for (let watchedThreadID of watchedIDs) {
threadCursors[watchedThreadID] = null;
}
const threadSelectionCriteria = { threadCursors, joinedThreads: true };
const [
fetchMessagesResult,
{ serverRequests, stateCheckStatus },
] = await Promise.all([
fetchMessageInfosSince(
viewer,
threadSelectionCriteria,
oldMessagesCurrentAsOf,
defaultNumberPerThread,
),
processClientResponses(
viewer,
clientResponses,
),
]);
const messagesResult = {
rawMessageInfos: fetchMessagesResult.rawMessageInfos,
truncationStatuses: fetchMessagesResult.truncationStatuses,
currentAsOf: mostRecentMessageTimestamp(
fetchMessagesResult.rawMessageInfos,
oldMessagesCurrentAsOf,
),
};
if (!sessionInitializationResult.sessionContinued) {
const [
threadsResult,
entriesResult,
currentUserInfo,
] = await Promise.all([
fetchThreadInfos(viewer),
fetchEntryInfos(viewer, [ calendarQuery ]),
fetchCurrentUserInfo(viewer),
]);
const payload: StateSyncFullSocketPayload = {
type: stateSyncPayloadTypes.FULL,
messagesResult,
threadInfos: threadsResult.threadInfos,
currentUserInfo,
rawEntryInfos: entriesResult.rawEntryInfos,
userInfos: values({
...fetchMessagesResult.userInfos,
...entriesResult.userInfos,
...threadsResult.userInfos,
}),
};
if (viewer.sessionChanged) {
// If initializeSession encounters sessionIdentifierTypes.BODY_SESSION_ID,
// but the session is unspecified or expired, it will set a new sessionID
// and specify viewer.sessionChanged
const { sessionID } = viewer;
invariant(sessionID !== null && sessionID !== undefined, "should be set");
payload.sessionID = sessionID;
viewer.sessionChanged = false;
}
responses.push({
type: serverSocketMessageTypes.STATE_SYNC,
responseTo: message.id,
payload,
});
} else {
const promises = {};
promises.activityUpdate = updateActivityTime(viewer);
promises.deleteExpiredUpdates = deleteUpdatesBeforeTimeTargettingSession(
viewer,
oldUpdatesCurrentAsOf,
);
promises.fetchUpdateResult = fetchUpdateInfos(
viewer,
oldUpdatesCurrentAsOf,
calendarQuery,
);
if (stateCheckStatus) {
promises.stateCheck = checkState(viewer, stateCheckStatus, calendarQuery);
}
const { fetchUpdateResult, stateCheck } = await promiseAll(promises);
const updateUserInfos = fetchUpdateResult.userInfos;
const { updateInfos } = fetchUpdateResult;
const newUpdatesCurrentAsOf = mostRecentUpdateTimestamp(
[...updateInfos],
oldUpdatesCurrentAsOf,
);
const updatesResult = {
newUpdates: updateInfos,
currentAsOf: newUpdatesCurrentAsOf,
};
let sessionUpdate = sessionInitializationResult.sessionUpdate;
if (stateCheck && stateCheck.sessionUpdate) {
sessionUpdate = { ...sessionUpdate, ...stateCheck.sessionUpdate };
}
await commitSessionUpdate(viewer, sessionUpdate);
if (stateCheck && stateCheck.checkStateRequest) {
serverRequests.push(stateCheck.checkStateRequest);
}
responses.push({
type: serverSocketMessageTypes.STATE_SYNC,
responseTo: message.id,
payload: {
type: stateSyncPayloadTypes.INCREMENTAL,
messagesResult,
updatesResult,
deltaEntryInfos:
sessionInitializationResult.deltaEntryInfoResult.rawEntryInfos,
userInfos: values({
...fetchMessagesResult.userInfos,
...updateUserInfos,
...sessionInitializationResult.deltaEntryInfoResult.userInfos,
}),
},
});
}
- if (serverRequests.length > 0) {
+ // Clients that support sockets always keep their server aware of their
+ // device token, without needing any requests
+ const filteredServerRequests = serverRequests.filter(
+ request => request.type !== serverRequestTypes.DEVICE_TOKEN,
+ );
+ if (filteredServerRequests.length > 0) {
responses.push({
type: serverSocketMessageTypes.REQUESTS,
- payload: {
- serverRequests,
- },
+ responseTo: message.id,
+ payload: { serverRequests: filteredServerRequests },
});
}
return responses;
}
+async function handleResponsesClientSocketMessage(
+ viewer: Viewer,
+ message: ResponsesClientSocketMessage,
+): Promise<ServerSocketMessage[]> {
+ const { clientResponses } = message.payload;
+ const { stateCheckStatus } = await processClientResponses(
+ viewer,
+ clientResponses,
+ );
+
+ const serverRequests = [];
+ if (stateCheckStatus && stateCheckStatus.status !== "state_check") {
+ const { sessionUpdate, checkStateRequest } = await checkState(
+ viewer,
+ stateCheckStatus,
+ viewer.calendarQuery,
+ );
+ if (sessionUpdate) {
+ await commitSessionUpdate(viewer, sessionUpdate);
+ }
+ if (checkStateRequest) {
+ serverRequests.push(checkStateRequest);
+ }
+ }
+
+ // We send a response message regardless of whether we have any requests,
+ // since we need to ack the client's responses
+ return [{
+ type: serverSocketMessageTypes.REQUESTS,
+ responseTo: message.id,
+ payload: { serverRequests },
+ }];
+}
+
export {
onConnection,
};
diff --git a/server/src/utils/validation-utils.js b/server/src/utils/validation-utils.js
index ff0444d53..0a1a7ccea 100644
--- a/server/src/utils/validation-utils.js
+++ b/server/src/utils/validation-utils.js
@@ -1,205 +1,205 @@
// @flow
import type { Viewer } from '../session/viewer';
import t from 'tcomb';
import { ServerError } from 'lib/utils/errors';
import { verifyClientSupported } from '../session/version';
async function validateInput(viewer: Viewer, inputValidator: *, input: *) {
await checkClientSupported(viewer, inputValidator, input);
checkInputValidator(inputValidator, input);
}
-async function checkInputValidator(inputValidator: *, input: *) {
+function checkInputValidator(inputValidator: *, input: *) {
if (!inputValidator || inputValidator.is(input)) {
return;
}
const error = new ServerError('invalid_parameters');
error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null;
throw error;
}
async function checkClientSupported(viewer: Viewer, inputValidator: *, input: *) {
let platformDetails;
if (inputValidator) {
platformDetails = findFirstInputMatchingValidator(
inputValidator,
tPlatformDetails,
input,
);
}
if (!platformDetails && inputValidator) {
const platform = findFirstInputMatchingValidator(
inputValidator,
tPlatform,
input,
);
if (platform) {
platformDetails = { platform };
}
}
if (!platformDetails) {
({ platformDetails } = viewer);
}
await verifyClientSupported(viewer, platformDetails);
}
const fakePassword = "********";
function sanitizeInput(inputValidator: *, input: *) {
if (!inputValidator) {
return input;
}
if (inputValidator === tPassword && typeof input === "string") {
return fakePassword;
}
if (
inputValidator.meta.kind === "maybe" &&
inputValidator.meta.type === tPassword &&
typeof input === "string"
) {
return fakePassword;
}
if (
inputValidator.meta.kind !== "interface" ||
typeof input !== "object" ||
!input
) {
return input;
}
const result = {};
for (let key in input) {
const value = input[key];
const validator = inputValidator.meta.props[key];
result[key] = sanitizeInput(validator, value);
}
return result;
}
function findFirstInputMatchingValidator(
wholeInputValidator: *,
inputValidatorToMatch: *,
input: *,
): any {
if (!wholeInputValidator || input === null || input === undefined) {
return null;
}
if (
wholeInputValidator === inputValidatorToMatch &&
wholeInputValidator.is(input)
) {
return input;
}
if (wholeInputValidator.meta.kind === "maybe") {
return findFirstInputMatchingValidator(
wholeInputValidator.meta.type,
inputValidatorToMatch,
input,
);
}
if (
wholeInputValidator.meta.kind === "interface" &&
typeof input === "object"
) {
for (let key in input) {
const value = input[key];
const validator = wholeInputValidator.meta.props[key];
const innerResult = findFirstInputMatchingValidator(
validator,
inputValidatorToMatch,
value,
);
if (innerResult) {
return innerResult;
}
}
}
if (wholeInputValidator.meta.kind === "union") {
for (let validator of wholeInputValidator.meta.types) {
if (validator.is(input)) {
return findFirstInputMatchingValidator(
validator,
inputValidatorToMatch,
input,
);
}
}
}
if (
wholeInputValidator.meta.kind === "list" &&
Array.isArray(input)
) {
const validator = wholeInputValidator.meta.type;
for (let value of input) {
const innerResult = findFirstInputMatchingValidator(
validator,
inputValidatorToMatch,
value,
);
if (innerResult) {
return innerResult;
}
}
}
return null;
}
function tBool(value: bool) {
return t.irreducible('literal bool', x => x === value);
}
function tString(value: string) {
return t.irreducible('literal string', x => x === value);
}
function tShape(spec: {[key: string]: *}) {
return t.interface(spec, { strict: true });
}
function tRegex(regex: RegExp) {
return t.refinement(t.String, val => regex.test(val));
}
function tNumEnum(assertFunc: (input: number) => *) {
return t.refinement(
t.Number,
(input: number) => {
try {
assertFunc(input);
return true;
} catch (e) {
return false;
}
},
);
}
const tDate = tRegex(/^[0-9]{4}-[0-1][0-9]-[0-3][0-9]$/);
const tColor = tRegex(/^[a-fA-F0-9]{6}$/); // we don't include # char
const tPlatform = t.enums.of(['ios', 'android', 'web']);
const tDeviceType = t.enums.of(['ios', 'android']);
const tPlatformDetails = tShape({
platform: tPlatform,
codeVersion: t.maybe(t.Number),
stateVersion: t.maybe(t.Number),
});
const tPassword = t.refinement(t.String, (password: string) => password);
export {
validateInput,
checkInputValidator,
checkClientSupported,
tBool,
tString,
tShape,
tRegex,
tNumEnum,
tDate,
tColor,
tPlatform,
tDeviceType,
tPlatformDetails,
tPassword,
};
diff --git a/web/redux-setup.js b/web/redux-setup.js
index 9658345a0..5d20126b1 100644
--- a/web/redux-setup.js
+++ b/web/redux-setup.js
@@ -1,175 +1,173 @@
// @flow
import type { BaseNavInfo } from 'lib/types/nav-types';
import type { ThreadStore } from 'lib/types/thread-types';
import type { EntryStore } from 'lib/types/entry-types';
import type { BaseAction } from 'lib/types/redux-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import type { CurrentUserInfo, UserInfo } from 'lib/types/user-types';
import type { VerifyField } from 'lib/types/verify-types';
import type { MessageStore } from 'lib/types/message-types';
-import type { ServerRequest } from 'lib/types/request-types';
import type { CalendarFilter } from 'lib/types/filter-types';
import { setNewSessionActionType } from 'lib/utils/action-utils';
import type { ConnectionInfo } from 'lib/types/socket-types';
import PropTypes from 'prop-types';
import invariant from 'invariant';
import Visibility from 'visibilityjs';
import baseReducer from 'lib/reducers/master-reducer';
import {
newThreadActionTypes,
deleteThreadActionTypes,
} from 'lib/actions/thread-actions';
import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors';
import { activeThreadSelector } from './selectors/nav-selectors';
export type NavInfo = {|
...$Exact<BaseNavInfo>,
tab: "calendar" | "chat",
verify: ?string,
activeChatThreadID: ?string,
|};
export const navInfoPropType = PropTypes.shape({
startDate: PropTypes.string.isRequired,
endDate: PropTypes.string.isRequired,
tab: PropTypes.oneOf(["calendar", "chat"]).isRequired,
verify: PropTypes.string,
activeChatThreadID: PropTypes.string,
});
export type WindowDimensions = {| width: number, height: number |};
export type AppState = {|
navInfo: NavInfo,
currentUserInfo: ?CurrentUserInfo,
sessionID: ?string,
verifyField: ?VerifyField,
resetPasswordUsername: string,
entryStore: EntryStore,
threadStore: ThreadStore,
userInfos: {[id: string]: UserInfo},
messageStore: MessageStore,
drafts: {[key: string]: string},
updatesCurrentAsOf: number,
loadingStatuses: {[key: string]: {[idx: number]: LoadingStatus}},
- activeServerRequests: $ReadOnlyArray<ServerRequest>,
calendarFilters: $ReadOnlyArray<CalendarFilter>,
urlPrefix: string,
windowDimensions: WindowDimensions,
cookie?: void,
deviceToken?: void,
baseHref: string,
connection: ConnectionInfo,
watchedThreadIDs: $ReadOnlyArray<string>,
|};
export const updateNavInfoActionType = "UPDATE_NAV_INFO";
export const updateWindowDimensions = "UPDATE_WINDOW_DIMENSIONS";
export type Action =
| BaseAction
| {| type: "UPDATE_NAV_INFO", payload: NavInfo |}
| {|
type: "UPDATE_WINDOW_DIMENSIONS",
payload: WindowDimensions,
|};
export function reducer(oldState: AppState | void, action: Action) {
invariant(oldState, "should be set");
let state = oldState;
if (action.type === updateNavInfoActionType) {
return validateState(
oldState,
{
...state,
navInfo: action.payload,
},
);
} else if (action.type === updateWindowDimensions) {
return validateState(
oldState,
{
...state,
windowDimensions: action.payload,
},
);
}
if (action.type === setNewSessionActionType) {
state = {
...state,
sessionID: action.payload.sessionChange.sessionID,
};
}
return validateState(oldState, baseReducer(state, action));
}
function validateState(oldState: AppState, state: AppState): AppState {
if (
state.navInfo.activeChatThreadID &&
!state.threadStore.threadInfos[state.navInfo.activeChatThreadID]
) {
// Makes sure the active thread always exists
state = {
...state,
navInfo: {
...state.navInfo,
activeChatThreadID: mostRecentReadThreadSelector(state),
},
};
}
const activeThread = activeThreadSelector(state);
if (
activeThread &&
!Visibility.hidden() &&
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;
}
diff --git a/web/socket.react.js b/web/socket.react.js
index 0ecaf3a22..846200ec5 100644
--- a/web/socket.react.js
+++ b/web/socket.react.js
@@ -1,32 +1,32 @@
// @flow
import type { AppState } from './redux-setup';
import { connect } from 'lib/utils/redux-utils';
import {
clientResponsesSelector,
sessionStateFuncSelector,
} from 'lib/selectors/socket-selectors';
import { logInExtraInfoSelector } from 'lib/selectors/account-selectors';
import Socket from 'lib/components/socket.react';
import {
openSocketSelector,
sessionIdentificationSelector,
} from './selectors/socket-selectors';
import { activeThreadSelector } from './selectors/nav-selectors';
export default connect(
(state: AppState) => ({
openSocket: openSocketSelector(state),
- clientResponses: clientResponsesSelector(state),
+ getClientResponses: clientResponsesSelector(state),
activeThread: activeThreadSelector(state),
sessionStateFunc: sessionStateFuncSelector(state),
sessionIdentification: sessionIdentificationSelector(state),
cookie: state.cookie,
urlPrefix: state.urlPrefix,
logInExtraInfo: logInExtraInfoSelector(state),
}),
null,
true,
)(Socket);

File Metadata

Mime Type
text/x-diff
Expires
Mon, Dec 23, 8:50 AM (21 h, 37 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2690670
Default Alt Text
(168 KB)

Event Timeline