Page MenuHomePhabricator

No OneTemporary

diff --git a/lib/actions/entry-actions.js b/lib/actions/entry-actions.js
index 342afdd7b..dfdc6f522 100644
--- a/lib/actions/entry-actions.js
+++ b/lib/actions/entry-actions.js
@@ -1,189 +1,186 @@
// @flow
import type {
RawEntryInfo,
CalendarQuery,
SaveEntryInfo,
SaveEntryResponse,
CreateEntryInfo,
CreateEntryPayload,
DeleteEntryInfo,
DeleteEntryResponse,
RestoreEntryInfo,
RestoreEntryResponse,
FetchEntryInfosResult,
CalendarQueryUpdateResult,
} from '../types/entry-types';
import type { FetchJSON } from '../utils/fetch-json';
import type { HistoryRevisionInfo } from '../types/history-types';
import { dateFromString } from '../utils/date-utils';
-import { values } from '../utils/objects';
const fetchEntriesActionTypes = Object.freeze({
started: 'FETCH_ENTRIES_STARTED',
success: 'FETCH_ENTRIES_SUCCESS',
failed: 'FETCH_ENTRIES_FAILED',
});
async function fetchEntries(
fetchJSON: FetchJSON,
calendarQuery: CalendarQuery,
): Promise<FetchEntryInfosResult> {
const response = await fetchJSON('fetch_entries', calendarQuery);
return {
rawEntryInfos: response.rawEntryInfos,
- userInfos: values(response.userInfos),
};
}
const updateCalendarQueryActionTypes = Object.freeze({
started: 'UPDATE_CALENDAR_QUERY_STARTED',
success: 'UPDATE_CALENDAR_QUERY_SUCCESS',
failed: 'UPDATE_CALENDAR_QUERY_FAILED',
});
async function updateCalendarQuery(
fetchJSON: FetchJSON,
calendarQuery: CalendarQuery,
reduxAlreadyUpdated: boolean = false,
): Promise<CalendarQueryUpdateResult> {
const response = await fetchJSON('update_calendar_query', calendarQuery);
- const { rawEntryInfos, deletedEntryIDs, userInfos } = response;
+ const { rawEntryInfos, deletedEntryIDs } = response;
return {
rawEntryInfos,
deletedEntryIDs,
- userInfos,
calendarQuery,
calendarQueryAlreadyUpdated: reduxAlreadyUpdated,
};
}
const createLocalEntryActionType = 'CREATE_LOCAL_ENTRY';
function createLocalEntry(
threadID: string,
localID: number,
dateString: string,
creatorID: string,
): RawEntryInfo {
const date = dateFromString(dateString);
const newEntryInfo: RawEntryInfo = {
localID: `local${localID}`,
threadID,
text: '',
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
creationTime: Date.now(),
creatorID,
deleted: false,
};
return newEntryInfo;
}
const createEntryActionTypes = Object.freeze({
started: 'CREATE_ENTRY_STARTED',
success: 'CREATE_ENTRY_SUCCESS',
failed: 'CREATE_ENTRY_FAILED',
});
async function createEntry(
fetchJSON: FetchJSON,
request: CreateEntryInfo,
): Promise<CreateEntryPayload> {
const result = await fetchJSON('create_entry', request);
return {
entryID: result.entryID,
newMessageInfos: result.newMessageInfos,
threadID: request.threadID,
localID: request.localID,
updatesResult: result.updatesResult,
};
}
const saveEntryActionTypes = Object.freeze({
started: 'SAVE_ENTRY_STARTED',
success: 'SAVE_ENTRY_SUCCESS',
failed: 'SAVE_ENTRY_FAILED',
});
const concurrentModificationResetActionType = 'CONCURRENT_MODIFICATION_RESET';
async function saveEntry(
fetchJSON: FetchJSON,
request: SaveEntryInfo,
): Promise<SaveEntryResponse> {
const result = await fetchJSON('update_entry', request);
return {
entryID: result.entryID,
newMessageInfos: result.newMessageInfos,
updatesResult: result.updatesResult,
};
}
const deleteEntryActionTypes = Object.freeze({
started: 'DELETE_ENTRY_STARTED',
success: 'DELETE_ENTRY_SUCCESS',
failed: 'DELETE_ENTRY_FAILED',
});
async function deleteEntry(
fetchJSON: FetchJSON,
info: DeleteEntryInfo,
): Promise<DeleteEntryResponse> {
const response = await fetchJSON('delete_entry', {
...info,
timestamp: Date.now(),
});
return {
newMessageInfos: response.newMessageInfos,
threadID: response.threadID,
updatesResult: response.updatesResult,
};
}
const fetchRevisionsForEntryActionTypes = Object.freeze({
started: 'FETCH_REVISIONS_FOR_ENTRY_STARTED',
success: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS',
failed: 'FETCH_REVISIONS_FOR_ENTRY_FAILED',
});
async function fetchRevisionsForEntry(
fetchJSON: FetchJSON,
entryID: string,
): Promise<$ReadOnlyArray<HistoryRevisionInfo>> {
const response = await fetchJSON('fetch_entry_revisions', { id: entryID });
return response.result;
}
const restoreEntryActionTypes = Object.freeze({
started: 'RESTORE_ENTRY_STARTED',
success: 'RESTORE_ENTRY_SUCCESS',
failed: 'RESTORE_ENTRY_FAILED',
});
async function restoreEntry(
fetchJSON: FetchJSON,
info: RestoreEntryInfo,
): Promise<RestoreEntryResponse> {
const response = await fetchJSON('restore_entry', {
...info,
timestamp: Date.now(),
});
return {
newMessageInfos: response.newMessageInfos,
updatesResult: response.updatesResult,
};
}
export {
fetchEntriesActionTypes,
fetchEntries,
updateCalendarQueryActionTypes,
updateCalendarQuery,
createLocalEntryActionType,
createLocalEntry,
createEntryActionTypes,
createEntry,
saveEntryActionTypes,
concurrentModificationResetActionType,
saveEntry,
deleteEntryActionTypes,
deleteEntry,
fetchRevisionsForEntryActionTypes,
fetchRevisionsForEntry,
restoreEntryActionTypes,
restoreEntry,
};
diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js
index b899e9fd1..d5f57e78e 100644
--- a/lib/actions/message-actions.js
+++ b/lib/actions/message-actions.js
@@ -1,119 +1,115 @@
// @flow
import type { FetchJSON } from '../utils/fetch-json';
import type {
FetchMessageInfosPayload,
SendMessageResult,
} from '../types/message-types';
-import { values } from '../utils/objects';
-
const fetchMessagesBeforeCursorActionTypes = Object.freeze({
started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED',
success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS',
failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED',
});
async function fetchMessagesBeforeCursor(
fetchJSON: FetchJSON,
threadID: string,
beforeMessageID: string,
): Promise<FetchMessageInfosPayload> {
const response = await fetchJSON('fetch_messages', {
cursors: {
[threadID]: beforeMessageID,
},
});
return {
threadID,
rawMessageInfos: response.rawMessageInfos,
truncationStatus: response.truncationStatuses[threadID],
- userInfos: values(response.userInfos),
};
}
const fetchMostRecentMessagesActionTypes = Object.freeze({
started: 'FETCH_MOST_RECENT_MESSAGES_STARTED',
success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS',
failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED',
});
async function fetchMostRecentMessages(
fetchJSON: FetchJSON,
threadID: string,
): Promise<FetchMessageInfosPayload> {
const response = await fetchJSON('fetch_messages', {
cursors: {
[threadID]: null,
},
});
return {
threadID,
rawMessageInfos: response.rawMessageInfos,
truncationStatus: response.truncationStatuses[threadID],
- userInfos: values(response.userInfos),
};
}
const sendTextMessageActionTypes = Object.freeze({
started: 'SEND_TEXT_MESSAGE_STARTED',
success: 'SEND_TEXT_MESSAGE_SUCCESS',
failed: 'SEND_TEXT_MESSAGE_FAILED',
});
async function sendTextMessage(
fetchJSON: FetchJSON,
threadID: string,
localID: string,
text: string,
): Promise<SendMessageResult> {
const response = await fetchJSON('create_text_message', {
threadID,
localID,
text,
});
return {
id: response.newMessageInfo.id,
time: response.newMessageInfo.time,
};
}
const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE';
const sendMultimediaMessageActionTypes = Object.freeze({
started: 'SEND_MULTIMEDIA_MESSAGE_STARTED',
success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS',
failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED',
});
async function sendMultimediaMessage(
fetchJSON: FetchJSON,
threadID: string,
localID: string,
mediaIDs: $ReadOnlyArray<string>,
): Promise<SendMessageResult> {
const response = await fetchJSON('create_multimedia_message', {
threadID,
localID,
mediaIDs,
});
return {
id: response.newMessageInfo.id,
time: response.newMessageInfo.time,
};
}
const saveMessagesActionType = 'SAVE_MESSAGES';
const processMessagesActionType = 'PROCESS_MESSAGES';
const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE';
export {
fetchMessagesBeforeCursorActionTypes,
fetchMessagesBeforeCursor,
fetchMostRecentMessagesActionTypes,
fetchMostRecentMessages,
sendTextMessageActionTypes,
sendTextMessage,
createLocalMessageActionType,
sendMultimediaMessageActionTypes,
sendMultimediaMessage,
saveMessagesActionType,
processMessagesActionType,
messageStorePruneActionType,
};
diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js
index 76bc4e31e..50c068281 100644
--- a/lib/actions/thread-actions.js
+++ b/lib/actions/thread-actions.js
@@ -1,173 +1,172 @@
// @flow
import type {
ChangeThreadSettingsPayload,
LeaveThreadPayload,
UpdateThreadRequest,
NewThreadRequest,
NewThreadResult,
ClientThreadJoinRequest,
ThreadJoinPayload,
} from '../types/thread-types';
import type { FetchJSON } from '../utils/fetch-json';
import invariant from 'invariant';
import { values } from '../utils/objects';
const deleteThreadActionTypes = Object.freeze({
started: 'DELETE_THREAD_STARTED',
success: 'DELETE_THREAD_SUCCESS',
failed: 'DELETE_THREAD_FAILED',
});
async function deleteThread(
fetchJSON: FetchJSON,
threadID: string,
currentAccountPassword: string,
): Promise<LeaveThreadPayload> {
const response = await fetchJSON('delete_thread', {
threadID,
accountPassword: currentAccountPassword,
});
return {
updatesResult: response.updatesResult,
};
}
const changeThreadSettingsActionTypes = Object.freeze({
started: 'CHANGE_THREAD_SETTINGS_STARTED',
success: 'CHANGE_THREAD_SETTINGS_SUCCESS',
failed: 'CHANGE_THREAD_SETTINGS_FAILED',
});
async function changeThreadSettings(
fetchJSON: FetchJSON,
request: UpdateThreadRequest,
): Promise<ChangeThreadSettingsPayload> {
const response = await fetchJSON('update_thread', request);
invariant(
Object.keys(request.changes).length > 0,
'No changes provided to changeThreadSettings!',
);
return {
threadID: request.threadID,
updatesResult: response.updatesResult,
newMessageInfos: response.newMessageInfos,
};
}
const removeUsersFromThreadActionTypes = Object.freeze({
started: 'REMOVE_USERS_FROM_THREAD_STARTED',
success: 'REMOVE_USERS_FROM_THREAD_SUCCESS',
failed: 'REMOVE_USERS_FROM_THREAD_FAILED',
});
async function removeUsersFromThread(
fetchJSON: FetchJSON,
threadID: string,
memberIDs: string[],
): Promise<ChangeThreadSettingsPayload> {
const response = await fetchJSON('remove_members', {
threadID,
memberIDs,
});
return {
threadID,
updatesResult: response.updatesResult,
newMessageInfos: response.newMessageInfos,
};
}
const changeThreadMemberRolesActionTypes = Object.freeze({
started: 'CHANGE_THREAD_MEMBER_ROLES_STARTED',
success: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS',
failed: 'CHANGE_THREAD_MEMBER_ROLES_FAILED',
});
async function changeThreadMemberRoles(
fetchJSON: FetchJSON,
threadID: string,
memberIDs: string[],
newRole: string,
): Promise<ChangeThreadSettingsPayload> {
const response = await fetchJSON('update_role', {
threadID,
memberIDs,
role: newRole,
});
return {
threadID,
updatesResult: response.updatesResult,
newMessageInfos: response.newMessageInfos,
};
}
const newThreadActionTypes = Object.freeze({
started: 'NEW_THREAD_STARTED',
success: 'NEW_THREAD_SUCCESS',
failed: 'NEW_THREAD_FAILED',
});
async function newThread(
fetchJSON: FetchJSON,
request: NewThreadRequest,
): Promise<NewThreadResult> {
const response = await fetchJSON('create_thread', request);
return {
newThreadID: response.newThreadID,
updatesResult: response.updatesResult,
newMessageInfos: response.newMessageInfos,
};
}
const joinThreadActionTypes = Object.freeze({
started: 'JOIN_THREAD_STARTED',
success: 'JOIN_THREAD_SUCCESS',
failed: 'JOIN_THREAD_FAILED',
});
async function joinThread(
fetchJSON: FetchJSON,
request: ClientThreadJoinRequest,
): Promise<ThreadJoinPayload> {
const response = await fetchJSON('join_thread', request);
const userInfos = values(response.userInfos);
return {
updatesResult: response.updatesResult,
rawMessageInfos: response.rawMessageInfos,
truncationStatuses: response.truncationStatuses,
userInfos,
calendarResult: {
calendarQuery: request.calendarQuery,
rawEntryInfos: response.rawEntryInfos,
- userInfos,
},
};
}
const leaveThreadActionTypes = Object.freeze({
started: 'LEAVE_THREAD_STARTED',
success: 'LEAVE_THREAD_SUCCESS',
failed: 'LEAVE_THREAD_FAILED',
});
async function leaveThread(
fetchJSON: FetchJSON,
threadID: string,
): Promise<LeaveThreadPayload> {
const response = await fetchJSON('leave_thread', { threadID });
return {
updatesResult: response.updatesResult,
};
}
export {
deleteThreadActionTypes,
deleteThread,
changeThreadSettingsActionTypes,
changeThreadSettings,
removeUsersFromThreadActionTypes,
removeUsersFromThread,
changeThreadMemberRolesActionTypes,
changeThreadMemberRoles,
newThreadActionTypes,
newThread,
joinThreadActionTypes,
joinThread,
leaveThreadActionTypes,
leaveThread,
};
diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js
index bedf6ff43..c43f34881 100644
--- a/lib/actions/user-actions.js
+++ b/lib/actions/user-actions.js
@@ -1,322 +1,320 @@
// @flow
import type { FetchJSON } from '../utils/fetch-json';
import type { HandleVerificationCodeResult } from '../types/verify-types';
import type { UserInfo, AccountUpdate } from '../types/user-types';
import type {
ChangeUserSettingsResult,
LogOutResult,
LogInInfo,
LogInResult,
RegisterResult,
UpdatePasswordInfo,
RegisterInfo,
AccessRequest,
} from '../types/account-types';
import type {
SubscriptionUpdateRequest,
SubscriptionUpdateResult,
} from '../types/subscription-types';
import type { UserSearchResult } from '../types/search-types';
import type { PreRequestUserState } from '../types/session-types';
import threadWatcher from '../shared/thread-watcher';
import { getConfig } from '../utils/config';
import sleep from '../utils/sleep';
const logOutActionTypes = Object.freeze({
started: 'LOG_OUT_STARTED',
success: 'LOG_OUT_SUCCESS',
failed: 'LOG_OUT_FAILED',
});
async function logOut(
fetchJSON: FetchJSON,
preRequestUserState: PreRequestUserState,
): Promise<LogOutResult> {
let response = null;
try {
response = await Promise.race([
fetchJSON('log_out', {}),
(async () => {
await sleep(500);
throw new Error('log_out took more than 500ms');
})(),
]);
} catch {}
const currentUserInfo = response ? response.currentUserInfo : null;
return { currentUserInfo, preRequestUserState };
}
const deleteAccountActionTypes = Object.freeze({
started: 'DELETE_ACCOUNT_STARTED',
success: 'DELETE_ACCOUNT_SUCCESS',
failed: 'DELETE_ACCOUNT_FAILED',
});
async function deleteAccount(
fetchJSON: FetchJSON,
password: string,
preRequestUserState: PreRequestUserState,
): Promise<LogOutResult> {
let response = null;
try {
response = await Promise.race([
fetchJSON('delete_account', { password }),
(async () => {
await sleep(500);
throw new Error('delete_account took more than 500ms');
})(),
]);
} catch {}
const currentUserInfo = response ? response.currentUserInfo : null;
return { currentUserInfo, preRequestUserState };
}
const registerActionTypes = Object.freeze({
started: 'REGISTER_STARTED',
success: 'REGISTER_SUCCESS',
failed: 'REGISTER_FAILED',
});
async function register(
fetchJSON: FetchJSON,
registerInfo: RegisterInfo,
): Promise<RegisterResult> {
const response = await fetchJSON('create_account', {
...registerInfo,
platformDetails: getConfig().platformDetails,
});
return {
currentUserInfo: {
id: response.id,
username: registerInfo.username,
email: registerInfo.email,
emailVerified: false,
},
rawMessageInfos: response.rawMessageInfos,
threadInfos: response.cookieChange.threadInfos,
userInfos: response.cookieChange.userInfos,
calendarQuery: registerInfo.calendarQuery,
};
}
function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] {
const merged = {};
for (let userInfoArray of userInfoArrays) {
for (let userInfo of userInfoArray) {
merged[userInfo.id] = userInfo;
}
}
const flattened = [];
for (let id in merged) {
flattened.push(merged[id]);
}
return flattened;
}
const cookieInvalidationResolutionAttempt =
'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT';
const appStartNativeCredentialsAutoLogIn =
'APP_START_NATIVE_CREDENTIALS_AUTO_LOG_IN';
const appStartReduxLoggedInButInvalidCookie =
'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE';
const socketAuthErrorResolutionAttempt = 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT';
const logInActionTypes = Object.freeze({
started: 'LOG_IN_STARTED',
success: 'LOG_IN_SUCCESS',
failed: 'LOG_IN_FAILED',
});
async function logIn(
fetchJSON: FetchJSON,
logInInfo: LogInInfo,
): Promise<LogInResult> {
const watchedIDs = threadWatcher.getWatchedIDs();
const response = await fetchJSON('log_in', {
...logInInfo,
watchedIDs,
platformDetails: getConfig().platformDetails,
});
const userInfos = mergeUserInfos(
response.userInfos,
response.cookieChange.userInfos,
);
return {
threadInfos: response.cookieChange.threadInfos,
currentUserInfo: response.currentUserInfo,
calendarResult: {
calendarQuery: logInInfo.calendarQuery,
rawEntryInfos: response.rawEntryInfos,
- userInfos,
},
messagesResult: {
messageInfos: response.rawMessageInfos,
truncationStatus: response.truncationStatuses,
watchedIDsAtRequestTime: watchedIDs,
currentAsOf: response.serverTime,
},
userInfos,
updatesCurrentAsOf: response.serverTime,
};
}
const resetPasswordActionTypes = Object.freeze({
started: 'RESET_PASSWORD_STARTED',
success: 'RESET_PASSWORD_SUCCESS',
failed: 'RESET_PASSWORD_FAILED',
});
async function resetPassword(
fetchJSON: FetchJSON,
updatePasswordInfo: UpdatePasswordInfo,
): Promise<LogInResult> {
const watchedIDs = threadWatcher.getWatchedIDs();
const response = await fetchJSON('update_password', {
...updatePasswordInfo,
watchedIDs,
platformDetails: getConfig().platformDetails,
});
const userInfos = mergeUserInfos(
response.userInfos,
response.cookieChange.userInfos,
);
return {
threadInfos: response.cookieChange.threadInfos,
currentUserInfo: response.currentUserInfo,
calendarResult: {
calendarQuery: updatePasswordInfo.calendarQuery,
rawEntryInfos: response.rawEntryInfos,
- userInfos,
},
messagesResult: {
messageInfos: response.rawMessageInfos,
truncationStatus: response.truncationStatuses,
watchedIDsAtRequestTime: watchedIDs,
currentAsOf: response.serverTime,
},
userInfos,
updatesCurrentAsOf: response.serverTime,
};
}
const forgotPasswordActionTypes = Object.freeze({
started: 'FORGOT_PASSWORD_STARTED',
success: 'FORGOT_PASSWORD_SUCCESS',
failed: 'FORGOT_PASSWORD_FAILED',
});
async function forgotPassword(
fetchJSON: FetchJSON,
usernameOrEmail: string,
): Promise<void> {
await fetchJSON('send_password_reset_email', { usernameOrEmail });
}
const changeUserSettingsActionTypes = Object.freeze({
started: 'CHANGE_USER_SETTINGS_STARTED',
success: 'CHANGE_USER_SETTINGS_SUCCESS',
failed: 'CHANGE_USER_SETTINGS_FAILED',
});
async function changeUserSettings(
fetchJSON: FetchJSON,
accountUpdate: AccountUpdate,
): Promise<ChangeUserSettingsResult> {
await fetchJSON('update_account', accountUpdate);
return { email: accountUpdate.updatedFields.email };
}
const resendVerificationEmailActionTypes = Object.freeze({
started: 'RESEND_VERIFICATION_EMAIL_STARTED',
success: 'RESEND_VERIFICATION_EMAIL_SUCCESS',
failed: 'RESEND_VERIFICATION_EMAIL_FAILED',
});
async function resendVerificationEmail(fetchJSON: FetchJSON): Promise<void> {
await fetchJSON('send_verification_email', {});
}
const handleVerificationCodeActionTypes = Object.freeze({
started: 'HANDLE_VERIFICATION_CODE_STARTED',
success: 'HANDLE_VERIFICATION_CODE_SUCCESS',
failed: 'HANDLE_VERIFICATION_CODE_FAILED',
});
async function handleVerificationCode(
fetchJSON: FetchJSON,
code: string,
): Promise<HandleVerificationCodeResult> {
const result = await fetchJSON('verify_code', { code });
const { verifyField, resetPasswordUsername } = result;
return { verifyField, resetPasswordUsername };
}
const searchUsersActionTypes = Object.freeze({
started: 'SEARCH_USERS_STARTED',
success: 'SEARCH_USERS_SUCCESS',
failed: 'SEARCH_USERS_FAILED',
});
async function searchUsers(
fetchJSON: FetchJSON,
usernamePrefix: string,
): Promise<UserSearchResult> {
const response = await fetchJSON('search_users', { prefix: usernamePrefix });
return {
userInfos: response.userInfos,
};
}
const updateSubscriptionActionTypes = Object.freeze({
started: 'UPDATE_SUBSCRIPTION_STARTED',
success: 'UPDATE_SUBSCRIPTION_SUCCESS',
failed: 'UPDATE_SUBSCRIPTION_FAILED',
});
async function updateSubscription(
fetchJSON: FetchJSON,
subscriptionUpdate: SubscriptionUpdateRequest,
): Promise<SubscriptionUpdateResult> {
const response = await fetchJSON(
'update_user_subscription',
subscriptionUpdate,
);
return {
threadID: subscriptionUpdate.threadID,
subscription: response.threadSubscription,
};
}
const requestAccessActionTypes = Object.freeze({
started: 'REQUEST_ACCESS_STARTED',
success: 'REQUEST_ACCESS_SUCCESS',
failed: 'REQUEST_ACCESS_FAILED',
});
async function requestAccess(
fetchJSON: FetchJSON,
accessRequest: AccessRequest,
): Promise<void> {
await fetchJSON('request_access', accessRequest);
}
export {
logOutActionTypes,
logOut,
deleteAccountActionTypes,
deleteAccount,
registerActionTypes,
register,
cookieInvalidationResolutionAttempt,
appStartNativeCredentialsAutoLogIn,
appStartReduxLoggedInButInvalidCookie,
socketAuthErrorResolutionAttempt,
logInActionTypes,
logIn,
resetPasswordActionTypes,
resetPassword,
forgotPasswordActionTypes,
forgotPassword,
changeUserSettingsActionTypes,
changeUserSettings,
resendVerificationEmailActionTypes,
resendVerificationEmail,
handleVerificationCodeActionTypes,
handleVerificationCode,
searchUsersActionTypes,
searchUsers,
updateSubscriptionActionTypes,
updateSubscription,
requestAccessActionTypes,
requestAccess,
};
diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js
index 9b396ad9f..7b7565cd4 100644
--- a/lib/reducers/user-reducer.js
+++ b/lib/reducers/user-reducer.js
@@ -1,278 +1,264 @@
// @flow
import type { BaseAction } from '../types/redux-types';
import type { CurrentUserInfo, UserStore, UserInfo } from '../types/user-types';
import { updateTypes, processUpdatesActionType } from '../types/update-types';
import {
serverRequestTypes,
processServerRequestsActionType,
} from '../types/request-types';
import {
fullStateSyncActionType,
incrementalStateSyncActionType,
} from '../types/socket-types';
import {
type UserInconsistencyReportCreationRequest,
reportTypes,
} from '../types/report-types';
import invariant from 'invariant';
import _keyBy from 'lodash/fp/keyBy';
import _isEqual from 'lodash/fp/isEqual';
import { setNewSessionActionType } from '../utils/action-utils';
import {
- fetchEntriesActionTypes,
- updateCalendarQueryActionTypes,
createEntryActionTypes,
saveEntryActionTypes,
deleteEntryActionTypes,
restoreEntryActionTypes,
} from '../actions/entry-actions';
import {
logOutActionTypes,
deleteAccountActionTypes,
logInActionTypes,
registerActionTypes,
resetPasswordActionTypes,
changeUserSettingsActionTypes,
- searchUsersActionTypes,
} from '../actions/user-actions';
import { joinThreadActionTypes } from '../actions/thread-actions';
-import {
- fetchMessagesBeforeCursorActionTypes,
- fetchMostRecentMessagesActionTypes,
-} from '../actions/message-actions';
import { getConfig } from '../utils/config';
import { actionLogger } from '../utils/action-logger';
import { sanitizeAction } from '../utils/sanitization';
function reduceCurrentUserInfo(
state: ?CurrentUserInfo,
action: BaseAction,
): ?CurrentUserInfo {
if (
action.type === logInActionTypes.success ||
action.type === resetPasswordActionTypes.success ||
action.type === registerActionTypes.success ||
action.type === logOutActionTypes.success ||
action.type === deleteAccountActionTypes.success
) {
if (!_isEqual(action.payload.currentUserInfo)(state)) {
return action.payload.currentUserInfo;
}
} else if (
action.type === setNewSessionActionType &&
action.payload.sessionChange.currentUserInfo
) {
const { sessionChange } = action.payload;
if (!_isEqual(sessionChange.currentUserInfo)(state)) {
return sessionChange.currentUserInfo;
}
} else if (action.type === fullStateSyncActionType) {
const { currentUserInfo } = action.payload;
if (!_isEqual(currentUserInfo)(state)) {
return currentUserInfo;
}
} else if (
action.type === incrementalStateSyncActionType ||
action.type === processUpdatesActionType
) {
for (let update of action.payload.updatesResult.newUpdates) {
if (
update.type === updateTypes.UPDATE_CURRENT_USER &&
!_isEqual(update.currentUserInfo)(state)
) {
return update.currentUserInfo;
}
}
} else if (action.type === changeUserSettingsActionTypes.success) {
invariant(
state && !state.anonymous,
"can't change settings if not logged in",
);
const email = action.payload.email;
if (!email) {
return state;
}
return {
id: state.id,
username: state.username,
email: email,
emailVerified: false,
};
} else if (action.type === processServerRequestsActionType) {
const checkStateRequest = action.payload.serverRequests.find(
candidate => candidate.type === serverRequestTypes.CHECK_STATE,
);
if (
checkStateRequest &&
checkStateRequest.stateChanges &&
checkStateRequest.stateChanges.currentUserInfo &&
!_isEqual(checkStateRequest.stateChanges.currentUserInfo)(state)
) {
return checkStateRequest.stateChanges.currentUserInfo;
}
}
return state;
}
function findInconsistencies(
action: BaseAction,
beforeStateCheck: { [id: string]: UserInfo },
afterStateCheck: { [id: string]: UserInfo },
): UserInconsistencyReportCreationRequest[] {
if (_isEqual(beforeStateCheck)(afterStateCheck)) {
return [];
}
return [
{
type: reportTypes.USER_INCONSISTENCY,
platformDetails: getConfig().platformDetails,
action: sanitizeAction(action),
beforeStateCheck,
afterStateCheck,
lastActions: actionLogger.interestingActionSummaries,
time: Date.now(),
},
];
}
function reduceUserInfos(state: UserStore, action: BaseAction): UserStore {
- if (
- action.type === joinThreadActionTypes.success ||
- action.type === fetchMessagesBeforeCursorActionTypes.success ||
- action.type === fetchMostRecentMessagesActionTypes.success ||
- action.type === fetchEntriesActionTypes.success ||
- action.type === updateCalendarQueryActionTypes.success ||
- action.type === searchUsersActionTypes.success
- ) {
+ if (action.type === joinThreadActionTypes.success) {
const newUserInfos = _keyBy(userInfo => userInfo.id)(
action.payload.userInfos,
);
// $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63
const updated = { ...state.userInfos, ...newUserInfos };
if (!_isEqual(state.userInfos)(updated)) {
return {
userInfos: updated,
inconsistencyReports: state.inconsistencyReports,
};
}
} else if (
action.type === logOutActionTypes.success ||
action.type === deleteAccountActionTypes.success ||
(action.type === setNewSessionActionType &&
action.payload.sessionChange.cookieInvalidated)
) {
if (Object.keys(state.userInfos).length === 0) {
return state;
}
return {
userInfos: {},
inconsistencyReports: state.inconsistencyReports,
};
} else if (
action.type === logInActionTypes.success ||
action.type === registerActionTypes.success ||
action.type === resetPasswordActionTypes.success ||
action.type === fullStateSyncActionType
) {
const newUserInfos = _keyBy(userInfo => userInfo.id)(
action.payload.userInfos,
);
if (!_isEqual(state.userInfos)(newUserInfos)) {
return {
userInfos: newUserInfos,
inconsistencyReports: state.inconsistencyReports,
};
}
} else if (
action.type === incrementalStateSyncActionType ||
action.type === processUpdatesActionType
) {
const newUserInfos = _keyBy(userInfo => userInfo.id)(
action.payload.userInfos,
);
// $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63
const updated = { ...state.userInfos, ...newUserInfos };
for (let update of action.payload.updatesResult.newUpdates) {
if (update.type === updateTypes.DELETE_ACCOUNT) {
delete updated[update.deletedUserID];
}
}
if (!_isEqual(state.userInfos)(updated)) {
return {
userInfos: updated,
inconsistencyReports: state.inconsistencyReports,
};
}
} else if (
action.type === createEntryActionTypes.success ||
action.type === saveEntryActionTypes.success ||
action.type === restoreEntryActionTypes.success
) {
const newUserInfos = _keyBy(userInfo => userInfo.id)(
action.payload.updatesResult.userInfos,
);
// $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63
const updated = { ...state.userInfos, ...newUserInfos };
if (!_isEqual(state.userInfos)(updated)) {
return {
userInfos: updated,
inconsistencyReports: state.inconsistencyReports,
};
}
} else if (action.type === deleteEntryActionTypes.success && action.payload) {
const { updatesResult } = action.payload;
const newUserInfos = _keyBy(userInfo => userInfo.id)(
updatesResult.userInfos,
);
// $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63
const updated = { ...state.userInfos, ...newUserInfos };
if (!_isEqual(state.userInfos)(updated)) {
return {
userInfos: updated,
inconsistencyReports: state.inconsistencyReports,
};
}
} else if (action.type === processServerRequestsActionType) {
const checkStateRequest = action.payload.serverRequests.find(
candidate => candidate.type === serverRequestTypes.CHECK_STATE,
);
if (!checkStateRequest || !checkStateRequest.stateChanges) {
return state;
}
const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges;
if (!userInfos && !deleteUserInfoIDs) {
return state;
}
const newUserInfos = { ...state.userInfos };
if (userInfos) {
for (const userInfo of userInfos) {
newUserInfos[userInfo.id] = userInfo;
}
}
if (deleteUserInfoIDs) {
for (const deleteUserInfoID of deleteUserInfoIDs) {
delete newUserInfos[deleteUserInfoID];
}
}
const newInconsistencies = findInconsistencies(
action,
state.userInfos,
newUserInfos,
);
return {
userInfos: newUserInfos,
inconsistencyReports: [
...state.inconsistencyReports,
...newInconsistencies,
],
};
}
return state;
}
export { reduceCurrentUserInfo, reduceUserInfos };
diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js
index 92072cdb8..e5f79beb0 100644
--- a/lib/selectors/user-selectors.js
+++ b/lib/selectors/user-selectors.js
@@ -1,178 +1,179 @@
// @flow
import type { BaseAppState } from '../types/redux-types';
import type {
UserInfo,
RelativeUserInfo,
AccountUserInfo,
} from '../types/user-types';
import {
type RawThreadInfo,
type MemberInfo,
type RelativeMemberInfo,
threadPermissions,
} from '../types/thread-types';
import { createSelector } from 'reselect';
import _memoize from 'lodash/memoize';
import _keys from 'lodash/keys';
import SearchIndex from '../shared/search-index';
import { threadActualMembers } from '../shared/thread-utils';
// Used for specific message payloads that include an array of user IDs, ie.
// array of initial users, array of added users
function userIDsToRelativeUserInfos(
userIDs: string[],
viewerID: ?string,
userInfos: { [id: string]: UserInfo },
): RelativeUserInfo[] {
const relativeUserInfos = [];
for (let userID of userIDs) {
const username = userInfos[userID] ? userInfos[userID].username : null;
if (userID === viewerID) {
relativeUserInfos.unshift({
id: userID,
username,
isViewer: true,
});
} else {
relativeUserInfos.push({
id: userID,
username,
isViewer: false,
});
}
}
return relativeUserInfos;
}
// Includes current user at the start
const baseRelativeMemberInfoSelectorForMembersOfThread = (threadID: string) =>
createSelector(
(state: BaseAppState<*>) => state.threadStore.threadInfos[threadID],
(state: BaseAppState<*>) =>
state.currentUserInfo && state.currentUserInfo.id,
(state: BaseAppState<*>) => state.userStore.userInfos,
(
threadInfo: ?RawThreadInfo,
currentUserID: ?string,
userInfos: { [id: string]: UserInfo },
): RelativeMemberInfo[] => {
const relativeMemberInfos = [];
if (!threadInfo) {
return relativeMemberInfos;
}
const memberInfos = threadInfo.members;
for (let memberInfo of memberInfos) {
const username = userInfos[memberInfo.id]
? userInfos[memberInfo.id].username
: null;
const canChangeRoles =
memberInfo.permissions[threadPermissions.CHANGE_ROLE] &&
memberInfo.permissions[threadPermissions.CHANGE_ROLE].value;
if (
memberInfo.id === currentUserID &&
(memberInfo.role || canChangeRoles)
) {
relativeMemberInfos.unshift({
id: memberInfo.id,
role: memberInfo.role,
permissions: memberInfo.permissions,
username,
isViewer: true,
});
} else if (memberInfo.id !== currentUserID) {
relativeMemberInfos.push({
id: memberInfo.id,
role: memberInfo.role,
permissions: memberInfo.permissions,
username,
isViewer: false,
});
}
}
return relativeMemberInfos;
},
);
const relativeMemberInfoSelectorForMembersOfThread: (
threadID: string,
) => (state: BaseAppState<*>) => RelativeMemberInfo[] = _memoize(
baseRelativeMemberInfoSelectorForMembersOfThread,
);
// If threadID is null, then all users except the logged-in user are returned
const baseUserInfoSelectorForOtherMembersOfThread = (threadID: ?string) =>
createSelector(
(state: BaseAppState<*>) => state.userStore.userInfos,
(state: BaseAppState<*>) =>
state.currentUserInfo && state.currentUserInfo.id,
(state: BaseAppState<*>) =>
threadID && state.threadStore.threadInfos[threadID]
? state.threadStore.threadInfos[threadID].members
: null,
(
userInfos: { [id: string]: UserInfo },
currentUserID: ?string,
members: ?$ReadOnlyArray<MemberInfo>,
): { [id: string]: AccountUserInfo } => {
const others = {};
const memberUserIDs = members
? threadActualMembers(members)
: _keys(userInfos);
for (let memberID of memberUserIDs) {
if (
memberID !== currentUserID &&
userInfos[memberID] &&
userInfos[memberID].username
) {
others[memberID] = userInfos[memberID];
}
}
return others;
},
);
const userInfoSelectorForOtherMembersOfThread: (
threadID: ?string,
) => (state: BaseAppState<*>) => { [id: string]: AccountUserInfo } = _memoize(
baseUserInfoSelectorForOtherMembersOfThread,
);
function searchIndexFromUserInfos(userInfos: {
[id: string]: AccountUserInfo,
}) {
const searchIndex = new SearchIndex();
for (const id in userInfos) {
searchIndex.addEntry(id, userInfos[id].username);
}
return searchIndex;
}
const baseUserSearchIndexForOtherMembersOfThread = (threadID: ?string) =>
createSelector(
userInfoSelectorForOtherMembersOfThread(threadID),
searchIndexFromUserInfos,
);
const userSearchIndexForOtherMembersOfThread: (
threadID: ?string,
) => (state: BaseAppState<*>) => SearchIndex = _memoize(
baseUserSearchIndexForOtherMembersOfThread,
);
const isLoggedIn = (state: BaseAppState<*>) =>
!!(
state.currentUserInfo &&
!state.currentUserInfo.anonymous &&
state.dataLoaded
);
export {
userIDsToRelativeUserInfos,
relativeMemberInfoSelectorForMembersOfThread,
userInfoSelectorForOtherMembersOfThread,
+ searchIndexFromUserInfos,
userSearchIndexForOtherMembersOfThread,
isLoggedIn,
};
diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js
index 5986aa1ec..c826a1b80 100644
--- a/lib/types/entry-types.js
+++ b/lib/types/entry-types.js
@@ -1,235 +1,231 @@
// @flow
import type { RawMessageInfo } from './message-types';
-import type { UserInfo, AccountUserInfo } from './user-types';
+import type { AccountUserInfo } from './user-types';
import {
type CalendarFilter,
calendarFilterPropType,
defaultCalendarFilters,
} from './filter-types';
import type { CreateUpdatesResponse } from './update-types';
import type { Platform } from './device-types';
import type { ClientEntryInconsistencyReportCreationRequest } from './report-types';
import PropTypes from 'prop-types';
import {
fifteenDaysEarlier,
fifteenDaysLater,
thisMonthDates,
} from '../utils/date-utils';
export type RawEntryInfo = {|
id?: string, // null if local copy without ID yet
localID?: string, // for optimistic creations
threadID: string,
text: string,
year: number,
month: number, // 1-indexed
day: number, // 1-indexed
creationTime: number, // millisecond timestamp
creatorID: string,
deleted: boolean,
|};
export const rawEntryInfoPropType = PropTypes.shape({
id: PropTypes.string,
localID: PropTypes.string,
threadID: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
month: PropTypes.number.isRequired,
day: PropTypes.number.isRequired,
creationTime: PropTypes.number.isRequired,
creatorID: PropTypes.string.isRequired,
deleted: PropTypes.bool.isRequired,
});
export type EntryInfo = {|
id?: string, // null if local copy without ID yet
localID?: string, // for optimistic creations
threadID: string,
text: string,
year: number,
month: number, // 1-indexed
day: number, // 1-indexed
creationTime: number, // millisecond timestamp
creator: ?string,
deleted: boolean,
|};
export const entryInfoPropType = PropTypes.shape({
id: PropTypes.string,
localID: PropTypes.string,
threadID: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
month: PropTypes.number.isRequired,
day: PropTypes.number.isRequired,
creationTime: PropTypes.number.isRequired,
creator: PropTypes.string,
deleted: PropTypes.bool.isRequired,
});
export type EntryStore = {|
entryInfos: { [id: string]: RawEntryInfo },
daysToEntries: { [day: string]: string[] },
lastUserInteractionCalendar: number,
inconsistencyReports: $ReadOnlyArray<ClientEntryInconsistencyReportCreationRequest>,
|};
export type CalendarQuery = {|
startDate: string,
endDate: string,
filters: $ReadOnlyArray<CalendarFilter>,
|};
export const defaultCalendarQuery = (
platform: ?Platform,
timeZone?: ?string,
) => {
if (platform === 'web') {
return {
...thisMonthDates(timeZone),
filters: defaultCalendarFilters,
};
} else {
return {
startDate: fifteenDaysEarlier(timeZone).valueOf(),
endDate: fifteenDaysLater(timeZone).valueOf(),
filters: defaultCalendarFilters,
};
}
};
export const calendarQueryPropType = PropTypes.shape({
startDate: PropTypes.string.isRequired,
endDate: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(calendarFilterPropType).isRequired,
});
export type SaveEntryInfo = {|
entryID: string,
text: string,
prevText: string,
timestamp: number,
calendarQuery: CalendarQuery,
|};
export type SaveEntryRequest = {|
entryID: string,
text: string,
prevText: string,
timestamp: number,
calendarQuery?: CalendarQuery,
|};
export type SaveEntryResponse = {|
entryID: string,
newMessageInfos: $ReadOnlyArray<RawMessageInfo>,
updatesResult: CreateUpdatesResponse,
|};
export type SaveEntryPayload = {|
...SaveEntryResponse,
threadID: string,
|};
export type CreateEntryInfo = {|
text: string,
timestamp: number,
date: string,
threadID: string,
localID: string,
calendarQuery: CalendarQuery,
|};
export type CreateEntryRequest = {|
text: string,
timestamp: number,
date: string,
threadID: string,
localID?: string,
calendarQuery?: CalendarQuery,
|};
export type CreateEntryPayload = {|
...SaveEntryPayload,
localID: string,
|};
export type DeleteEntryInfo = {|
entryID: string,
prevText: string,
calendarQuery: CalendarQuery,
|};
export type DeleteEntryRequest = {|
entryID: string,
prevText: string,
timestamp: number,
calendarQuery?: CalendarQuery,
|};
export type RestoreEntryInfo = {|
entryID: string,
calendarQuery: CalendarQuery,
|};
export type RestoreEntryRequest = {|
entryID: string,
timestamp: number,
calendarQuery?: CalendarQuery,
|};
export type DeleteEntryResponse = {|
newMessageInfos: $ReadOnlyArray<RawMessageInfo>,
threadID: string,
updatesResult: CreateUpdatesResponse,
|};
export type RestoreEntryResponse = {|
newMessageInfos: $ReadOnlyArray<RawMessageInfo>,
updatesResult: CreateUpdatesResponse,
|};
export type RestoreEntryPayload = {|
...RestoreEntryResponse,
threadID: string,
|};
export type FetchEntryInfosResponse = {|
rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
userInfos: { [id: string]: AccountUserInfo },
|};
export type FetchEntryInfosResult = {|
rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
- userInfos: $ReadOnlyArray<AccountUserInfo>,
|};
export type DeltaEntryInfosResponse = {|
rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
deletedEntryIDs: $ReadOnlyArray<string>,
- userInfos: { [id: string]: AccountUserInfo },
|};
export type DeltaEntryInfosResult = {|
rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
deletedEntryIDs: $ReadOnlyArray<string>,
userInfos: $ReadOnlyArray<AccountUserInfo>,
|};
export type CalendarResult = {|
rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
calendarQuery: CalendarQuery,
- userInfos: $ReadOnlyArray<UserInfo>,
|};
export type CalendarQueryUpdateStartingPayload = {|
calendarQuery?: CalendarQuery,
|};
export type CalendarQueryUpdateResult = {|
rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
deletedEntryIDs: $ReadOnlyArray<string>,
- userInfos: $ReadOnlyArray<AccountUserInfo>,
calendarQuery: CalendarQuery,
calendarQueryAlreadyUpdated: boolean,
|};
diff --git a/lib/types/message-types.js b/lib/types/message-types.js
index 7e46bc65d..a1d812558 100644
--- a/lib/types/message-types.js
+++ b/lib/types/message-types.js
@@ -1,786 +1,784 @@
// @flow
import {
type ThreadInfo,
threadInfoPropType,
type ThreadType,
threadTypePropType,
} from './thread-types';
import {
type RelativeUserInfo,
relativeUserInfoPropType,
- type UserInfo,
type UserInfos,
} from './user-types';
import { type Media, type Image, mediaPropType } from './media-types';
import invariant from 'invariant';
import PropTypes from 'prop-types';
export const messageTypes = Object.freeze({
TEXT: 0,
CREATE_THREAD: 1,
ADD_MEMBERS: 2,
CREATE_SUB_THREAD: 3,
CHANGE_SETTINGS: 4,
REMOVE_MEMBERS: 5,
CHANGE_ROLE: 6,
LEAVE_THREAD: 7,
JOIN_THREAD: 8,
CREATE_ENTRY: 9,
EDIT_ENTRY: 10,
DELETE_ENTRY: 11,
RESTORE_ENTRY: 12,
// When the server has a message to deliver that the client can't properly
// render because the client is too old, the server will send this message
// type instead. Consequently, there is no MessageData for UNSUPPORTED - just
// a RawMessageInfo and a MessageInfo. Note that native/persist.js handles
// converting these MessageInfos when the client is upgraded.
UNSUPPORTED: 13,
IMAGES: 14,
MULTIMEDIA: 15,
});
export type MessageType = $Values<typeof messageTypes>;
export function assertMessageType(ourMessageType: number): MessageType {
invariant(
ourMessageType === 0 ||
ourMessageType === 1 ||
ourMessageType === 2 ||
ourMessageType === 3 ||
ourMessageType === 4 ||
ourMessageType === 5 ||
ourMessageType === 6 ||
ourMessageType === 7 ||
ourMessageType === 8 ||
ourMessageType === 9 ||
ourMessageType === 10 ||
ourMessageType === 11 ||
ourMessageType === 12 ||
ourMessageType === 13 ||
ourMessageType === 14 ||
ourMessageType === 15,
'number is not MessageType enum',
);
return ourMessageType;
}
const composableMessageTypes = new Set([
messageTypes.TEXT,
messageTypes.IMAGES,
messageTypes.MULTIMEDIA,
]);
export function isComposableMessageType(ourMessageType: MessageType): boolean {
return composableMessageTypes.has(ourMessageType);
}
export function assertComposableMessageType(
ourMessageType: MessageType,
): MessageType {
invariant(
isComposableMessageType(ourMessageType),
'MessageType is not composed',
);
return ourMessageType;
}
export function messageDataLocalID(messageData: MessageData) {
if (
messageData.type !== messageTypes.TEXT &&
messageData.type !== messageTypes.IMAGES &&
messageData.type !== messageTypes.MULTIMEDIA
) {
return null;
}
return messageData.localID;
}
const mediaMessageTypes = new Set([
messageTypes.IMAGES,
messageTypes.MULTIMEDIA,
]);
export function isMediaMessageType(ourMessageType: MessageType): boolean {
return mediaMessageTypes.has(ourMessageType);
}
export function assetMediaMessageType(
ourMessageType: MessageType,
): MessageType {
invariant(isMediaMessageType(ourMessageType), 'MessageType is not media');
return ourMessageType;
}
// *MessageData = passed to createMessages function to insert into database
// Raw*MessageInfo = used by server, and contained in client's local store
// *MessageInfo = used by client in UI code
export type TextMessageData = {|
type: 0,
localID?: string, // for optimistic creations. included by new clients
threadID: string,
creatorID: string,
time: number,
text: string,
|};
type CreateThreadMessageData = {|
type: 1,
threadID: string,
creatorID: string,
time: number,
initialThreadState: {|
type: ThreadType,
name: ?string,
parentThreadID: ?string,
color: string,
memberIDs: string[],
|},
|};
type AddMembersMessageData = {|
type: 2,
threadID: string,
creatorID: string,
time: number,
addedUserIDs: string[],
|};
type CreateSubthreadMessageData = {|
type: 3,
threadID: string,
creatorID: string,
time: number,
childThreadID: string,
|};
type ChangeSettingsMessageData = {|
type: 4,
threadID: string,
creatorID: string,
time: number,
field: string,
value: string | number,
|};
type RemoveMembersMessageData = {|
type: 5,
threadID: string,
creatorID: string,
time: number,
removedUserIDs: string[],
|};
type ChangeRoleMessageData = {|
type: 6,
threadID: string,
creatorID: string,
time: number,
userIDs: string[],
newRole: string,
|};
type LeaveThreadMessageData = {|
type: 7,
threadID: string,
creatorID: string,
time: number,
|};
type JoinThreadMessageData = {|
type: 8,
threadID: string,
creatorID: string,
time: number,
|};
type CreateEntryMessageData = {|
type: 9,
threadID: string,
creatorID: string,
time: number,
entryID: string,
date: string,
text: string,
|};
type EditEntryMessageData = {|
type: 10,
threadID: string,
creatorID: string,
time: number,
entryID: string,
date: string,
text: string,
|};
type DeleteEntryMessageData = {|
type: 11,
threadID: string,
creatorID: string,
time: number,
entryID: string,
date: string,
text: string,
|};
type RestoreEntryMessageData = {|
type: 12,
threadID: string,
creatorID: string,
time: number,
entryID: string,
date: string,
text: string,
|};
export type ImagesMessageData = {|
type: 14,
localID?: string, // for optimistic creations. included by new clients
threadID: string,
creatorID: string,
time: number,
media: $ReadOnlyArray<Image>,
|};
export type MediaMessageData = {|
type: 15,
localID?: string, // for optimistic creations. included by new clients
threadID: string,
creatorID: string,
time: number,
media: $ReadOnlyArray<Media>,
|};
export type MessageData =
| TextMessageData
| CreateThreadMessageData
| AddMembersMessageData
| CreateSubthreadMessageData
| ChangeSettingsMessageData
| RemoveMembersMessageData
| ChangeRoleMessageData
| LeaveThreadMessageData
| JoinThreadMessageData
| CreateEntryMessageData
| EditEntryMessageData
| DeleteEntryMessageData
| RestoreEntryMessageData
| ImagesMessageData
| MediaMessageData;
export type MultimediaMessageData = ImagesMessageData | MediaMessageData;
export type RawTextMessageInfo = {|
...TextMessageData,
id?: string, // null if local copy without ID yet
|};
export type RawImagesMessageInfo = {|
...ImagesMessageData,
id?: string, // null if local copy without ID yet
|};
export type RawMediaMessageInfo = {|
...MediaMessageData,
id?: string, // null if local copy without ID yet
|};
export type RawMultimediaMessageInfo =
| RawImagesMessageInfo
| RawMediaMessageInfo;
export type RawComposableMessageInfo =
| RawTextMessageInfo
| RawMultimediaMessageInfo;
type RawRobotextMessageInfo =
| {|
...CreateThreadMessageData,
id: string,
|}
| {|
...AddMembersMessageData,
id: string,
|}
| {|
...CreateSubthreadMessageData,
id: string,
|}
| {|
...ChangeSettingsMessageData,
id: string,
|}
| {|
...RemoveMembersMessageData,
id: string,
|}
| {|
...ChangeRoleMessageData,
id: string,
|}
| {|
...LeaveThreadMessageData,
id: string,
|}
| {|
...JoinThreadMessageData,
id: string,
|}
| {|
...CreateEntryMessageData,
id: string,
|}
| {|
...EditEntryMessageData,
id: string,
|}
| {|
...DeleteEntryMessageData,
id: string,
|}
| {|
...RestoreEntryMessageData,
id: string,
|}
| {|
type: 13,
id: string,
threadID: string,
creatorID: string,
time: number,
robotext: string,
unsupportedMessageInfo: Object,
|};
export type RawMessageInfo = RawComposableMessageInfo | RawRobotextMessageInfo;
export type LocallyComposedMessageInfo = {
localID: string,
threadID: string,
...
};
export type TextMessageInfo = {|
type: 0,
id?: string, // null if local copy without ID yet
localID?: string, // for optimistic creations
threadID: string,
creator: RelativeUserInfo,
time: number, // millisecond timestamp
text: string,
|};
export type ImagesMessageInfo = {|
type: 14,
id?: string, // null if local copy without ID yet
localID?: string, // for optimistic creations
threadID: string,
creator: RelativeUserInfo,
time: number, // millisecond timestamp
media: $ReadOnlyArray<Image>,
|};
export type MediaMessageInfo = {|
type: 15,
id?: string, // null if local copy without ID yet
localID?: string, // for optimistic creations
threadID: string,
creator: RelativeUserInfo,
time: number, // millisecond timestamp
media: $ReadOnlyArray<Media>,
|};
export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo;
export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo;
export type RobotextMessageInfo =
| {|
type: 1,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
initialThreadState: {|
type: ThreadType,
name: ?string,
parentThreadInfo: ?ThreadInfo,
color: string,
otherMembers: RelativeUserInfo[],
|},
|}
| {|
type: 2,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
addedMembers: RelativeUserInfo[],
|}
| {|
type: 3,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
childThreadInfo: ThreadInfo,
|}
| {|
type: 4,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
field: string,
value: string | number,
|}
| {|
type: 5,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
removedMembers: RelativeUserInfo[],
|}
| {|
type: 6,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
members: RelativeUserInfo[],
newRole: string,
|}
| {|
type: 7,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
|}
| {|
type: 8,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
|}
| {|
type: 9,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
entryID: string,
date: string,
text: string,
|}
| {|
type: 10,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
entryID: string,
date: string,
text: string,
|}
| {|
type: 11,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
entryID: string,
date: string,
text: string,
|}
| {|
type: 12,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
entryID: string,
date: string,
text: string,
|}
| {|
type: 13,
id: string,
threadID: string,
creator: RelativeUserInfo,
time: number,
robotext: string,
unsupportedMessageInfo: Object,
|};
export type PreviewableMessageInfo =
| RobotextMessageInfo
| MultimediaMessageInfo;
export type MessageInfo = ComposableMessageInfo | RobotextMessageInfo;
export const messageInfoPropType = PropTypes.oneOfType([
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.TEXT]).isRequired,
id: PropTypes.string,
localID: PropTypes.string,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.CREATE_THREAD]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
initialThreadState: PropTypes.shape({
type: threadTypePropType.isRequired,
name: PropTypes.string,
parentThreadInfo: threadInfoPropType,
color: PropTypes.string.isRequired,
otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired,
}).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.ADD_MEMBERS]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
addedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.CREATE_SUB_THREAD]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
childThreadInfo: threadInfoPropType.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.CHANGE_SETTINGS]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
field: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.REMOVE_MEMBERS]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
removedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.CHANGE_ROLE]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
members: PropTypes.arrayOf(relativeUserInfoPropType).isRequired,
newRole: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.LEAVE_THREAD]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.JOIN_THREAD]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.CREATE_ENTRY]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
entryID: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.EDIT_ENTRY]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
entryID: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.DELETE_ENTRY]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
entryID: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.RESTORE_ENTRY]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
entryID: PropTypes.string.isRequired,
date: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.UNSUPPORTED]).isRequired,
id: PropTypes.string.isRequired,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
robotext: PropTypes.string.isRequired,
unsupportedMessageInfo: PropTypes.object.isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.IMAGES]).isRequired,
id: PropTypes.string,
localID: PropTypes.string,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
media: PropTypes.arrayOf(mediaPropType).isRequired,
}),
PropTypes.shape({
type: PropTypes.oneOf([messageTypes.MULTIMEDIA]).isRequired,
id: PropTypes.string,
localID: PropTypes.string,
threadID: PropTypes.string.isRequired,
creator: relativeUserInfoPropType.isRequired,
time: PropTypes.number.isRequired,
media: PropTypes.arrayOf(mediaPropType).isRequired,
}),
]);
export type ThreadMessageInfo = {|
messageIDs: string[],
startReached: boolean,
lastNavigatedTo: number, // millisecond timestamp
lastPruned: number, // millisecond timestamp
|};
// Tracks client-local information about a message that hasn't been assigned an
// ID by the server yet. As soon as the client gets an ack from the server for
// this message, it will clear the LocalMessageInfo.
export type LocalMessageInfo = {|
sendFailed?: boolean,
|};
export const localMessageInfoPropType = PropTypes.shape({
sendFailed: PropTypes.bool,
});
export type MessageStore = {|
messages: { [id: string]: RawMessageInfo },
threads: { [threadID: string]: ThreadMessageInfo },
local: { [id: string]: LocalMessageInfo },
currentAsOf: number,
|};
export const messageTruncationStatus = Object.freeze({
// EXHAUSTIVE means we've reached the start of the thread. Either the result
// set includes the very first message for that thread, or there is nothing
// behind the cursor you queried for. Given that the client only ever issues
// ranged queries whose range, when unioned with what is in state, represent
// the set of all messages for a given thread, we can guarantee that getting
// EXHAUSTIVE means the start has been reached.
EXHAUSTIVE: 'exhaustive',
// TRUNCATED is rare, and means that the server can't guarantee that the
// result set for a given thread is contiguous with what the client has in its
// state. If the client can't verify the contiguousness itself, it needs to
// replace its Redux store's contents with what it is in this payload.
// 1) getMessageInfosSince: Result set for thread is equal to max, and the
// truncation status isn't EXHAUSTIVE (ie. doesn't include the very first
// message).
// 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the
// result set for thread is equal to max, and the truncation status isn't
// EXHAUSTIVE. If cursors are specified, we never return truncated, since
// the cursor given us guarantees the contiguousness of the result set.
// Note that in the reducer, we can guarantee contiguousness if there is any
// intersection between messageIDs in the result set and the set currently in
// the Redux store.
TRUNCATED: 'truncated',
// UNCHANGED means the result set is guaranteed to be contiguous with what the
// client has in its state, but is not EXHAUSTIVE. Basically, it's anything
// that isn't either EXHAUSTIVE or TRUNCATED.
UNCHANGED: 'unchanged',
});
export type MessageTruncationStatus = $Values<typeof messageTruncationStatus>;
export function assertMessageTruncationStatus(
ourMessageTruncationStatus: string,
): MessageTruncationStatus {
invariant(
ourMessageTruncationStatus === 'truncated' ||
ourMessageTruncationStatus === 'unchanged' ||
ourMessageTruncationStatus === 'exhaustive',
'string is not ourMessageTruncationStatus enum',
);
return ourMessageTruncationStatus;
}
export type MessageTruncationStatuses = {
[threadID: string]: MessageTruncationStatus,
};
export type ThreadCursors = { [threadID: string]: ?string };
export type ThreadSelectionCriteria = {|
threadCursors?: ?ThreadCursors,
joinedThreads?: ?boolean,
|};
export type FetchMessageInfosRequest = {|
cursors: ThreadCursors,
numberPerThread?: ?number,
|};
export type FetchMessageInfosResult = {|
rawMessageInfos: RawMessageInfo[],
truncationStatuses: MessageTruncationStatuses,
userInfos: UserInfos,
|};
export type FetchMessageInfosPayload = {|
threadID: string,
rawMessageInfos: RawMessageInfo[],
truncationStatus: MessageTruncationStatus,
- userInfos: UserInfo[],
|};
export type MessagesResponse = {|
rawMessageInfos: RawMessageInfo[],
truncationStatuses: MessageTruncationStatuses,
currentAsOf: number,
|};
export const defaultNumberPerThread = 20;
export type SendMessageResponse = {|
newMessageInfo: RawMessageInfo,
|};
export type SendMessageResult = {|
id: string,
time: number,
|};
export type SendMessagePayload = {|
localID: string,
serverID: string,
threadID: string,
time: number,
|};
export type SendTextMessageRequest = {|
threadID: string,
localID?: string,
text: string,
|};
export type SendMultimediaMessageRequest = {|
threadID: string,
localID: string,
mediaIDs: $ReadOnlyArray<string>,
|};
// Used for the message info included in log-in type actions
export type GenericMessagesResult = {|
messageInfos: RawMessageInfo[],
truncationStatus: MessageTruncationStatuses,
watchedIDsAtRequestTime: $ReadOnlyArray<string>,
currentAsOf: number,
|};
export type SaveMessagesPayload = {|
rawMessageInfos: $ReadOnlyArray<RawMessageInfo>,
updatesCurrentAsOf: number,
|};
export type NewMessagesPayload = {|
messagesResult: MessagesResponse,
|};
export type MessageStorePrunePayload = {|
threadIDs: $ReadOnlyArray<string>,
|};
diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js
index 2cee528fa..431f6e43a 100644
--- a/native/chat/compose-thread.react.js
+++ b/native/chat/compose-thread.react.js
@@ -1,531 +1,513 @@
// @flow
import type { AppState } from '../redux/redux-setup';
import type { LoadingStatus } from 'lib/types/loading-types';
import { loadingStatusPropType } from 'lib/types/loading-types';
import {
type ThreadInfo,
threadInfoPropType,
type ThreadType,
threadTypes,
threadTypePropType,
type NewThreadRequest,
type NewThreadResult,
} from 'lib/types/thread-types';
import {
type AccountUserInfo,
accountUserInfoPropType,
} from 'lib/types/user-types';
import type { DispatchActionPromise } from 'lib/utils/action-utils';
-import type { UserSearchResult } from 'lib/types/search-types';
import type { ChatNavigationProp } from './chat.react';
import type { NavigationRoute } from '../navigation/route-names';
import * as React from 'react';
import PropTypes from 'prop-types';
import { View, Text, Alert } from 'react-native';
import invariant from 'invariant';
import _flow from 'lodash/fp/flow';
import _filter from 'lodash/fp/filter';
import _sortBy from 'lodash/fp/sortBy';
import { createSelector } from 'reselect';
import { connect } from 'lib/utils/redux-utils';
import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions';
-import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors';
import {
userInfoSelectorForOtherMembersOfThread,
userSearchIndexForOtherMembersOfThread,
} from 'lib/selectors/user-selectors';
import SearchIndex from 'lib/shared/search-index';
import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils';
import { getUserSearchResults } from 'lib/shared/search-utils';
-import { registerFetchKey } from 'lib/reducers/loading-reducer';
import { threadInfoSelector } from 'lib/selectors/thread-selectors';
import TagInput from '../components/tag-input.react';
import UserList from '../components/user-list.react';
import ThreadList from '../components/thread-list.react';
import LinkButton from '../components/link-button.react';
import { MessageListRouteName } from '../navigation/route-names';
import ThreadVisibility from '../components/thread-visibility.react';
import {
type Colors,
colorsPropType,
colorsSelector,
styleSelector,
} from '../themes/colors';
const tagInputProps = {
placeholder: 'username',
autoFocus: true,
returnKeyType: 'go',
};
export type ComposeThreadParams = {|
threadType?: ThreadType,
parentThreadInfo?: ThreadInfo,
|};
type Props = {|
navigation: ChatNavigationProp<'ComposeThread'>,
route: NavigationRoute<'ComposeThread'>,
// Redux state
parentThreadInfo: ?ThreadInfo,
loadingStatus: LoadingStatus,
otherUserInfos: { [id: string]: AccountUserInfo },
userSearchIndex: SearchIndex,
threadInfos: { [id: string]: ThreadInfo },
colors: Colors,
styles: typeof styles,
// Redux dispatch functions
dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
newThread: (request: NewThreadRequest) => Promise<NewThreadResult>,
- searchUsers: (usernamePrefix: string) => Promise<UserSearchResult>,
|};
type State = {|
usernameInputText: string,
userInfoInputArray: $ReadOnlyArray<AccountUserInfo>,
|};
type PropsAndState = {| ...Props, ...State |};
class ComposeThread extends React.PureComponent<Props, State> {
static propTypes = {
navigation: PropTypes.shape({
setParams: PropTypes.func.isRequired,
setOptions: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
pushNewThread: PropTypes.func.isRequired,
}).isRequired,
route: PropTypes.shape({
key: PropTypes.string.isRequired,
params: PropTypes.shape({
threadType: threadTypePropType,
parentThreadInfo: threadInfoPropType,
}).isRequired,
}).isRequired,
parentThreadInfo: threadInfoPropType,
loadingStatus: loadingStatusPropType.isRequired,
otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired,
userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired,
threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired,
colors: colorsPropType.isRequired,
styles: PropTypes.objectOf(PropTypes.object).isRequired,
dispatchActionPromise: PropTypes.func.isRequired,
newThread: PropTypes.func.isRequired,
- searchUsers: PropTypes.func.isRequired,
};
state = {
usernameInputText: '',
userInfoInputArray: [],
};
tagInput: ?TagInput<AccountUserInfo>;
createThreadPressed = false;
waitingOnThreadID: ?string;
constructor(props: Props) {
super(props);
this.setLinkButton(true);
}
setLinkButton(enabled: boolean) {
this.props.navigation.setOptions({
headerRight: () => (
<LinkButton
text="Create"
onPress={this.onPressCreateThread}
disabled={!enabled}
/>
),
});
}
- componentDidMount() {
- this.searchUsers('');
- }
-
componentDidUpdate(prevProps: Props) {
const oldReduxParentThreadInfo = prevProps.parentThreadInfo;
const newReduxParentThreadInfo = this.props.parentThreadInfo;
if (
newReduxParentThreadInfo &&
newReduxParentThreadInfo !== oldReduxParentThreadInfo
) {
this.props.navigation.setParams({
parentThreadInfo: newReduxParentThreadInfo,
});
}
if (
this.waitingOnThreadID &&
this.props.threadInfos[this.waitingOnThreadID] &&
!prevProps.threadInfos[this.waitingOnThreadID]
) {
const threadInfo = this.props.threadInfos[this.waitingOnThreadID];
this.props.navigation.pushNewThread(threadInfo);
}
}
static getParentThreadInfo(props: {
route: NavigationRoute<'ComposeThread'>,
}): ?ThreadInfo {
return props.route.params.parentThreadInfo;
}
userSearchResultsSelector = createSelector(
(propsAndState: PropsAndState) => propsAndState.usernameInputText,
(propsAndState: PropsAndState) => propsAndState.otherUserInfos,
(propsAndState: PropsAndState) => propsAndState.userSearchIndex,
(propsAndState: PropsAndState) => propsAndState.userInfoInputArray,
(propsAndState: PropsAndState) =>
ComposeThread.getParentThreadInfo(propsAndState),
(
text: string,
userInfos: { [id: string]: AccountUserInfo },
searchIndex: SearchIndex,
userInfoInputArray: $ReadOnlyArray<AccountUserInfo>,
parentThreadInfo: ?ThreadInfo,
) =>
getUserSearchResults(
text,
userInfos,
searchIndex,
userInfoInputArray.map(userInfo => userInfo.id),
parentThreadInfo,
),
);
get userSearchResults() {
return this.userSearchResultsSelector({ ...this.props, ...this.state });
}
existingThreadsSelector = createSelector(
(propsAndState: PropsAndState) =>
ComposeThread.getParentThreadInfo(propsAndState),
(propsAndState: PropsAndState) => propsAndState.threadInfos,
(propsAndState: PropsAndState) => propsAndState.userInfoInputArray,
(
parentThreadInfo: ?ThreadInfo,
threadInfos: { [id: string]: ThreadInfo },
userInfoInputArray: $ReadOnlyArray<AccountUserInfo>,
) => {
const userIDs = userInfoInputArray.map(userInfo => userInfo.id);
if (userIDs.length === 0) {
return [];
}
return _flow(
_filter(
(threadInfo: ThreadInfo) =>
threadInFilterList(threadInfo) &&
(!parentThreadInfo ||
threadInfo.parentThreadID === parentThreadInfo.id) &&
userIDs.every(userID => userIsMember(threadInfo, userID)),
),
_sortBy(
([
'members.length',
(threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0),
]: $ReadOnlyArray<string | ((threadInfo: ThreadInfo) => mixed)>),
),
)(threadInfos);
},
);
get existingThreads() {
return this.existingThreadsSelector({ ...this.props, ...this.state });
}
render() {
let existingThreadsSection = null;
const { existingThreads, userSearchResults } = this;
if (existingThreads.length > 0) {
existingThreadsSection = (
<View style={this.props.styles.existingThreads}>
<View style={this.props.styles.existingThreadsRow}>
<Text style={this.props.styles.existingThreadsLabel}>
Existing threads
</Text>
</View>
<View style={this.props.styles.existingThreadList}>
<ThreadList
threadInfos={existingThreads}
onSelect={this.onSelectExistingThread}
itemTextStyle={this.props.styles.listItem}
/>
</View>
</View>
);
}
let parentThreadRow = null;
const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props);
if (parentThreadInfo) {
const threadType = this.props.route.params.threadType;
invariant(
threadType !== undefined && threadType !== null,
`no threadType provided for ${parentThreadInfo.id}`,
);
const threadVisibilityColor = this.props.colors.modalForegroundLabel;
parentThreadRow = (
<View style={this.props.styles.parentThreadRow}>
<ThreadVisibility
threadType={threadType}
color={threadVisibilityColor}
/>
<Text style={this.props.styles.parentThreadLabel}>within</Text>
<Text style={this.props.styles.parentThreadName}>
{parentThreadInfo.uiName}
</Text>
</View>
);
}
const inputProps = {
...tagInputProps,
onSubmitEditing: this.onPressCreateThread,
};
return (
<View style={this.props.styles.container}>
{parentThreadRow}
<View style={this.props.styles.userSelectionRow}>
<Text style={this.props.styles.tagInputLabel}>To: </Text>
<View style={this.props.styles.tagInputContainer}>
<TagInput
value={this.state.userInfoInputArray}
onChange={this.onChangeTagInput}
text={this.state.usernameInputText}
onChangeText={this.setUsernameInputText}
labelExtractor={this.tagDataLabelExtractor}
inputProps={inputProps}
innerRef={this.tagInputRef}
/>
</View>
</View>
<View style={this.props.styles.userList}>
<UserList
userInfos={userSearchResults}
onSelect={this.onUserSelect}
itemTextStyle={this.props.styles.listItem}
/>
</View>
{existingThreadsSection}
</View>
);
}
tagInputRef = (tagInput: ?TagInput<AccountUserInfo>) => {
this.tagInput = tagInput;
};
onChangeTagInput = (userInfoInputArray: $ReadOnlyArray<AccountUserInfo>) => {
this.setState({ userInfoInputArray });
};
tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username;
setUsernameInputText = (text: string) => {
- this.searchUsers(text);
this.setState({ usernameInputText: text });
};
- searchUsers(usernamePrefix: string) {
- this.props.dispatchActionPromise(
- searchUsersActionTypes,
- this.props.searchUsers(usernamePrefix),
- );
- }
-
onUserSelect = (userID: string) => {
for (let existingUserInfo of this.state.userInfoInputArray) {
if (userID === existingUserInfo.id) {
return;
}
}
const userInfoInputArray = [
...this.state.userInfoInputArray,
this.props.otherUserInfos[userID],
];
this.setState({
userInfoInputArray,
usernameInputText: '',
});
};
onPressCreateThread = () => {
if (this.createThreadPressed) {
return;
}
if (this.state.userInfoInputArray.length === 0) {
Alert.alert(
'Chatting to yourself?',
'Are you sure you want to create a thread containing only yourself?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Confirm', onPress: this.dispatchNewChatThreadAction },
],
);
} else {
this.dispatchNewChatThreadAction();
}
};
dispatchNewChatThreadAction = async () => {
this.createThreadPressed = true;
this.props.dispatchActionPromise(
newThreadActionTypes,
this.newChatThreadAction(),
);
};
async newChatThreadAction() {
this.setLinkButton(false);
try {
const threadTypeParam = this.props.route.params.threadType;
const threadType = threadTypeParam
? threadTypeParam
: threadTypes.CHAT_SECRET;
const initialMemberIDs = this.state.userInfoInputArray.map(
(userInfo: AccountUserInfo) => userInfo.id,
);
const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props);
const result = await this.props.newThread({
type: threadType,
parentThreadID: parentThreadInfo ? parentThreadInfo.id : null,
initialMemberIDs,
color: parentThreadInfo ? parentThreadInfo.color : null,
});
this.waitingOnThreadID = result.newThreadID;
return result;
} catch (e) {
this.createThreadPressed = false;
this.setLinkButton(true);
Alert.alert(
'Unknown error',
'Uhh... try again?',
[{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }],
{ cancelable: false },
);
throw e;
}
}
onErrorAcknowledged = () => {
invariant(this.tagInput, 'tagInput should be set');
this.tagInput.focus();
};
onUnknownErrorAlertAcknowledged = () => {
this.setState({ usernameInputText: '' }, this.onErrorAcknowledged);
};
onSelectExistingThread = (threadID: string) => {
const threadInfo = this.props.threadInfos[threadID];
this.props.navigation.navigate({
name: MessageListRouteName,
params: { threadInfo },
key: `${MessageListRouteName}${threadInfo.id}`,
});
};
}
const styles = {
container: {
flex: 1,
},
existingThreadList: {
backgroundColor: 'modalBackground',
flex: 1,
paddingRight: 12,
},
existingThreads: {
flex: 1,
},
existingThreadsLabel: {
color: 'modalForegroundSecondaryLabel',
fontSize: 16,
paddingLeft: 12,
textAlign: 'center',
},
existingThreadsRow: {
backgroundColor: 'modalForeground',
borderBottomWidth: 1,
borderColor: 'modalForegroundBorder',
borderTopWidth: 1,
paddingVertical: 6,
},
listItem: {
color: 'modalForegroundLabel',
},
parentThreadLabel: {
color: 'modalSubtextLabel',
fontSize: 16,
paddingLeft: 6,
},
parentThreadName: {
color: 'modalForegroundLabel',
fontSize: 16,
paddingLeft: 6,
},
parentThreadRow: {
alignItems: 'center',
backgroundColor: 'modalSubtext',
flexDirection: 'row',
paddingLeft: 12,
paddingVertical: 6,
},
tagInputContainer: {
flex: 1,
marginLeft: 8,
paddingRight: 12,
},
tagInputLabel: {
color: 'modalForegroundSecondaryLabel',
fontSize: 16,
paddingLeft: 12,
},
userList: {
backgroundColor: 'modalBackground',
flex: 1,
paddingLeft: 35,
paddingRight: 12,
},
userSelectionRow: {
alignItems: 'center',
backgroundColor: 'modalForeground',
borderBottomWidth: 1,
borderColor: 'modalForegroundBorder',
flexDirection: 'row',
paddingVertical: 6,
},
};
const stylesSelector = styleSelector(styles);
const loadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes);
-registerFetchKey(searchUsersActionTypes);
export default connect(
(
state: AppState,
ownProps: {
route: NavigationRoute<'ComposeThread'>,
},
) => {
let reduxParentThreadInfo = null;
const parentThreadInfo = ownProps.route.params.parentThreadInfo;
if (parentThreadInfo) {
reduxParentThreadInfo = threadInfoSelector(state)[parentThreadInfo.id];
}
return {
parentThreadInfo: reduxParentThreadInfo,
loadingStatus: loadingStatusSelector(state),
otherUserInfos: userInfoSelectorForOtherMembersOfThread((null: ?string))(
state,
),
userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state),
threadInfos: threadInfoSelector(state),
colors: colorsSelector(state),
styles: stylesSelector(state),
viewerID: state.currentUserInfo && state.currentUserInfo.id,
};
},
- { newThread, searchUsers },
+ { newThread },
)(ComposeThread);
diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js
index 8329cf1ce..1f911c699 100644
--- a/native/chat/settings/add-users-modal.react.js
+++ b/native/chat/settings/add-users-modal.react.js
@@ -1,371 +1,353 @@
// @flow
import type { AppState } from '../../redux/redux-setup';
import {
type ThreadInfo,
threadInfoPropType,
type ChangeThreadSettingsPayload,
type UpdateThreadRequest,
} from 'lib/types/thread-types';
import {
type AccountUserInfo,
accountUserInfoPropType,
} from 'lib/types/user-types';
import type { DispatchActionPromise } from 'lib/utils/action-utils';
-import type { UserSearchResult } from 'lib/types/search-types';
import type { LoadingStatus } from 'lib/types/loading-types';
import { loadingStatusPropType } from 'lib/types/loading-types';
import type { RootNavigationProp } from '../../navigation/root-navigator.react';
import type { NavigationRoute } from '../../navigation/route-names';
import * as React from 'react';
import { View, Text, ActivityIndicator, Alert } from 'react-native';
import PropTypes from 'prop-types';
import invariant from 'invariant';
import { createSelector } from 'reselect';
import {
userInfoSelectorForOtherMembersOfThread,
userSearchIndexForOtherMembersOfThread,
} from 'lib/selectors/user-selectors';
import SearchIndex from 'lib/shared/search-index';
import { getUserSearchResults } from 'lib/shared/search-utils';
import { connect } from 'lib/utils/redux-utils';
import {
changeThreadSettingsActionTypes,
changeThreadSettings,
} from 'lib/actions/thread-actions';
-import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors';
-import { registerFetchKey } from 'lib/reducers/loading-reducer';
import { threadActualMembers } from 'lib/shared/thread-utils';
import { threadInfoSelector } from 'lib/selectors/thread-selectors';
import UserList from '../../components/user-list.react';
import TagInput from '../../components/tag-input.react';
import Button from '../../components/button.react';
import Modal from '../../components/modal.react';
import { styleSelector } from '../../themes/colors';
const tagInputProps = {
placeholder: 'Select users to add',
autoFocus: true,
returnKeyType: 'go',
};
export type AddUsersModalParams = {|
presentedFrom: string,
threadInfo: ThreadInfo,
|};
type Props = {|
navigation: RootNavigationProp<'AddUsersModal'>,
route: NavigationRoute<'AddUsersModal'>,
// Redux state
parentThreadInfo: ?ThreadInfo,
otherUserInfos: { [id: string]: AccountUserInfo },
userSearchIndex: SearchIndex,
changeThreadSettingsLoadingStatus: LoadingStatus,
styles: typeof styles,
// Redux dispatch functions
dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
changeThreadSettings: (
request: UpdateThreadRequest,
) => Promise<ChangeThreadSettingsPayload>,
- searchUsers: (usernamePrefix: string) => Promise<UserSearchResult>,
|};
type State = {|
usernameInputText: string,
userInfoInputArray: $ReadOnlyArray<AccountUserInfo>,
|};
type PropsAndState = {| ...Props, ...State |};
class AddUsersModal extends React.PureComponent<Props, State> {
static propTypes = {
navigation: PropTypes.shape({
goBackOnce: PropTypes.func.isRequired,
}).isRequired,
route: PropTypes.shape({
params: PropTypes.shape({
threadInfo: threadInfoPropType.isRequired,
}).isRequired,
}).isRequired,
parentThreadInfo: threadInfoPropType,
otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired,
userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired,
changeThreadSettingsLoadingStatus: loadingStatusPropType.isRequired,
styles: PropTypes.objectOf(PropTypes.object).isRequired,
dispatchActionPromise: PropTypes.func.isRequired,
changeThreadSettings: PropTypes.func.isRequired,
- searchUsers: PropTypes.func.isRequired,
};
state = {
usernameInputText: '',
userInfoInputArray: [],
};
tagInput: ?TagInput<AccountUserInfo> = null;
- componentDidMount() {
- this.searchUsers('');
- }
-
- searchUsers(usernamePrefix: string) {
- this.props.dispatchActionPromise(
- searchUsersActionTypes,
- this.props.searchUsers(usernamePrefix),
- );
- }
-
userSearchResultsSelector = createSelector(
(propsAndState: PropsAndState) => propsAndState.usernameInputText,
(propsAndState: PropsAndState) => propsAndState.otherUserInfos,
(propsAndState: PropsAndState) => propsAndState.userSearchIndex,
(propsAndState: PropsAndState) => propsAndState.userInfoInputArray,
(propsAndState: PropsAndState) => propsAndState.route.params.threadInfo,
(propsAndState: PropsAndState) => propsAndState.parentThreadInfo,
(
text: string,
userInfos: { [id: string]: AccountUserInfo },
searchIndex: SearchIndex,
userInfoInputArray: $ReadOnlyArray<AccountUserInfo>,
threadInfo: ThreadInfo,
parentThreadInfo: ?ThreadInfo,
) => {
const excludeUserIDs = userInfoInputArray
.map(userInfo => userInfo.id)
.concat(threadActualMembers(threadInfo.members));
return getUserSearchResults(
text,
userInfos,
searchIndex,
excludeUserIDs,
parentThreadInfo,
);
},
);
get userSearchResults() {
return this.userSearchResultsSelector({ ...this.props, ...this.state });
}
render() {
let addButton = null;
const inputLength = this.state.userInfoInputArray.length;
if (inputLength > 0) {
let activityIndicator = null;
if (this.props.changeThreadSettingsLoadingStatus === 'loading') {
activityIndicator = (
<View style={this.props.styles.activityIndicator}>
<ActivityIndicator color="white" />
</View>
);
}
const addButtonText = `Add (${inputLength})`;
addButton = (
<Button
onPress={this.onPressAdd}
style={this.props.styles.addButton}
disabled={this.props.changeThreadSettingsLoadingStatus === 'loading'}
>
{activityIndicator}
<Text style={this.props.styles.addText}>{addButtonText}</Text>
</Button>
);
}
let cancelButton;
if (this.props.changeThreadSettingsLoadingStatus !== 'loading') {
cancelButton = (
<Button onPress={this.close} style={this.props.styles.cancelButton}>
<Text style={this.props.styles.cancelText}>Cancel</Text>
</Button>
);
} else {
cancelButton = <View />;
}
const inputProps = {
...tagInputProps,
onSubmitEditing: this.onPressAdd,
};
return (
<Modal navigation={this.props.navigation}>
<TagInput
value={this.state.userInfoInputArray}
onChange={this.onChangeTagInput}
text={this.state.usernameInputText}
onChangeText={this.setUsernameInputText}
labelExtractor={this.tagDataLabelExtractor}
defaultInputWidth={160}
maxHeight={36}
inputProps={inputProps}
innerRef={this.tagInputRef}
/>
<UserList
userInfos={this.userSearchResults}
onSelect={this.onUserSelect}
/>
<View style={this.props.styles.buttons}>
{cancelButton}
{addButton}
</View>
</Modal>
);
}
close = () => {
this.props.navigation.goBackOnce();
};
tagInputRef = (tagInput: ?TagInput<AccountUserInfo>) => {
this.tagInput = tagInput;
};
onChangeTagInput = (userInfoInputArray: $ReadOnlyArray<AccountUserInfo>) => {
if (this.props.changeThreadSettingsLoadingStatus === 'loading') {
return;
}
this.setState({ userInfoInputArray });
};
tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username;
setUsernameInputText = (text: string) => {
if (this.props.changeThreadSettingsLoadingStatus === 'loading') {
return;
}
- this.searchUsers(text);
this.setState({ usernameInputText: text });
};
onUserSelect = (userID: string) => {
if (this.props.changeThreadSettingsLoadingStatus === 'loading') {
return;
}
for (let existingUserInfo of this.state.userInfoInputArray) {
if (userID === existingUserInfo.id) {
return;
}
}
const userInfoInputArray = [
...this.state.userInfoInputArray,
this.props.otherUserInfos[userID],
];
this.setState({
userInfoInputArray,
usernameInputText: '',
});
};
onPressAdd = () => {
if (this.state.userInfoInputArray.length === 0) {
return;
}
this.props.dispatchActionPromise(
changeThreadSettingsActionTypes,
this.addUsersToThread(),
);
};
async addUsersToThread() {
try {
const newMemberIDs = this.state.userInfoInputArray.map(
userInfo => userInfo.id,
);
const result = await this.props.changeThreadSettings({
threadID: this.props.route.params.threadInfo.id,
changes: { newMemberIDs },
});
this.close();
return result;
} catch (e) {
Alert.alert(
'Unknown error',
'Uhh... try again?',
[{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }],
{ cancelable: false },
);
throw e;
}
}
onErrorAcknowledged = () => {
invariant(this.tagInput, 'nameInput should be set');
this.tagInput.focus();
};
onUnknownErrorAlertAcknowledged = () => {
this.setState(
{
userInfoInputArray: [],
usernameInputText: '',
},
this.onErrorAcknowledged,
);
};
}
const styles = {
activityIndicator: {
paddingRight: 6,
},
addButton: {
backgroundColor: 'greenButton',
borderRadius: 3,
flexDirection: 'row',
paddingHorizontal: 10,
paddingVertical: 4,
},
addText: {
color: 'white',
fontSize: 18,
},
buttons: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
},
cancelButton: {
backgroundColor: 'modalButton',
borderRadius: 3,
paddingHorizontal: 10,
paddingVertical: 4,
},
cancelText: {
color: 'modalButtonLabel',
fontSize: 18,
},
};
const stylesSelector = styleSelector(styles);
const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector(
changeThreadSettingsActionTypes,
);
-registerFetchKey(searchUsersActionTypes);
export default connect(
(
state: AppState,
ownProps: {
route: NavigationRoute<'AddUsersModal'>,
},
) => {
let parentThreadInfo = null;
const { parentThreadID } = ownProps.route.params.threadInfo;
if (parentThreadID) {
parentThreadInfo = threadInfoSelector(state)[parentThreadID];
}
return {
parentThreadInfo,
otherUserInfos: userInfoSelectorForOtherMembersOfThread((null: ?string))(
state,
),
userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state),
changeThreadSettingsLoadingStatus: changeThreadSettingsLoadingStatusSelector(
state,
),
styles: stylesSelector(state),
};
},
- { changeThreadSettings, searchUsers },
+ { changeThreadSettings },
)(AddUsersModal);
diff --git a/native/more/add-friends-modal.react.js b/native/more/add-friends-modal.react.js
index 94e1c1f10..74865c0b1 100644
--- a/native/more/add-friends-modal.react.js
+++ b/native/more/add-friends-modal.react.js
@@ -1,233 +1,230 @@
// @flow
import type { AccountUserInfo } from 'lib/types/user-types';
import type { UserSearchResult } from 'lib/types/search-types';
import type { DispatchActionPromise } from 'lib/utils/action-utils';
import type { RootNavigationProp } from '../navigation/root-navigator.react';
import type { NavigationRoute } from '../navigation/route-names';
import type { AppState } from '../redux/redux-setup';
import * as React from 'react';
import { Text, View } from 'react-native';
import { CommonActions } from '@react-navigation/native';
import { createSelector } from 'reselect';
+import _keyBy from 'lodash/fp/keyBy';
-import {
- userInfoSelectorForOtherMembersOfThread,
- userSearchIndexForOtherMembersOfThread,
-} from 'lib/selectors/user-selectors';
+import { searchIndexFromUserInfos } from 'lib/selectors/user-selectors';
import { registerFetchKey } from 'lib/reducers/loading-reducer';
import { getUserSearchResults } from 'lib/shared/search-utils';
-import SearchIndex from 'lib/shared/search-index';
import { connect } from 'lib/utils/redux-utils';
import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions';
import UserList from '../components/user-list.react';
import Modal from '../components/modal.react';
import Button from '../components/button.react';
import TagInput from '../components/tag-input.react';
import { styleSelector } from '../themes/colors';
const tagInputProps = {
placeholder: 'Select users to invite',
autoFocus: true,
returnKeyType: 'go',
};
type Props = {|
navigation: RootNavigationProp<'AddFriendsModal'>,
route: NavigationRoute<'AddFriendsModal'>,
// Redux state
- otherUserInfos: { [id: string]: AccountUserInfo },
- userSearchIndex: SearchIndex,
+ viewerID: ?string,
styles: typeof styles,
// Redux dispatch functions
dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
searchUsers: (usernamePrefix: string) => Promise<UserSearchResult>,
sendFriendRequest: (userIDs: string[]) => Promise<void>,
|};
type State = {|
usernameInputText: string,
userInfoInputArray: $ReadOnlyArray<AccountUserInfo>,
+ userInfos: { [id: string]: AccountUserInfo },
|};
type PropsAndState = {| ...Props, ...State |};
class AddFriendsModal extends React.PureComponent<Props, State> {
state = {
usernameInputText: '',
userInfoInputArray: [],
+ userInfos: {},
};
tagInput: ?TagInput<AccountUserInfo> = null;
componentDidMount() {
this.searchUsers('');
}
- searchUsers(usernamePrefix: string) {
- this.props.dispatchActionPromise(
- searchUsersActionTypes,
- this.props.searchUsers(usernamePrefix),
- );
+ async searchUsers(usernamePrefix: string) {
+ const { userInfos } = await this.props.searchUsers(usernamePrefix);
+ this.setState({ userInfos: _keyBy(userInfo => userInfo.id)(userInfos) });
}
userSearchResultsSelector = createSelector(
(propsAndState: PropsAndState) => propsAndState.usernameInputText,
- (propsAndState: PropsAndState) => propsAndState.otherUserInfos,
- (propsAndState: PropsAndState) => propsAndState.userSearchIndex,
+ (propsAndState: PropsAndState) => propsAndState.userInfos,
+ (propsAndState: PropsAndState) => propsAndState.viewerID,
(propsAndState: PropsAndState) => propsAndState.userInfoInputArray,
(
text: string,
userInfos: { [id: string]: AccountUserInfo },
- searchIndex: SearchIndex,
+ viewerID: ?string,
userInfoInputArray: $ReadOnlyArray<AccountUserInfo>,
) => {
- // TODO: exclude current and blocked friends
- const excludeUserIDs = userInfoInputArray.map(userInfo => userInfo.id);
+ const excludeUserIDs = userInfoInputArray
+ .map(userInfo => userInfo.id)
+ .concat(viewerID || []);
+ const searchIndex = searchIndexFromUserInfos(userInfos);
return getUserSearchResults(text, userInfos, searchIndex, excludeUserIDs);
},
);
get userSearchResults() {
return this.userSearchResultsSelector({ ...this.props, ...this.state });
}
render() {
let addButton = null;
const inputLength = this.state.userInfoInputArray.length;
if (inputLength > 0) {
const addButtonText = `Add (${inputLength})`;
addButton = (
<Button onPress={this.onPressAdd} style={this.props.styles.addButton}>
<Text style={this.props.styles.addText}>{addButtonText}</Text>
</Button>
);
}
const inputProps = {
...tagInputProps,
onSubmitEditing: this.onPressAdd,
};
return (
<Modal navigation={this.props.navigation}>
<TagInput
value={this.state.userInfoInputArray}
onChange={this.onChangeTagInput}
text={this.state.usernameInputText}
onChangeText={this.setUsernameInputText}
labelExtractor={this.tagDataLabelExtractor}
defaultInputWidth={160}
maxHeight={36}
inputProps={inputProps}
innerRef={this.tagInputRef}
/>
<UserList
userInfos={this.userSearchResults}
onSelect={this.onUserSelect}
/>
<View style={this.props.styles.buttons}>
<Button onPress={this.close} style={this.props.styles.cancelButton}>
<Text style={this.props.styles.cancelText}>Cancel</Text>
</Button>
{addButton}
</View>
</Modal>
);
}
tagInputRef = (tagInput: ?TagInput<AccountUserInfo>) => {
this.tagInput = tagInput;
};
tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username;
setUsernameInputText = (text: string) => {
this.searchUsers(text);
this.setState({ usernameInputText: text });
};
onChangeTagInput = (userInfoInputArray: $ReadOnlyArray<AccountUserInfo>) => {
this.setState({ userInfoInputArray });
};
onUserSelect = (userID: string) => {
if (this.state.userInfoInputArray.find(o => o.id === userID)) {
return;
}
- const selectedUserInfo = this.props.otherUserInfos[userID];
+ const selectedUserInfo = this.state.userInfos[userID];
this.setState(state => ({
userInfoInputArray: state.userInfoInputArray.concat(selectedUserInfo),
usernameInputText: '',
}));
};
onPressAdd = () => {
if (this.state.userInfoInputArray.length === 0) {
return;
}
this.props.navigation.goBack();
};
goBackOnce() {
this.props.navigation.dispatch(state => ({
...CommonActions.goBack(),
target: state.key,
}));
}
close = () => {
this.goBackOnce();
};
}
const styles = {
activityIndicator: {
paddingRight: 6,
},
addButton: {
backgroundColor: 'greenButton',
borderRadius: 3,
flexDirection: 'row',
paddingHorizontal: 10,
paddingVertical: 4,
},
addText: {
color: 'white',
fontSize: 18,
},
buttons: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
},
cancelButton: {
backgroundColor: 'modalButton',
borderRadius: 3,
paddingHorizontal: 10,
paddingVertical: 4,
},
cancelText: {
color: 'modalButtonLabel',
fontSize: 18,
},
};
const stylesSelector = styleSelector(styles);
registerFetchKey(searchUsersActionTypes);
export default connect(
(state: AppState) => {
return {
- otherUserInfos: userInfoSelectorForOtherMembersOfThread(null)(state),
- userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state),
+ viewerID: state.currentUserInfo && state.currentUserInfo.id,
styles: stylesSelector(state),
};
},
{ searchUsers },
)(AddFriendsModal);
diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js
index e465154e3..8598f9e0a 100644
--- a/server/src/creators/thread-creator.js
+++ b/server/src/creators/thread-creator.js
@@ -1,228 +1,228 @@
// @flow
import {
type NewThreadRequest,
type NewThreadResponse,
threadTypes,
threadPermissions,
} from 'lib/types/thread-types';
import { messageTypes } from 'lib/types/message-types';
import type { Viewer } from '../session/viewer';
import invariant from 'invariant';
import { generateRandomColor } from 'lib/shared/thread-utils';
import { ServerError } from 'lib/utils/errors';
import { promiseAll } from 'lib/utils/promises';
import { hasMinCodeVersion } from 'lib/shared/version-utils';
import { dbQuery, SQL } from '../database';
import { checkThreadPermission } from '../fetchers/thread-fetchers';
import createIDs from './id-creator';
import { createInitialRolesForNewThread } from './role-creator';
import { fetchKnownUserInfos } from '../fetchers/user-fetchers';
import {
changeRole,
recalculateAllPermissions,
commitMembershipChangeset,
setJoinsToUnread,
getRelationshipRowsForUsers,
getParentThreadRelationshipRowsForNewUsers,
} from '../updaters/thread-permission-updaters';
import createMessages from './message-creator';
async function createThread(
viewer: Viewer,
request: NewThreadRequest,
createRelationships?: boolean = false,
): Promise<NewThreadResponse> {
if (!viewer.loggedIn) {
throw new ServerError('not_logged_in');
}
const threadType = request.type;
const parentThreadID = request.parentThreadID ? request.parentThreadID : null;
const initialMemberIDs =
request.initialMemberIDs && request.initialMemberIDs.length > 0
? request.initialMemberIDs
- : [];
+ : null;
if (threadType !== threadTypes.CHAT_SECRET && !parentThreadID) {
throw new ServerError('invalid_parameters');
}
const checkPromises = {};
if (parentThreadID) {
checkPromises.hasParentPermission = checkThreadPermission(
viewer,
parentThreadID,
threadPermissions.CREATE_SUBTHREADS,
);
}
if (initialMemberIDs) {
checkPromises.fetchInitialMembers = fetchKnownUserInfos(
viewer,
initialMemberIDs,
);
}
const checkResults = await promiseAll(checkPromises);
if (checkResults.hasParentPermission === false) {
throw new ServerError('invalid_credentials');
}
const viewerNeedsRelationshipsWith = [];
if (checkResults.fetchInitialMembers) {
invariant(initialMemberIDs, 'should be set');
for (const initialMemberID of initialMemberIDs) {
if (checkResults.fetchInitialMembers[initialMemberID]) {
continue;
}
if (!createRelationships) {
throw new ServerError('invalid_credentials');
}
viewerNeedsRelationshipsWith.push(initialMemberID);
}
}
const [id] = await createIDs('threads', 1);
const newRoles = await createInitialRolesForNewThread(id, threadType);
const name = request.name ? request.name : null;
const description = request.description ? request.description : null;
const color = request.color
? request.color.toLowerCase()
: generateRandomColor();
const time = Date.now();
const row = [
id,
threadType,
name,
description,
viewer.userID,
time,
color,
parentThreadID,
newRoles.default.id,
];
const query = SQL`
INSERT INTO threads(id, type, name, description, creator,
creation_time, color, parent_thread_id, default_role)
VALUES ${[row]}
`;
await dbQuery(query);
const [
creatorChangeset,
initialMembersChangeset,
recalculatePermissionsChangeset,
] = await Promise.all([
changeRole(id, [viewer.userID], newRoles.creator.id),
initialMemberIDs ? changeRole(id, initialMemberIDs, null) : undefined,
recalculateAllPermissions(id, threadType),
]);
if (!creatorChangeset) {
throw new ServerError('unknown_error');
}
const {
membershipRows: creatorMembershipRows,
relationshipRows: creatorRelationshipRows,
} = creatorChangeset;
const initialMemberAndCreatorIDs = initialMemberIDs
? [...initialMemberIDs, viewer.userID]
: [viewer.userID];
const {
membershipRows: recalculateMembershipRows,
relationshipRows: recalculateRelationshipRows,
} = recalculatePermissionsChangeset;
const membershipRows = [
...creatorMembershipRows,
...recalculateMembershipRows,
];
const relationshipRows = [
...creatorRelationshipRows,
...recalculateRelationshipRows,
];
if (initialMemberIDs) {
if (!initialMembersChangeset) {
throw new ServerError('unknown_error');
}
relationshipRows.push(
...getRelationshipRowsForUsers(
viewer.userID,
viewerNeedsRelationshipsWith,
),
);
const {
membershipRows: initialMembersMembershipRows,
relationshipRows: initialMembersRelationshipRows,
} = initialMembersChangeset;
const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers(
id,
recalculateMembershipRows,
initialMemberAndCreatorIDs,
);
membershipRows.push(...initialMembersMembershipRows);
relationshipRows.push(
...initialMembersRelationshipRows,
...parentRelationshipRows,
);
}
setJoinsToUnread(membershipRows, viewer.userID, id);
const messageDatas = [
{
type: messageTypes.CREATE_THREAD,
threadID: id,
creatorID: viewer.userID,
time,
initialThreadState: {
type: threadType,
name,
parentThreadID,
color,
memberIDs: initialMemberAndCreatorIDs,
},
},
];
if (parentThreadID) {
messageDatas.push({
type: messageTypes.CREATE_SUB_THREAD,
threadID: parentThreadID,
creatorID: viewer.userID,
time,
childThreadID: id,
});
}
const changeset = { membershipRows, relationshipRows };
const [newMessageInfos, commitResult] = await Promise.all([
createMessages(viewer, messageDatas),
commitMembershipChangeset(viewer, changeset),
]);
const { threadInfos, viewerUpdates } = commitResult;
if (hasMinCodeVersion(viewer.platformDetails, 62)) {
return {
newThreadID: id,
updatesResult: {
newUpdates: viewerUpdates,
},
newMessageInfos,
};
}
return {
newThreadInfo: threadInfos[id],
updatesResult: {
newUpdates: viewerUpdates,
},
newMessageInfos,
};
}
export default createThread;
diff --git a/server/src/fetchers/entry-fetchers.js b/server/src/fetchers/entry-fetchers.js
index 745c688ac..6ccae138f 100644
--- a/server/src/fetchers/entry-fetchers.js
+++ b/server/src/fetchers/entry-fetchers.js
@@ -1,339 +1,319 @@
// @flow
import type {
CalendarQuery,
FetchEntryInfosResponse,
DeltaEntryInfosResponse,
RawEntryInfo,
} from 'lib/types/entry-types';
import type { HistoryRevisionInfo } from 'lib/types/history-types';
import type { Viewer } from '../session/viewer';
import {
threadPermissions,
type ThreadPermission,
} from 'lib/types/thread-types';
import { calendarThreadFilterTypes } from 'lib/types/filter-types';
import invariant from 'invariant';
import { permissionLookup } from 'lib/permissions/thread-permissions';
import { ServerError } from 'lib/utils/errors';
import {
filteredThreadIDs,
filterExists,
nonExcludeDeletedCalendarFilters,
} from 'lib/selectors/calendar-filter-selectors';
-import {
- rawEntryInfoWithinCalendarQuery,
- usersInRawEntryInfos,
-} from 'lib/shared/entry-utils';
+import { rawEntryInfoWithinCalendarQuery } from 'lib/shared/entry-utils';
import {
dbQuery,
SQL,
SQLStatement,
mergeAndConditions,
mergeOrConditions,
} from '../database';
import { creationString } from '../utils/idempotent';
async function fetchEntryInfo(
viewer: Viewer,
entryID: string,
): Promise<?RawEntryInfo> {
const results = await fetchEntryInfosByID(viewer, [entryID]);
if (results.length === 0) {
return null;
}
return results[0];
}
function rawEntryInfoFromRow(row: Object): RawEntryInfo {
return {
id: row.id.toString(),
threadID: row.threadID.toString(),
text: row.text,
year: row.year,
month: row.month,
day: row.day,
creationTime: row.creationTime,
creatorID: row.creatorID.toString(),
deleted: !!row.deleted,
};
}
const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`;
async function fetchEntryInfosByID(
viewer: Viewer,
entryIDs: $ReadOnlyArray<string>,
): Promise<RawEntryInfo[]> {
if (entryIDs.length === 0) {
return [];
}
const viewerID = viewer.id;
const query = SQL`
SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year,
e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID,
e.deleted, e.creator AS creatorID
FROM entries e
LEFT JOIN days d ON d.id = e.day
LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID}
WHERE e.id IN (${entryIDs}) AND
JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE
`;
const [result] = await dbQuery(query);
return result.map(rawEntryInfoFromRow);
}
function sqlConditionForCalendarQuery(
calendarQuery: CalendarQuery,
): ?SQLStatement {
const { filters, startDate, endDate } = calendarQuery;
const conditions = [];
conditions.push(SQL`d.date BETWEEN ${startDate} AND ${endDate}`);
const filterToThreadIDs = filteredThreadIDs(filters);
if (filterToThreadIDs && filterToThreadIDs.size > 0) {
conditions.push(SQL`d.thread IN (${[...filterToThreadIDs]})`);
} else if (filterToThreadIDs) {
// Filter to empty set means the result is empty
return null;
} else {
conditions.push(SQL`m.role != 0`);
}
if (filterExists(filters, calendarThreadFilterTypes.NOT_DELETED)) {
conditions.push(SQL`e.deleted = 0`);
}
return mergeAndConditions(conditions);
}
async function fetchEntryInfos(
viewer: Viewer,
calendarQueries: $ReadOnlyArray<CalendarQuery>,
): Promise<FetchEntryInfosResponse> {
const queryConditions = calendarQueries
.map(sqlConditionForCalendarQuery)
.filter(condition => condition);
if (queryConditions.length === 0) {
return { rawEntryInfos: [], userInfos: {} };
}
const queryCondition = mergeOrConditions(queryConditions);
const viewerID = viewer.id;
const query = SQL`
SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year,
e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID,
e.deleted, e.creator AS creatorID, u.username AS creator
FROM entries e
LEFT JOIN days d ON d.id = e.day
LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID}
LEFT JOIN users u ON u.id = e.creator
WHERE JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE AND
`;
query.append(queryCondition);
query.append(SQL`ORDER BY e.creation_time DESC`);
const [result] = await dbQuery(query);
const rawEntryInfos = [];
- const userInfos = {};
for (let row of result) {
rawEntryInfos.push(rawEntryInfoFromRow(row));
- if (row.creator) {
- const creatorID = row.creatorID.toString();
- userInfos[creatorID] = {
- id: creatorID,
- username: row.creator,
- };
- }
}
- return { rawEntryInfos, userInfos };
+ return { rawEntryInfos, userInfos: {} };
}
async function checkThreadPermissionForEntry(
viewer: Viewer,
entryID: string,
permission: ThreadPermission,
): Promise<boolean> {
const viewerID = viewer.id;
const query = SQL`
SELECT m.permissions, t.id
FROM entries e
LEFT JOIN days d ON d.id = e.day
LEFT JOIN threads t ON t.id = d.thread
LEFT JOIN memberships m ON m.thread = t.id AND m.user = ${viewerID}
WHERE e.id = ${entryID}
`;
const [result] = await dbQuery(query);
if (result.length === 0) {
return false;
}
const row = result[0];
if (row.id === null) {
return false;
}
return permissionLookup(row.permissions, permission);
}
async function fetchEntryRevisionInfo(
viewer: Viewer,
entryID: string,
): Promise<$ReadOnlyArray<HistoryRevisionInfo>> {
const hasPermission = await checkThreadPermissionForEntry(
viewer,
entryID,
threadPermissions.VISIBLE,
);
if (!hasPermission) {
throw new ServerError('invalid_credentials');
}
const query = SQL`
SELECT r.id, u.username AS author, r.text, r.last_update AS lastUpdate,
r.deleted, d.thread AS threadID, r.entry AS entryID
FROM revisions r
LEFT JOIN users u ON u.id = r.author
LEFT JOIN entries e ON e.id = r.entry
LEFT JOIN days d ON d.id = e.day
WHERE r.entry = ${entryID}
ORDER BY r.last_update DESC
`;
const [result] = await dbQuery(query);
const revisions = [];
for (let row of result) {
revisions.push({
id: row.id.toString(),
author: row.author,
text: row.text,
lastUpdate: row.lastUpdate,
deleted: !!row.deleted,
threadID: row.threadID.toString(),
entryID: row.entryID.toString(),
});
}
return revisions;
}
// calendarQueries are the "difference" queries we get from subtracting the old
// CalendarQuery from the new one. See calendarQueryDifference.
// oldCalendarQuery is the old CalendarQuery. We make sure none of the returned
// RawEntryInfos match the old CalendarQuery, so that only the difference is
// returned.
async function fetchEntriesForSession(
viewer: Viewer,
calendarQueries: $ReadOnlyArray<CalendarQuery>,
oldCalendarQuery: CalendarQuery,
): Promise<DeltaEntryInfosResponse> {
// If we're not including deleted entries, we will try and set deletedEntryIDs
// so that the client can catch possibly stale deleted entryInfos
let filterDeleted = null;
for (let calendarQuery of calendarQueries) {
const notDeletedFilterExists = filterExists(
calendarQuery.filters,
calendarThreadFilterTypes.NOT_DELETED,
);
if (filterDeleted === null) {
filterDeleted = notDeletedFilterExists;
} else {
invariant(
filterDeleted === notDeletedFilterExists,
'one of the CalendarQueries returned by calendarQueryDifference has ' +
'a NOT_DELETED filter but another does not: ' +
JSON.stringify(calendarQueries),
);
}
}
let calendarQueriesForFetch = calendarQueries;
if (filterDeleted) {
// Because in the filterDeleted case we still need the deleted RawEntryInfos
// in order to construct deletedEntryIDs, we get rid of the NOT_DELETED
// filters before passing the CalendarQueries to fetchEntryInfos. We will
// filter out the deleted RawEntryInfos in a later step.
calendarQueriesForFetch = calendarQueriesForFetch.map(calendarQuery => ({
...calendarQuery,
filters: nonExcludeDeletedCalendarFilters(calendarQuery.filters),
}));
}
- const { rawEntryInfos, userInfos } = await fetchEntryInfos(
+ const { rawEntryInfos } = await fetchEntryInfos(
viewer,
calendarQueriesForFetch,
);
const entryInfosNotInOldQuery = rawEntryInfos.filter(
rawEntryInfo =>
!rawEntryInfoWithinCalendarQuery(rawEntryInfo, oldCalendarQuery),
);
let filteredRawEntryInfos = entryInfosNotInOldQuery;
let deletedEntryIDs = [];
if (filterDeleted) {
filteredRawEntryInfos = entryInfosNotInOldQuery.filter(
rawEntryInfo => !rawEntryInfo.deleted,
);
deletedEntryIDs = entryInfosNotInOldQuery
.filter(rawEntryInfo => rawEntryInfo.deleted)
.map(rawEntryInfo => {
const { id } = rawEntryInfo;
invariant(
id !== null && id !== undefined,
'serverID should be set in fetchEntryInfos result',
);
return id;
});
}
- const userIDs = new Set(usersInRawEntryInfos(filteredRawEntryInfos));
- const filteredUserInfos = {};
- for (let userID in userInfos) {
- if (!userIDs.has(userID)) {
- continue;
- }
- filteredUserInfos[userID] = userInfos[userID];
- }
return {
rawEntryInfos: filteredRawEntryInfos,
deletedEntryIDs,
- userInfos: filteredUserInfos,
};
}
async function fetchEntryInfoForLocalID(
viewer: Viewer,
localID: ?string,
): Promise<?RawEntryInfo> {
if (!localID || !viewer.hasSessionInfo) {
return null;
}
const creation = creationString(viewer, localID);
const viewerID = viewer.id;
const query = SQL`
SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year,
e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID,
e.deleted, e.creator AS creatorID
FROM entries e
LEFT JOIN days d ON d.id = e.day
LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID}
WHERE e.creator = ${viewerID} AND e.creation = ${creation} AND
JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE
`;
const [result] = await dbQuery(query);
if (result.length === 0) {
return null;
}
return rawEntryInfoFromRow(result[0]);
}
export {
fetchEntryInfo,
fetchEntryInfosByID,
fetchEntryInfos,
checkThreadPermissionForEntry,
fetchEntryRevisionInfo,
fetchEntriesForSession,
fetchEntryInfoForLocalID,
};
diff --git a/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js
index 4a1e31c68..78a05d596 100644
--- a/server/src/fetchers/message-fetchers.js
+++ b/server/src/fetchers/message-fetchers.js
@@ -1,730 +1,729 @@
// @flow
import type { PushInfo } from '../push/send';
import type { UserInfos } from 'lib/types/user-types';
import {
type RawMessageInfo,
messageTypes,
type MessageType,
assertMessageType,
type ThreadSelectionCriteria,
type MessageTruncationStatus,
messageTruncationStatus,
type FetchMessageInfosResult,
type RawTextMessageInfo,
} from 'lib/types/message-types';
import { threadPermissions } from 'lib/types/thread-types';
import type { Viewer } from '../session/viewer';
import invariant from 'invariant';
import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils';
import {
sortMessageInfoList,
shimUnsupportedRawMessageInfos,
createMediaMessageInfo,
} from 'lib/shared/message-utils';
import { permissionLookup } from 'lib/permissions/thread-permissions';
import { ServerError } from 'lib/utils/errors';
import { dbQuery, SQL, mergeOrConditions } from '../database';
import { fetchUserInfos } from './user-fetchers';
import { creationString, localIDFromCreationString } from '../utils/idempotent';
import { mediaFromRow } from './upload-fetchers';
export type CollapsableNotifInfo = {|
collapseKey: ?string,
existingMessageInfos: RawMessageInfo[],
newMessageInfos: RawMessageInfo[],
|};
export type FetchCollapsableNotifsResult = {|
usersToCollapsableNotifInfo: { [userID: string]: CollapsableNotifInfo[] },
userInfos: UserInfos,
|};
// This function doesn't filter RawMessageInfos based on what messageTypes the
// client supports, since each user can have multiple clients. The caller must
// handle this filtering.
async function fetchCollapsableNotifs(
pushInfo: PushInfo,
): Promise<FetchCollapsableNotifsResult> {
// First, we need to fetch any notifications that should be collapsed
const usersToCollapseKeysToInfo = {};
const usersToCollapsableNotifInfo = {};
for (let userID in pushInfo) {
usersToCollapseKeysToInfo[userID] = {};
usersToCollapsableNotifInfo[userID] = [];
for (let rawMessageInfo of pushInfo[userID].messageInfos) {
const collapseKey = notifCollapseKeyForRawMessageInfo(rawMessageInfo);
if (!collapseKey) {
const collapsableNotifInfo = {
collapseKey,
existingMessageInfos: [],
newMessageInfos: [rawMessageInfo],
};
usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo);
continue;
}
if (!usersToCollapseKeysToInfo[userID][collapseKey]) {
usersToCollapseKeysToInfo[userID][collapseKey] = {
collapseKey,
existingMessageInfos: [],
newMessageInfos: [],
};
}
usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push(
rawMessageInfo,
);
}
}
const sqlTuples = [];
for (let userID in usersToCollapseKeysToInfo) {
const collapseKeysToInfo = usersToCollapseKeysToInfo[userID];
for (let collapseKey in collapseKeysToInfo) {
sqlTuples.push(
SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`,
);
}
}
if (sqlTuples.length === 0) {
return { usersToCollapsableNotifInfo, userInfos: {} };
}
const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`;
const collapseQuery = SQL`
SELECT m.id, m.thread AS threadID, m.content, m.time, m.type,
u.username AS creator, m.user AS creatorID,
stm.permissions AS subthread_permissions, n.user, n.collapse_key,
up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret,
up.extra AS uploadExtra
FROM notifications n
LEFT JOIN messages m ON m.id = n.message
LEFT JOIN uploads up
ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]})
AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$')
LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user
LEFT JOIN memberships stm
ON m.type = ${messageTypes.CREATE_SUB_THREAD}
AND stm.thread = m.content AND stm.user = n.user
LEFT JOIN users u ON u.id = m.user
WHERE n.rescinded = 0 AND
JSON_EXTRACT(mm.permissions, ${visPermissionExtractString}) IS TRUE AND
`;
collapseQuery.append(mergeOrConditions(sqlTuples));
collapseQuery.append(SQL`ORDER BY m.time DESC`);
const [collapseResult] = await dbQuery(collapseQuery);
const { userInfos, messages } = parseMessageSQLResult(collapseResult);
for (let message of messages) {
const { rawMessageInfo, rows } = message;
const [row] = rows;
const info = usersToCollapseKeysToInfo[row.user][row.collapse_key];
info.existingMessageInfos.push(rawMessageInfo);
}
for (let userID in usersToCollapseKeysToInfo) {
const collapseKeysToInfo = usersToCollapseKeysToInfo[userID];
for (let collapseKey in collapseKeysToInfo) {
const info = collapseKeysToInfo[collapseKey];
usersToCollapsableNotifInfo[userID].push({
collapseKey: info.collapseKey,
existingMessageInfos: sortMessageInfoList(info.existingMessageInfos),
newMessageInfos: sortMessageInfoList(info.newMessageInfos),
});
}
}
return { usersToCollapsableNotifInfo, userInfos };
}
type MessageSQLResult = {|
messages: $ReadOnlyArray<{|
rawMessageInfo: RawMessageInfo,
rows: $ReadOnlyArray<Object>,
|}>,
userInfos: UserInfos,
|};
function parseMessageSQLResult(
rows: $ReadOnlyArray<Object>,
viewer?: Viewer,
): MessageSQLResult {
const userInfos = {},
rowsByID = new Map();
for (let row of rows) {
const creatorID = row.creatorID.toString();
userInfos[creatorID] = {
id: creatorID,
username: row.creator,
};
const id = row.id.toString();
const currentRowsForID = rowsByID.get(id);
if (currentRowsForID) {
currentRowsForID.push(row);
} else {
rowsByID.set(id, [row]);
}
}
const messages = [];
for (let messageRows of rowsByID.values()) {
const rawMessageInfo = rawMessageInfoFromRows(messageRows, viewer);
if (rawMessageInfo) {
messages.push({ rawMessageInfo, rows: messageRows });
}
}
return { messages, userInfos };
}
function assertSingleRow(rows: $ReadOnlyArray<Object>): Object {
if (rows.length === 0) {
throw new Error('expected single row, but none present!');
} else if (rows.length !== 1) {
const messageIDs = rows.map(row => row.id.toString());
console.log(
`expected single row, but there are multiple! ${messageIDs.join(', ')}`,
);
}
return rows[0];
}
function mostRecentRowType(rows: $ReadOnlyArray<Object>): MessageType {
if (rows.length === 0) {
throw new Error('expected row, but none present!');
}
return assertMessageType(rows[0].type);
}
function rawMessageInfoFromRows(
rows: $ReadOnlyArray<Object>,
viewer?: ?Viewer,
): ?RawMessageInfo {
const type = mostRecentRowType(rows);
if (type === messageTypes.TEXT) {
const row = assertSingleRow(rows);
const rawTextMessageInfo: RawTextMessageInfo = {
type: messageTypes.TEXT,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
text: row.content,
};
const localID = localIDFromCreationString(viewer, row.creation);
if (localID) {
rawTextMessageInfo.localID = localID;
}
return rawTextMessageInfo;
} else if (type === messageTypes.CREATE_THREAD) {
const row = assertSingleRow(rows);
const dbInitialThreadState = JSON.parse(row.content);
// For legacy clients before the rename
const initialThreadState = {
...dbInitialThreadState,
visibilityRules: dbInitialThreadState.type,
};
return {
type: messageTypes.CREATE_THREAD,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
initialThreadState,
};
} else if (type === messageTypes.ADD_MEMBERS) {
const row = assertSingleRow(rows);
return {
type: messageTypes.ADD_MEMBERS,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
addedUserIDs: JSON.parse(row.content),
};
} else if (type === messageTypes.CREATE_SUB_THREAD) {
const row = assertSingleRow(rows);
const subthreadPermissions = row.subthread_permissions;
if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) {
return null;
}
return {
type: messageTypes.CREATE_SUB_THREAD,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
childThreadID: row.content,
};
} else if (type === messageTypes.CHANGE_SETTINGS) {
const row = assertSingleRow(rows);
const content = JSON.parse(row.content);
const field = Object.keys(content)[0];
return {
type: messageTypes.CHANGE_SETTINGS,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
field,
value: content[field],
};
} else if (type === messageTypes.REMOVE_MEMBERS) {
const row = assertSingleRow(rows);
return {
type: messageTypes.REMOVE_MEMBERS,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
removedUserIDs: JSON.parse(row.content),
};
} else if (type === messageTypes.CHANGE_ROLE) {
const row = assertSingleRow(rows);
const content = JSON.parse(row.content);
return {
type: messageTypes.CHANGE_ROLE,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
userIDs: content.userIDs,
newRole: content.newRole,
};
} else if (type === messageTypes.LEAVE_THREAD) {
const row = assertSingleRow(rows);
return {
type: messageTypes.LEAVE_THREAD,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
};
} else if (type === messageTypes.JOIN_THREAD) {
const row = assertSingleRow(rows);
return {
type: messageTypes.JOIN_THREAD,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
};
} else if (type === messageTypes.CREATE_ENTRY) {
const row = assertSingleRow(rows);
const content = JSON.parse(row.content);
return {
type: messageTypes.CREATE_ENTRY,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
entryID: content.entryID,
date: content.date,
text: content.text,
};
} else if (type === messageTypes.EDIT_ENTRY) {
const row = assertSingleRow(rows);
const content = JSON.parse(row.content);
return {
type: messageTypes.EDIT_ENTRY,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
entryID: content.entryID,
date: content.date,
text: content.text,
};
} else if (type === messageTypes.DELETE_ENTRY) {
const row = assertSingleRow(rows);
const content = JSON.parse(row.content);
return {
type: messageTypes.DELETE_ENTRY,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
entryID: content.entryID,
date: content.date,
text: content.text,
};
} else if (type === messageTypes.RESTORE_ENTRY) {
const row = assertSingleRow(rows);
const content = JSON.parse(row.content);
return {
type: messageTypes.RESTORE_ENTRY,
id: row.id.toString(),
threadID: row.threadID.toString(),
time: row.time,
creatorID: row.creatorID.toString(),
entryID: content.entryID,
date: content.date,
text: content.text,
};
} else if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) {
const media = rows.filter(row => row.uploadID).map(mediaFromRow);
const [row] = rows;
return createMediaMessageInfo({
threadID: row.threadID.toString(),
creatorID: row.creatorID.toString(),
media,
id: row.id.toString(),
localID: localIDFromCreationString(viewer, row.creation),
time: row.time,
});
} else {
invariant(false, `unrecognized messageType ${type}`);
}
}
const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`;
async function fetchMessageInfos(
viewer: Viewer,
criteria: ThreadSelectionCriteria,
numberPerThread: number,
): Promise<FetchMessageInfosResult> {
const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria);
const truncationStatuses = {};
const viewerID = viewer.id;
const query = SQL`
SELECT * FROM (
SELECT x.id, x.content, x.time, x.type, x.user AS creatorID,
x.creation, u.username AS creator, x.subthread_permissions,
x.uploadID, x.uploadType, x.uploadSecret, x.uploadExtra,
@num := if(
@thread = x.thread,
if(@message = x.id, @num, @num + 1),
1
) AS number,
@message := x.id AS messageID,
@thread := x.thread AS threadID
FROM (SELECT @num := 0, @thread := '', @message := '') init
JOIN (
SELECT m.id, m.thread, m.user, m.content, m.time, m.type,
m.creation, stm.permissions AS subthread_permissions,
up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret,
up.extra AS uploadExtra
FROM messages m
LEFT JOIN uploads up
ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]})
AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$')
LEFT JOIN memberships mm
ON mm.thread = m.thread AND mm.user = ${viewerID}
LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD}
AND stm.thread = m.content AND stm.user = ${viewerID}
WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND
`;
query.append(threadSelectionClause);
query.append(SQL`
ORDER BY m.thread, m.time DESC
) x
LEFT JOIN users u ON u.id = x.user
) y
WHERE y.number <= ${numberPerThread}
`);
const [result] = await dbQuery(query);
- const { userInfos, messages } = parseMessageSQLResult(result, viewer);
+ const { messages } = parseMessageSQLResult(result, viewer);
const rawMessageInfos = [];
const threadToMessageCount = new Map();
for (let message of messages) {
const { rawMessageInfo } = message;
rawMessageInfos.push(rawMessageInfo);
const { threadID } = rawMessageInfo;
const currentCountValue = threadToMessageCount.get(threadID);
const currentCount = currentCountValue ? currentCountValue : 0;
threadToMessageCount.set(threadID, currentCount + 1);
}
for (let [threadID, messageCount] of threadToMessageCount) {
// If there are fewer messages returned than the max for a given thread,
// then our result set includes all messages in the query range for that
// thread
truncationStatuses[threadID] =
messageCount < numberPerThread
? messageTruncationStatus.EXHAUSTIVE
: messageTruncationStatus.TRUNCATED;
}
for (let rawMessageInfo of rawMessageInfos) {
if (rawMessageInfo.type === messageTypes.CREATE_THREAD) {
// If a CREATE_THREAD message for a given thread is in the result set,
// then our result set includes all messages in the query range for that
// thread
truncationStatuses[rawMessageInfo.threadID] =
messageTruncationStatus.EXHAUSTIVE;
}
}
for (let threadID in criteria.threadCursors) {
const truncationStatus = truncationStatuses[threadID];
if (truncationStatus === null || truncationStatus === undefined) {
// If nothing was returned for a thread that was explicitly queried for,
// then our result set includes all messages in the query range for that
// thread
truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE;
} else if (truncationStatus === messageTruncationStatus.TRUNCATED) {
// If a cursor was specified for a given thread, then the result is
// guaranteed to be contiguous with what the client has, and as such the
// result should never be TRUNCATED
truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED;
}
}
- const allUserInfos = await fetchAllUsers(rawMessageInfos, userInfos);
const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos(
rawMessageInfos,
viewer.platformDetails,
);
return {
rawMessageInfos: shimmedRawMessageInfos,
truncationStatuses,
- userInfos: allUserInfos,
+ userInfos: {},
};
}
function threadSelectionCriteriaToSQLClause(criteria: ThreadSelectionCriteria) {
const conditions = [];
if (criteria.joinedThreads === true) {
conditions.push(SQL`mm.role != 0`);
}
if (criteria.threadCursors) {
for (let threadID in criteria.threadCursors) {
const cursor = criteria.threadCursors[threadID];
if (cursor) {
conditions.push(SQL`(m.thread = ${threadID} AND m.id < ${cursor})`);
} else {
conditions.push(SQL`m.thread = ${threadID}`);
}
}
}
if (conditions.length === 0) {
throw new ServerError('internal_error');
}
return mergeOrConditions(conditions);
}
function threadSelectionCriteriaToInitialTruncationStatuses(
criteria: ThreadSelectionCriteria,
defaultTruncationStatus: MessageTruncationStatus,
) {
const truncationStatuses = {};
if (criteria.threadCursors) {
for (let threadID in criteria.threadCursors) {
truncationStatuses[threadID] = defaultTruncationStatus;
}
}
return truncationStatuses;
}
async function fetchAllUsers(
rawMessageInfos: $ReadOnlyArray<RawMessageInfo>,
userInfos: UserInfos,
): Promise<UserInfos> {
const allAddedUserIDs = [];
for (let rawMessageInfo of rawMessageInfos) {
let newUsers = [];
if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) {
newUsers = rawMessageInfo.addedUserIDs;
} else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) {
newUsers = rawMessageInfo.initialThreadState.memberIDs;
}
for (let userID of newUsers) {
if (!userInfos[userID]) {
allAddedUserIDs.push(userID);
}
}
}
if (allAddedUserIDs.length === 0) {
return userInfos;
}
const newUserInfos = await fetchUserInfos(allAddedUserIDs);
// $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63
return {
...userInfos,
...newUserInfos,
};
}
async function fetchMessageInfosSince(
viewer: Viewer,
criteria: ThreadSelectionCriteria,
currentAsOf: number,
maxNumberPerThread: number,
): Promise<FetchMessageInfosResult> {
const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria);
const truncationStatuses = threadSelectionCriteriaToInitialTruncationStatuses(
criteria,
messageTruncationStatus.UNCHANGED,
);
const viewerID = viewer.id;
const query = SQL`
SELECT m.id, m.thread AS threadID, m.content, m.time, m.type,
m.creation, u.username AS creator, m.user AS creatorID,
stm.permissions AS subthread_permissions,
up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret,
up.extra AS uploadExtra
FROM messages m
LEFT JOIN uploads up
ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]})
AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$')
LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID}
LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD}
AND stm.thread = m.content AND stm.user = ${viewerID}
LEFT JOIN users u ON u.id = m.user
WHERE m.time > ${currentAsOf} AND
JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND
`;
query.append(threadSelectionClause);
query.append(SQL`
ORDER BY m.thread, m.time DESC
`);
const [result] = await dbQuery(query);
const { userInfos: allCreatorUserInfos, messages } = parseMessageSQLResult(
result,
viewer,
);
const rawMessageInfos = [];
const userInfos = {};
let currentThreadID = null;
let numMessagesForCurrentThreadID = 0;
for (let message of messages) {
const { rawMessageInfo } = message;
const { threadID } = rawMessageInfo;
if (threadID !== currentThreadID) {
currentThreadID = threadID;
numMessagesForCurrentThreadID = 1;
truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED;
} else {
numMessagesForCurrentThreadID++;
}
if (numMessagesForCurrentThreadID <= maxNumberPerThread) {
if (rawMessageInfo.type === messageTypes.CREATE_THREAD) {
// If a CREATE_THREAD message is here, then we have all messages
truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE;
}
const { creatorID } = rawMessageInfo;
const userInfo = allCreatorUserInfos[creatorID];
if (userInfo) {
userInfos[creatorID] = userInfo;
}
rawMessageInfos.push(rawMessageInfo);
} else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) {
truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED;
}
}
const allUserInfos = await fetchAllUsers(rawMessageInfos, userInfos);
const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos(
rawMessageInfos,
viewer.platformDetails,
);
return {
rawMessageInfos: shimmedRawMessageInfos,
truncationStatuses,
userInfos: allUserInfos,
};
}
async function getMessageFetchResultFromRedisMessages(
viewer: Viewer,
rawMessageInfos: $ReadOnlyArray<RawMessageInfo>,
): Promise<FetchMessageInfosResult> {
const truncationStatuses = {};
for (let rawMessageInfo of rawMessageInfos) {
truncationStatuses[rawMessageInfo.threadID] =
messageTruncationStatus.UNCHANGED;
}
const userInfos = await fetchAllUsers(rawMessageInfos, {});
const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos(
rawMessageInfos,
viewer.platformDetails,
);
return {
rawMessageInfos: shimmedRawMessageInfos,
truncationStatuses,
userInfos,
};
}
async function fetchMessageInfoForLocalID(
viewer: Viewer,
localID: ?string,
): Promise<?RawMessageInfo> {
if (!localID || !viewer.hasSessionInfo) {
return null;
}
const creation = creationString(viewer, localID);
const viewerID = viewer.id;
const query = SQL`
SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation,
m.user AS creatorID, stm.permissions AS subthread_permissions,
up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret,
up.extra AS uploadExtra
FROM messages m
LEFT JOIN uploads up
ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]})
AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$')
LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID}
LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD}
AND stm.thread = m.content AND stm.user = ${viewerID}
WHERE m.user = ${viewerID} AND m.creation = ${creation} AND
JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE
`;
const [result] = await dbQuery(query);
if (result.length === 0) {
return null;
}
return rawMessageInfoFromRows(result, viewer);
}
const entryIDExtractString = '$.entryID';
async function fetchMessageInfoForEntryAction(
viewer: Viewer,
messageType: MessageType,
entryID: string,
threadID: string,
): Promise<?RawMessageInfo> {
const viewerID = viewer.id;
const query = SQL`
SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation,
m.user AS creatorID, up.id AS uploadID, up.type AS uploadType,
up.secret AS uploadSecret, up.extra AS uploadExtra
FROM messages m
LEFT JOIN uploads up
ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]})
AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$')
LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID}
WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND
m.type = ${messageType} AND
JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND
JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE
`;
const [result] = await dbQuery(query);
if (result.length === 0) {
return null;
}
return rawMessageInfoFromRows(result, viewer);
}
export {
fetchCollapsableNotifs,
fetchMessageInfos,
fetchMessageInfosSince,
getMessageFetchResultFromRedisMessages,
fetchMessageInfoForLocalID,
fetchMessageInfoForEntryAction,
};
diff --git a/server/src/responders/entry-responders.js b/server/src/responders/entry-responders.js
index 8e6613f13..96f020e73 100644
--- a/server/src/responders/entry-responders.js
+++ b/server/src/responders/entry-responders.js
@@ -1,258 +1,258 @@
// @flow
import type { Viewer } from '../session/viewer';
import type {
CalendarQuery,
SaveEntryRequest,
CreateEntryRequest,
DeleteEntryRequest,
DeleteEntryResponse,
RestoreEntryRequest,
RestoreEntryResponse,
FetchEntryInfosResponse,
DeltaEntryInfosResult,
SaveEntryResponse,
} from 'lib/types/entry-types';
import type {
FetchEntryRevisionInfosResult,
FetchEntryRevisionInfosRequest,
} from 'lib/types/history-types';
import { calendarThreadFilterTypes } from 'lib/types/filter-types';
import t from 'tcomb';
import { ServerError } from 'lib/utils/errors';
import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors';
-import { values } from 'lib/utils/objects';
import {
validateInput,
tString,
tShape,
tDate,
} from '../utils/validation-utils';
import { verifyThreadIDs } from '../fetchers/thread-fetchers';
import {
fetchEntryInfos,
fetchEntryRevisionInfo,
fetchEntriesForSession,
} from '../fetchers/entry-fetchers';
import createEntry from '../creators/entry-creator';
import {
updateEntry,
compareNewCalendarQuery,
} from '../updaters/entry-updaters';
import { deleteEntry, restoreEntry } from '../deleters/entry-deleters';
import { commitSessionUpdate } from '../updaters/session-updaters';
const entryQueryInputValidator = tShape({
navID: t.maybe(t.String),
startDate: tDate,
endDate: tDate,
includeDeleted: t.maybe(t.Boolean),
filters: t.maybe(
t.list(
t.union([
tShape({
type: tString(calendarThreadFilterTypes.NOT_DELETED),
}),
tShape({
type: tString(calendarThreadFilterTypes.THREAD_LIST),
threadIDs: t.list(t.String),
}),
]),
),
),
});
const newEntryQueryInputValidator = tShape({
startDate: tDate,
endDate: tDate,
filters: t.list(
t.union([
tShape({
type: tString(calendarThreadFilterTypes.NOT_DELETED),
}),
tShape({
type: tString(calendarThreadFilterTypes.THREAD_LIST),
threadIDs: t.list(t.String),
}),
]),
),
});
function normalizeCalendarQuery(input: any): CalendarQuery {
if (input.filters) {
return {
startDate: input.startDate,
endDate: input.endDate,
filters: input.filters,
};
}
const filters = [];
if (!input.includeDeleted) {
filters.push({ type: calendarThreadFilterTypes.NOT_DELETED });
}
if (input.navID !== 'home') {
filters.push({
type: calendarThreadFilterTypes.THREAD_LIST,
threadIDs: [input.navID],
});
}
return {
startDate: input.startDate,
endDate: input.endDate,
filters,
};
}
async function verifyCalendarQueryThreadIDs(
request: CalendarQuery,
): Promise<void> {
const threadIDsToFilterTo = filteredThreadIDs(request.filters);
if (threadIDsToFilterTo && threadIDsToFilterTo.size > 0) {
const verifiedThreadIDs = await verifyThreadIDs([...threadIDsToFilterTo]);
if (verifiedThreadIDs.length !== threadIDsToFilterTo.size) {
throw new ServerError('invalid_parameters');
}
}
}
async function entryFetchResponder(
viewer: Viewer,
input: any,
): Promise<FetchEntryInfosResponse> {
await validateInput(viewer, entryQueryInputValidator, input);
const request = normalizeCalendarQuery(input);
await verifyCalendarQueryThreadIDs(request);
return await fetchEntryInfos(viewer, [request]);
}
const entryRevisionHistoryFetchInputValidator = tShape({
id: t.String,
});
async function entryRevisionFetchResponder(
viewer: Viewer,
input: any,
): Promise<FetchEntryRevisionInfosResult> {
const request: FetchEntryRevisionInfosRequest = input;
await validateInput(viewer, entryRevisionHistoryFetchInputValidator, request);
const entryHistory = await fetchEntryRevisionInfo(viewer, request.id);
return { result: entryHistory };
}
const createEntryRequestInputValidator = tShape({
text: t.String,
sessionID: t.maybe(t.String),
timestamp: t.Number,
date: tDate,
threadID: t.String,
localID: t.maybe(t.String),
calendarQuery: t.maybe(newEntryQueryInputValidator),
});
async function entryCreationResponder(
viewer: Viewer,
input: any,
): Promise<SaveEntryResponse> {
const request: CreateEntryRequest = input;
await validateInput(viewer, createEntryRequestInputValidator, request);
return await createEntry(viewer, request);
}
const saveEntryRequestInputValidator = tShape({
entryID: t.String,
text: t.String,
prevText: t.String,
sessionID: t.maybe(t.String),
timestamp: t.Number,
calendarQuery: t.maybe(newEntryQueryInputValidator),
});
async function entryUpdateResponder(
viewer: Viewer,
input: any,
): Promise<SaveEntryResponse> {
const request: SaveEntryRequest = input;
await validateInput(viewer, saveEntryRequestInputValidator, request);
return await updateEntry(viewer, request);
}
const deleteEntryRequestInputValidator = tShape({
entryID: t.String,
prevText: t.String,
sessionID: t.maybe(t.String),
timestamp: t.Number,
calendarQuery: t.maybe(newEntryQueryInputValidator),
});
async function entryDeletionResponder(
viewer: Viewer,
input: any,
): Promise<DeleteEntryResponse> {
const request: DeleteEntryRequest = input;
await validateInput(viewer, deleteEntryRequestInputValidator, request);
return await deleteEntry(viewer, request);
}
const restoreEntryRequestInputValidator = tShape({
entryID: t.String,
sessionID: t.maybe(t.String),
timestamp: t.Number,
calendarQuery: t.maybe(newEntryQueryInputValidator),
});
async function entryRestorationResponder(
viewer: Viewer,
input: any,
): Promise<RestoreEntryResponse> {
const request: RestoreEntryRequest = input;
await validateInput(viewer, restoreEntryRequestInputValidator, request);
return await restoreEntry(viewer, request);
}
async function calendarQueryUpdateResponder(
viewer: Viewer,
input: any,
): Promise<DeltaEntryInfosResult> {
const request: CalendarQuery = input;
await validateInput(viewer, newEntryQueryInputValidator, input);
await verifyCalendarQueryThreadIDs(request);
if (!viewer.loggedIn) {
throw new ServerError('not_logged_in');
}
const {
difference,
oldCalendarQuery,
sessionUpdate,
} = compareNewCalendarQuery(viewer, request);
const [response] = await Promise.all([
fetchEntriesForSession(viewer, difference, oldCalendarQuery),
commitSessionUpdate(viewer, sessionUpdate),
]);
return {
rawEntryInfos: response.rawEntryInfos,
deletedEntryIDs: response.deletedEntryIDs,
- userInfos: values(response.userInfos),
+ // Old clients expect userInfos object
+ userInfos: [],
};
}
export {
entryQueryInputValidator,
newEntryQueryInputValidator,
normalizeCalendarQuery,
verifyCalendarQueryThreadIDs,
entryFetchResponder,
entryRevisionFetchResponder,
entryCreationResponder,
entryUpdateResponder,
entryDeletionResponder,
entryRestorationResponder,
calendarQueryUpdateResponder,
};

File Metadata

Mime Type
text/x-diff
Expires
Mon, Dec 23, 4:44 AM (17 h, 22 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2690366
Default Alt Text
(152 KB)

Event Timeline