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