Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F3509846
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
168 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rCOMM Comm
Attached
Detach File
Event Timeline
Log In to Comment