{
const appURLFacts = getAppURLFactsFromRequestURL(req.originalUrl);
const { basePath, baseDomain } = appURLFacts;
const baseURL = basePath.replace(/\/$/, '');
const baseHref = baseDomain + baseURL;
const loadingPromise = getWebpackCompiledRootComponentForSSR();
const hasNotAcknowledgedPoliciesPromise = hasAnyNotAcknowledgedPolicies(
viewer.id,
baseLegalPolicies,
);
let initialNavInfo;
try {
initialNavInfo = navInfoFromURL(req.url, {
now: currentDateInTimeZone(viewer.timeZone),
});
} catch (e) {
throw new ServerError(e.message);
}
const calendarQuery = {
startDate: initialNavInfo.startDate,
endDate: initialNavInfo.endDate,
filters: defaultCalendarFilters,
};
const messageSelectionCriteria = { joinedThreads: true };
const initialTime = Date.now();
const assetInfoPromise = getAssetInfo();
const threadInfoPromise = fetchThreadInfos(viewer);
const messageInfoPromise = fetchMessageInfos(
viewer,
messageSelectionCriteria,
defaultNumberPerThread,
);
const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]);
const currentUserInfoPromise = fetchCurrentUserInfo(viewer);
const userInfoPromise = fetchKnownUserInfos(viewer);
const sessionIDPromise = (async () => {
if (viewer.loggedIn) {
await setNewSession(viewer, calendarQuery, initialTime);
}
return viewer.sessionID;
})();
const threadStorePromise = (async () => {
const [{ threadInfos }, hasNotAcknowledgedPolicies] = await Promise.all([
threadInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
return { threadInfos: hasNotAcknowledgedPolicies ? {} : threadInfos };
})();
const messageStorePromise = (async () => {
const [
{ threadInfos },
{ rawMessageInfos, truncationStatuses },
hasNotAcknowledgedPolicies,
] = await Promise.all([
threadInfoPromise,
messageInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
if (hasNotAcknowledgedPolicies) {
return {
messages: {},
threads: {},
local: {},
currentAsOf: 0,
};
}
const { messageStore: freshStore } = freshMessageStore(
rawMessageInfos,
truncationStatuses,
mostRecentMessageTimestamp(rawMessageInfos, initialTime),
threadInfos,
);
return freshStore;
})();
const entryStorePromise = (async () => {
const [{ rawEntryInfos }, hasNotAcknowledgedPolicies] = await Promise.all([
entryInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
if (hasNotAcknowledgedPolicies) {
return {
entryInfos: {},
daysToEntries: {},
lastUserInteractionCalendar: 0,
};
}
return {
entryInfos: _keyBy('id')(rawEntryInfos),
daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos),
lastUserInteractionCalendar: initialTime,
};
})();
const userStorePromise = (async () => {
const [userInfos, hasNotAcknowledgedPolicies] = await Promise.all([
userInfoPromise,
hasNotAcknowledgedPoliciesPromise,
]);
return {
userInfos: hasNotAcknowledgedPolicies ? {} : userInfos,
inconsistencyReports: [],
};
})();
const navInfoPromise = (async () => {
const [{ threadInfos }, messageStore, currentUserInfo, userStore] =
await Promise.all([
threadInfoPromise,
messageStorePromise,
currentUserInfoPromise,
userStorePromise,
]);
const finalNavInfo = initialNavInfo;
const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID;
if (
requestedActiveChatThreadID &&
!threadIsPending(requestedActiveChatThreadID) &&
!threadHasPermission(
threadInfos[requestedActiveChatThreadID],
threadPermissions.VISIBLE,
)
) {
finalNavInfo.activeChatThreadID = null;
}
if (!finalNavInfo.activeChatThreadID) {
const mostRecentThread = mostRecentlyReadThread(
messageStore,
threadInfos,
);
if (mostRecentThread) {
finalNavInfo.activeChatThreadID = mostRecentThread;
}
}
if (
finalNavInfo.activeChatThreadID &&
threadIsPending(finalNavInfo.activeChatThreadID) &&
finalNavInfo.pendingThread?.id !== finalNavInfo.activeChatThreadID
) {
const pendingThreadData = parsePendingThreadID(
finalNavInfo.activeChatThreadID,
);
if (
pendingThreadData &&
pendingThreadData.threadType !== threadTypes.SIDEBAR &&
currentUserInfo.id
) {
const { userInfos } = userStore;
const members = [...pendingThreadData.memberIDs, currentUserInfo.id]
.map(id => {
const userInfo = userInfos[id];
if (!userInfo || !userInfo.username) {
return undefined;
}
const { username } = userInfo;
return { id, username };
})
.filter(Boolean);
const newPendingThread = createPendingThread({
viewerID: currentUserInfo.id,
threadType: pendingThreadData.threadType,
members,
});
finalNavInfo.activeChatThreadID = newPendingThread.id;
finalNavInfo.pendingThread = newPendingThread;
}
}
return finalNavInfo;
})();
const currentAsOfPromise = (async () => {
const hasNotAcknowledgedPolicies = await hasNotAcknowledgedPoliciesPromise;
return hasNotAcknowledgedPolicies ? 0 : initialTime;
})();
const pushApiPublicKeyPromise = (async () => {
const pushConfig = await getWebPushConfig();
if (!pushConfig) {
if (process.env.NODE_ENV !== 'development') {
console.warn('keyserver/secrets/web_push_config.json should exist');
}
return null;
}
return pushConfig.publicKey;
})();
const { jsURL, fontsURL, cssInclude } = await assetInfoPromise;
// prettier-ignore
res.write(html`
${getTitle(0)}
${cssInclude}
`);
const Loading = await loadingPromise;
const reactStream = renderToNodeStream();
reactStream.pipe(res, { end: false });
await waitForStream(reactStream);
res.write(html`
`);
}
export { websiteResponder };
diff --git a/keyserver/src/socket/socket.js b/keyserver/src/socket/socket.js
index b7fafaff6..6070d60b6 100644
--- a/keyserver/src/socket/socket.js
+++ b/keyserver/src/socket/socket.js
@@ -1,811 +1,822 @@
// @flow
import type { $Request } from 'express';
import invariant from 'invariant';
import _debounce from 'lodash/debounce.js';
import t from 'tcomb';
import WebSocket from 'ws';
import { baseLegalPolicies } from 'lib/facts/policies.js';
import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js';
import {
serverRequestSocketTimeout,
serverResponseTimeout,
} from 'lib/shared/timeouts.js';
import { mostRecentUpdateTimestamp } from 'lib/shared/update-utils.js';
import type { Shape } from 'lib/types/core.js';
import { endpointIsSocketSafe } from 'lib/types/endpoints.js';
import { defaultNumberPerThread } from 'lib/types/message-types.js';
import { redisMessageTypes, type RedisMessage } from 'lib/types/redis-types.js';
+import { serverRequestTypes } from 'lib/types/request-types.js';
import {
cookieSources,
sessionCheckFrequency,
stateCheckInactivityActivationInterval,
} from 'lib/types/session-types.js';
import {
type ClientSocketMessage,
type InitialClientSocketMessage,
type ResponsesClientSocketMessage,
type ServerStateSyncFullSocketPayload,
type ServerServerSocketMessage,
type ErrorServerSocketMessage,
type AuthErrorServerSocketMessage,
type PingClientSocketMessage,
type AckUpdatesClientSocketMessage,
type APIRequestClientSocketMessage,
clientSocketMessageTypes,
stateSyncPayloadTypes,
serverSocketMessageTypes,
} from 'lib/types/socket-types.js';
import { ServerError } from 'lib/utils/errors.js';
import { values } from 'lib/utils/objects.js';
import { promiseAll } from 'lib/utils/promises.js';
import SequentialPromiseResolver from 'lib/utils/sequential-promise-resolver.js';
import sleep from 'lib/utils/sleep.js';
import { tShape, tCookie } from 'lib/utils/validation-utils.js';
import { RedisSubscriber } from './redis.js';
import {
clientResponseInputValidator,
processClientResponses,
initializeSession,
checkState,
} from './session-utils.js';
import { fetchUpdateInfosWithRawUpdateInfos } from '../creators/update-creator.js';
import { deleteActivityForViewerSession } from '../deleters/activity-deleters.js';
import { deleteCookie } from '../deleters/cookie-deleters.js';
import { deleteUpdatesBeforeTimeTargetingSession } from '../deleters/update-deleters.js';
import { jsonEndpoints } from '../endpoints.js';
import { fetchEntryInfos } from '../fetchers/entry-fetchers.js';
import {
fetchMessageInfosSince,
getMessageFetchResultFromRedisMessages,
} from '../fetchers/message-fetchers.js';
import { fetchThreadInfos } from '../fetchers/thread-fetchers.js';
import { fetchUpdateInfos } from '../fetchers/update-fetchers.js';
import {
fetchCurrentUserInfo,
fetchKnownUserInfos,
} from '../fetchers/user-fetchers.js';
import {
newEntryQueryInputValidator,
verifyCalendarQueryThreadIDs,
} from '../responders/entry-responders.js';
import { handleAsyncPromise } from '../responders/handlers.js';
import {
fetchViewerForSocket,
extendCookieLifespan,
createNewAnonymousCookie,
} from '../session/cookies.js';
import { Viewer } from '../session/viewer.js';
import { commitSessionUpdate } from '../updaters/session-updaters.js';
import { assertSecureRequest } from '../utils/security-utils.js';
import {
checkInputValidator,
checkClientSupported,
policiesValidator,
} from '../utils/validation-utils.js';
const clientSocketMessageInputValidator = t.union([
tShape({
type: t.irreducible(
'clientSocketMessageTypes.INITIAL',
x => x === clientSocketMessageTypes.INITIAL,
),
id: t.Number,
payload: tShape({
sessionIdentification: tShape({
cookie: t.maybe(tCookie),
sessionID: t.maybe(t.String),
}),
sessionState: tShape({
calendarQuery: newEntryQueryInputValidator,
messagesCurrentAsOf: t.Number,
updatesCurrentAsOf: t.Number,
watchedIDs: t.list(t.String),
}),
clientResponses: t.list(clientResponseInputValidator),
}),
}),
tShape({
type: t.irreducible(
'clientSocketMessageTypes.RESPONSES',
x => x === clientSocketMessageTypes.RESPONSES,
),
id: t.Number,
payload: tShape({
clientResponses: t.list(clientResponseInputValidator),
}),
}),
tShape({
type: t.irreducible(
'clientSocketMessageTypes.PING',
x => x === clientSocketMessageTypes.PING,
),
id: t.Number,
}),
tShape({
type: t.irreducible(
'clientSocketMessageTypes.ACK_UPDATES',
x => x === clientSocketMessageTypes.ACK_UPDATES,
),
id: t.Number,
payload: tShape({
currentAsOf: t.Number,
}),
}),
tShape({
type: t.irreducible(
'clientSocketMessageTypes.API_REQUEST',
x => x === clientSocketMessageTypes.API_REQUEST,
),
id: t.Number,
payload: tShape({
endpoint: t.String,
input: t.Object,
}),
}),
]);
function onConnection(ws: WebSocket, req: $Request) {
assertSecureRequest(req);
new Socket(ws, req);
}
type StateCheckConditions = {
activityRecentlyOccurred: boolean,
stateCheckOngoing: boolean,
};
class Socket {
ws: WebSocket;
httpRequest: $Request;
viewer: ?Viewer;
redis: ?RedisSubscriber;
redisPromiseResolver: SequentialPromiseResolver;
stateCheckConditions: StateCheckConditions = {
activityRecentlyOccurred: true,
stateCheckOngoing: false,
};
stateCheckTimeoutID: ?TimeoutID;
constructor(ws: WebSocket, httpRequest: $Request) {
this.ws = ws;
this.httpRequest = httpRequest;
ws.on('message', this.onMessage);
ws.on('close', this.onClose);
this.resetTimeout();
this.redisPromiseResolver = new SequentialPromiseResolver(this.sendMessage);
}
onMessage = async (
messageString: string | Buffer | ArrayBuffer | Array,
) => {
invariant(typeof messageString === 'string', 'message should be string');
let clientSocketMessage: ?ClientSocketMessage;
try {
this.resetTimeout();
clientSocketMessage = JSON.parse(messageString);
checkInputValidator(
clientSocketMessageInputValidator,
clientSocketMessage,
);
if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) {
if (this.viewer) {
// This indicates that the user sent multiple INITIAL messages.
throw new ServerError('socket_already_initialized');
}
this.viewer = await fetchViewerForSocket(
this.httpRequest,
clientSocketMessage,
);
if (!this.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');
}
}
const { viewer } = this;
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,
);
await policiesValidator(viewer, baseLegalPolicies);
const serverResponses = await this.handleClientSocketMessage(
clientSocketMessage,
);
if (!this.redis) {
this.redis = new RedisSubscriber(
{ userID: viewer.userID, sessionID: viewer.session },
this.onRedisMessage,
);
}
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');
}
if (clientSocketMessage.type !== clientSocketMessageTypes.PING) {
handleAsyncPromise(extendCookieLifespan(viewer.cookieID));
}
for (const response of serverResponses) {
this.sendMessage(response);
}
if (clientSocketMessage.type === clientSocketMessageTypes.INITIAL) {
this.onSuccessfulConnection();
}
} 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;
}
this.markActivityOccurred();
this.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 (this.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: this.viewer.cookiePairString,
currentUserInfo: {
id: this.viewer.cookieID,
anonymous: true,
},
};
}
this.sendMessage(authErrorMessage);
this.ws.close(4100, error.message);
return;
} else if (error.message === 'client_version_unsupported') {
const { viewer } = this;
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) {
// It is normally not safe to pass the result of
// createNewAnonymousCookie to the Viewer constructor. That is because
// createNewAnonymousCookie leaves several fields of
// AnonymousViewerData unset, and consequently Viewer will throw when
// access is attempted. It is only safe here because we can guarantee
// that only cookiePairString and cookieID are accessed on anonViewer
// below.
const anonViewer = new Viewer(anonymousViewerData);
authErrorMessage.sessionChange = {
cookie: anonViewer.cookiePairString,
currentUserInfo: {
id: anonViewer.cookieID,
anonymous: true,
},
};
}
this.sendMessage(authErrorMessage);
this.ws.close(4101, error.message);
return;
}
if (error.payload) {
this.sendMessage({
type: serverSocketMessageTypes.ERROR,
responseTo,
message: error.message,
payload: error.payload,
});
} else {
this.sendMessage({
type: serverSocketMessageTypes.ERROR,
responseTo,
message: error.message,
});
}
if (error.message === 'not_logged_in') {
this.ws.close(4102, error.message);
} else if (error.message === 'session_mutated_from_socket') {
this.ws.close(4103, error.message);
} else {
this.markActivityOccurred();
}
}
};
onClose = async () => {
this.clearStateCheckTimeout();
this.resetTimeout.cancel();
this.debouncedAfterActivity.cancel();
if (this.viewer && this.viewer.hasSessionInfo) {
await deleteActivityForViewerSession(this.viewer);
}
if (this.redis) {
this.redis.quit();
this.redis = null;
}
};
sendMessage = (message: ServerServerSocketMessage) => {
invariant(
this.ws.readyState > 0,
"shouldn't send message until connection established",
);
if (this.ws.readyState === 1) {
this.ws.send(JSON.stringify(message));
}
};
async handleClientSocketMessage(
message: ClientSocketMessage,
): Promise {
const resultPromise = (async () => {
if (message.type === clientSocketMessageTypes.INITIAL) {
this.markActivityOccurred();
return await this.handleInitialClientSocketMessage(message);
} else if (message.type === clientSocketMessageTypes.RESPONSES) {
this.markActivityOccurred();
return await this.handleResponsesClientSocketMessage(message);
} else if (message.type === clientSocketMessageTypes.PING) {
return this.handlePingClientSocketMessage(message);
} else if (message.type === clientSocketMessageTypes.ACK_UPDATES) {
this.markActivityOccurred();
return await this.handleAckUpdatesClientSocketMessage(message);
} else if (message.type === clientSocketMessageTypes.API_REQUEST) {
this.markActivityOccurred();
return await this.handleAPIRequestClientSocketMessage(message);
}
return [];
})();
const timeoutPromise = (async () => {
await sleep(serverResponseTimeout);
throw new ServerError('socket_response_timeout');
})();
return await Promise.race([resultPromise, timeoutPromise]);
}
async handleInitialClientSocketMessage(
message: InitialClientSocketMessage,
): Promise {
const { viewer } = this;
invariant(viewer, 'should be set');
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 (const watchedThreadID of watchedIDs) {
threadCursors[watchedThreadID] = null;
}
const messageSelectionCriteria = {
threadCursors,
joinedThreads: true,
newerThan: oldMessagesCurrentAsOf,
};
const [fetchMessagesResult, { serverRequests, activityUpdateResult }] =
await Promise.all([
fetchMessageInfosSince(
viewer,
messageSelectionCriteria,
defaultNumberPerThread,
),
processClientResponses(viewer, clientResponses),
]);
const messagesResult = {
rawMessageInfos: fetchMessagesResult.rawMessageInfos,
truncationStatuses: fetchMessagesResult.truncationStatuses,
currentAsOf: mostRecentMessageTimestamp(
fetchMessagesResult.rawMessageInfos,
oldMessagesCurrentAsOf,
),
};
+ if (
+ viewer.userAgent?.includes('Electron') &&
+ viewer.platform === 'web' &&
+ !serverRequests.find(
+ request => request.type === serverRequestTypes.PLATFORM_DETAILS,
+ )
+ ) {
+ serverRequests.push({ type: serverRequestTypes.PLATFORM_DETAILS });
+ }
+
if (!sessionInitializationResult.sessionContinued) {
const [threadsResult, entriesResult, currentUserInfo, knownUserInfos] =
await Promise.all([
fetchThreadInfos(viewer),
fetchEntryInfos(viewer, [calendarQuery]),
fetchCurrentUserInfo(viewer),
fetchKnownUserInfos(viewer),
]);
const payload: ServerStateSyncFullSocketPayload = {
type: stateSyncPayloadTypes.FULL,
messagesResult,
threadInfos: threadsResult.threadInfos,
currentUserInfo,
rawEntryInfos: entriesResult.rawEntryInfos,
userInfos: values(knownUserInfos),
updatesCurrentAsOf: oldUpdatesCurrentAsOf,
};
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 { sessionUpdate, deltaEntryInfoResult } =
sessionInitializationResult;
const promises = {};
promises.deleteExpiredUpdates = deleteUpdatesBeforeTimeTargetingSession(
viewer,
oldUpdatesCurrentAsOf,
);
promises.fetchUpdateResult = fetchUpdateInfos(
viewer,
oldUpdatesCurrentAsOf,
calendarQuery,
);
promises.sessionUpdate = commitSessionUpdate(viewer, sessionUpdate);
const { fetchUpdateResult } = await promiseAll(promises);
const { updateInfos, userInfos } = fetchUpdateResult;
const newUpdatesCurrentAsOf = mostRecentUpdateTimestamp(
[...updateInfos],
oldUpdatesCurrentAsOf,
);
const updatesResult = {
newUpdates: updateInfos,
currentAsOf: newUpdatesCurrentAsOf,
};
responses.push({
type: serverSocketMessageTypes.STATE_SYNC,
responseTo: message.id,
payload: {
type: stateSyncPayloadTypes.INCREMENTAL,
messagesResult,
updatesResult,
deltaEntryInfos: deltaEntryInfoResult.rawEntryInfos,
deletedEntryIDs: deltaEntryInfoResult.deletedEntryIDs,
userInfos: values(userInfos),
},
});
}
if (serverRequests.length > 0 || clientResponses.length > 0) {
// We send this message first since the STATE_SYNC triggers the client's
// connection status to shift to "connected", and we want to make sure the
// client responses are cleared from Redux before that happens
responses.unshift({
type: serverSocketMessageTypes.REQUESTS,
responseTo: message.id,
payload: { serverRequests },
});
}
if (activityUpdateResult) {
// Same reason for unshifting as above
responses.unshift({
type: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE,
responseTo: message.id,
payload: activityUpdateResult,
});
}
return responses;
}
async handleResponsesClientSocketMessage(
message: ResponsesClientSocketMessage,
): Promise {
const { viewer } = this;
invariant(viewer, 'should be set');
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);
this.setStateCheckConditions({ stateCheckOngoing: false });
}
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 },
},
];
}
handlePingClientSocketMessage(
message: PingClientSocketMessage,
): ServerServerSocketMessage[] {
return [
{
type: serverSocketMessageTypes.PONG,
responseTo: message.id,
},
];
}
async handleAckUpdatesClientSocketMessage(
message: AckUpdatesClientSocketMessage,
): Promise {
const { viewer } = this;
invariant(viewer, 'should be set');
const { currentAsOf } = message.payload;
await Promise.all([
deleteUpdatesBeforeTimeTargetingSession(viewer, currentAsOf),
commitSessionUpdate(viewer, { lastUpdate: currentAsOf }),
]);
return [];
}
async handleAPIRequestClientSocketMessage(
message: APIRequestClientSocketMessage,
): Promise {
if (!endpointIsSocketSafe(message.payload.endpoint)) {
throw new ServerError('endpoint_unsafe_for_socket');
}
const { viewer } = this;
invariant(viewer, 'should be set');
const responder = jsonEndpoints[message.payload.endpoint];
await policiesValidator(viewer, responder.requiredPolicies);
const response = await responder.responder(viewer, message.payload.input);
return [
{
type: serverSocketMessageTypes.API_RESPONSE,
responseTo: message.id,
payload: response,
},
];
}
onRedisMessage = async (message: RedisMessage) => {
try {
await this.processRedisMessage(message);
} catch (e) {
console.warn(e);
}
};
async processRedisMessage(message: RedisMessage) {
if (message.type === redisMessageTypes.START_SUBSCRIPTION) {
this.ws.terminate();
} else if (message.type === redisMessageTypes.NEW_UPDATES) {
const { viewer } = this;
invariant(viewer, 'should be set');
if (message.ignoreSession && message.ignoreSession === viewer.session) {
return;
}
const rawUpdateInfos = message.updates;
this.redisPromiseResolver.add(
(async () => {
const { updateInfos, userInfos } =
await fetchUpdateInfosWithRawUpdateInfos(rawUpdateInfos, {
viewer,
});
if (updateInfos.length === 0) {
console.warn(
'could not get any UpdateInfos from redisMessageTypes.NEW_UPDATES',
);
return null;
}
this.markActivityOccurred();
return {
type: serverSocketMessageTypes.UPDATES,
payload: {
updatesResult: {
currentAsOf: mostRecentUpdateTimestamp([...updateInfos], 0),
newUpdates: updateInfos,
},
userInfos: values(userInfos),
},
};
})(),
);
} else if (message.type === redisMessageTypes.NEW_MESSAGES) {
const { viewer } = this;
invariant(viewer, 'should be set');
const rawMessageInfos = message.messages;
const messageFetchResult = getMessageFetchResultFromRedisMessages(
viewer,
rawMessageInfos,
);
if (messageFetchResult.rawMessageInfos.length === 0) {
console.warn(
'could not get any rawMessageInfos from ' +
'redisMessageTypes.NEW_MESSAGES',
);
return;
}
this.redisPromiseResolver.add(
(async () => {
this.markActivityOccurred();
return {
type: serverSocketMessageTypes.MESSAGES,
payload: {
messagesResult: {
rawMessageInfos: messageFetchResult.rawMessageInfos,
truncationStatuses: messageFetchResult.truncationStatuses,
currentAsOf: mostRecentMessageTimestamp(
messageFetchResult.rawMessageInfos,
0,
),
},
},
};
})(),
);
}
}
onSuccessfulConnection() {
if (this.ws.readyState !== 1) {
return;
}
this.handleStateCheckConditionsUpdate();
}
// The Socket will timeout by calling this.ws.terminate()
// serverRequestSocketTimeout milliseconds after the last
// time resetTimeout is called
resetTimeout = _debounce(
() => this.ws.terminate(),
serverRequestSocketTimeout,
);
debouncedAfterActivity = _debounce(
() => this.setStateCheckConditions({ activityRecentlyOccurred: false }),
stateCheckInactivityActivationInterval,
);
markActivityOccurred = () => {
if (this.ws.readyState !== 1) {
return;
}
this.setStateCheckConditions({ activityRecentlyOccurred: true });
this.debouncedAfterActivity();
};
clearStateCheckTimeout() {
const { stateCheckTimeoutID } = this;
if (stateCheckTimeoutID) {
clearTimeout(stateCheckTimeoutID);
this.stateCheckTimeoutID = null;
}
}
setStateCheckConditions(newConditions: Shape) {
this.stateCheckConditions = {
...this.stateCheckConditions,
...newConditions,
};
this.handleStateCheckConditionsUpdate();
}
get stateCheckCanStart() {
return Object.values(this.stateCheckConditions).every(cond => !cond);
}
handleStateCheckConditionsUpdate() {
if (!this.stateCheckCanStart) {
this.clearStateCheckTimeout();
return;
}
if (this.stateCheckTimeoutID) {
return;
}
const { viewer } = this;
if (!viewer) {
return;
}
const timeUntilStateCheck =
viewer.sessionLastValidated + sessionCheckFrequency - Date.now();
if (timeUntilStateCheck <= 0) {
this.initiateStateCheck();
} else {
this.stateCheckTimeoutID = setTimeout(
this.initiateStateCheck,
timeUntilStateCheck,
);
}
}
initiateStateCheck = async () => {
this.setStateCheckConditions({ stateCheckOngoing: true });
const { viewer } = this;
invariant(viewer, 'should be set');
const { checkStateRequest } = await checkState(
viewer,
{ status: 'state_check' },
viewer.calendarQuery,
);
invariant(checkStateRequest, 'should be set');
this.sendMessage({
type: serverSocketMessageTypes.REQUESTS,
payload: { serverRequests: [checkStateRequest] },
});
};
}
export { onConnection };
diff --git a/lib/types/device-types.js b/lib/types/device-types.js
index adc3197f4..6a8a71229 100644
--- a/lib/types/device-types.js
+++ b/lib/types/device-types.js
@@ -1,34 +1,34 @@
// @flow
import invariant from 'invariant';
export type DeviceType = 'ios' | 'android';
-export type Platform = DeviceType | 'web';
+export type Platform = DeviceType | 'web' | 'windows' | 'macos';
export function isDeviceType(platform: ?string): boolean {
return platform === 'ios' || platform === 'android';
}
export function assertDeviceType(deviceType: ?string): DeviceType {
invariant(
deviceType === 'ios' || deviceType === 'android',
'string is not DeviceType enum',
);
return deviceType;
}
export function isWebPlatform(platform: ?string): boolean {
- return platform === 'web';
+ return platform === 'web' || platform === 'windows' || platform === 'macos';
}
export type DeviceTokenUpdateRequest = {
+deviceToken: string,
+deviceType?: DeviceType,
+platformDetails?: PlatformDetails,
};
export type PlatformDetails = {
+platform: Platform,
+codeVersion?: number,
+stateVersion?: number,
};
diff --git a/lib/types/electron-types.js b/lib/types/electron-types.js
index 1d2667c39..cbf20e36c 100644
--- a/lib/types/electron-types.js
+++ b/lib/types/electron-types.js
@@ -1,20 +1,21 @@
// @flow
type OnNavigateListener = ({
+canGoBack: boolean,
+canGoForward: boolean,
}) => void;
type OnNewVersionAvailableListener = (version: string) => void;
export type ElectronBridge = {
// Returns a callback that you can call to remove the listener
+onNavigate: OnNavigateListener => () => void,
+clearHistory: () => void,
+doubleClickTopBar: () => void,
+setBadge: (number | string | null) => void,
+version?: string,
// Returns a callback that you can call to remove the listener
+onNewVersionAvailable?: OnNewVersionAvailableListener => () => void,
+updateToNewVersion?: () => void,
+ +platform?: 'windows' | 'macos',
};
diff --git a/lib/utils/validation-utils.js b/lib/utils/validation-utils.js
index c55875ca1..79ce9699d 100644
--- a/lib/utils/validation-utils.js
+++ b/lib/utils/validation-utils.js
@@ -1,105 +1,111 @@
// @flow
import t from 'tcomb';
import type {
TStructProps,
TIrreducible,
TRefinement,
TEnums,
TInterface,
TUnion,
} from 'tcomb';
import {
validEmailRegex,
oldValidUsernameRegex,
validHexColorRegex,
} from '../shared/account-utils.js';
function tBool(value: boolean): TIrreducible {
return t.irreducible('literal bool', x => x === value);
}
function tString(value: string): TIrreducible {
return t.irreducible('literal string', x => x === value);
}
function tNumber(value: number): TIrreducible {
return t.irreducible('literal number', x => x === value);
}
function tShape(spec: TStructProps): TInterface {
return t.interface(spec, { strict: true });
}
type TRegex = TRefinement;
function tRegex(regex: RegExp): TRegex {
return t.refinement(t.String, val => regex.test(val));
}
function tNumEnum(nums: $ReadOnlyArray): TRefinement {
return t.refinement(t.Number, (input: number) => {
for (const num of nums) {
if (input === num) {
return true;
}
}
return false;
});
}
const tDate: TRegex = tRegex(/^[0-9]{4}-[0-1][0-9]-[0-3][0-9]$/);
const tColor: TRegex = tRegex(validHexColorRegex); // we don't include # char
-const tPlatform: TEnums = t.enums.of(['ios', 'android', 'web']);
+const tPlatform: TEnums = t.enums.of([
+ 'ios',
+ 'android',
+ 'web',
+ 'windows',
+ 'macos',
+]);
const tDeviceType: TEnums = t.enums.of(['ios', 'android']);
const tPlatformDetails: TInterface = tShape({
platform: tPlatform,
codeVersion: t.maybe(t.Number),
stateVersion: t.maybe(t.Number),
});
const tPassword: TRefinement = t.refinement(
t.String,
(password: string) => !!password,
);
const tCookie: TRegex = tRegex(/^(user|anonymous)=[0-9]+:[0-9a-f]+$/);
const tEmail: TRegex = tRegex(validEmailRegex);
const tOldValidUsername: TRegex = tRegex(oldValidUsernameRegex);
const tID: TRefinement = t.refinement(t.String, (id: string) => !!id);
const tMediaMessagePhoto: TInterface = tShape({
type: tString('photo'),
uploadID: t.String,
});
const tMediaMessageVideo: TInterface = tShape({
type: tString('video'),
uploadID: t.String,
thumbnailUploadID: t.String,
});
const tMediaMessageMedia: TUnion = t.union([
tMediaMessagePhoto,
tMediaMessageVideo,
]);
export {
tBool,
tString,
tNumber,
tShape,
tRegex,
tNumEnum,
tDate,
tColor,
tPlatform,
tDeviceType,
tPlatformDetails,
tPassword,
tCookie,
tEmail,
tOldValidUsername,
tID,
tMediaMessagePhoto,
tMediaMessageVideo,
tMediaMessageMedia,
};
diff --git a/web/app.react.js b/web/app.react.js
index 81b4cdb99..65cf6dbf9 100644
--- a/web/app.react.js
+++ b/web/app.react.js
@@ -1,347 +1,347 @@
// @flow
import 'basscss/css/basscss.min.css';
import './theme.css';
import { config as faConfig } from '@fortawesome/fontawesome-svg-core';
import classnames from 'classnames';
import _isEqual from 'lodash/fp/isEqual.js';
import * as React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { useDispatch } from 'react-redux';
import { WagmiConfig } from 'wagmi';
import {
fetchEntriesActionTypes,
updateCalendarQueryActionTypes,
} from 'lib/actions/entry-actions.js';
import {
ModalProvider,
useModalContext,
} from 'lib/components/modal-provider.react.js';
import {
createLoadingStatusSelector,
combineLoadingStatuses,
} from 'lib/selectors/loading-selectors.js';
import { unreadCount } from 'lib/selectors/thread-selectors.js';
import { isLoggedIn } from 'lib/selectors/user-selectors.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { registerConfig } from 'lib/utils/config.js';
import { WagmiENSCacheProvider, wagmiClient } from 'lib/utils/wagmi-utils.js';
import Calendar from './calendar/calendar.react.js';
import Chat from './chat/chat.react.js';
import { TooltipProvider } from './chat/tooltip-provider.js';
import NavigationArrows from './components/navigation-arrows.react.js';
import electron from './electron.js';
import InputStateContainer from './input/input-state-container.react.js';
import LoadingIndicator from './loading-indicator.react.js';
import { MenuProvider } from './menu-provider.react.js';
import UpdateModalHandler from './modals/update-modal.react.js';
import SettingsSwitcher from './navigation-panels/settings-switcher.react.js';
import Topbar from './navigation-panels/topbar.react.js';
import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js';
import { updateNavInfoActionType } from './redux/action-types.js';
import DeviceIDUpdater from './redux/device-id-updater.js';
import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler.js';
import DisconnectedBar from './redux/disconnected-bar.js';
import FocusHandler from './redux/focus-handler.react.js';
import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js';
import { useSelector } from './redux/redux-utils.js';
import VisibilityHandler from './redux/visibility-handler.react.js';
import history from './router-history.js';
import AccountSettings from './settings/account-settings.react.js';
import DangerZone from './settings/danger-zone.react.js';
import CommunityPicker from './sidebar/community-picker.react.js';
import Splash from './splash/splash.react.js';
import './typography.css';
import css from './style.css';
import getTitle from './title/getTitle.js';
import { type NavInfo } from './types/nav-types.js';
import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js';
// We want Webpack's css-loader and style-loader to handle the Fontawesome CSS,
// so we disable the autoAddCss logic and import the CSS file. Otherwise every
// icon flashes huge for a second before the CSS is loaded.
import '@fortawesome/fontawesome-svg-core/styles.css';
faConfig.autoAddCss = false;
registerConfig({
// We can't securely cache credentials on web, so we have no way to recover
// from a cookie invalidation
resolveInvalidatedCookie: null,
// We use httponly cookies on web to protect against XSS attacks, so we have
// no access to the cookies from JavaScript
setCookieOnRequest: false,
setSessionIDOnRequest: true,
// Never reset the calendar range
calendarRangeInactivityLimit: null,
- platformDetails: { platform: 'web' },
+ platformDetails: { platform: electron?.platform ?? 'web' },
});
type BaseProps = {
+location: {
+pathname: string,
...
},
};
type Props = {
...BaseProps,
// Redux state
+navInfo: NavInfo,
+entriesLoadingStatus: LoadingStatus,
+loggedIn: boolean,
+activeThreadCurrentlyUnread: boolean,
// Redux dispatch functions
+dispatch: Dispatch,
+modals: $ReadOnlyArray,
};
class App extends React.PureComponent {
componentDidMount() {
const {
navInfo,
location: { pathname },
loggedIn,
} = this.props;
const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn);
if (pathname !== newURL) {
history.replace(newURL);
}
}
componentDidUpdate(prevProps: Props) {
const {
navInfo,
location: { pathname },
loggedIn,
} = this.props;
if (!_isEqual(navInfo)(prevProps.navInfo)) {
const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn);
if (newURL !== pathname) {
history.push(newURL);
}
} else if (pathname !== prevProps.location.pathname) {
const newNavInfo = navInfoFromURL(pathname, { navInfo });
if (!_isEqual(newNavInfo)(navInfo)) {
this.props.dispatch({
type: updateNavInfoActionType,
payload: newNavInfo,
});
}
} else if (loggedIn !== prevProps.loggedIn) {
const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn);
if (newURL !== pathname) {
history.replace(newURL);
}
}
if (loggedIn !== prevProps.loggedIn) {
electron?.clearHistory();
}
}
onWordmarkClicked = () => {
this.props.dispatch({
type: updateNavInfoActionType,
payload: { tab: 'chat' },
});
};
render() {
let content;
if (this.props.loggedIn) {
content = this.renderMainContent();
} else {
content = ;
}
return (
{content}
{this.props.modals}
);
}
onHeaderDoubleClick = () => electron?.doubleClickTopBar();
stopDoubleClickPropagation = electron ? e => e.stopPropagation() : null;
renderMainContent() {
const mainContent = this.getMainContentWithSwitcher();
let navigationArrows = null;
if (electron) {
navigationArrows = ;
}
const headerClasses = classnames({
[css.header]: true,
[css['electron-draggable']]: electron,
});
const wordmarkClasses = classnames({
[css.wordmark]: true,
[css['electron-non-draggable']]: electron,
});
return (
);
}
getMainContentWithSwitcher() {
const { tab, settingsSection } = this.props.navInfo;
let mainContent;
if (tab === 'settings') {
if (settingsSection === 'account') {
mainContent = ;
} else if (settingsSection === 'danger-zone') {
mainContent = ;
}
return (
);
}
if (tab === 'calendar') {
mainContent = ;
} else if (tab === 'chat') {
mainContent = ;
}
const mainContentClass = classnames(
css['main-content-container'],
css['main-content-container-column'],
);
return (
);
}
}
const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector(
fetchEntriesActionTypes,
);
const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector(
updateCalendarQueryActionTypes,
);
const ConnectedApp: React.ComponentType = React.memo(
function ConnectedApp(props) {
const activeChatThreadID = useSelector(
state => state.navInfo.activeChatThreadID,
);
const navInfo = useSelector(state => state.navInfo);
const fetchEntriesLoadingStatus = useSelector(
fetchEntriesLoadingStatusSelector,
);
const updateCalendarQueryLoadingStatus = useSelector(
updateCalendarQueryLoadingStatusSelector,
);
const entriesLoadingStatus = combineLoadingStatuses(
fetchEntriesLoadingStatus,
updateCalendarQueryLoadingStatus,
);
const loggedIn = useSelector(isLoggedIn);
const activeThreadCurrentlyUnread = useSelector(
state =>
!activeChatThreadID ||
!!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread,
);
const boundUnreadCount = useSelector(unreadCount);
React.useEffect(() => {
document.title = getTitle(boundUnreadCount);
electron?.setBadge(boundUnreadCount === 0 ? null : boundUnreadCount);
}, [boundUnreadCount]);
const dispatch = useDispatch();
const modalContext = useModalContext();
const modals = React.useMemo(
() =>
modalContext.modals.map(([modal, key]) => (
{modal}
)),
[modalContext.modals],
);
return (
);
},
);
function AppWithProvider(props: BaseProps): React.Node {
return (
);
}
export default AppWithProvider;