diff --git a/lib/actions/activity-actions.js b/lib/actions/activity-actions.js index c9cbeaf0d..3d3b67d40 100644 --- a/lib/actions/activity-actions.js +++ b/lib/actions/activity-actions.js @@ -1,56 +1,54 @@ // @flow import type { ActivityUpdate, ActivityUpdateSuccessPayload, SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, SetThreadUnreadStatusResult, } from '../types/activity-types'; import type { FetchJSON } from '../utils/fetch-json'; const updateActivityActionTypes = Object.freeze({ started: 'UPDATE_ACTIVITY_STARTED', success: 'UPDATE_ACTIVITY_SUCCESS', failed: 'UPDATE_ACTIVITY_FAILED', }); -async function updateActivity( - fetchJSON: FetchJSON, +const updateActivity = (fetchJSON: FetchJSON) => async ( activityUpdates: $ReadOnlyArray, -): Promise { +): Promise => { const response = await fetchJSON('update_activity', { updates: activityUpdates, }); return { activityUpdates, result: { unfocusedToUnread: response.unfocusedToUnread, }, }; -} +}; const setThreadUnreadStatusActionTypes = Object.freeze({ started: 'SET_THREAD_UNREAD_STATUS_STARTED', success: 'SET_THREAD_UNREAD_STATUS_SUCCESS', failed: 'SET_THREAD_UNREAD_STATUS_FAILED', }); -async function setThreadUnreadStatus( - fetchJSON: FetchJSON, +const setThreadUnreadStatus = (fetchJSON: FetchJSON) => async ( request: SetThreadUnreadStatusRequest, -): Promise { +): Promise => { const response: SetThreadUnreadStatusResult = await fetchJSON( 'set_thread_unread_status', request, ); return { resetToUnread: response.resetToUnread, threadID: request.threadID, }; -} +}; export { updateActivityActionTypes, updateActivity, setThreadUnreadStatusActionTypes, setThreadUnreadStatus, }; diff --git a/lib/actions/device-actions.js b/lib/actions/device-actions.js index 001f5c497..4b16796f1 100644 --- a/lib/actions/device-actions.js +++ b/lib/actions/device-actions.js @@ -1,22 +1,21 @@ // @flow import { getConfig } from '../utils/config'; import type { FetchJSON } from '../utils/fetch-json'; const setDeviceTokenActionTypes = Object.freeze({ started: 'SET_DEVICE_TOKEN_STARTED', success: 'SET_DEVICE_TOKEN_SUCCESS', failed: 'SET_DEVICE_TOKEN_FAILED', }); -async function setDeviceToken( - fetchJSON: FetchJSON, +const setDeviceToken = (fetchJSON: FetchJSON) => async ( deviceToken: string, -): Promise { +): Promise => { await fetchJSON('update_device_token', { deviceToken, platformDetails: getConfig().platformDetails, }); return deviceToken; -} +}; export { setDeviceTokenActionTypes, setDeviceToken }; diff --git a/lib/actions/entry-actions.js b/lib/actions/entry-actions.js index 247b858be..829169235 100644 --- a/lib/actions/entry-actions.js +++ b/lib/actions/entry-actions.js @@ -1,185 +1,178 @@ // @flow import type { RawEntryInfo, CalendarQuery, SaveEntryInfo, SaveEntryResponse, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResponse, RestoreEntryInfo, RestoreEntryResponse, FetchEntryInfosResult, CalendarQueryUpdateResult, } from '../types/entry-types'; import type { HistoryRevisionInfo } from '../types/history-types'; import { dateFromString } from '../utils/date-utils'; import type { FetchJSON } from '../utils/fetch-json'; const fetchEntriesActionTypes = Object.freeze({ started: 'FETCH_ENTRIES_STARTED', success: 'FETCH_ENTRIES_SUCCESS', failed: 'FETCH_ENTRIES_FAILED', }); -async function fetchEntries( - fetchJSON: FetchJSON, +const fetchEntries = (fetchJSON: FetchJSON) => async ( calendarQuery: CalendarQuery, -): Promise { +): Promise => { const response = await fetchJSON('fetch_entries', calendarQuery); return { rawEntryInfos: response.rawEntryInfos, }; -} +}; const updateCalendarQueryActionTypes = Object.freeze({ started: 'UPDATE_CALENDAR_QUERY_STARTED', success: 'UPDATE_CALENDAR_QUERY_SUCCESS', failed: 'UPDATE_CALENDAR_QUERY_FAILED', }); -async function updateCalendarQuery( - fetchJSON: FetchJSON, +const updateCalendarQuery = (fetchJSON: FetchJSON) => async ( calendarQuery: CalendarQuery, reduxAlreadyUpdated: boolean = false, -): Promise { +): Promise => { const response = await fetchJSON('update_calendar_query', calendarQuery); const { rawEntryInfos, deletedEntryIDs } = response; return { rawEntryInfos, deletedEntryIDs, 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, +const createEntry = (fetchJSON: FetchJSON) => async ( request: CreateEntryInfo, -): Promise { +): Promise => { 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, +const saveEntry = (fetchJSON: FetchJSON) => async ( request: SaveEntryInfo, -): Promise { +): Promise => { 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, +const deleteEntry = (fetchJSON: FetchJSON) => async ( info: DeleteEntryInfo, -): Promise { +): Promise => { 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, +const fetchRevisionsForEntry = (fetchJSON: FetchJSON) => async ( entryID: string, -): Promise<$ReadOnlyArray> { +): Promise<$ReadOnlyArray> => { 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, +const restoreEntry = (fetchJSON: FetchJSON) => async ( info: RestoreEntryInfo, -): Promise { +): Promise => { 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 9af7ad012..6fcf5ff59 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,145 +1,141 @@ // @flow import invariant from 'invariant'; import type { FetchMessageInfosPayload, SendMessageResult, } from '../types/message-types'; import type { FetchJSON, FetchResultInfo } from '../utils/fetch-json'; 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, +const fetchMessagesBeforeCursor = (fetchJSON: FetchJSON) => async ( threadID: string, beforeMessageID: string, -): Promise { +): Promise => { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: beforeMessageID, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; -} +}; 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, +const fetchMostRecentMessages = (fetchJSON: FetchJSON) => async ( threadID: string, -): Promise { +): Promise => { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: null, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; -} +}; const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); -async function sendTextMessage( - fetchJSON: FetchJSON, +const sendTextMessage = (fetchJSON: FetchJSON) => async ( threadID: string, localID: string, text: string, -): Promise { +): Promise => { let resultInfo; const getResultInfo = (passedResultInfo: FetchResultInfo) => { resultInfo = passedResultInfo; }; const response = await fetchJSON( 'create_text_message', { threadID, localID, text, }, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before fetchJSON resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; -} +}; 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, +const sendMultimediaMessage = (fetchJSON: FetchJSON) => async ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, -): Promise { +): Promise => { let resultInfo; const getResultInfo = (passedResultInfo: FetchResultInfo) => { resultInfo = passedResultInfo; }; const response = await fetchJSON( 'create_multimedia_message', { threadID, localID, mediaIDs, }, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before fetchJSON resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; -} +}; 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/relationship-actions.js b/lib/actions/relationship-actions.js index 1c74e3567..81e5d0e7d 100644 --- a/lib/actions/relationship-actions.js +++ b/lib/actions/relationship-actions.js @@ -1,33 +1,32 @@ // @flow import type { RelationshipRequest, RelationshipErrors, } from '../types/relationship-types'; import { ServerError } from '../utils/errors'; import type { FetchJSON } from '../utils/fetch-json'; const updateRelationshipsActionTypes = Object.freeze({ started: 'UPDATE_RELATIONSHIPS_STARTED', success: 'UPDATE_RELATIONSHIPS_SUCCESS', failed: 'UPDATE_RELATIONSHIPS_FAILED', }); -async function updateRelationships( - fetchJSON: FetchJSON, +const updateRelationships = (fetchJSON: FetchJSON) => async ( request: RelationshipRequest, -): Promise { +): Promise => { const errors = await fetchJSON('update_relationships', request); const { invalid_user, already_friends, user_blocked } = errors; if (invalid_user) { throw new ServerError('invalid_user', errors); } else if (already_friends) { throw new ServerError('already_friends', errors); } else if (user_blocked) { throw new ServerError('user_blocked', errors); } return errors; -} +}; export { updateRelationshipsActionTypes, updateRelationships }; diff --git a/lib/actions/report-actions.js b/lib/actions/report-actions.js index 4c1729a55..abc7de886 100644 --- a/lib/actions/report-actions.js +++ b/lib/actions/report-actions.js @@ -1,43 +1,41 @@ // @flow import type { ClientReportCreationRequest, ReportCreationResponse, } from '../types/report-types'; import type { FetchJSON } from '../utils/fetch-json'; const sendReportActionTypes = Object.freeze({ started: 'SEND_REPORT_STARTED', success: 'SEND_REPORT_SUCCESS', failed: 'SEND_REPORT_FAILED', }); const fetchJSONOptions = { timeout: 60000 }; -async function sendReport( - fetchJSON: FetchJSON, +const sendReport = (fetchJSON: FetchJSON) => async ( request: ClientReportCreationRequest, -): Promise { +): Promise => { const response = await fetchJSON('create_report', request, fetchJSONOptions); return { id: response.id }; -} +}; const sendReportsActionTypes = Object.freeze({ started: 'SEND_REPORTS_STARTED', success: 'SEND_REPORTS_SUCCESS', failed: 'SEND_REPORTS_FAILED', }); -async function sendReports( - fetchJSON: FetchJSON, +const sendReports = (fetchJSON: FetchJSON) => async ( reports: $ReadOnlyArray, -): Promise { +): Promise => { await fetchJSON('create_reports', { reports }, fetchJSONOptions); -} +}; const queueReportsActionType = 'QUEUE_REPORTS'; export { sendReportActionTypes, sendReport, sendReportsActionTypes, sendReports, queueReportsActionType, }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index c35dce4dc..f33268c3d 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,172 +1,165 @@ // @flow import invariant from 'invariant'; import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, NewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, } from '../types/thread-types'; import type { FetchJSON } from '../utils/fetch-json'; 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, +const deleteThread = (fetchJSON: FetchJSON) => async ( threadID: string, currentAccountPassword: string, -): Promise { +): Promise => { 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, +const changeThreadSettings = (fetchJSON: FetchJSON) => async ( request: UpdateThreadRequest, -): Promise { +): Promise => { 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, +const removeUsersFromThread = (fetchJSON: FetchJSON) => async ( threadID: string, memberIDs: string[], -): Promise { +): Promise => { 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, +const changeThreadMemberRoles = (fetchJSON: FetchJSON) => async ( threadID: string, memberIDs: string[], newRole: string, -): Promise { +): Promise => { 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, +const newThread = (fetchJSON: FetchJSON) => async ( request: NewThreadRequest, -): Promise { +): Promise => { const response = await fetchJSON('create_thread', request); return { newThreadID: response.newThreadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, userInfos: response.userInfos, }; -} +}; const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); -async function joinThread( - fetchJSON: FetchJSON, +const joinThread = (fetchJSON: FetchJSON) => async ( request: ClientThreadJoinRequest, -): Promise { +): Promise => { 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, }, }; -} +}; const leaveThreadActionTypes = Object.freeze({ started: 'LEAVE_THREAD_STARTED', success: 'LEAVE_THREAD_SUCCESS', failed: 'LEAVE_THREAD_FAILED', }); -async function leaveThread( - fetchJSON: FetchJSON, +const leaveThread = (fetchJSON: FetchJSON) => async ( threadID: string, -): Promise { +): Promise => { 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/upload-actions.js b/lib/actions/upload-actions.js index 9c866e1eb..a70b30a47 100644 --- a/lib/actions/upload-actions.js +++ b/lib/actions/upload-actions.js @@ -1,69 +1,70 @@ // @flow import type { Shape } from '../types/core'; import type { UploadMultimediaResult, Dimensions } from '../types/media-types'; import type { FetchJSON } from '../utils/fetch-json'; import type { UploadBlob } from '../utils/upload-blob'; export type MultimediaUploadCallbacks = Shape<{| onProgress: (percent: number) => void, abortHandler: (abort: () => void) => void, uploadBlob: UploadBlob, |}>; export type MultimediaUploadExtras = Shape<{| ...Dimensions, loop: boolean |}>; -async function uploadMultimedia( - fetchJSON: FetchJSON, +const uploadMultimedia = (fetchJSON: FetchJSON) => async ( multimedia: Object, extras: MultimediaUploadExtras, callbacks?: MultimediaUploadCallbacks, -): Promise { +): Promise => { const onProgress = callbacks && callbacks.onProgress; const abortHandler = callbacks && callbacks.abortHandler; const uploadBlob = callbacks && callbacks.uploadBlob; const stringExtras = {}; if (extras.height !== null && extras.height !== undefined) { stringExtras.height = extras.height.toString(); } if (extras.width !== null && extras.width !== undefined) { stringExtras.width = extras.width.toString(); } if (extras.loop) { stringExtras.loop = '1'; } const response = await fetchJSON( 'upload_multimedia', { multimedia: [multimedia], ...stringExtras, }, { onProgress, abortHandler, blobUpload: uploadBlob ? uploadBlob : true, }, ); const [uploadResult] = response.results; return { id: uploadResult.id, uri: uploadResult.uri, dimensions: uploadResult.dimensions, mediaType: uploadResult.mediaType, loop: uploadResult.loop, }; -} +}; const updateMultimediaMessageMediaActionType = 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA'; -async function deleteUpload(fetchJSON: FetchJSON, id: string): Promise { +const deleteUpload = (fetchJSON: FetchJSON) => async ( + id: string, +): Promise => { await fetchJSON('delete_upload', { id }); -} +}; export { uploadMultimedia, updateMultimediaMessageMediaActionType, deleteUpload, }; diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index 427c7ea9c..30a8ee358 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,320 +1,311 @@ // @flow import threadWatcher from '../shared/thread-watcher'; import type { ChangeUserSettingsResult, LogOutResult, LogInInfo, LogInResult, RegisterResult, UpdatePasswordInfo, RegisterInfo, AccessRequest, } from '../types/account-types'; import type { UserSearchResult } from '../types/search-types'; import type { PreRequestUserState } from '../types/session-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types'; import type { UserInfo, AccountUpdate } from '../types/user-types'; import type { HandleVerificationCodeResult } from '../types/verify-types'; import { getConfig } from '../utils/config'; import type { FetchJSON } from '../utils/fetch-json'; 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, +const logOut = (fetchJSON: FetchJSON) => async ( preRequestUserState: PreRequestUserState, -): Promise { +): Promise => { 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, +const deleteAccount = (fetchJSON: FetchJSON) => async ( password: string, preRequestUserState: PreRequestUserState, -): Promise { +): Promise => { const response = await fetchJSON('delete_account', { password }); return { currentUserInfo: response.currentUserInfo, preRequestUserState }; -} +}; const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); -async function register( - fetchJSON: FetchJSON, +const register = (fetchJSON: FetchJSON) => async ( registerInfo: RegisterInfo, -): Promise { +): Promise => { 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', }); const logInFetchJSONOptions = { timeout: 20000 }; -async function logIn( - fetchJSON: FetchJSON, +const logIn = (fetchJSON: FetchJSON) => async ( logInInfo: LogInInfo, -): Promise { +): Promise => { const watchedIDs = threadWatcher.getWatchedIDs(); const { source, ...restLogInInfo } = logInInfo; const response = await fetchJSON( 'log_in', { ...restLogInInfo, watchedIDs, platformDetails: getConfig().platformDetails, }, logInFetchJSONOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, source, }; -} +}; const resetPasswordActionTypes = Object.freeze({ started: 'RESET_PASSWORD_STARTED', success: 'RESET_PASSWORD_SUCCESS', failed: 'RESET_PASSWORD_FAILED', }); -async function resetPassword( - fetchJSON: FetchJSON, +const resetPassword = (fetchJSON: FetchJSON) => async ( updatePasswordInfo: UpdatePasswordInfo, -): Promise { +): Promise => { const watchedIDs = threadWatcher.getWatchedIDs(); const response = await fetchJSON( 'update_password', { ...updatePasswordInfo, watchedIDs, platformDetails: getConfig().platformDetails, }, logInFetchJSONOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: updatePasswordInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, 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, +const forgotPassword = (fetchJSON: FetchJSON) => async ( usernameOrEmail: string, -): Promise { +): Promise => { 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, +const changeUserSettings = (fetchJSON: FetchJSON) => async ( accountUpdate: AccountUpdate, -): Promise { +): Promise => { 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 { +const resendVerificationEmail = ( + fetchJSON: FetchJSON, +) => async (): Promise => { 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, +const handleVerificationCode = (fetchJSON: FetchJSON) => async ( code: string, -): Promise { +): Promise => { 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, +const searchUsers = (fetchJSON: FetchJSON) => async ( usernamePrefix: string, -): Promise { +): Promise => { 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, +const updateSubscription = (fetchJSON: FetchJSON) => async ( subscriptionUpdate: SubscriptionUpdateRequest, -): Promise { +): Promise => { 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, +const requestAccess = (fetchJSON: FetchJSON) => async ( accessRequest: AccessRequest, -): Promise { +): Promise => { 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/socket/activity-handler.react.js b/lib/socket/activity-handler.react.js index af7841c11..98181e360 100644 --- a/lib/socket/activity-handler.react.js +++ b/lib/socket/activity-handler.react.js @@ -1,122 +1,123 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { updateActivityActionTypes, updateActivity, } from '../actions/activity-actions'; import { getMostRecentNonLocalMessageID } from '../shared/message-utils'; import { queueActivityUpdatesActionType } from '../types/activity-types'; import { useServerCall, useDispatchActionPromise } from '../utils/action-utils'; import { useSelector } from '../utils/redux-utils'; type Props = {| +activeThread: ?string, +frozen: boolean, |}; function ActivityHandler(props: Props) { const { activeThread, frozen } = props; const prevActiveThreadRef = React.useRef(); React.useEffect(() => { prevActiveThreadRef.current = activeThread; }, [activeThread]); const prevActiveThread = prevActiveThreadRef.current; const connection = useSelector((state) => state.connection); const connectionStatus = connection.status; const prevConnectionStatusRef = React.useRef(); React.useEffect(() => { prevConnectionStatusRef.current = connectionStatus; }, [connectionStatus]); const prevConnectionStatus = prevConnectionStatusRef.current; const activeThreadLatestMessage = useSelector((state) => { if (!activeThread) { return undefined; } return getMostRecentNonLocalMessageID(activeThread, state.messageStore); }); const prevActiveThreadLatestMessageRef = React.useRef(); React.useEffect(() => { prevActiveThreadLatestMessageRef.current = activeThreadLatestMessage; }, [activeThreadLatestMessage]); const prevActiveThreadLatestMessage = prevActiveThreadLatestMessageRef.current; const canSend = connectionStatus === 'connected' && !frozen; const prevCanSendRef = React.useRef(); React.useEffect(() => { prevCanSendRef.current = canSend; }, [canSend]); const prevCanSend = prevCanSendRef.current; const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateActivity = useServerCall(updateActivity); React.useEffect(() => { const activityUpdates = []; if (activeThread !== prevActiveThread) { if (prevActiveThread) { activityUpdates.push({ focus: false, threadID: prevActiveThread, latestMessage: prevActiveThreadLatestMessage, }); } if (activeThread) { activityUpdates.push({ focus: true, threadID: activeThread, latestMessage: activeThreadLatestMessage, }); } } if ( !frozen && connectionStatus !== 'connected' && prevConnectionStatus === 'connected' && activeThread ) { // When the server closes a socket it also deletes any activity rows // associated with that socket's session. If that activity is still // ongoing, we should make sure that we clarify that with the server once // we reconnect. activityUpdates.push({ focus: true, threadID: activeThread, + latestMessage: activeThreadLatestMessage, }); } if (activityUpdates.length > 0) { dispatch({ type: queueActivityUpdatesActionType, payload: { activityUpdates }, }); } if (!canSend) { return; } if (!prevCanSend) { activityUpdates.unshift(...connection.queuedActivityUpdates); } if (activityUpdates.length === 0) { return; } dispatchActionPromise( updateActivityActionTypes, callUpdateActivity(activityUpdates), ); }); return null; } export default ActivityHandler; diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js index bca265eca..06a48d68f 100644 --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1,828 +1,829 @@ // @flow import type { LogOutResult, LogInStartingPayload, LogInResult, RegisterResult, } from './account-types'; import type { ActivityUpdateSuccessPayload, QueueActivityUpdatesPayload, SetThreadUnreadStatusPayload, } from './activity-types'; import type { RawEntryInfo, EntryStore, CalendarQuery, SaveEntryPayload, CreateEntryPayload, DeleteEntryResponse, RestoreEntryPayload, FetchEntryInfosResult, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from './entry-types'; import type { CalendarFilter, CalendarThreadFilter, SetCalendarDeletedFilterPayload, } from './filter-types'; import type { LifecycleState } from './lifecycle-state-types'; import type { LoadingStatus, LoadingInfo } from './loading-types'; import type { UpdateMultimediaMessageMediaPayload } from './media-types'; import type { MessageStore, RawMultimediaMessageInfo, FetchMessageInfosPayload, SendMessagePayload, SaveMessagesPayload, NewMessagesPayload, MessageStorePrunePayload, LocallyComposedMessageInfo, } from './message-types'; import type { RawTextMessageInfo } from './messages/text'; import type { BaseNavInfo } from './nav-types'; +import type { RelationshipErrors } from './relationship-types'; import type { ClearDeliveredReportsPayload, ClientReportCreationRequest, QueueReportsPayload, } from './report-types'; import type { ProcessServerRequestsPayload } from './request-types'; import type { UserSearchResult } from './search-types'; import type { SetSessionPayload } from './session-types'; import type { ConnectionInfo, StateSyncFullActionPayload, StateSyncIncrementalActionPayload, UpdateConnectionStatusPayload, SetLateResponsePayload, UpdateDisconnectedBarPayload, } from './socket-types'; import type { SubscriptionUpdateResult } from './subscription-types'; import type { ThreadStore, ChangeThreadSettingsPayload, LeaveThreadPayload, NewThreadResult, ThreadJoinPayload, } from './thread-types'; import type { UpdatesResultWithUserInfos } from './update-types'; import type { CurrentUserInfo, UserStore } from './user-types'; export type BaseAppState = { navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, // millisecond timestamp loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, dataLoaded: boolean, }; // Web JS runtime doesn't have access to the cookie for security reasons. // Native JS doesn't have a sessionID because the cookieID is used instead. // Web JS doesn't have a device token because it's not a device... export type NativeAppState = BaseAppState<*> & { sessionID?: void, deviceToken: ?string, cookie: ?string, }; export type WebAppState = BaseAppState<*> & { sessionID: ?string, deviceToken?: void, cookie?: void, }; export type AppState = NativeAppState | WebAppState; export type BaseAction = | {| +type: '@@redux/INIT', +payload?: void, |} | {| +type: 'FETCH_ENTRIES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_SUCCESS', +payload: FetchEntryInfosResult, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_LOCAL_ENTRY', +payload: RawEntryInfo, |} | {| +type: 'CREATE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_SUCCESS', +payload: CreateEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_SUCCESS', +payload: SaveEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CONCURRENT_MODIFICATION_RESET', +payload: {| +id: string, +dbText: string, |}, |} | {| +type: 'DELETE_ENTRY_STARTED', +loadingInfo: LoadingInfo, +payload: {| +localID: ?string, +serverID: ?string, |}, |} | {| +type: 'DELETE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ENTRY_SUCCESS', +payload: ?DeleteEntryResponse, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'LOG_IN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'REGISTER_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_SUCCESS', +payload: RegisterResult, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_STARTED', +payload: {| calendarQuery: CalendarQuery |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_SUCCESS', +payload: {| +email: string, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_SUCCESS', +payload: NewThreadResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', +payload: {| +entryID: string, +text: string, +deleted: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_SUCCESS', +payload: RestoreEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_SUCCESS', +payload: ThreadJoinPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_NEW_SESSION', +payload: SetSessionPayload, |} | {| +type: 'persist/REHYDRATE', +payload: ?BaseAppState<*>, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_STARTED', +loadingInfo: LoadingInfo, +payload: RawTextMessageInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_STARTED', +loadingInfo: LoadingInfo, +payload: RawMultimediaMessageInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_SUCCESS', +payload: UserSearchResult, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_DRAFT', +payload: { +key: string, +draft: string, }, |} | {| +type: 'UPDATE_ACTIVITY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_SUCCESS', +payload: ActivityUpdateSuccessPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_STARTED', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_SUCCESS', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'QUEUE_REPORTS', +payload: QueueReportsPayload, |} | {| +type: 'SET_URL_PREFIX', +payload: string, |} | {| +type: 'SAVE_MESSAGES', +payload: SaveMessagesPayload, |} | {| +type: 'UPDATE_CALENDAR_THREAD_FILTER', +payload: CalendarThreadFilter, |} | {| +type: 'CLEAR_CALENDAR_THREAD_FILTER', +payload?: void, |} | {| +type: 'SET_CALENDAR_DELETED_FILTER', +payload: SetCalendarDeletedFilterPayload, |} | {| +type: 'UPDATE_SUBSCRIPTION_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_SUCCESS', +payload: SubscriptionUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_STARTED', +loadingInfo: LoadingInfo, +payload?: CalendarQueryUpdateStartingPayload, |} | {| +type: 'UPDATE_CALENDAR_QUERY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_SUCCESS', +payload: CalendarQueryUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FULL_STATE_SYNC', +payload: StateSyncFullActionPayload, |} | {| +type: 'INCREMENTAL_STATE_SYNC', +payload: StateSyncIncrementalActionPayload, |} | {| +type: 'PROCESS_SERVER_REQUESTS', +payload: ProcessServerRequestsPayload, |} | {| +type: 'UPDATE_CONNECTION_STATUS', +payload: UpdateConnectionStatusPayload, |} | {| +type: 'QUEUE_ACTIVITY_UPDATES', +payload: QueueActivityUpdatesPayload, |} | {| +type: 'UNSUPERVISED_BACKGROUND', +payload?: void, |} | {| +type: 'UPDATE_LIFECYCLE_STATE', +payload: LifecycleState, |} | {| +type: 'PROCESS_UPDATES', +payload: UpdatesResultWithUserInfos, |} | {| +type: 'PROCESS_MESSAGES', +payload: NewMessagesPayload, |} | {| +type: 'MESSAGE_STORE_PRUNE', +payload: MessageStorePrunePayload, |} | {| +type: 'SET_LATE_RESPONSE', +payload: SetLateResponsePayload, |} | {| +type: 'UPDATE_DISCONNECTED_BAR', +payload: UpdateDisconnectedBarPayload, |} | {| +type: 'REQUEST_ACCESS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', +payload: UpdateMultimediaMessageMediaPayload, |} | {| +type: 'CREATE_LOCAL_MESSAGE', +payload: LocallyComposedMessageInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_SUCCESS', - +payload?: void, + +payload: RelationshipErrors, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_STARTED', +payload: {| +threadID: string, +unread: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_SUCCESS', +payload: SetThreadUnreadStatusPayload, |}; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); export type SuperAction = { type: string, payload?: ActionPayload, loadingInfo?: LoadingInfo, error?: boolean, }; type ThunkedAction = (dispatch: Dispatch) => void; export type PromisedAction = (dispatch: Dispatch) => Promise; export type Dispatch = ((promisedAction: PromisedAction) => Promise) & ((thunkedAction: ThunkedAction) => void) & ((action: SuperAction) => boolean); // This is lifted from redux-persist/lib/constants.js // I don't want to add redux-persist to the web/server bundles... // import { REHYDRATE } from 'redux-persist'; export const rehydrateActionType = 'persist/REHYDRATE'; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 4e9f335a6..9921e33d3 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,427 +1,425 @@ // @flow import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { cookieInvalidationResolutionAttempt } from '../actions/user-actions'; import { serverCallStateSelector } from '../selectors/server-calls'; import type { LogInActionSource, LogInStartingPayload, LogInResult, } from '../types/account-types'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; import { getConfig } from './config'; import fetchJSON from './fetch-json'; import type { FetchJSON, FetchJSONOptions } from './fetch-json'; let nextPromiseIndex = 0; export type ActionTypes = { started: AT, success: BT, failed: CT, }; function wrapActionPromise< AT: string, // *_STARTED action type (string literal) AP: ActionPayload, // *_STARTED payload BT: string, // *_SUCCESS action type (string literal) BP: ActionPayload, // *_SUCCESS payload CT: string, // *_FAILED action type (string literal) >( actionTypes: ActionTypes, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?AP, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!( loadingOptions && loadingOptions.trackMultipleRequests ), customKeyName: loadingOptions && loadingOptions.customKeyName ? loadingOptions.customKeyName : null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: AT), loadingInfo, payload: (startingPayload: AP), } : { type: (actionTypes.started: AT), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: BT), payload: (result: BP), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: CT), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPromise = < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise() { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = {| +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, |}; let currentlyWaitingForNewCookie = false; let fetchJSONCallsWaitingForNewCookie: ((fetchJSON: ?FetchJSON) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, source: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, payload: { sessionChange, preRequestUserState, error, source }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, source: LogInActionSource, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let fetchJSONCallback = null; const boundFetchJSON = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?FetchJSONOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession(dispatch, sessionChange, null, error, source); }; try { const result = await fetchJSON( cookie, innerBoundSetNewSession, () => new Promise((r) => r(null)), () => new Promise((r) => r(null)), urlPrefix, null, 'disconnected', null, endpoint, data, options, ); if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } return result; } catch (e) { if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, source }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise((r) => (fetchJSONCallback = r)); }; await resolveInvalidatedCookie( boundFetchJSON, dispatchRecoveryAttempt, source, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoFetchJSON( params: BindServerCallsParams, ): FetchJSON { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before fetchJSON sends a request, to make sure // that we're not in the middle of trying to recover an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = fetchJSONCallsWaitingForNewCookie; fetchJSONCallsWaitingForNewCookie = []; const newFetchJSON = newSessionChange ? bindCookieAndUtilsIntoFetchJSON({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newFetchJSON); } return newFetchJSON; }; // If this function is called, fetchJSON got a response invalidating its // cookie, and is wondering if it should just like... give up? Or if there's // a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return (endpoint: Endpoint, data: Object, options?: ?FetchJSONOptions) => fetchJSON( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, socketAPIHandler, endpoint, data, options, ); } -export type ActionFunc = ( - fetchJSON: FetchJSON, - ...rest: $FlowFixMe -) => Promise<*>; +export type ActionFunc = (fetchJSON: FetchJSON) => F; type BindServerCallsParams = {| dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, |}; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where fetchJSON is called. We don't want to bother propagating // the cookie (and any future config info that fetchJSON needs) through to the // server calls so they can pass it to fetchJSON. Instead, we "curry" the cookie // onto fetchJSON within react-redux's connect's mapStateToProps function, and // then pass that "bound" fetchJSON that no longer needs the cookie as a // parameter on to the server call. -const baseCreateBoundServerCallsSelector = (actionFunc: ActionFunc) => { - return createSelector( +const baseCreateBoundServerCallsSelector = ( + actionFunc: ActionFunc, +): ((BindServerCallsParams) => F) => + createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => { const boundFetchJSON = bindCookieAndUtilsIntoFetchJSON({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); - return (...rest: $FlowFixMe) => actionFunc(boundFetchJSON, ...rest); + return actionFunc(boundFetchJSON); }, ); -}; -const createBoundServerCallsSelector: ( - actionFunc: ActionFunc, -) => (state: BindServerCallsParams) => BoundServerCall = _memoize( - baseCreateBoundServerCallsSelector, -); -export type BoundServerCall = (...rest: $FlowFixMe) => Promise; +type CreateBoundServerCallsSelectorType = ( + ActionFunc, +) => (BindServerCallsParams) => F; +const createBoundServerCallsSelector: CreateBoundServerCallsSelectorType = (_memoize( + baseCreateBoundServerCallsSelector, +): any); -function useServerCall(serverCall: ActionFunc): BoundServerCall { +function useServerCall(serverCall: ActionFunc): F { const dispatch = useDispatch(); const serverCallState = useSelector(serverCallStateSelector); return React.useMemo( () => createBoundServerCallsSelector(serverCall)({ ...serverCallState, dispatch, }), [serverCall, dispatch, serverCallState], ); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, setNewSessionActionType, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, registerActiveSocket, useServerCall, }; diff --git a/native/account/resolve-invalidated-cookie.js b/native/account/resolve-invalidated-cookie.js index b4ae214f5..6af020132 100644 --- a/native/account/resolve-invalidated-cookie.js +++ b/native/account/resolve-invalidated-cookie.js @@ -1,62 +1,62 @@ // @flow import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import type { LogInActionSource } from 'lib/types/account-types'; import type { DispatchRecoveryAttempt } from 'lib/utils/action-utils'; import type { FetchJSON } from 'lib/utils/fetch-json'; import { getGlobalNavContext } from '../navigation/icky-global'; import { store } from '../redux/redux-setup'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import { fetchNativeKeychainCredentials, getNativeSharedWebCredentials, } from './native-credentials'; async function resolveInvalidatedCookie( fetchJSON: FetchJSON, dispatchRecoveryAttempt: DispatchRecoveryAttempt, source?: LogInActionSource, ) { const keychainCredentials = await fetchNativeKeychainCredentials(); if (keychainCredentials) { const extraInfo = nativeLogInExtraInfoSelector({ redux: store.getState(), navContext: getGlobalNavContext(), })(); const { calendarQuery } = extraInfo; const newCookie = await dispatchRecoveryAttempt( logInActionTypes, - logIn(fetchJSON, { + logIn(fetchJSON)({ usernameOrEmail: keychainCredentials.username, password: keychainCredentials.password, source, ...extraInfo, }), { calendarQuery }, ); if (newCookie) { return; } } const sharedWebCredentials = getNativeSharedWebCredentials(); if (sharedWebCredentials) { const extraInfo = nativeLogInExtraInfoSelector({ redux: store.getState(), navContext: getGlobalNavContext(), })(); const { calendarQuery } = extraInfo; await dispatchRecoveryAttempt( logInActionTypes, - logIn(fetchJSON, { + logIn(fetchJSON)({ usernameOrEmail: sharedWebCredentials.username, password: sharedWebCredentials.password, source, ...extraInfo, }), { calendarQuery }, ); } } export { resolveInvalidatedCookie }; diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index faf0ef916..6947332be 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,118 +1,114 @@ // @flow import invariant from 'invariant'; import { Alert } from 'react-native'; import { removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, } from 'lib/actions/thread-actions'; import { memberIsAdmin, roleIsAdminRole } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types'; -import type { - DispatchFunctions, - ActionFunc, - BoundServerCall, -} from 'lib/utils/action-utils'; +import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; import { createTooltip, type TooltipParams, type TooltipRoute, } from '../../navigation/tooltip.react'; import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{| +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, |}>; function onRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + bindServerCall: (serverCall: ActionFunc) => F, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = bindServerCall(removeUsersFromThread); const onConfirmRemoveUser = () => { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( removeUsersFromThreadActionTypes, boundRemoveUsersFromThread(threadInfo.id, [memberInfo.id]), { customKeyName }, ); }; const userText = stringForUser(memberInfo); Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); } function onToggleAdmin( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + bindServerCall: (serverCall: ActionFunc) => F, ) { const { memberInfo, threadInfo } = route.params; const isCurrentlyAdmin = memberIsAdmin(memberInfo, threadInfo); const boundChangeThreadMemberRoles = bindServerCall(changeThreadMemberRoles); const onConfirmMakeAdmin = () => { let newRole = null; for (let roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( changeThreadMemberRolesActionTypes, boundChangeThreadMemberRoles(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); }; const userText = stringForUser(memberInfo); const actionClause = isCurrentlyAdmin ? `remove ${userText} as an admin` : `make ${userText} an admin`; Alert.alert( 'Confirm action', `Are you sure you want to ${actionClause} of this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmMakeAdmin }, ], { cancelable: true }, ); } const spec = { entries: [ { id: 'remove_user', text: 'Remove user', onPress: onRemoveUser }, { id: 'remove_admin', text: 'Remove admin', onPress: onToggleAdmin }, { id: 'make_admin', text: 'Make admin', onPress: onToggleAdmin }, ], }; const ThreadSettingsMemberTooltipModal = createTooltip< 'ThreadSettingsMemberTooltipModal', >(ThreadSettingsMemberTooltipButton, spec); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/sidebar-navigation.js b/native/chat/sidebar-navigation.js index 70d7d8795..bde678181 100644 --- a/native/chat/sidebar-navigation.js +++ b/native/chat/sidebar-navigation.js @@ -1,98 +1,94 @@ // @flow import invariant from 'invariant'; import { createPendingSidebar } from 'lib/shared/thread-utils'; -import type { - DispatchFunctions, - ActionFunc, - BoundServerCall, -} from 'lib/utils/action-utils'; +import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; import { getDefaultTextMessageRules } from '../markdown/rules.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { MessageListRouteName } from '../navigation/route-names'; import type { TooltipRoute } from '../navigation/tooltip.react'; function onPressGoToSidebar( route: | TooltipRoute<'RobotextMessageTooltipModal'> | TooltipRoute<'TextMessageTooltipModal'> | TooltipRoute<'MultimediaTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + bindServerCall: (serverCall: ActionFunc) => F, inputState: ?InputState, navigation: | AppNavigationProp<'RobotextMessageTooltipModal'> | AppNavigationProp<'TextMessageTooltipModal'> | AppNavigationProp<'MultimediaTooltipModal'>, ) { let threadCreatedFromMessage; // Necessary for Flow... if (route.name === 'RobotextMessageTooltipModal') { threadCreatedFromMessage = route.params.item.threadCreatedFromMessage; } else { threadCreatedFromMessage = route.params.item.threadCreatedFromMessage; } invariant( threadCreatedFromMessage, 'threadCreatedFromMessage should be set in onPressGoToSidebar', ); navigation.navigate({ name: MessageListRouteName, params: { threadInfo: threadCreatedFromMessage, }, key: `${MessageListRouteName}${threadCreatedFromMessage.id}`, }); } function onPressCreateSidebar( route: | TooltipRoute<'RobotextMessageTooltipModal'> | TooltipRoute<'TextMessageTooltipModal'> | TooltipRoute<'MultimediaTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + bindServerCall: (serverCall: ActionFunc) => F, inputState: ?InputState, navigation: | AppNavigationProp<'RobotextMessageTooltipModal'> | AppNavigationProp<'TextMessageTooltipModal'> | AppNavigationProp<'MultimediaTooltipModal'>, viewerID: ?string, ) { invariant( viewerID, 'viewerID should be set in TextMessageTooltipModal.onPressCreateSidebar', ); let itemFromParams; // Necessary for Flow... if (route.name === 'RobotextMessageTooltipModal') { itemFromParams = route.params.item; } else { itemFromParams = route.params.item; } const { messageInfo, threadInfo } = itemFromParams; const pendingSidebarInfo = createPendingSidebar( messageInfo, threadInfo, viewerID, getDefaultTextMessageRules().simpleMarkdownRules, ); const sourceMessageID = messageInfo.id; navigation.navigate({ name: MessageListRouteName, params: { threadInfo: pendingSidebarInfo, sourceMessageID, }, key: `${MessageListRouteName}${pendingSidebarInfo.id}`, }); } export { onPressGoToSidebar, onPressCreateSidebar }; diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js index 6656eca64..cf3356ba9 100644 --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -1,73 +1,69 @@ // @flow import Clipboard from '@react-native-community/clipboard'; import invariant from 'invariant'; import { createMessageReply } from 'lib/shared/message-utils'; -import type { - DispatchFunctions, - ActionFunc, - BoundServerCall, -} from 'lib/utils/action-utils'; +import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; import type { InputState } from '../input/input-state'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { createTooltip, tooltipHeight, type TooltipParams, type TooltipRoute, } from '../navigation/tooltip.react'; import { onPressGoToSidebar, onPressCreateSidebar } from './sidebar-navigation'; import TextMessageTooltipButton from './text-message-tooltip-button.react'; import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; export type TextMessageTooltipModalParams = TooltipParams<{| +item: ChatTextMessageInfoItemWithHeight, |}>; const confirmCopy = () => displayActionResultModal('copied!'); function onPressCopy(route: TooltipRoute<'TextMessageTooltipModal'>) { Clipboard.setString(route.params.item.messageInfo.text); setTimeout(confirmCopy); } function onPressReply( route: TooltipRoute<'TextMessageTooltipModal'>, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + bindServerCall: (serverCall: ActionFunc) => F, inputState: ?InputState, ) { invariant( inputState, 'inputState should be set in TextMessageTooltipModal.onPressReply', ); inputState.addReply(createMessageReply(route.params.item.messageInfo.text)); } const spec = { entries: [ { id: 'copy', text: 'Copy', onPress: onPressCopy }, { id: 'reply', text: 'Reply', onPress: onPressReply }, { id: 'create_sidebar', text: 'Create sidebar', onPress: onPressCreateSidebar, }, { id: 'open_sidebar', text: 'Go to sidebar', onPress: onPressGoToSidebar, }, ], }; const TextMessageTooltipModal = createTooltip<'TextMessageTooltipModal'>( TextMessageTooltipButton, spec, ); const textMessageTooltipHeight = tooltipHeight(spec.entries.length); export { TextMessageTooltipModal, textMessageTooltipHeight }; diff --git a/native/more/relationship-list-item-tooltip-modal.react.js b/native/more/relationship-list-item-tooltip-modal.react.js index e209644b7..eeda894d9 100644 --- a/native/more/relationship-list-item-tooltip-modal.react.js +++ b/native/more/relationship-list-item-tooltip-modal.react.js @@ -1,127 +1,123 @@ // @flow import * as React from 'react'; import { Alert, TouchableOpacity } from 'react-native'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions'; import { stringForUser } from 'lib/shared/user-utils'; import type { RelativeUserInfo } from 'lib/types/user-types'; -import type { - DispatchFunctions, - ActionFunc, - BoundServerCall, -} from 'lib/utils/action-utils'; +import type { DispatchFunctions, ActionFunc } from 'lib/utils/action-utils'; import PencilIcon from '../components/pencil-icon.react'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { createTooltip, type TooltipParams } from '../navigation/tooltip.react'; type Action = 'unfriend' | 'unblock'; export type RelationshipListItemTooltipModalParams = TooltipParams<{| +relativeUserInfo: RelativeUserInfo, |}>; type OnRemoveUserProps = {| ...RelationshipListItemTooltipModalParams, +action: Action, |}; function onRemoveUser( props: OnRemoveUserProps, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + bindServerCall: (serverCall: ActionFunc) => F, ) { const boundRemoveRelationships = bindServerCall(updateRelationships); const callRemoveRelationships = async () => { try { return await boundRemoveRelationships({ action: props.action, userIDs: [props.relativeUserInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: true, }); throw e; } }; const onConfirmRemoveUser = () => { const customKeyName = `${updateRelationshipsActionTypes.started}:${props.relativeUserInfo.id}`; dispatchFunctions.dispatchActionPromise( updateRelationshipsActionTypes, callRemoveRelationships(), { customKeyName }, ); }; const userText = stringForUser(props.relativeUserInfo); const action = { unfriend: 'removal', unblock: 'unblock', }[props.action]; const message = { unfriend: `remove ${userText} from friends?`, unblock: `unblock ${userText}?`, }[props.action]; Alert.alert( `Confirm ${action}`, `Are you sure you want to ${message}`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); } const spec = { entries: [ { id: 'unfriend', text: 'Unfriend', onPress: (route, dispatchFunctions, bindServerCall) => onRemoveUser( { ...route.params, action: 'unfriend' }, dispatchFunctions, bindServerCall, ), }, { id: 'unblock', text: 'Unblock', onPress: (route, dispatchFunctions, bindServerCall) => onRemoveUser( { ...route.params, action: 'unblock' }, dispatchFunctions, bindServerCall, ), }, ], }; type Props = { +navigation: AppNavigationProp<'RelationshipListItemTooltipModal'>, ... }; class RelationshipListItemTooltipButton extends React.PureComponent { render() { return ( ); } onPress = () => { this.props.navigation.goBackOnce(); }; } const RelationshipListItemTooltipModal = createTooltip< 'RelationshipListItemTooltipModal', >(RelationshipListItemTooltipButton, spec); export default RelationshipListItemTooltipModal; diff --git a/native/more/relationship-list-item.react.js b/native/more/relationship-list-item.react.js index 894903256..45bb9df5a 100644 --- a/native/more/relationship-list-item.react.js +++ b/native/more/relationship-list-item.react.js @@ -1,339 +1,342 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Alert, View, Text, TouchableOpacity, ActivityIndicator, } from 'react-native'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type RelationshipRequest, type RelationshipAction, + type RelationshipErrors, userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types'; import type { AccountUserInfo, GlobalAccountUserInfo, } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import PencilIcon from '../components/pencil-icon.react'; import { SingleLine } from '../components/single-line.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { RelationshipListItemTooltipModalRouteName, FriendListRouteName, BlockListRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; import type { RelationshipListNavigate } from './relationship-list.react'; type BaseProps = {| +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +relationshipListRoute: NavigationRoute<'FriendList' | 'BlockList'>, +navigate: RelationshipListNavigate, +onSelect: (selectedUser: GlobalAccountUserInfo) => void, |}; type Props = {| ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs - +updateRelationships: (request: RelationshipRequest) => Promise, + +updateRelationships: ( + request: RelationshipRequest, + ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; class RelationshipListItem extends React.PureComponent { editButton = React.createRef>(); render() { const { lastListItem, removeUserLoadingStatus, userInfo, relationshipListRoute, } = this.props; const relationshipsToEdit = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BOTH_BLOCKED, userRelationshipStatus.BLOCKED_BY_VIEWER, ], }[relationshipListRoute.name]; const canEditFriendRequest = { [FriendListRouteName]: true, [BlockListRouteName]: false, }[relationshipListRoute.name]; const borderBottom = lastListItem ? null : this.props.styles.borderBottom; let editButton = null; if (removeUserLoadingStatus === 'loading') { editButton = ( ); } else if (relationshipsToEdit.includes(userInfo.relationshipStatus)) { editButton = ( ); } else if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED && canEditFriendRequest ) { editButton = ( Accept Reject ); } else if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT && canEditFriendRequest ) { editButton = ( Cancel request ); } else { editButton = ( Add ); } return ( {this.props.userInfo.username} {editButton} ); } onSelect = () => { const { id, username } = this.props.userInfo; this.props.onSelect({ id, username }); }; visibleEntryIDs() { const { relationshipListRoute } = this.props; const id = { [FriendListRouteName]: 'unfriend', [BlockListRouteName]: 'unblock', }[relationshipListRoute.name]; return [id]; } onPressEdit = () => { if (this.props.keyboardState?.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; const { overlayContext, userInfo } = this.props; invariant( overlayContext, 'RelationshipListItem should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); if (!editButton.current || !verticalBounds) { return; } const { relationshipStatus, ...restUserInfo } = userInfo; const relativeUserInfo = { ...restUserInfo, isViewer: false, }; editButton.current.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate({ name: RelationshipListItemTooltipModalRouteName, params: { presentedFrom: this.props.relationshipListRoute.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), relativeUserInfo, }, }); }); }; // We need to set onLayout in order to allow .measure() to be on the ref onLayout = () => {}; onPressFriendUser = () => { this.onPressUpdateFriendship(relationshipActions.FRIEND); }; onPressUnfriendUser = () => { this.onPressUpdateFriendship(relationshipActions.UNFRIEND); }; onPressUpdateFriendship(action: RelationshipAction) { const { id } = this.props.userInfo; const customKeyName = `${updateRelationshipsActionTypes.started}:${id}`; this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateFriendship(action), { customKeyName }, ); } async updateFriendship(action: RelationshipAction) { try { return await this.props.updateRelationships({ action, userIDs: [this.props.userInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: true, }); throw e; } } } const unboundStyles = { editButton: { paddingLeft: 10, }, container: { flex: 1, paddingHorizontal: 12, backgroundColor: 'panelForeground', }, innerContainer: { paddingVertical: 10, paddingHorizontal: 12, borderColor: 'panelForegroundBorder', flexDirection: 'row', }, borderBottom: { borderBottomWidth: 1, }, buttonContainer: { flexDirection: 'row', }, editButtonWithMargin: { marginLeft: 15, }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, }, blueAction: { color: 'link', fontSize: 16, paddingLeft: 6, }, redAction: { color: 'redText', fontSize: 16, paddingLeft: 6, }, }; export default React.memo(function ConnectedRelationshipListItem( props: BaseProps, ) { const removeUserLoadingStatus = useSelector((state) => createLoadingStatusSelector( updateRelationshipsActionTypes, `${updateRelationshipsActionTypes.started}:${props.userInfo.id}`, )(state), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const boundUpdateRelationships = useServerCall(updateRelationships); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); return ( ); }); diff --git a/native/more/relationship-list.react.js b/native/more/relationship-list.react.js index ed3d01e18..aba58d398 100644 --- a/native/more/relationship-list.react.js +++ b/native/more/relationship-list.react.js @@ -1,569 +1,572 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, FlatList, Alert, Platform } from 'react-native'; import { createSelector } from 'reselect'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions'; import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { type UserRelationships, type RelationshipRequest, + type RelationshipErrors, userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types'; import type { UserSearchResult } from 'lib/types/search-types'; import type { UserInfos, GlobalAccountUserInfo, AccountUserInfo, } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import LinkButton from '../components/link-button.react'; import { createTagInput, BaseTagInput } from '../components/tag-input.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { FriendListRouteName, BlockListRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; import type { MoreNavigationProp } from './more.react'; import RelationshipListItem from './relationship-list-item.react'; const TagInput = createTagInput(); export type RelationshipListNavigate = $PropertyType< MoreNavigationProp<'FriendList' | 'BlockList'>, 'navigate', >; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; type ListItem = | {| +type: 'empty', +because: 'no-relationships' | 'no-results' |} | {| +type: 'header' |} | {| +type: 'footer' |} | {| +type: 'user', +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, |}; type BaseProps = {| +navigation: MoreNavigationProp<>, +route: NavigationRoute<'FriendList' | 'BlockList'>, |}; type Props = {| ...BaseProps, // Redux state +relationships: UserRelationships, +userInfos: UserInfos, +viewerID: ?string, +userStoreSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, - +updateRelationships: (request: RelationshipRequest) => Promise, + +updateRelationships: ( + request: RelationshipRequest, + ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +verticalBounds: ?VerticalBounds, +searchInputText: string, +serverSearchResults: $ReadOnlyArray, +currentTags: $ReadOnlyArray, +userStoreSearchResults: Set, |}; type PropsAndState = {| ...Props, ...State |}; class RelationshipList extends React.PureComponent { flatListContainerRef = React.createRef(); tagInput: ?BaseTagInput = null; state: State = { verticalBounds: null, searchInputText: '', serverSearchResults: [], userStoreSearchResults: new Set(), currentTags: [], }; componentDidMount() { this.setSaveButton(false); } componentDidUpdate(prevProps: Props, prevState: State) { const prevTags = prevState.currentTags.length; const currentTags = this.state.currentTags.length; if (prevTags !== 0 && currentTags === 0) { this.setSaveButton(false); } else if (prevTags === 0 && currentTags !== 0) { this.setSaveButton(true); } } setSaveButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } static keyExtractor(item: ListItem) { if (item.userInfo) { return item.userInfo.id; } else if (item.type === 'empty') { return 'empty'; } else if (item.type === 'header') { return 'header'; } else if (item.type === 'footer') { return 'footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); } get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'RelationshipList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = RelationshipList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } render() { const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( Search: ); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.relationships, (propsAndState: PropsAndState) => propsAndState.route.name, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (propsAndState: PropsAndState) => propsAndState.searchInputText, (propsAndState: PropsAndState) => propsAndState.serverSearchResults, (propsAndState: PropsAndState) => propsAndState.userStoreSearchResults, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.currentTags, ( relationships: UserRelationships, routeName: 'FriendList' | 'BlockList', verticalBounds: ?VerticalBounds, searchInputText: string, serverSearchResults: $ReadOnlyArray, userStoreSearchResults: Set, userInfos: UserInfos, viewerID: ?string, currentTags: $ReadOnlyArray, ) => { const defaultUsers = { [FriendListRouteName]: relationships.friends, [BlockListRouteName]: relationships.blocked, }[routeName]; const excludeUserIDsArray = currentTags .map((userInfo) => userInfo.id) .concat(viewerID || []); const excludeUserIDs = new Set(excludeUserIDsArray); let displayUsers = defaultUsers; if (searchInputText !== '') { const mergedUserInfos: { [id: string]: AccountUserInfo } = {}; for (const userInfo of serverSearchResults) { mergedUserInfos[userInfo.id] = userInfo; } for (const id of userStoreSearchResults) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedUserInfos[id] = { id, username, relationshipStatus }; } } const sortToEnd = []; const userSearchResults = []; const sortRelationshipTypesToEnd = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], }[routeName]; for (const userID in mergedUserInfos) { if (excludeUserIDs.has(userID)) { continue; } const userInfo = mergedUserInfos[userID]; if ( sortRelationshipTypesToEnd.includes(userInfo.relationshipStatus) ) { sortToEnd.push(userInfo); } else { userSearchResults.push(userInfo); } } displayUsers = userSearchResults.concat(sortToEnd); } let emptyItem; if (displayUsers.length === 0 && searchInputText === '') { emptyItem = { type: 'empty', because: 'no-relationships' }; } else if (displayUsers.length === 0) { emptyItem = { type: 'empty', because: 'no-results' }; } const mappedUsers = displayUsers.map((userInfo, index) => ({ type: 'user', userInfo, lastListItem: displayUsers.length - 1 === index, verticalBounds, })); return [] .concat(emptyItem ? emptyItem : []) .concat(emptyItem ? [] : { type: 'header' }) .concat(mappedUsers) .concat(emptyItem ? [] : { type: 'footer' }); }, ); tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; tagDataLabelExtractor = (userInfo: GlobalAccountUserInfo) => userInfo.username; onChangeTagInput = (currentTags: $ReadOnlyArray) => { this.setState({ currentTags }); }; onChangeSearchText = async (searchText: string) => { const excludeStatuses = { [FriendListRouteName]: [ userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], [BlockListRouteName]: [], }[this.props.route.name]; const results = this.props.userStoreSearchIndex .getSearchResults(searchText) .filter((userID) => { const relationship = this.props.userInfos[userID].relationshipStatus; return !excludeStatuses.includes(relationship); }); this.setState({ searchInputText: searchText, userStoreSearchResults: new Set(results), }); const serverSearchResults = await this.searchUsers(searchText); const filteredServerSearchResults = serverSearchResults.filter( (searchUserInfo) => { const userInfo = this.props.userInfos[searchUserInfo.id]; return ( !userInfo || !excludeStatuses.includes(userInfo.relationshipStatus) ); }, ); this.setState({ serverSearchResults: filteredServerSearchResults }); }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos; } onFlatListContainerLayout = () => { const { flatListContainerRef } = this; if (!flatListContainerRef.current) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainerRef.current.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }, ); }; onSelect = (selectedUser: GlobalAccountUserInfo) => { this.setState((state) => { if (state.currentTags.find((o) => o.id === selectedUser.id)) { return null; } return { searchInputText: '', currentTags: state.currentTags.concat(selectedUser), }; }); }; onPressAdd = () => { if (this.state.currentTags.length === 0) { return; } this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateRelationships(), ); }; async updateRelationships() { const routeName = this.props.route.name; const action = { [FriendListRouteName]: relationshipActions.FRIEND, [BlockListRouteName]: relationshipActions.BLOCK, }[routeName]; const userIDs = this.state.currentTags.map((userInfo) => userInfo.id); try { const result = await this.props.updateRelationships({ action, userIDs, }); this.setState({ currentTags: [], searchInputText: '', }); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: true, onDismiss: this.onUnknownErrorAlertAcknowledged }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentTags: [], searchInputText: '', }, this.onErrorAcknowledged, ); }; renderItem = ({ item }: { item: ListItem }) => { if (item.type === 'empty') { const action = { [FriendListRouteName]: 'added', [BlockListRouteName]: 'blocked', }[this.props.route.name]; const emptyMessage = item.because === 'no-relationships' ? `You haven't ${action} any users yet` : 'No results'; return {emptyMessage}; } else if (item.type === 'header' || item.type === 'footer') { return ; } else if (item.type === 'user') { return ( ); } else { invariant(false, `unexpected RelationshipList item type ${item.type}`); } }; } const unboundStyles = { container: { flex: 1, backgroundColor: 'panelBackground', }, contentContainer: { paddingTop: 12, paddingBottom: 24, }, separator: { backgroundColor: 'panelForegroundBorder', height: Platform.OS === 'android' ? 1.5 : 1, }, emptyText: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, textAlign: 'center', paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 12, }, tagInput: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingLeft: 12, }, tagInputContainer: { alignItems: 'center', backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; registerFetchKey(searchUsersActionTypes); registerFetchKey(updateRelationshipsActionTypes); export default React.memo(function ConnectedRelationshipList( props: BaseProps, ) { const relationships = useSelector(userRelationshipsSelector); const userInfos = useSelector((state) => state.userStore.userInfos); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const dispatchActionPromise = useDispatchActionPromise(); const callSearchUsers = useServerCall(searchUsers); const callUpdateRelationships = useServerCall(updateRelationships); return ( ); }); diff --git a/native/navigation/tooltip.react.js b/native/navigation/tooltip.react.js index f4e23af96..1db7cce8d 100644 --- a/native/navigation/tooltip.react.js +++ b/native/navigation/tooltip.react.js @@ -1,574 +1,573 @@ // @flow import type { LeafRoute } from '@react-navigation/native'; import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, StyleSheet, TouchableWithoutFeedback, Platform, TouchableOpacity, Text, ViewPropTypes, } from 'react-native'; import { TapticFeedback } from 'react-native-in-app-message'; import Animated from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { type ServerCallState, serverCallStatePropType, serverCallStateSelector, } from 'lib/selectors/server-calls'; import type { Dispatch } from 'lib/types/redux-types'; import { createBoundServerCallsSelector, useDispatchActionPromise, type DispatchActionPromise, type ActionFunc, type DispatchFunctions, - type BoundServerCall, } from 'lib/utils/action-utils'; import { SingleLine } from '../components/single-line.react'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; import { type DimensionsInfo, dimensionsInfoPropType, } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { type VerticalBounds, verticalBoundsPropType, type LayoutCoordinates, layoutCoordinatesPropType, } from '../types/layout-types'; import type { LayoutEvent } from '../types/react-native'; import type { ViewStyle, TextStyle } from '../types/styles'; import type { AppNavigationProp } from './app-navigator.react'; import { OverlayContext, type OverlayContextType, overlayContextPropType, } from './overlay-context'; import type { TooltipModalParamList } from './route-names'; /* eslint-disable import/no-named-as-default-member */ const { Value, Extrapolate, add, multiply, interpolate } = Animated; /* eslint-enable import/no-named-as-default-member */ export type TooltipEntry> = {| +id: string, +text: string, +onPress: ( route: TooltipRoute, dispatchFunctions: DispatchFunctions, - bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + bindServerCall: (serverCall: ActionFunc) => F, inputState: ?InputState, navigation: AppNavigationProp, viewerID: ?string, ) => mixed, |}; type TooltipItemProps = {| +spec: TooltipEntry, +onPress: (entry: TooltipEntry) => void, +containerStyle?: ViewStyle, +labelStyle?: TextStyle, |}; type TooltipSpec = {| +entries: $ReadOnlyArray>, +labelStyle?: ViewStyle, |}; export type TooltipParams = {| ...CustomProps, +presentedFrom: string, +initialCoordinates: LayoutCoordinates, +verticalBounds: VerticalBounds, +location?: 'above' | 'below', +margin?: number, +visibleEntryIDs?: $ReadOnlyArray, |}; export type TooltipRoute> = {| ...LeafRoute, +params: $ElementType, |}; type BaseTooltipProps = {| +navigation: AppNavigationProp, +route: TooltipRoute, |}; type ButtonProps = {| ...BaseTooltipProps, +progress: Value, |}; type TooltipProps = {| ...BaseTooltipProps, // Redux state +dimensions: DimensionsInfo, +serverCallState: ServerCallState, +viewerID: ?string, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // withOverlayContext +overlayContext: ?OverlayContextType, // withInputState +inputState: ?InputState, |}; function createTooltip>( ButtonComponent: React.ComponentType>, tooltipSpec: TooltipSpec, ): React.ComponentType> { class TooltipItem extends React.PureComponent> { static propTypes = { spec: PropTypes.shape({ text: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired, }).isRequired, onPress: PropTypes.func.isRequired, containerStyle: ViewPropTypes.style, labelStyle: Text.propTypes.style, }; render() { return ( {this.props.spec.text} ); } onPress = () => { this.props.onPress(this.props.spec); }; } class Tooltip extends React.PureComponent> { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ initialCoordinates: layoutCoordinatesPropType.isRequired, verticalBounds: verticalBoundsPropType.isRequired, location: PropTypes.oneOf(['above', 'below']), margin: PropTypes.number, visibleEntryIDs: PropTypes.arrayOf(PropTypes.string), }).isRequired, }).isRequired, dimensions: dimensionsInfoPropType.isRequired, serverCallState: serverCallStatePropType.isRequired, viewerID: PropTypes.string, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, overlayContext: overlayContextPropType, inputState: inputStatePropType, }; backdropOpacity: Value; tooltipContainerOpacity: Value; tooltipVerticalAbove: Value; tooltipVerticalBelow: Value; tooltipHorizontalOffset = new Value(0); tooltipHorizontal: Value; constructor(props: TooltipProps) { super(props); const { overlayContext } = props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; this.backdropOpacity = interpolate(position, { inputRange: [0, 1], outputRange: [0, 0.7], extrapolate: Extrapolate.CLAMP, }); this.tooltipContainerOpacity = interpolate(position, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const { margin } = this; this.tooltipVerticalAbove = interpolate(position, { inputRange: [0, 1], outputRange: [margin + this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipVerticalBelow = interpolate(position, { inputRange: [0, 1], outputRange: [-margin - this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipHorizontal = multiply( add(1, multiply(-1, position)), this.tooltipHorizontalOffset, ); } componentDidMount() { if (Platform.OS === 'ios') { TapticFeedback.impact(); } } get entries(): $ReadOnlyArray> { const { entries } = tooltipSpec; const { visibleEntryIDs } = this.props.route.params; if (!visibleEntryIDs) { return entries; } const visibleSet = new Set(visibleEntryIDs); return entries.filter((entry) => visibleSet.has(entry.id)); } get tooltipHeight(): number { return tooltipHeight(this.entries.length); } get location(): 'above' | 'below' { const { params } = this.props.route; const { location } = params; if (location) { return location; } const { initialCoordinates, verticalBounds } = params; const { y, height } = initialCoordinates; const contentTop = y; const contentBottom = y + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const { margin, tooltipHeight: curTooltipHeight } = this; const fullHeight = curTooltipHeight + margin; if ( contentBottom + fullHeight > boundsBottom && contentTop - fullHeight > boundsTop ) { return 'above'; } return 'below'; } get opacityStyle() { return { ...styles.backdrop, opacity: this.backdropOpacity, }; } get contentContainerStyle() { const { verticalBounds } = this.props.route.params; const fullScreenHeight = this.props.dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; return { ...styles.contentContainer, marginTop: top, marginBottom: bottom, }; } get buttonStyle() { const { params } = this.props.route; const { initialCoordinates, verticalBounds } = params; const { x, y, width, height } = initialCoordinates; return { width: Math.ceil(width), height: Math.ceil(height), marginTop: y - verticalBounds.y, marginLeft: x, }; } get margin() { const customMargin = this.props.route.params.margin; return customMargin !== null && customMargin !== undefined ? customMargin : 20; } get tooltipContainerStyle() { const { dimensions, route } = this.props; const { initialCoordinates, verticalBounds } = route.params; const { x, y, width, height } = initialCoordinates; const { margin, location } = this; const style = {}; style.position = 'absolute'; (style.alignItems = 'center'), (style.opacity = this.tooltipContainerOpacity); style.transform = [{ translateX: this.tooltipHorizontal }]; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { style.left = 0; style.minWidth = width + 2 * extraLeftSpace; } else { style.right = 0; style.minWidth = width + 2 * extraRightSpace; } if (location === 'above') { const fullScreenHeight = dimensions.height; style.bottom = fullScreenHeight - Math.max(y, verticalBounds.y) + margin; style.transform.push({ translateY: this.tooltipVerticalAbove }); } else { style.top = Math.min(y + height, verticalBounds.y + verticalBounds.height) + margin; style.transform.push({ translateY: this.tooltipVerticalBelow }); } const { overlayContext } = this.props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; style.transform.push({ scale: position }); return style; } render() { const { navigation, route, dimensions } = this.props; const { entries } = this; const items = entries.map((entry, index) => { const style = index !== entries.length - 1 ? styles.itemMargin : null; return ( ); }); let triangleStyle; const { initialCoordinates } = route.params; const { x, width } = initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { triangleStyle = { alignSelf: 'flex-start', left: extraLeftSpace + (width - 20) / 2, }; } else { triangleStyle = { alignSelf: 'flex-end', right: extraRightSpace + (width - 20) / 2, }; } let triangleDown = null; let triangleUp = null; const { location } = this; if (location === 'above') { triangleDown = ; } else { triangleUp = ; } const { overlayContext } = this.props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; return ( {triangleUp} {items} {triangleDown} ); } onPressBackdrop = () => { this.props.navigation.goBackOnce(); }; onPressEntry = (entry: TooltipEntry) => { this.props.navigation.goBackOnce(); const dispatchFunctions = { dispatch: this.props.dispatch, dispatchActionPromise: this.props.dispatchActionPromise, }; entry.onPress( this.props.route, dispatchFunctions, this.bindServerCall, this.props.inputState, this.props.navigation, this.props.viewerID, ); }; - bindServerCall = (serverCall: ActionFunc) => { + bindServerCall = (serverCall: ActionFunc): F => { const { cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = this.props.serverCallState; return createBoundServerCallsSelector(serverCall)({ dispatch: this.props.dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); }; onTooltipContainerLayout = (event: LayoutEvent) => { const { route, dimensions } = this.props; const { x, width } = route.params.initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; const actualWidth = event.nativeEvent.layout.width; if (extraLeftSpace < extraRightSpace) { const minWidth = width + 2 * extraLeftSpace; this.tooltipHorizontalOffset.setValue((minWidth - actualWidth) / 2); } else { const minWidth = width + 2 * extraRightSpace; this.tooltipHorizontalOffset.setValue((actualWidth - minWidth) / 2); } }; } return React.memo>(function ConnectedTooltip( props: BaseTooltipProps, ) { const dimensions = useSelector((state) => state.dimensions); const serverCallState = useSelector(serverCallStateSelector); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const overlayContext = React.useContext(OverlayContext); const inputState = React.useContext(InputStateContext); return ( ); }); } const styles = StyleSheet.create({ backdrop: { backgroundColor: 'black', bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, container: { flex: 1, }, contentContainer: { flex: 1, overflow: 'hidden', }, itemContainer: { padding: 10, }, itemMargin: { borderBottomColor: '#E1E1E1', borderBottomWidth: 1, }, items: { backgroundColor: 'white', borderRadius: 5, overflow: 'hidden', }, label: { color: '#444', fontSize: 14, lineHeight: 17, textAlign: 'center', }, triangleDown: { borderBottomColor: 'transparent', borderBottomWidth: 0, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'white', borderTopWidth: 10, height: 10, top: Platform.OS === 'android' ? -1 : 0, width: 10, }, triangleUp: { borderBottomColor: 'white', borderBottomWidth: 10, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'transparent', borderTopWidth: 0, bottom: Platform.OS === 'android' ? -1 : 0, height: 10, width: 10, }, }); function tooltipHeight(numEntries: number) { // 10 (triangle) + 37 * numEntries (entries) + numEntries - 1 (padding) return 9 + 38 * numEntries; } export { createTooltip, tooltipHeight }; diff --git a/web/modals/history/history-modal.react.js b/web/modals/history/history-modal.react.js index 50b7438df..b43a1928d 100644 --- a/web/modals/history/history-modal.react.js +++ b/web/modals/history/history-modal.react.js @@ -1,276 +1,278 @@ // @flow import classNames from 'classnames'; import dateFormat from 'dateformat'; import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _map from 'lodash/fp/map'; import _unionBy from 'lodash/fp/unionBy'; import * as React from 'react'; import { fetchEntriesActionTypes, fetchEntries, fetchRevisionsForEntryActionTypes, fetchRevisionsForEntry, } from 'lib/actions/entry-actions'; import { nonExcludeDeletedCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { EntryInfo, CalendarQuery, FetchEntryInfosResult, } from 'lib/types/entry-types'; import { type CalendarFilter } from 'lib/types/filter-types'; import type { HistoryMode, HistoryRevisionInfo } from 'lib/types/history-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { dateFromString } from 'lib/utils/date-utils'; import LoadingIndicator from '../../loading-indicator.react'; import { useSelector } from '../../redux/redux-utils'; import { allDaysToEntries } from '../../selectors/entry-selectors'; import Modal from '../modal.react'; import HistoryEntry from './history-entry.react'; import HistoryRevision from './history-revision.react'; import css from './history.css'; type BaseProps = {| +mode: HistoryMode, +dayString: string, +onClose: () => void, +currentEntryID?: ?string, |}; type Props = {| ...BaseProps, +entryInfos: ?(EntryInfo[]), +dayLoadingStatus: LoadingStatus, +entryLoadingStatus: LoadingStatus, +calendarFilters: $ReadOnlyArray, +dispatchActionPromise: DispatchActionPromise, +fetchEntries: ( calendarQuery: CalendarQuery, ) => Promise, - +fetchRevisionsForEntry: (entryID: string) => Promise, + +fetchRevisionsForEntry: ( + entryID: string, + ) => Promise<$ReadOnlyArray>, |}; type State = {| +mode: HistoryMode, +animateModeChange: boolean, +currentEntryID: ?string, +revisions: $ReadOnlyArray, |}; class HistoryModal extends React.PureComponent { static defaultProps = { currentEntryID: null }; constructor(props: Props) { super(props); this.state = { mode: props.mode, animateModeChange: false, currentEntryID: props.currentEntryID, revisions: [], }; } componentDidMount() { this.loadDay(); if (this.state.mode === 'entry') { invariant(this.state.currentEntryID, 'entry ID should be set'); this.loadEntry(this.state.currentEntryID); } } render() { let allHistoryButton = null; if (this.state.mode === 'entry') { allHistoryButton = ( < all entries ); } const historyDate = dateFromString(this.props.dayString); const prettyDate = dateFormat(historyDate, 'mmmm dS, yyyy'); const loadingStatus = this.state.mode === 'day' ? this.props.dayLoadingStatus : this.props.entryLoadingStatus; let entries; const entryInfos = this.props.entryInfos; if (entryInfos) { entries = _flow( _filter((entryInfo: EntryInfo) => entryInfo.id), _map((entryInfo: EntryInfo) => { const serverID = entryInfo.id; invariant(serverID, 'serverID should be set'); return ( ); }), )(entryInfos); } else { entries = []; } const revisionInfos = this.state.revisions.filter( (revisionInfo) => revisionInfo.entryID === this.state.currentEntryID, ); const revisions = []; for (let i = 0; i < revisionInfos.length; i++) { const revisionInfo = revisionInfos[i]; const nextRevisionInfo = revisionInfos[i + 1]; const isDeletionOrRestoration = nextRevisionInfo !== undefined && revisionInfo.deleted !== nextRevisionInfo.deleted; revisions.push( , ); } const animate = this.state.animateModeChange; const dayMode = this.state.mode === 'day'; const dayClasses = classNames({ [css.dayHistory]: true, [css.dayHistoryVisible]: dayMode && !animate, [css.dayHistoryInvisible]: !dayMode && !animate, [css.dayHistoryVisibleAnimate]: dayMode && animate, [css.dayHistoryInvisibleAnimate]: !dayMode && animate, }); const entryMode = this.state.mode === 'entry'; const entryClasses = classNames({ [css.entryHistory]: true, [css.entryHistoryVisible]: entryMode && !animate, [css.entryHistoryInvisible]: !entryMode && !animate, [css.entryHistoryVisibleAnimate]: entryMode && animate, [css.entryHistoryInvisibleAnimate]: !entryMode && animate, }); return (
{allHistoryButton} {prettyDate}
    {entries}
    {revisions}
); } loadDay() { this.props.dispatchActionPromise( fetchEntriesActionTypes, this.props.fetchEntries({ startDate: this.props.dayString, endDate: this.props.dayString, filters: this.props.calendarFilters, }), ); } loadEntry(entryID: string) { this.setState({ mode: 'entry', currentEntryID: entryID }); this.props.dispatchActionPromise( fetchRevisionsForEntryActionTypes, this.fetchRevisionsForEntryAction(entryID), ); } async fetchRevisionsForEntryAction(entryID: string) { const result = await this.props.fetchRevisionsForEntry(entryID); this.setState((prevState) => { // This merge here will preserve time ordering correctly const revisions = _unionBy('id')(result)(prevState.revisions); return { ...prevState, revisions }; }); return { entryID, text: result[0].text, deleted: result[0].deleted, }; } onClickEntry = (entryID: string) => { this.setState({ animateModeChange: true }); this.loadEntry(entryID); }; onClickAllEntries = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ mode: 'day', animateModeChange: true, }); }; animateAndLoadEntry = (entryID: string) => { this.setState({ animateModeChange: true }); this.loadEntry(entryID); }; } const dayLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const entryLoadingStatusSelector = createLoadingStatusSelector( fetchRevisionsForEntryActionTypes, ); export default React.memo(function ConnectedHistoryModal( props: BaseProps, ) { const entryInfos = useSelector( (state) => allDaysToEntries(state)[props.dayString], ); const dayLoadingStatus = useSelector(dayLoadingStatusSelector); const entryLoadingStatus = useSelector(entryLoadingStatusSelector); const calendarFilters = useSelector(nonExcludeDeletedCalendarFiltersSelector); const callFetchEntries = useServerCall(fetchEntries); const callFetchRevisionsForEntry = useServerCall(fetchRevisionsForEntry); const dispatchActionPromise = useDispatchActionPromise(); return ( ); });