diff --git a/lib/package.json b/lib/package.json index b1a99da77..33a8662cf 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,42 +1,41 @@ { "name": "lib", "version": "0.0.1", "type": "module", "private": true, "license": "BSD-3-Clause", "scripts": { "clean": "rm -rf node_modules/" }, "devDependencies": { "@babel/core": "^7.12.10", "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4", "@babel/plugin-proposal-object-rest-spread": "^7.11.0", "@babel/plugin-proposal-optional-chaining": "^7.11.0", "@babel/plugin-transform-runtime": "^7.11.5", "@babel/preset-flow": "^7.9.0", "@babel/preset-react": "^7.9.1", "flow-bin": "^0.122.0", "flow-typed": "^3.2.1" }, "dependencies": { "dateformat": "^3.0.3", "emoji-regex": "^9.2.0", "fast-json-stable-stringify": "^2.0.0", "file-type": "^12.3.0", "invariant": "^2.2.4", "lodash": "^4.17.19", - "prop-types": "^15.7.2", "react": "16.13.1", "react-redux": "^7.1.1", "reselect": "^4.0.0", "reselect-map": "^1.0.5", "string-hash": "^1.1.3", "tinycolor2": "^1.4.1", "tokenize-text": "^1.1.3", "url-parse-lax": "^3.0.0", "util-inspect": "^0.1.8", "simple-markdown": "^0.7.2", "utils-copy-error": "^1.0.1" } } diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 1707ac10d..58ba16f4a 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,925 +1,925 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference'; import _flow from 'lodash/fp/flow'; import _isEqual from 'lodash/fp/isEqual'; import _keyBy from 'lodash/fp/keyBy'; import _map from 'lodash/fp/map'; import _mapKeys from 'lodash/fp/mapKeys'; import _mapValues from 'lodash/fp/mapValues'; import _omit from 'lodash/fp/omit'; import _omitBy from 'lodash/fp/omitBy'; import _orderBy from 'lodash/fp/orderBy'; import _pick from 'lodash/fp/pick'; import _pickBy from 'lodash/fp/pickBy'; import _uniq from 'lodash/fp/uniq'; import { createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, sendTextMessageActionTypes, sendMultimediaMessageActionTypes, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, createLocalMessageActionType, } from '../actions/message-actions'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, leaveThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, } from '../actions/thread-actions'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, } from '../actions/user-actions'; import { messageID, combineTruncationStatuses, sortMessageInfoList, } from '../shared/message-utils'; import { threadHasPermission, threadInChatList } from '../shared/thread-utils'; import threadWatcher from '../shared/thread-watcher'; import { unshimMessageInfos } from '../shared/unshim-utils'; import { type RawMessageInfo, type LocalMessageInfo, type MessageStore, type MessageTruncationStatus, type MessagesResponse, messageTruncationStatus, messageTypes, defaultNumberPerThread, } from '../types/message-types'; import type { RawImagesMessageInfo } from '../types/messages/images'; import type { RawMediaMessageInfo } from '../types/messages/media'; import { type BaseAction, rehydrateActionType } from '../types/redux-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import { type RawThreadInfo, threadPermissions } from '../types/thread-types'; import { updateTypes, type UpdateInfo, processUpdatesActionType, } from '../types/update-types'; import { setNewSessionActionType } from '../utils/action-utils'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); const sortMessageIDs = (messages: { [id: string]: RawMessageInfo }) => _orderBy([(id: string) => messages[id].time, (id: string) => id])([ 'desc', 'desc', ]); // Input must already be ordered! function threadsToMessageIDsFromMessageInfos( orderedMessageInfos: $ReadOnlyArray, ): { [threadID: string]: string[] } { const threads: { [threadID: string]: string[] } = {}; for (let messageInfo of orderedMessageInfos) { const key = messageID(messageInfo); if (!threads[messageInfo.threadID]) { threads[messageInfo.threadID] = [key]; } else { threads[messageInfo.threadID].push(key); } } return threads; } function threadIsWatched( threadInfo: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadInfo.id)) ); } function freshMessageStore( - messageInfos: RawMessageInfo[], + messageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: number, threadInfos: { [threadID: string]: RawThreadInfo }, ): MessageStore { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = sortMessageInfoList(unshimmed); const messages = _keyBy(messageID)(orderedMessageInfos); const threadsToMessageIDs = threadsToMessageIDsFromMessageInfos( orderedMessageInfos, ); const lastPruned = Date.now(); const threads = _mapValuesWithKeys( (messageIDs: string[], threadID: string) => ({ messageIDs, startReached: truncationStatus[threadID] === messageTruncationStatus.EXHAUSTIVE, lastNavigatedTo: 0, lastPruned, }), )(threadsToMessageIDs); const watchedIDs = threadWatcher.getWatchedIDs(); for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threads[threadID] || !threadIsWatched(threadInfo, watchedIDs)) { continue; } threads[threadID] = { messageIDs: [], // We can conclude that startReached, since no messages were returned startReached: true, lastNavigatedTo: 0, lastPruned, }; } return { messages, threads, local: {}, currentAsOf }; } // oldMessageStore is from the old state // newMessageInfos, truncationStatus come from server function mergeNewMessages( oldMessageStore: MessageStore, newMessageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, threadInfos: { [threadID: string]: RawThreadInfo }, actionType: *, ): MessageStore { const unshimmed = unshimMessageInfos(newMessageInfos); const localIDsToServerIDs: Map = new Map(); const orderedNewMessageInfos = _flow( _map((messageInfo: RawMessageInfo) => { const { id: inputID } = messageInfo; invariant(inputID, 'new messageInfos should have serverID'); const currentMessageInfo = oldMessageStore.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? oldMessageStore.messages[inputLocalID] : null; if (currentMessageInfo && currentMessageInfo.localID) { // If the client already has a RawMessageInfo with this serverID, keep // any localID associated with the existing one. This is because we // use localIDs as React keys and changing React keys leads to loss of // component state. (The conditional below is for Flow) if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawImagesMessageInfo); } } else if (currentLocalMessageInfo && currentLocalMessageInfo.localID) { // If the client has a RawMessageInfo with this localID, but not with // the serverID, that means the message creation succeeded but the // success action never got processed. We set a key in // localIDsToServerIDs here to fix the messageIDs for the rest of the // MessageStore too. (The conditional below is for Flow) invariant(inputLocalID, 'should be set'); localIDsToServerIDs.set(inputLocalID, inputID); if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentLocalMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawImagesMessageInfo); } } else { // If neither the serverID nor the localID from the delivered // RawMessageInfo exists in the client store, then this message is // brand new to us. Ignore any localID provided by the server. // (The conditional below is for Flow) const { localID, ...rest } = messageInfo; if (rest.type === messageTypes.TEXT) { messageInfo = { ...rest }; } else if (rest.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...rest }: RawMediaMessageInfo); } else { messageInfo = ({ ...rest }: RawImagesMessageInfo); } } } return _isEqual(messageInfo)(currentMessageInfo) ? currentMessageInfo : messageInfo; }), sortMessageInfoList, )(unshimmed); const threadsToMessageIDs = threadsToMessageIDsFromMessageInfos( orderedNewMessageInfos, ); const oldMessageInfosToCombine = []; const mustResortThreadMessageIDs = []; const lastPruned = Date.now(); const watchedIDs = threadWatcher.getWatchedIDs(); const local = {}; const threads = _flow( _pickBy((messageIDs: string[], threadID: string) => threadIsWatched(threadInfos[threadID], watchedIDs), ), _mapValuesWithKeys((messageIDs: string[], threadID: string) => { const oldThread = oldMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (!oldThread) { if (actionType === fetchMessagesBeforeCursorActionTypes.success) { // Well, this is weird. Somehow fetchMessagesBeforeCursor got called // for a thread that doesn't exist in the messageStore. How did this // happen? How do we even know what cursor to use if we didn't have // any messages? Anyways, the messageStore is predicated on the // principle that it is current. We can't create a ThreadMessageInfo // for a thread if we can't guarantee this, as the client has no UX // for endReached, only for startReached. We'll have to bail out here. return null; } return { messageIDs, startReached: truncate === messageTruncationStatus.EXHAUSTIVE, lastNavigatedTo: 0, lastPruned, }; } let oldMessageIDsUnchanged = true; const oldMessageIDs = oldThread.messageIDs.map((oldID) => { const newID = localIDsToServerIDs.get(oldID); if (newID !== null && newID !== undefined) { oldMessageIDsUnchanged = false; return newID; } return oldID; }); if (truncate === messageTruncationStatus.TRUNCATED) { // If the result set in the payload isn't contiguous with what we have // now, that means we need to dump what we have in the state and replace // it with the result set. We do this to achieve our two goals for the // messageStore: currentness and contiguousness. return { messageIDs, startReached: false, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const oldNotInNew = _difference(oldMessageIDs)(messageIDs); for (let id of oldNotInNew) { const oldMessageInfo = oldMessageStore.messages[id]; invariant(oldMessageInfo, `could not find ${id} in messageStore`); oldMessageInfosToCombine.push(oldMessageInfo); const localInfo = oldMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } const startReached = oldThread.startReached || truncate === messageTruncationStatus.EXHAUSTIVE; if (_difference(messageIDs)(oldMessageIDs).length === 0) { if (startReached === oldThread.startReached && oldMessageIDsUnchanged) { return oldThread; } return { messageIDs: oldMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const mergedMessageIDs = [...messageIDs, ...oldNotInNew]; mustResortThreadMessageIDs.push(threadID); return { messageIDs: mergedMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; }), _pickBy((thread) => !!thread), )(threadsToMessageIDs); for (let threadID in oldMessageStore.threads) { if ( threads[threadID] || !threadIsWatched(threadInfos[threadID], watchedIDs) ) { continue; } let thread = oldMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (truncate === messageTruncationStatus.EXHAUSTIVE) { thread = { ...thread, startReached: true, }; } threads[threadID] = thread; for (let id of thread.messageIDs) { const messageInfo = oldMessageStore.messages[id]; if (messageInfo) { oldMessageInfosToCombine.push(messageInfo); } const localInfo = oldMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } } for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threads[threadID] || !threadIsWatched(threadInfo, watchedIDs)) { continue; } threads[threadID] = { messageIDs: [], // We can conclude that startReached, since no messages were returned startReached: true, lastNavigatedTo: 0, lastPruned, }; } const messages = _flow( sortMessageInfoList, _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); for (let threadID of mustResortThreadMessageIDs) { threads[threadID].messageIDs = sortMessageIDs(messages)( threads[threadID].messageIDs, ); } const currentAsOf = Math.max( orderedNewMessageInfos.length > 0 ? orderedNewMessageInfos[0].time : 0, oldMessageStore.currentAsOf, ); return { messages, threads, local, currentAsOf }; } function filterByNewThreadInfos( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): MessageStore { const watchedIDs = threadWatcher.getWatchedIDs(); const watchedThreadInfos = _pickBy((threadInfo: RawThreadInfo) => threadIsWatched(threadInfo, watchedIDs), )(threadInfos); const messageIDsToRemove = []; for (let threadID in messageStore.threads) { if (watchedThreadInfos[threadID]) { continue; } for (let id of messageStore.threads[threadID].messageIDs) { messageIDsToRemove.push(id); } } return { messages: _omit(messageIDsToRemove)(messageStore.messages), threads: _pick(Object.keys(watchedThreadInfos))(messageStore.threads), local: _omit(messageIDsToRemove)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; } function reduceMessageStore( messageStore: MessageStore, action: BaseAction, newThreadInfos: { [id: string]: RawThreadInfo }, ): MessageStore { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { const messagesResult = action.payload.messagesResult; return freshMessageStore( messagesResult.messageInfos, messagesResult.truncationStatus, messagesResult.currentAsOf, newThreadInfos, ); } else if (action.type === incrementalStateSyncActionType) { if ( action.payload.messagesResult.rawMessageInfos.length === 0 && action.payload.updatesResult.newUpdates.length === 0 ) { return messageStore; } const messagesResult = mergeUpdatesIntoMessagesResult( action.payload.messagesResult, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === processUpdatesActionType) { if (action.payload.updatesResult.newUpdates.length === 0) { return messageStore; } const mergedMessageInfos = []; const mergedTruncationStatuses = {}; const { newUpdates } = action.payload.updatesResult; for (let updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (let messageInfo of updateInfo.rawMessageInfos) { mergedMessageInfos.push(messageInfo); } mergedTruncationStatuses[ updateInfo.threadInfo.id ] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } if (Object.keys(mergedTruncationStatuses).length === 0) { return messageStore; } const newMessageStore = mergeNewMessages( messageStore, mergedMessageInfos, mergedTruncationStatuses, newThreadInfos, action.type, ); return { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType ) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, { [action.payload.threadID]: action.payload.truncationStatus }, newThreadInfos, action.type, ); } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === setNewSessionActionType ) { return filterByNewThreadInfos(messageStore, newThreadInfos); } else if (action.type === newThreadActionTypes.success) { const { newThreadID } = action.payload; const truncationStatuses = {}; for (let messageInfo of action.payload.newMessageInfos) { truncationStatuses[messageInfo.threadID] = messageInfo.threadID === newThreadID ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.UNCHANGED; } return mergeNewMessages( messageStore, action.payload.newMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === registerActionTypes.success) { const truncationStatuses = {}; for (let messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } return mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if ( action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === deleteEntryActionTypes.success) { const payload = action.payload; if (payload) { return mergeNewMessages( messageStore, payload.newMessageInfos, { [payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } } else if (action.type === restoreEntryActionTypes.success) { const { threadID } = action.payload; return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === joinThreadActionTypes.success) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, action.payload.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started ) { const { payload } = action; const { localID, threadID } = payload; invariant(localID, `localID should be set on ${action.type}`); if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [localID]: payload }; const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== localID, )(messageStore.local); const threads = { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: sortMessageIDs(messages)( messageStore.threads[threadID].messageIDs, ), }, }; return { ...messageStore, messages, threads, local }; } const { messageIDs } = messageStore.threads[threadID]; for (let existingMessageID of messageIDs) { const existingMessageInfo = messageStore.messages[existingMessageID]; if (existingMessageInfo && existingMessageInfo.localID === localID) { return messageStore; } } return { messages: { ...messageStore.messages, [localID]: payload, }, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: [localID, ...messageStore.threads[threadID].messageIDs], }, }, local: messageStore.local, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; return { messages: messageStore.messages, threads: messageStore.threads, local: { ...messageStore.local, [localID]: { sendFailed: true }, }, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === sendTextMessageActionTypes.success || action.type === sendMultimediaMessageActionTypes.success ) { const { payload } = action; const replaceMessageKey = (messageKey: string) => messageKey === payload.localID ? payload.serverID : messageKey; let newMessages; if (messageStore.messages[payload.serverID]) { // If somehow the serverID got in there already, we'll just update the // serverID message and scrub the localID one newMessages = _omitBy( (messageInfo: RawMessageInfo) => messageInfo.type === messageTypes.TEXT && !messageInfo.id && messageInfo.localID === payload.localID, )(messageStore.messages); } else if (messageStore.messages[payload.localID]) { // The normal case, the localID message gets replaced by the serverID one newMessages = _mapKeys(replaceMessageKey)(messageStore.messages); } else { // Well this is weird, we probably got deauthorized between when the // action was dispatched and when we ran this reducer... return messageStore; } newMessages[payload.serverID] = { ...newMessages[payload.serverID], id: payload.serverID, localID: payload.localID, time: payload.time, }; const threadID = payload.threadID; const newMessageIDs = _flow( _uniq, sortMessageIDs(newMessages), )(messageStore.threads[threadID].messageIDs.map(replaceMessageKey)); const currentAsOf = payload.interface === 'socket' ? Math.max(payload.time, messageStore.currentAsOf) : messageStore.currentAsOf; const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== payload.localID, )(messageStore.local); return { messages: newMessages, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }, local, currentAsOf, }; } else if (action.type === saveMessagesActionType) { const truncationStatuses = {}; for (let messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const newMessageStore = mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); return { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, // We avoid bumping currentAsOf because notifs may include a contracted // RawMessageInfo, so we want to make sure we still fetch it currentAsOf: messageStore.currentAsOf, }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const messageIDsToPrune = []; let newThreads = { ...messageStore.threads }; for (let threadID of action.payload.threadIDs) { let thread = newThreads[threadID]; if (!thread) { continue; } thread = { ...thread, lastPruned: now }; const newMessageIDs = [...thread.messageIDs]; const removed = newMessageIDs.splice(defaultNumberPerThread); if (removed.length > 0) { thread = { ...thread, messageIDs: newMessageIDs, startReached: false, }; } for (const id of removed) { messageIDsToPrune.push(id); } newThreads[threadID] = thread; } return { messages: _omit(messageIDsToPrune)(messageStore.messages), threads: newThreads, local: _omit(messageIDsToPrune)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; } else if (action.type === updateMultimediaMessageMediaActionType) { const { messageID: id, currentMediaID, mediaUpdate } = action.payload; const message = messageStore.messages[id]; invariant(message, `message with ID ${id} could not be found`); invariant( message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, `message with ID ${id} is not multimedia`, ); let replaced = false; const media = []; for (let singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else if (singleMedia.type === 'photo') { replaced = true; media.push({ ...singleMedia, ...mediaUpdate, }); } else if (singleMedia.type === 'video') { replaced = true; media.push({ ...singleMedia, ...mediaUpdate, }); } } invariant( replaced, `message ${id} did not contain media with ID ${currentMediaID}`, ); return { ...messageStore, messages: { ...messageStore.messages, [id]: { ...message, media, }, }, }; } else if (action.type === createLocalMessageActionType) { const messageInfo = action.payload; return { ...messageStore, messages: { ...messageStore.messages, [messageInfo.localID]: messageInfo, }, threads: { ...messageStore.threads, [messageInfo.threadID]: { ...messageStore.threads[messageInfo.threadID], messageIDs: [ messageInfo.localID, ...messageStore.threads[messageInfo.threadID].messageIDs, ], }, }, }; } else if (action.type === rehydrateActionType) { // When starting the app on native, we filter out any local-only multimedia // messages because the relevant context is no longer available const { messages, threads, local } = messageStore; const newMessages = {}; let newThreads = threads, newLocal = local; for (let id in messages) { const message = messages[id]; if ( (message.type !== messageTypes.IMAGES && message.type !== messageTypes.MULTIMEDIA) || message.id ) { newMessages[id] = message; continue; } const { threadID } = message; newThreads = { ...newThreads, [threadID]: { ...newThreads[threadID], messageIDs: newThreads[threadID].messageIDs.filter( (curMessageID) => curMessageID !== id, ), }, }; newLocal = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== id, )(newLocal); } if (newThreads === threads) { return messageStore; } return { ...messageStore, messages: newMessages, threads: newThreads, local: newLocal, }; } return messageStore; } function mergeUpdatesIntoMessagesResult( messagesResult: MessagesResponse, newUpdates: $ReadOnlyArray, ): MessagesResponse { const messageIDs = new Set( messagesResult.rawMessageInfos.map((messageInfo) => messageInfo.id), ); const mergedMessageInfos = [...messagesResult.rawMessageInfos]; const mergedTruncationStatuses = { ...messagesResult.truncationStatuses }; for (let updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (let messageInfo of updateInfo.rawMessageInfos) { if (messageIDs.has(messageInfo.id)) { continue; } mergedMessageInfos.push(messageInfo); messageIDs.add(messageInfo.id); } mergedTruncationStatuses[ updateInfo.threadInfo.id ] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } return { rawMessageInfos: mergedMessageInfos, truncationStatuses: mergedTruncationStatuses, currentAsOf: messagesResult.currentAsOf, }; } export { freshMessageStore, reduceMessageStore }; diff --git a/lib/selectors/server-calls.js b/lib/selectors/server-calls.js index 3625ff909..c0ac35d59 100644 --- a/lib/selectors/server-calls.js +++ b/lib/selectors/server-calls.js @@ -1,52 +1,40 @@ // @flow -import PropTypes from 'prop-types'; import { createSelector } from 'reselect'; import type { AppState } from '../types/redux-types'; -import { - type ConnectionStatus, - connectionStatusPropType, -} from '../types/socket-types'; -import { type CurrentUserInfo, currentUserPropType } from '../types/user-types'; +import { type ConnectionStatus } from '../types/socket-types'; +import { type CurrentUserInfo } from '../types/user-types'; export type ServerCallState = {| - cookie: ?string, - urlPrefix: string, - sessionID: ?string, - currentUserInfo: ?CurrentUserInfo, - connectionStatus: ConnectionStatus, + +cookie: ?string, + +urlPrefix: string, + +sessionID: ?string, + +currentUserInfo: ?CurrentUserInfo, + +connectionStatus: ConnectionStatus, |}; -const serverCallStatePropType = PropTypes.shape({ - cookie: PropTypes.string, - urlPrefix: PropTypes.string.isRequired, - sessionID: PropTypes.string, - currentUserInfo: currentUserPropType, - connectionStatus: connectionStatusPropType.isRequired, -}); - const serverCallStateSelector: ( state: AppState, ) => ServerCallState = createSelector( (state: AppState) => state.cookie, (state: AppState) => state.urlPrefix, (state: AppState) => state.sessionID, (state: AppState) => state.currentUserInfo, (state: AppState) => state.connection.status, ( cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => ({ cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }), ); -export { serverCallStatePropType, serverCallStateSelector }; +export { serverCallStateSelector }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index 5105b5f17..3fd1870f7 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,405 +1,405 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import _orderBy from 'lodash/fp/orderBy'; import { type ParserRules } from 'simple-markdown'; import { multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type MultimediaMessageData, type MessageStore, type ComposableMessageInfo, messageTypes, messageTruncationStatus, type RawComposableMessageInfo, } from '../types/message-types'; import type { ImagesMessageData } from '../types/messages/images'; import type { MediaMessageData } from '../types/messages/media'; import { type ThreadInfo } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { codeBlockRegex } from './markdown'; import { messageSpecs } from './messages/message-specs'; import { stringForUser } from './user-utils'; // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); const messageSpec = messageSpecs[messageInfo.type]; invariant( messageSpec.robotext, `we're not aware of messageType ${messageInfo.type}`, ); return messageSpec.robotext(messageInfo, creator, { encodedThreadEntity, robotextForUsers, robotextForUser, threadInfo, }); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } const creator = { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }; const createRelativeUserInfos = (userIDs: $ReadOnlyArray) => userIDsToRelativeUserInfos(userIDs, viewerID, userInfos); const createMessageInfoFromRaw = (rawInfo: RawMessageInfo) => createMessageInfo(rawInfo, viewerID, userInfos, threadInfos); const messageSpec = messageSpecs[rawMessageInfo.type]; return messageSpec.createMessageInfo(rawMessageInfo, creator, { threadInfos, createMessageInfoFromRaw, createRelativeUserInfos, }); } function sortMessageInfoList( messageInfos: $ReadOnlyArray, ): T[] { return _orderBy(['time', 'id'])(['desc', 'desc'])(messageInfos); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { const messageSpec = messageSpecs[messageData.type]; invariant( messageSpec.rawMessageInfoFromMessageData, `we're not aware of messageType ${messageData.type}`, ); return messageSpec.rawMessageInfoFromMessageData(messageData, id); } function mostRecentMessageTimestamp( - messageInfos: RawMessageInfo[], + messageInfos: $ReadOnlyArray, previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return messageSpecs[type].generatesNotifs; } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map((rawMessageInfo) => { const { shimUnsupportedMessageInfo } = messageSpecs[rawMessageInfo.type]; if (shimUnsupportedMessageInfo) { return shimUnsupportedMessageInfo(rawMessageInfo, platformDetails); } return rawMessageInfo; }); } function messagePreviewText( messageInfo: PreviewableMessageInfo, threadInfo: ThreadInfo, ): string { if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const creator = stringForUser(messageInfo.creator); const preview = multimediaMessagePreview(messageInfo); return `${creator} ${preview}`; } return robotextToRawString(robotextForMessageInfo(messageInfo, threadInfo)); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (let singleMedia of input.media) { if (singleMedia.type === 'video') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID } = input; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); } if (localID) { messageData.localID = localID; } return messageData; } type MediaMessageInfoCreationInput = $ReadOnly<{ ...$Exact, id?: ?string, }>; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input); const messageSpec = messageSpecs[messageData.type]; return messageSpec.rawMessageInfoFromMessageData(messageData, input.id); } function stripLocalID(rawMessageInfo: RawComposableMessageInfo) { const { localID, ...rest } = rawMessageInfo; return rest; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (const rawMessageInfo of input) { if (rawMessageInfo.localID) { invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); output.push(stripLocalID(rawMessageInfo)); } else { output.push(rawMessageInfo); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string) { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageReply(message: string) { // add `>` to each line to include empty lines in the quote const quotedMessage = message.replace(/^/gm, '> '); return quotedMessage + '\n\n'; } function getMostRecentNonLocalMessageID( threadID: string, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadID]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } export type GetMessageTitleViewerContext = | 'global_viewer' | 'individual_viewer'; function getMessageTitle( messageInfo: ComposableMessageInfo | RobotextMessageInfo, threadInfo: ThreadInfo, markdownRules: ParserRules, viewerContext?: GetMessageTitleViewerContext = 'individual_viewer', ): string { const { messageTitle } = messageSpecs[messageInfo.type]; return messageTitle({ messageInfo, threadInfo, markdownRules, viewerContext, }); } function removeCreatorAsViewer(messageInfo: Info): Info { return { ...messageInfo, creator: { ...messageInfo.creator, isViewer: false }, }; } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, getMessageTitle, removeCreatorAsViewer, }; diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index e87eb9137..c34333e8b 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,750 +1,727 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { updateActivityActionTypes } from '../actions/activity-actions'; import { socketAuthErrorResolutionAttempt, logOutActionTypes, } from '../actions/user-actions'; import { unsupervisedBackgroundActionType } from '../reducers/lifecycle-state-reducer'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts'; import type { LogOutResult } from '../types/account-types'; import type { CalendarQuery } from '../types/entry-types'; import type { Dispatch } from '../types/redux-types'; import { serverRequestTypes, type ClientClientResponse, type ServerRequest, } from '../types/request-types'; import { type SessionState, type SessionIdentification, - sessionIdentificationPropType, type PreRequestUserState, - preRequestUserStatePropType, } from '../types/session-types'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, - connectionInfoPropType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, } from '../types/socket-types'; import { actionLogger } from '../utils/action-logger'; import type { DispatchActionPromise } from '../utils/action-utils'; import { setNewSessionActionType, fetchNewCookieFromNativeCredentials, } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { ServerError } from '../utils/errors'; import { promiseAll } from '../utils/promises'; import sleep from '../utils/sleep'; import ActivityHandler from './activity-handler.react'; import APIRequestHandler from './api-request-handler.react'; import CalendarQueryHandler from './calendar-query-handler.react'; import { InflightRequests, SocketTimeout, SocketOffline, } from './inflight-requests'; import MessageHandler from './message-handler.react'; import ReportHandler from './report-handler.react'; import RequestResponseHandler from './request-response-handler.react'; import UpdateHandler from './update-handler.react'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = {| +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, |}; type Props = {| ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => WebSocket, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: (preRequestUserState: PreRequestUserState) => Promise, |}; type State = {| +inflightRequests: ?InflightRequests, |}; class Socket extends React.PureComponent { - static propTypes = { - detectUnsupervisedBackgroundRef: PropTypes.func, - active: PropTypes.bool.isRequired, - openSocket: PropTypes.func.isRequired, - getClientResponses: PropTypes.func.isRequired, - activeThread: PropTypes.string, - sessionStateFunc: PropTypes.func.isRequired, - sessionIdentification: sessionIdentificationPropType.isRequired, - cookie: PropTypes.string, - urlPrefix: PropTypes.string.isRequired, - connection: connectionInfoPropType.isRequired, - currentCalendarQuery: PropTypes.func.isRequired, - canSendReports: PropTypes.bool.isRequired, - frozen: PropTypes.bool.isRequired, - preRequestUserState: preRequestUserStatePropType.isRequired, - dispatch: PropTypes.func.isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - logOut: PropTypes.func.isRequired, - }; state: State = { inflightRequests: null, }; socket: ?WebSocket; nextClientMessageID = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent = getConfig().platformDetails.platform === 'web'; reopenConnectionAfterClosing = false; invalidationRecoveryInProgress = false; initializedWithUserState: ?PreRequestUserState; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || (getConfig().platformDetails.platform !== 'web' && (!this.props.cookie || !this.props.cookie.startsWith('user='))) ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus }, }); const socket = this.props.openSocket(); const openObject = {}; socket.onopen = () => { if (this.socket === socket) { this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected' }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting' }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting' }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect = _throttle(() => this.openSocket('reconnecting'), 2000); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render() { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } sendMessageWithoutID = (message: ClientSocketMessageWithoutID) => { const id = this.nextClientMessageID++; // These conditions all do the same thing and the runtime checks are only // necessary for Flow if (message.type === clientSocketMessageTypes.INITIAL) { this.sendMessage(({ ...message, id }: ClientInitialClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.sendMessage( ({ ...message, id }: ClientResponsesClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.PING) { this.sendMessage(({ ...message, id }: PingClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); } return id; }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } static messageFromEvent(event: MessageEvent): ?ServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } try { return JSON.parse(event.data); } catch (e) { console.log(e); return null; } } receiveMessage = async (event: MessageEvent) => { const message = Socket.messageFromEvent(event); if (!message) { return; } const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } for (let listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await fetchNewCookieFromNativeCredentials( this.props.dispatch, cookie, this.props.urlPrefix, socketAuthErrorResolutionAttempt, ); if (!recoverySessionChange && sessionChange) { // This should only happen in the cookieSources.BODY (native) case when // the resolution attempt failed const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, source: socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener = (listener: SocketListener) => { this.listeners.add(listener); }; removeListener = (listener: SocketListener) => { this.listeners.delete(listener); }; onClose = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const promises = {}; const clientResponses = []; if (!this.initialPlatformDetailsSent) { this.initialPlatformDetailsSent = true; clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); promises.activityUpdateMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); promises.stateSyncMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); const { stateSyncMessage, activityUpdateMessage } = await promiseAll( promises, ); if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: queuedActivityUpdates, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, source: undefined, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket = async (retriesLeft: number = 1) => { try { await this.sendInitialMessage(); } catch (e) { console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse = (messageID: number, isLate: boolean) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } detectUnsupervisedBackground = (alreadyClosed: boolean) => { // On native, sometimes the app is backgrounded without the proper callbacks // getting triggered. This leaves us in an incorrect state for two reasons: // (1) The connection is still considered to be active, causing API requests // to be processed via socket and failing. // (2) We rely on flipping foreground state in Redux to detect activity // changes, and thus won't think we need to update activity. if ( this.props.connection.status !== 'connected' || !this.messageLastReceived || this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || (actionLogger.mostRecentActionTime && actionLogger.mostRecentActionTime + 3000 < Date.now()) ) { return false; } if (!alreadyClosed) { this.cleanUpServerTerminatedSocket(); } this.props.dispatch({ type: unsupervisedBackgroundActionType, payload: null, }); return true; }; } export default Socket; diff --git a/lib/types/account-types.js b/lib/types/account-types.js index 9509872cb..1710a9a36 100644 --- a/lib/types/account-types.js +++ b/lib/types/account-types.js @@ -1,152 +1,152 @@ // @flow import type { PlatformDetails, DeviceType } from './device-types'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, GenericMessagesResult, } from './message-types'; import type { PreRequestUserState } from './session-types'; import type { RawThreadInfo } from './thread-types'; import type { UserInfo, LoggedOutUserInfo, LoggedInUserInfo, } from './user-types'; export type ResetPasswordRequest = {| - usernameOrEmail: string, + +usernameOrEmail: string, |}; export type LogOutResult = {| - currentUserInfo: ?LoggedOutUserInfo, - preRequestUserState: PreRequestUserState, + +currentUserInfo: ?LoggedOutUserInfo, + +preRequestUserState: PreRequestUserState, |}; export type LogOutResponse = {| - currentUserInfo: LoggedOutUserInfo, + +currentUserInfo: LoggedOutUserInfo, |}; export type RegisterInfo = {| ...LogInExtraInfo, - username: string, - email: string, - password: string, + +username: string, + +email: string, + +password: string, |}; type DeviceTokenUpdateRequest = {| - deviceToken: string, + +deviceToken: string, |}; export type RegisterRequest = {| - username: string, - email: string, - password: string, - calendarQuery?: ?CalendarQuery, - deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, - platformDetails: PlatformDetails, + +username: string, + +email: string, + +password: string, + +calendarQuery?: ?CalendarQuery, + +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, + +platformDetails: PlatformDetails, |}; export type RegisterResponse = {| id: string, rawMessageInfos: $ReadOnlyArray, - cookieChange: { + cookieChange: {| threadInfos: { [id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, - }, + |}, |}; export type RegisterResult = {| - currentUserInfo: LoggedInUserInfo, - rawMessageInfos: $ReadOnlyArray, - threadInfos: { [id: string]: RawThreadInfo }, - userInfos: $ReadOnlyArray, - calendarQuery: CalendarQuery, + +currentUserInfo: LoggedInUserInfo, + +rawMessageInfos: $ReadOnlyArray, + +threadInfos: { [id: string]: RawThreadInfo }, + +userInfos: $ReadOnlyArray, + +calendarQuery: CalendarQuery, |}; export type DeleteAccountRequest = {| - password: string, + +password: string, |}; export type ChangeUserSettingsResult = {| - email: ?string, + +email: ?string, |}; export type LogInActionSource = | 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT' | 'APP_START_NATIVE_CREDENTIALS_AUTO_LOG_IN' | 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE' | 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT'; export type LogInStartingPayload = {| - calendarQuery: CalendarQuery, - source?: LogInActionSource, + +calendarQuery: CalendarQuery, + +source?: LogInActionSource, |}; export type LogInExtraInfo = {| - calendarQuery: CalendarQuery, - deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, + +calendarQuery: CalendarQuery, + +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, |}; export type LogInInfo = {| ...LogInExtraInfo, - usernameOrEmail: string, - password: string, - source?: ?LogInActionSource, + +usernameOrEmail: string, + +password: string, + +source?: ?LogInActionSource, |}; export type LogInRequest = {| - usernameOrEmail: string, - password: string, - calendarQuery?: ?CalendarQuery, - deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, - platformDetails: PlatformDetails, - watchedIDs: $ReadOnlyArray, + +usernameOrEmail: string, + +password: string, + +calendarQuery?: ?CalendarQuery, + +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, + +platformDetails: PlatformDetails, + +watchedIDs: $ReadOnlyArray, |}; export type LogInResponse = {| currentUserInfo: LoggedInUserInfo, - rawMessageInfos: RawMessageInfo[], + rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, rawEntryInfos?: ?$ReadOnlyArray, serverTime: number, - cookieChange: { + cookieChange: {| threadInfos: { [id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, - }, + |}, |}; export type LogInResult = {| - threadInfos: { [id: string]: RawThreadInfo }, - currentUserInfo: LoggedInUserInfo, - messagesResult: GenericMessagesResult, - userInfos: UserInfo[], - calendarResult: CalendarResult, - updatesCurrentAsOf: number, - source?: ?LogInActionSource, + +threadInfos: { [id: string]: RawThreadInfo }, + +currentUserInfo: LoggedInUserInfo, + +messagesResult: GenericMessagesResult, + +userInfos: $ReadOnlyArray, + +calendarResult: CalendarResult, + +updatesCurrentAsOf: number, + +source?: ?LogInActionSource, |}; export type UpdatePasswordInfo = {| ...LogInExtraInfo, - code: string, - password: string, + +code: string, + +password: string, |}; export type UpdatePasswordRequest = {| code: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, |}; export type AccessRequest = {| - email: string, - platform: DeviceType, + +email: string, + +platform: DeviceType, |}; diff --git a/lib/types/activity-types.js b/lib/types/activity-types.js index 82c2bb371..99fcd492c 100644 --- a/lib/types/activity-types.js +++ b/lib/types/activity-types.js @@ -1,45 +1,38 @@ // @flow -import PropTypes from 'prop-types'; - export type ActivityUpdate = {| +focus: boolean, +threadID: string, +latestMessage: ?string, |}; -export const activityUpdatePropType = PropTypes.shape({ - focus: PropTypes.bool.isRequired, - threadID: PropTypes.string.isRequired, - latestMessage: PropTypes.string, -}); export type UpdateActivityRequest = {| - updates: $ReadOnlyArray, + +updates: $ReadOnlyArray, |}; export type UpdateActivityResult = {| - unfocusedToUnread: string[], + +unfocusedToUnread: $ReadOnlyArray, |}; export type ActivityUpdateSuccessPayload = {| - activityUpdates: $ReadOnlyArray, - result: UpdateActivityResult, + +activityUpdates: $ReadOnlyArray, + +result: UpdateActivityResult, |}; export const queueActivityUpdatesActionType = 'QUEUE_ACTIVITY_UPDATES'; export type QueueActivityUpdatesPayload = {| - activityUpdates: $ReadOnlyArray, + +activityUpdates: $ReadOnlyArray, |}; export type SetThreadUnreadStatusRequest = {| +unread: boolean, +threadID: string, +latestMessage: ?string, |}; export type SetThreadUnreadStatusResult = {| +resetToUnread: boolean, |}; export type SetThreadUnreadStatusPayload = {| ...SetThreadUnreadStatusResult, +threadID: string, |}; diff --git a/lib/types/device-types.js b/lib/types/device-types.js index fdcd19ad6..3b54ed102 100644 --- a/lib/types/device-types.js +++ b/lib/types/device-types.js @@ -1,39 +1,30 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; export type DeviceType = 'ios' | 'android'; export type Platform = DeviceType | 'web'; -export const platformPropType = PropTypes.oneOf(['ios', 'android', 'web']); - export function isDeviceType(platform: ?string) { return platform === 'ios' || platform === 'android'; } export function assertDeviceType(deviceType: ?string): DeviceType { invariant( deviceType === 'ios' || deviceType === 'android', 'string is not DeviceType enum', ); return deviceType; } export type DeviceTokenUpdateRequest = {| +deviceToken: string, +deviceType?: DeviceType, +platformDetails?: PlatformDetails, |}; export type PlatformDetails = {| - platform: Platform, - codeVersion?: number, - stateVersion?: number, + +platform: Platform, + +codeVersion?: number, + +stateVersion?: number, |}; - -export const platformDetailsPropType = PropTypes.shape({ - platform: platformPropType.isRequired, - codeVersion: PropTypes.number, - stateVersion: PropTypes.number, -}); diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js index c0c4561fc..32a665fa2 100644 --- a/lib/types/entry-types.js +++ b/lib/types/entry-types.js @@ -1,231 +1,193 @@ // @flow -import PropTypes from 'prop-types'; - import { fifteenDaysEarlier, fifteenDaysLater, thisMonthDates, } from '../utils/date-utils'; import type { Platform } from './device-types'; -import { - type CalendarFilter, - calendarFilterPropType, - defaultCalendarFilters, -} from './filter-types'; +import { type CalendarFilter, defaultCalendarFilters } from './filter-types'; import type { RawMessageInfo } from './message-types'; import type { ClientEntryInconsistencyReportCreationRequest } from './report-types'; import type { CreateUpdatesResponse } from './update-types'; import type { AccountUserInfo } from './user-types'; export type RawEntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creatorID: string, deleted: boolean, |}; -export const rawEntryInfoPropType = PropTypes.shape({ - id: PropTypes.string, - localID: PropTypes.string, - threadID: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - year: PropTypes.number.isRequired, - month: PropTypes.number.isRequired, - day: PropTypes.number.isRequired, - creationTime: PropTypes.number.isRequired, - creatorID: PropTypes.string.isRequired, - deleted: PropTypes.bool.isRequired, -}); - export type EntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creator: ?string, deleted: boolean, |}; -export const entryInfoPropType = PropTypes.shape({ - id: PropTypes.string, - localID: PropTypes.string, - threadID: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - year: PropTypes.number.isRequired, - month: PropTypes.number.isRequired, - day: PropTypes.number.isRequired, - creationTime: PropTypes.number.isRequired, - creator: PropTypes.string, - deleted: PropTypes.bool.isRequired, -}); - export type EntryStore = {| - entryInfos: { [id: string]: RawEntryInfo }, - daysToEntries: { [day: string]: string[] }, - lastUserInteractionCalendar: number, - inconsistencyReports: $ReadOnlyArray, + +entryInfos: { [id: string]: RawEntryInfo }, + +daysToEntries: { [day: string]: string[] }, + +lastUserInteractionCalendar: number, + +inconsistencyReports: $ReadOnlyArray, |}; export type CalendarQuery = {| - startDate: string, - endDate: string, - filters: $ReadOnlyArray, + +startDate: string, + +endDate: string, + +filters: $ReadOnlyArray, |}; export const defaultCalendarQuery = ( platform: ?Platform, timeZone?: ?string, ) => { if (platform === 'web') { return { ...thisMonthDates(timeZone), filters: defaultCalendarFilters, }; } else { return { startDate: fifteenDaysEarlier(timeZone).valueOf(), endDate: fifteenDaysLater(timeZone).valueOf(), filters: defaultCalendarFilters, }; } }; -export const calendarQueryPropType = PropTypes.shape({ - startDate: PropTypes.string.isRequired, - endDate: PropTypes.string.isRequired, - filters: PropTypes.arrayOf(calendarFilterPropType).isRequired, -}); - export type SaveEntryInfo = {| - entryID: string, - text: string, - prevText: string, - timestamp: number, - calendarQuery: CalendarQuery, + +entryID: string, + +text: string, + +prevText: string, + +timestamp: number, + +calendarQuery: CalendarQuery, |}; export type SaveEntryRequest = {| - entryID: string, - text: string, - prevText: string, - timestamp: number, - calendarQuery?: CalendarQuery, + +entryID: string, + +text: string, + +prevText: string, + +timestamp: number, + +calendarQuery?: CalendarQuery, |}; export type SaveEntryResponse = {| - entryID: string, - newMessageInfos: $ReadOnlyArray, - updatesResult: CreateUpdatesResponse, + +entryID: string, + +newMessageInfos: $ReadOnlyArray, + +updatesResult: CreateUpdatesResponse, |}; export type SaveEntryPayload = {| ...SaveEntryResponse, - threadID: string, + +threadID: string, |}; export type CreateEntryInfo = {| - text: string, - timestamp: number, - date: string, - threadID: string, - localID: string, - calendarQuery: CalendarQuery, + +text: string, + +timestamp: number, + +date: string, + +threadID: string, + +localID: string, + +calendarQuery: CalendarQuery, |}; export type CreateEntryRequest = {| - text: string, - timestamp: number, - date: string, - threadID: string, - localID?: string, - calendarQuery?: CalendarQuery, + +text: string, + +timestamp: number, + +date: string, + +threadID: string, + +localID?: string, + +calendarQuery?: CalendarQuery, |}; export type CreateEntryPayload = {| ...SaveEntryPayload, - localID: string, + +localID: string, |}; export type DeleteEntryInfo = {| - entryID: string, - prevText: string, - calendarQuery: CalendarQuery, + +entryID: string, + +prevText: string, + +calendarQuery: CalendarQuery, |}; export type DeleteEntryRequest = {| - entryID: string, - prevText: string, - timestamp: number, - calendarQuery?: CalendarQuery, + +entryID: string, + +prevText: string, + +timestamp: number, + +calendarQuery?: CalendarQuery, |}; export type RestoreEntryInfo = {| - entryID: string, - calendarQuery: CalendarQuery, + +entryID: string, + +calendarQuery: CalendarQuery, |}; export type RestoreEntryRequest = {| - entryID: string, - timestamp: number, - calendarQuery?: CalendarQuery, + +entryID: string, + +timestamp: number, + +calendarQuery?: CalendarQuery, |}; export type DeleteEntryResponse = {| - newMessageInfos: $ReadOnlyArray, - threadID: string, - updatesResult: CreateUpdatesResponse, + +newMessageInfos: $ReadOnlyArray, + +threadID: string, + +updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryResponse = {| - newMessageInfos: $ReadOnlyArray, - updatesResult: CreateUpdatesResponse, + +newMessageInfos: $ReadOnlyArray, + +updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryPayload = {| ...RestoreEntryResponse, - threadID: string, + +threadID: string, |}; export type FetchEntryInfosBase = {| - rawEntryInfos: $ReadOnlyArray, + +rawEntryInfos: $ReadOnlyArray, |}; export type FetchEntryInfosResponse = {| ...FetchEntryInfosBase, - userInfos: { [id: string]: AccountUserInfo }, + +userInfos: { [id: string]: AccountUserInfo }, |}; export type FetchEntryInfosResult = FetchEntryInfosBase; export type DeltaEntryInfosResponse = {| - rawEntryInfos: $ReadOnlyArray, - deletedEntryIDs: $ReadOnlyArray, + +rawEntryInfos: $ReadOnlyArray, + +deletedEntryIDs: $ReadOnlyArray, |}; export type DeltaEntryInfosResult = {| - rawEntryInfos: $ReadOnlyArray, - deletedEntryIDs: $ReadOnlyArray, - userInfos: $ReadOnlyArray, + +rawEntryInfos: $ReadOnlyArray, + +deletedEntryIDs: $ReadOnlyArray, + +userInfos: $ReadOnlyArray, |}; export type CalendarResult = {| - rawEntryInfos: $ReadOnlyArray, - calendarQuery: CalendarQuery, + +rawEntryInfos: $ReadOnlyArray, + +calendarQuery: CalendarQuery, |}; export type CalendarQueryUpdateStartingPayload = {| - calendarQuery?: CalendarQuery, + +calendarQuery?: CalendarQuery, |}; export type CalendarQueryUpdateResult = {| - rawEntryInfos: $ReadOnlyArray, - deletedEntryIDs: $ReadOnlyArray, - calendarQuery: CalendarQuery, - calendarQueryAlreadyUpdated: boolean, + +rawEntryInfos: $ReadOnlyArray, + +deletedEntryIDs: $ReadOnlyArray, + +calendarQuery: CalendarQuery, + +calendarQueryAlreadyUpdated: boolean, |}; diff --git a/lib/types/filter-types.js b/lib/types/filter-types.js index 29593e930..080bea045 100644 --- a/lib/types/filter-types.js +++ b/lib/types/filter-types.js @@ -1,51 +1,34 @@ // @flow -import PropTypes from 'prop-types'; - -import { type ThreadInfo, threadInfoPropType } from './thread-types'; +import { type ThreadInfo } from './thread-types'; export const calendarThreadFilterTypes = Object.freeze({ THREAD_LIST: 'threads', NOT_DELETED: 'not_deleted', }); export type CalendarThreadFilterType = $Values< typeof calendarThreadFilterTypes, >; export type CalendarThreadFilter = {| - type: 'threads', - threadIDs: $ReadOnlyArray, + +type: 'threads', + +threadIDs: $ReadOnlyArray, |}; export type CalendarFilter = {| type: 'not_deleted' |} | CalendarThreadFilter; -export const calendarFilterPropType = PropTypes.oneOfType([ - PropTypes.shape({ - type: PropTypes.oneOf([calendarThreadFilterTypes.NOT_DELETED]).isRequired, - }), - PropTypes.shape({ - type: PropTypes.oneOf([calendarThreadFilterTypes.THREAD_LIST]).isRequired, - threadIDs: PropTypes.arrayOf(PropTypes.string).isRequired, - }), -]); - export const defaultCalendarFilters: $ReadOnlyArray = [ { type: calendarThreadFilterTypes.NOT_DELETED }, ]; export const updateCalendarThreadFilter = 'UPDATE_CALENDAR_THREAD_FILTER'; export const clearCalendarThreadFilter = 'CLEAR_CALENDAR_THREAD_FILTER'; export const setCalendarDeletedFilter = 'SET_CALENDAR_DELETED_FILTER'; export type SetCalendarDeletedFilterPayload = {| - includeDeleted: boolean, + +includeDeleted: boolean, |}; export type FilterThreadInfo = {| threadInfo: ThreadInfo, numVisibleEntries: number, |}; - -export const filterThreadInfoPropType = PropTypes.shape({ - threadInfo: threadInfoPropType.isRequired, - numVisibleEntries: PropTypes.number.isRequired, -}); diff --git a/lib/types/history-types.js b/lib/types/history-types.js index 47ba45f1a..d129466bf 100644 --- a/lib/types/history-types.js +++ b/lib/types/history-types.js @@ -1,31 +1,20 @@ // @flow -import PropTypes from 'prop-types'; - export type HistoryMode = 'day' | 'entry'; export type HistoryRevisionInfo = {| - id: string, - entryID: string, - author: ?string, - text: string, - lastUpdate: number, - deleted: boolean, - threadID: string, + +id: string, + +entryID: string, + +author: ?string, + +text: string, + +lastUpdate: number, + +deleted: boolean, + +threadID: string, |}; -export const historyRevisionInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - entryID: PropTypes.string.isRequired, - author: PropTypes.string, - text: PropTypes.string.isRequired, - lastUpdate: PropTypes.number.isRequired, - deleted: PropTypes.bool.isRequired, - threadID: PropTypes.string.isRequired, -}); export type FetchEntryRevisionInfosRequest = {| - id: string, + +id: string, |}; export type FetchEntryRevisionInfosResult = {| - result: $ReadOnlyArray, + +result: $ReadOnlyArray, |}; diff --git a/lib/types/loading-types.js b/lib/types/loading-types.js index 6933bb18c..7f8d17d0f 100644 --- a/lib/types/loading-types.js +++ b/lib/types/loading-types.js @@ -1,21 +1,13 @@ // @flow -import PropTypes from 'prop-types'; - export type LoadingStatus = 'inactive' | 'loading' | 'error'; -export const loadingStatusPropType = PropTypes.oneOf([ - 'inactive', - 'loading', - 'error', -]); - export type LoadingOptions = {| - trackMultipleRequests?: boolean, - customKeyName?: string, + +trackMultipleRequests?: boolean, + +customKeyName?: string, |}; export type LoadingInfo = {| - fetchIndex: number, - trackMultipleRequests: boolean, - customKeyName: ?string, + +fetchIndex: number, + +trackMultipleRequests: boolean, + +customKeyName: ?string, |}; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 0b9d6b452..4fd32ad23 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,458 +1,453 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import type { FetchResultInfoInterface } from '../utils/fetch-json'; import type { AddMembersMessageData, AddMembersMessageInfo, RawAddMembersMessageInfo, } from './messages/add-members'; import type { ChangeRoleMessageData, ChangeRoleMessageInfo, RawChangeRoleMessageInfo, } from './messages/change-role'; import type { ChangeSettingsMessageData, ChangeSettingsMessageInfo, RawChangeSettingsMessageInfo, } from './messages/change-settings'; import type { CreateEntryMessageData, CreateEntryMessageInfo, RawCreateEntryMessageInfo, } from './messages/create-entry'; import type { CreateSidebarMessageData, CreateSidebarMessageInfo, RawCreateSidebarMessageInfo, } from './messages/create-sidebar'; import type { CreateSubthreadMessageData, CreateSubthreadMessageInfo, RawCreateSubthreadMessageInfo, } from './messages/create-subthread'; import type { CreateThreadMessageData, CreateThreadMessageInfo, RawCreateThreadMessageInfo, } from './messages/create-thread'; import type { DeleteEntryMessageData, DeleteEntryMessageInfo, RawDeleteEntryMessageInfo, } from './messages/delete-entry'; import type { EditEntryMessageData, EditEntryMessageInfo, RawEditEntryMessageInfo, } from './messages/edit-entry'; import type { ImagesMessageData, ImagesMessageInfo, RawImagesMessageInfo, } from './messages/images'; import type { JoinThreadMessageData, JoinThreadMessageInfo, RawJoinThreadMessageInfo, } from './messages/join-thread'; import type { LeaveThreadMessageData, LeaveThreadMessageInfo, RawLeaveThreadMessageInfo, } from './messages/leave-thread'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from './messages/media'; import type { RawRemoveMembersMessageInfo, RemoveMembersMessageData, RemoveMembersMessageInfo, } from './messages/remove-members'; import type { RawRestoreEntryMessageInfo, RestoreEntryMessageData, RestoreEntryMessageInfo, } from './messages/restore-entry'; import type { RawTextMessageInfo, TextMessageData, TextMessageInfo, } from './messages/text'; import type { RawUnsupportedMessageInfo, UnsupportedMessageInfo, } from './messages/unsupported'; import type { RawUpdateRelationshipMessageInfo, UpdateRelationshipMessageData, UpdateRelationshipMessageInfo, } from './messages/update-relationship'; import { type RelativeUserInfo, type UserInfos } from './user-types'; export const messageTypes = Object.freeze({ TEXT: 0, CREATE_THREAD: 1, ADD_MEMBERS: 2, CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, UPDATE_RELATIONSHIP: 16, SIDEBAR_SOURCE: 17, CREATE_SIDEBAR: 18, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15 || ourMessageType === 16 || ourMessageType === 17 || ourMessageType === 18, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function assertComposableRawMessage( message: RawMessageInfo, ): RawComposableMessageInfo { invariant( message.type === messageTypes.TEXT || message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, 'Message is not composable', ); return message; } export function messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type SidebarSourceMessageData = {| +type: 17, +threadID: string, +creatorID: string, +time: number, +sourceMessage?: RawComposableMessageInfo | RawRobotextMessageInfo, |}; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | UpdateRelationshipMessageData | SidebarSourceMessageData | CreateSidebarMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; export type RawRobotextMessageInfo = | RawCreateThreadMessageInfo | RawAddMembersMessageInfo | RawCreateSubthreadMessageInfo | RawChangeSettingsMessageInfo | RawRemoveMembersMessageInfo | RawChangeRoleMessageInfo | RawLeaveThreadMessageInfo | RawJoinThreadMessageInfo | RawCreateEntryMessageInfo | RawEditEntryMessageInfo | RawDeleteEntryMessageInfo | RawRestoreEntryMessageInfo | RawUpdateRelationshipMessageInfo | RawCreateSidebarMessageInfo | RawUnsupportedMessageInfo; export type RawSidebarSourceMessageInfo = {| ...SidebarSourceMessageData, id: string, |}; export type RawMessageInfo = | RawComposableMessageInfo | RawRobotextMessageInfo | RawSidebarSourceMessageInfo; export type LocallyComposedMessageInfo = { - localID: string, - threadID: string, + +localID: string, + +threadID: string, ... }; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | CreateThreadMessageInfo | AddMembersMessageInfo | CreateSubthreadMessageInfo | ChangeSettingsMessageInfo | RemoveMembersMessageInfo | ChangeRoleMessageInfo | LeaveThreadMessageInfo | JoinThreadMessageInfo | CreateEntryMessageInfo | EditEntryMessageInfo | DeleteEntryMessageInfo | RestoreEntryMessageInfo | UnsupportedMessageInfo | UpdateRelationshipMessageInfo | CreateSidebarMessageInfo; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; export type SidebarSourceMessageInfo = {| +type: 17, +id: string, +threadID: string, +creator: RelativeUserInfo, +time: number, +sourceMessage: ComposableMessageInfo | RobotextMessageInfo, |}; export type MessageInfo = | ComposableMessageInfo | RobotextMessageInfo | SidebarSourceMessageInfo; export type ThreadMessageInfo = {| messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp |}; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = {| - sendFailed?: boolean, + +sendFailed?: boolean, |}; -export const localMessageInfoPropType = PropTypes.shape({ - sendFailed: PropTypes.bool, -}); - export type MessageStore = {| - messages: { [id: string]: RawMessageInfo }, - threads: { [threadID: string]: ThreadMessageInfo }, - local: { [id: string]: LocalMessageInfo }, - currentAsOf: number, + +messages: { [id: string]: RawMessageInfo }, + +threads: { [threadID: string]: ThreadMessageInfo }, + +local: { [id: string]: LocalMessageInfo }, + +currentAsOf: number, |}; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; -export type ThreadCursors = { [threadID: string]: ?string }; +export type ThreadCursors = { +[threadID: string]: ?string }; export type ThreadSelectionCriteria = {| - threadCursors?: ?ThreadCursors, - joinedThreads?: ?boolean, + +threadCursors?: ?ThreadCursors, + +joinedThreads?: ?boolean, |}; export type FetchMessageInfosRequest = {| - cursors: ThreadCursors, - numberPerThread?: ?number, + +cursors: ThreadCursors, + +numberPerThread?: ?number, |}; export type FetchMessageInfosResponse = {| - rawMessageInfos: RawMessageInfo[], - truncationStatuses: MessageTruncationStatuses, - userInfos: UserInfos, + +rawMessageInfos: $ReadOnlyArray, + +truncationStatuses: MessageTruncationStatuses, + +userInfos: UserInfos, |}; export type FetchMessageInfosResult = {| - rawMessageInfos: RawMessageInfo[], - truncationStatuses: MessageTruncationStatuses, + +rawMessageInfos: $ReadOnlyArray, + +truncationStatuses: MessageTruncationStatuses, |}; export type FetchMessageInfosPayload = {| - threadID: string, - rawMessageInfos: RawMessageInfo[], - truncationStatus: MessageTruncationStatus, + +threadID: string, + +rawMessageInfos: $ReadOnlyArray, + +truncationStatus: MessageTruncationStatus, |}; export type MessagesResponse = {| - rawMessageInfos: RawMessageInfo[], - truncationStatuses: MessageTruncationStatuses, - currentAsOf: number, + +rawMessageInfos: $ReadOnlyArray, + +truncationStatuses: MessageTruncationStatuses, + +currentAsOf: number, |}; export const defaultNumberPerThread = 20; export type SendMessageResponse = {| +newMessageInfo: RawMessageInfo, |}; export type SendMessageResult = {| +id: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendMessagePayload = {| +localID: string, +serverID: string, +threadID: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendTextMessageRequest = {| - threadID: string, - localID?: string, - text: string, + +threadID: string, + +localID?: string, + +text: string, |}; export type SendMultimediaMessageRequest = {| - threadID: string, - localID: string, - mediaIDs: $ReadOnlyArray, + +threadID: string, + +localID: string, + +mediaIDs: $ReadOnlyArray, |}; // Used for the message info included in log-in type actions export type GenericMessagesResult = {| - messageInfos: RawMessageInfo[], - truncationStatus: MessageTruncationStatuses, - watchedIDsAtRequestTime: $ReadOnlyArray, - currentAsOf: number, + +messageInfos: RawMessageInfo[], + +truncationStatus: MessageTruncationStatuses, + +watchedIDsAtRequestTime: $ReadOnlyArray, + +currentAsOf: number, |}; export type SaveMessagesPayload = {| - rawMessageInfos: $ReadOnlyArray, - updatesCurrentAsOf: number, + +rawMessageInfos: $ReadOnlyArray, + +updatesCurrentAsOf: number, |}; export type NewMessagesPayload = {| - messagesResult: MessagesResponse, + +messagesResult: MessagesResponse, |}; export type MessageStorePrunePayload = {| - threadIDs: $ReadOnlyArray, + +threadIDs: $ReadOnlyArray, |}; diff --git a/lib/types/session-types.js b/lib/types/session-types.js index cffef47b7..b2a16a9eb 100644 --- a/lib/types/session-types.js +++ b/lib/types/session-types.js @@ -1,118 +1,104 @@ // @flow -import PropTypes from 'prop-types'; - import type { LogInActionSource } from './account-types'; import type { Shape } from './core'; import type { CalendarQuery } from './entry-types'; import type { RawThreadInfo } from './thread-types'; import { type UserInfo, type CurrentUserInfo, type LoggedOutUserInfo, - currentUserPropType, } from './user-types'; export const cookieLifetime = 30 * 24 * 60 * 60 * 1000; // in milliseconds // Interval the server waits after a state check before starting a new one export const sessionCheckFrequency = 3 * 60 * 1000; // in milliseconds // How long the server debounces after activity before initiating a state check export const stateCheckInactivityActivationInterval = 3 * 1000; // in milliseconds // On native, we specify the cookie directly in the request and response body. // We do this because: // (1) We don't have the same XSS risks as we do on web, so there is no need to // prevent JavaScript from knowing the cookie password. // (2) In the past the internal cookie logic on Android has been buggy. // https://github.com/facebook/react-native/issues/12956 is an example // issue. By specifying the cookie in the body we retain full control of how // that data is passed, without necessitating any native modules like // react-native-cookies. export const cookieSources = Object.freeze({ BODY: 0, HEADER: 1, }); export type CookieSource = $Values; // On native, we use the cookieID as a unique session identifier. This is // because there is no way to have two instances of an app running. On the other // hand, on web it is possible to have two sessions open using the same cookie, // so we have a unique sessionID specified in the request body. export const sessionIdentifierTypes = Object.freeze({ COOKIE_ID: 0, BODY_SESSION_ID: 1, }); export type SessionIdentifierType = $Values; export const cookieTypes = Object.freeze({ USER: 'user', ANONYMOUS: 'anonymous', }); export type CookieType = $Values; export type ServerSessionChange = | {| cookieInvalidated: false, threadInfos: { [id: string]: RawThreadInfo }, - userInfos: UserInfo[], + userInfos: $ReadOnlyArray, sessionID?: null | string, cookie?: string, |} | {| cookieInvalidated: true, threadInfos: { [id: string]: RawThreadInfo }, - userInfos: UserInfo[], + userInfos: $ReadOnlyArray, currentUserInfo: LoggedOutUserInfo, sessionID?: null | string, cookie?: string, |}; export type ClientSessionChange = | {| - cookieInvalidated: false, - currentUserInfo?: ?CurrentUserInfo, - sessionID?: null | string, - cookie?: string, + +cookieInvalidated: false, + +currentUserInfo?: ?CurrentUserInfo, + +sessionID?: null | string, + +cookie?: string, |} | {| - cookieInvalidated: true, - currentUserInfo: LoggedOutUserInfo, - sessionID?: null | string, - cookie?: string, + +cookieInvalidated: true, + +currentUserInfo: LoggedOutUserInfo, + +sessionID?: null | string, + +cookie?: string, |}; export type PreRequestUserState = {| - currentUserInfo: ?CurrentUserInfo, - cookie: ?string, - sessionID: ?string, + +currentUserInfo: ?CurrentUserInfo, + +cookie: ?string, + +sessionID: ?string, |}; -export const preRequestUserStatePropType = PropTypes.shape({ - currentUserInfo: currentUserPropType, - cookie: PropTypes.string, - sessionID: PropTypes.string, -}); - export type SetSessionPayload = {| sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, source: ?LogInActionSource, |}; export type SessionState = {| calendarQuery: CalendarQuery, messagesCurrentAsOf: number, updatesCurrentAsOf: number, watchedIDs: $ReadOnlyArray, |}; export type SessionIdentification = Shape<{| cookie: ?string, sessionID: ?string, |}>; - -export const sessionIdentificationPropType = PropTypes.shape({ - cookie: PropTypes.string, - sessionID: PropTypes.string, -}); diff --git a/lib/types/socket-types.js b/lib/types/socket-types.js index f126f88ae..3d934edab 100644 --- a/lib/types/socket-types.js +++ b/lib/types/socket-types.js @@ -1,314 +1,296 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import { type ActivityUpdate, type UpdateActivityResult, - activityUpdatePropType, } from './activity-types'; import type { Platform } from './device-types'; import type { APIRequest } from './endpoints'; import { type RawEntryInfo, type CalendarQuery, defaultCalendarQuery, - calendarQueryPropType, } from './entry-types'; import type { MessagesResponse, NewMessagesPayload } from './message-types'; import type { ServerRequest, ClientResponse, ClientClientResponse, } from './request-types'; import type { SessionState, SessionIdentification } from './session-types'; import type { RawThreadInfo } from './thread-types'; import type { UpdatesResult, UpdatesResultWithUserInfos } from './update-types'; import type { UserInfo, CurrentUserInfo, LoggedOutUserInfo, } from './user-types'; // The types of messages that the client sends across the socket export const clientSocketMessageTypes = Object.freeze({ INITIAL: 0, RESPONSES: 1, //ACTIVITY_UPDATES: 2, (DEPRECATED) PING: 3, ACK_UPDATES: 4, API_REQUEST: 5, }); export type ClientSocketMessageType = $Values; export function assertClientSocketMessageType( ourClientSocketMessageType: number, ): ClientSocketMessageType { invariant( ourClientSocketMessageType === 0 || ourClientSocketMessageType === 1 || ourClientSocketMessageType === 3 || ourClientSocketMessageType === 4 || ourClientSocketMessageType === 5, 'number is not ClientSocketMessageType enum', ); return ourClientSocketMessageType; } export type InitialClientSocketMessage = {| - type: 0, - id: number, - payload: {| - sessionIdentification: SessionIdentification, - sessionState: SessionState, - clientResponses: $ReadOnlyArray, + +type: 0, + +id: number, + +payload: {| + +sessionIdentification: SessionIdentification, + +sessionState: SessionState, + +clientResponses: $ReadOnlyArray, |}, |}; export type ResponsesClientSocketMessage = {| - type: 1, - id: number, - payload: {| - clientResponses: $ReadOnlyArray, + +type: 1, + +id: number, + +payload: {| + +clientResponses: $ReadOnlyArray, |}, |}; export type PingClientSocketMessage = {| - type: 3, - id: number, + +type: 3, + +id: number, |}; export type AckUpdatesClientSocketMessage = {| - type: 4, - id: number, - payload: {| - currentAsOf: number, + +type: 4, + +id: number, + +payload: {| + +currentAsOf: number, |}, |}; export type APIRequestClientSocketMessage = {| - type: 5, - id: number, - payload: APIRequest, + +type: 5, + +id: number, + +payload: APIRequest, |}; export type ClientSocketMessage = | InitialClientSocketMessage | ResponsesClientSocketMessage | PingClientSocketMessage | AckUpdatesClientSocketMessage | APIRequestClientSocketMessage; export type ClientInitialClientSocketMessage = {| - type: 0, - id: number, - payload: {| - sessionIdentification: SessionIdentification, - sessionState: SessionState, - clientResponses: $ReadOnlyArray, + +type: 0, + +id: number, + +payload: {| + +sessionIdentification: SessionIdentification, + +sessionState: SessionState, + +clientResponses: $ReadOnlyArray, |}, |}; export type ClientResponsesClientSocketMessage = {| - type: 1, - id: number, - payload: {| - clientResponses: $ReadOnlyArray, + +type: 1, + +id: number, + +payload: {| + +clientResponses: $ReadOnlyArray, |}, |}; export type ClientClientSocketMessage = | ClientInitialClientSocketMessage | ClientResponsesClientSocketMessage | PingClientSocketMessage | AckUpdatesClientSocketMessage | APIRequestClientSocketMessage; export type ClientSocketMessageWithoutID = $Diff< ClientClientSocketMessage, { id: number }, >; // The types of messages that the server sends across the socket export const serverSocketMessageTypes = Object.freeze({ STATE_SYNC: 0, REQUESTS: 1, ERROR: 2, AUTH_ERROR: 3, ACTIVITY_UPDATE_RESPONSE: 4, PONG: 5, UPDATES: 6, MESSAGES: 7, API_RESPONSE: 8, }); export type ServerSocketMessageType = $Values; export function assertServerSocketMessageType( ourServerSocketMessageType: number, ): ServerSocketMessageType { invariant( ourServerSocketMessageType === 0 || ourServerSocketMessageType === 1 || ourServerSocketMessageType === 2 || ourServerSocketMessageType === 3 || ourServerSocketMessageType === 4 || ourServerSocketMessageType === 5 || ourServerSocketMessageType === 6 || ourServerSocketMessageType === 7 || ourServerSocketMessageType === 8, 'number is not ServerSocketMessageType enum', ); return ourServerSocketMessageType; } export const stateSyncPayloadTypes = Object.freeze({ FULL: 0, INCREMENTAL: 1, }); export type FullStateSync = {| - messagesResult: MessagesResponse, - threadInfos: { [id: string]: RawThreadInfo }, - currentUserInfo: CurrentUserInfo, - rawEntryInfos: $ReadOnlyArray, - userInfos: $ReadOnlyArray, - updatesCurrentAsOf: number, + +messagesResult: MessagesResponse, + +threadInfos: { [id: string]: RawThreadInfo }, + +currentUserInfo: CurrentUserInfo, + +rawEntryInfos: $ReadOnlyArray, + +userInfos: $ReadOnlyArray, + +updatesCurrentAsOf: number, |}; export type StateSyncFullActionPayload = {| ...FullStateSync, - calendarQuery: CalendarQuery, + +calendarQuery: CalendarQuery, |}; export const fullStateSyncActionType = 'FULL_STATE_SYNC'; export type StateSyncFullSocketPayload = {| ...FullStateSync, - type: 0, + +type: 0, // Included iff client is using sessionIdentifierTypes.BODY_SESSION_ID - sessionID?: string, + +sessionID?: string, |}; export type IncrementalStateSync = {| - messagesResult: MessagesResponse, - updatesResult: UpdatesResult, - deltaEntryInfos: $ReadOnlyArray, - deletedEntryIDs: $ReadOnlyArray, - userInfos: $ReadOnlyArray, + +messagesResult: MessagesResponse, + +updatesResult: UpdatesResult, + +deltaEntryInfos: $ReadOnlyArray, + +deletedEntryIDs: $ReadOnlyArray, + +userInfos: $ReadOnlyArray, |}; export type StateSyncIncrementalActionPayload = {| ...IncrementalStateSync, - calendarQuery: CalendarQuery, + +calendarQuery: CalendarQuery, |}; export const incrementalStateSyncActionType = 'INCREMENTAL_STATE_SYNC'; type StateSyncIncrementalSocketPayload = {| - type: 1, + +type: 1, ...IncrementalStateSync, |}; export type StateSyncSocketPayload = | StateSyncFullSocketPayload | StateSyncIncrementalSocketPayload; export type StateSyncServerSocketMessage = {| - type: 0, - responseTo: number, - payload: StateSyncSocketPayload, + +type: 0, + +responseTo: number, + +payload: StateSyncSocketPayload, |}; export type RequestsServerSocketMessage = {| - type: 1, - responseTo?: number, - payload: {| - serverRequests: $ReadOnlyArray, + +type: 1, + +responseTo?: number, + +payload: {| + +serverRequests: $ReadOnlyArray, |}, |}; export type ErrorServerSocketMessage = {| type: 2, responseTo?: number, message: string, payload?: Object, |}; export type AuthErrorServerSocketMessage = {| type: 3, responseTo: number, message: string, // If unspecified, it is because the client is using cookieSources.HEADER, // which means the server can't update the cookie from a socket message. - sessionChange?: { + sessionChange?: {| cookie: string, currentUserInfo: LoggedOutUserInfo, - }, + |}, |}; export type ActivityUpdateResponseServerSocketMessage = {| - type: 4, - responseTo: number, - payload: UpdateActivityResult, + +type: 4, + +responseTo: number, + +payload: UpdateActivityResult, |}; export type PongServerSocketMessage = {| - type: 5, - responseTo: number, + +type: 5, + +responseTo: number, |}; export type UpdatesServerSocketMessage = {| - type: 6, - payload: UpdatesResultWithUserInfos, + +type: 6, + +payload: UpdatesResultWithUserInfos, |}; export type MessagesServerSocketMessage = {| - type: 7, - payload: NewMessagesPayload, + +type: 7, + +payload: NewMessagesPayload, |}; export type APIResponseServerSocketMessage = {| - type: 8, - responseTo: number, - payload: Object, + +type: 8, + +responseTo: number, + +payload: Object, |}; export type ServerSocketMessage = | StateSyncServerSocketMessage | RequestsServerSocketMessage | ErrorServerSocketMessage | AuthErrorServerSocketMessage | ActivityUpdateResponseServerSocketMessage | PongServerSocketMessage | UpdatesServerSocketMessage | MessagesServerSocketMessage | APIResponseServerSocketMessage; export type SocketListener = (message: ServerSocketMessage) => void; export type ConnectionStatus = | 'connecting' | 'connected' | 'reconnecting' | 'disconnecting' | 'forcedDisconnecting' | 'disconnected'; export type ConnectionInfo = {| - status: ConnectionStatus, - queuedActivityUpdates: $ReadOnlyArray, - actualizedCalendarQuery: CalendarQuery, - lateResponses: $ReadOnlyArray, - showDisconnectedBar: boolean, + +status: ConnectionStatus, + +queuedActivityUpdates: $ReadOnlyArray, + +actualizedCalendarQuery: CalendarQuery, + +lateResponses: $ReadOnlyArray, + +showDisconnectedBar: boolean, |}; -export const connectionStatusPropType = PropTypes.oneOf([ - 'connecting', - 'connected', - 'reconnecting', - 'disconnecting', - 'forcedDisconnecting', - 'disconnected', -]); -export const connectionInfoPropType = PropTypes.shape({ - status: connectionStatusPropType.isRequired, - queuedActivityUpdates: PropTypes.arrayOf(activityUpdatePropType).isRequired, - actualizedCalendarQuery: calendarQueryPropType.isRequired, - lateResponses: PropTypes.arrayOf(PropTypes.number).isRequired, - showDisconnectedBar: PropTypes.bool.isRequired, -}); export const defaultConnectionInfo = (platform: Platform, timeZone?: ?string) => ({ status: 'connecting', queuedActivityUpdates: [], actualizedCalendarQuery: defaultCalendarQuery(platform, timeZone), lateResponses: [], showDisconnectedBar: false, }: ConnectionInfo); export const updateConnectionStatusActionType = 'UPDATE_CONNECTION_STATUS'; export type UpdateConnectionStatusPayload = {| - status: ConnectionStatus, + +status: ConnectionStatus, |}; export const setLateResponseActionType = 'SET_LATE_RESPONSE'; export type SetLateResponsePayload = {| - messageID: number, - isLate: boolean, + +messageID: number, + +isLate: boolean, |}; export const updateDisconnectedBarActionType = 'UPDATE_DISCONNECTED_BAR'; -export type UpdateDisconnectedBarPayload = {| visible: boolean |}; +export type UpdateDisconnectedBarPayload = {| +visible: boolean |}; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index 2c7b00b21..242e00c16 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,430 +1,342 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import type { Shape } from './core'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, } from './message-types'; import type { ClientThreadInconsistencyReportCreationRequest } from './report-types'; import type { ThreadSubscription } from './subscription-types'; import type { UpdateInfo } from './update-types'; import type { UserInfo, AccountUserInfo } from './user-types'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) // an open subthread. has parent, top-level (not sidebar), and visible to all // members of parent CHAT_NESTED_OPEN: 3, // basic thread type. optional parent, top-level (not sidebar), visible only // to its members CHAT_SECRET: 4, // has parent, not top-level (appears under parent in inbox), and visible to // all members of parent SIDEBAR: 5, // canonical thread for each pair of users. represents the friendship PERSONAL: 6, // canonical thread for each single user PRIVATE: 7, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6 || threadType === 7, 'number is not ThreadType enum', ); return threadType; } export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD: 'edit_thread', DELETE_THREAD: 'delete_thread', CREATE_SUBTHREADS: 'create_subthreads', CREATE_SIDEBARS: 'create_sidebars', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', LEAVE_THREAD: 'leave_thread', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'create_sidebars' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role' || ourThreadPermissions === 'leave_thread', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', OPEN: 'open_', OPEN_DESCENDANT: 'descendant_open_', }); export type ThreadPermissionInfo = - | {| value: true, source: string |} - | {| value: false, source: null |}; + | {| +value: true, +source: string |} + | {| +value: false, +source: null |}; export type ThreadPermissionsBlob = { - [permission: string]: ThreadPermissionInfo, + +[permission: string]: ThreadPermissionInfo, }; -export type ThreadRolePermissionsBlob = { [permission: string]: boolean }; +export type ThreadRolePermissionsBlob = { +[permission: string]: boolean }; export type ThreadPermissionsInfo = { - [permission: ThreadPermission]: ThreadPermissionInfo, + +[permission: ThreadPermission]: ThreadPermissionInfo, }; -export const threadPermissionsInfoPropType = PropTypes.objectOf( - PropTypes.oneOfType([ - PropTypes.shape({ - value: PropTypes.oneOf([true]), - source: PropTypes.string.isRequired, - }), - PropTypes.shape({ - value: PropTypes.oneOf([false]), - source: PropTypes.oneOf([null]), - }), - ]), -); export type MemberInfo = {| +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +isSender: boolean, |}; -export const memberInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - role: PropTypes.string, - permissions: threadPermissionsInfoPropType.isRequired, - isSender: PropTypes.bool.isRequired, -}); export type RelativeMemberInfo = {| ...MemberInfo, - username: ?string, - isViewer: boolean, + +username: ?string, + +isViewer: boolean, |}; -export const relativeMemberInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - role: PropTypes.string, - permissions: threadPermissionsInfoPropType.isRequired, - username: PropTypes.string, - isViewer: PropTypes.bool.isRequired, - isSender: PropTypes.bool.isRequired, -}); export type RoleInfo = {| - id: string, - name: string, - permissions: ThreadRolePermissionsBlob, - isDefault: boolean, + +id: string, + +name: string, + +permissions: ThreadRolePermissionsBlob, + +isDefault: boolean, |}; export type ThreadCurrentUserInfo = {| - role: ?string, - permissions: ThreadPermissionsInfo, - subscription: ThreadSubscription, - unread: ?boolean, + +role: ?string, + +permissions: ThreadPermissionsInfo, + +subscription: ThreadSubscription, + +unread: ?boolean, |}; export type RawThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, sourceMessageID?: string, repliesCount: number, |}; export type ThreadInfo = {| id: string, type: ThreadType, name: ?string, uiName: string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, sourceMessageID?: string, repliesCount: number, |}; export type OptimisticThreadInfo = {| +threadInfo: ThreadInfo, +sourceMessageID?: string, |}; -export const threadTypePropType = PropTypes.oneOf([ - threadTypes.CHAT_NESTED_OPEN, - threadTypes.CHAT_SECRET, - threadTypes.SIDEBAR, - threadTypes.PERSONAL, - threadTypes.PRIVATE, -]); - -const rolePropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - permissions: PropTypes.objectOf(PropTypes.bool).isRequired, - isDefault: PropTypes.bool.isRequired, -}); - -const currentUserPropType = PropTypes.shape({ - role: PropTypes.string, - permissions: threadPermissionsInfoPropType.isRequired, - subscription: PropTypes.shape({ - pushNotifs: PropTypes.bool.isRequired, - home: PropTypes.bool.isRequired, - }).isRequired, - unread: PropTypes.bool, -}); - -export const rawThreadInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - type: threadTypePropType.isRequired, - name: PropTypes.string, - description: PropTypes.string, - color: PropTypes.string.isRequired, - creationTime: PropTypes.number.isRequired, - parentThreadID: PropTypes.string, - members: PropTypes.arrayOf(memberInfoPropType).isRequired, - roles: PropTypes.objectOf(rolePropType).isRequired, - currentUser: currentUserPropType.isRequired, - sourceMessageID: PropTypes.string, - repliesCount: PropTypes.number.isRequired, -}); - -export const threadInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - type: threadTypePropType.isRequired, - name: PropTypes.string, - uiName: PropTypes.string.isRequired, - description: PropTypes.string, - color: PropTypes.string.isRequired, - creationTime: PropTypes.number.isRequired, - parentThreadID: PropTypes.string, - members: PropTypes.arrayOf(memberInfoPropType).isRequired, - roles: PropTypes.objectOf(rolePropType).isRequired, - currentUser: currentUserPropType.isRequired, - sourceMessageID: PropTypes.string, - repliesCount: PropTypes.number.isRequired, -}); - -export const optimisticThreadInfoPropType = PropTypes.shape({ - threadInfo: threadInfoPropType.isRequired, - sourceMessageID: PropTypes.string, -}); - export type ServerMemberInfo = {| +id: string, +role: ?string, +permissions: ThreadPermissionsInfo, +subscription: ThreadSubscription, +unread: ?boolean, +isSender: boolean, |}; export type ServerThreadInfo = {| - id: string, - type: ThreadType, - name: ?string, - description: ?string, - color: string, // hex, without "#" or "0x" - creationTime: number, // millisecond timestamp - parentThreadID: ?string, - members: $ReadOnlyArray, - roles: { [id: string]: RoleInfo }, - sourceMessageID?: string, - repliesCount: number, + +id: string, + +type: ThreadType, + +name: ?string, + +description: ?string, + +color: string, // hex, without "#" or "0x" + +creationTime: number, // millisecond timestamp + +parentThreadID: ?string, + +members: $ReadOnlyArray, + +roles: { [id: string]: RoleInfo }, + +sourceMessageID?: string, + +repliesCount: number, |}; export type ThreadStore = {| - threadInfos: { [id: string]: RawThreadInfo }, - inconsistencyReports: $ReadOnlyArray, + +threadInfos: { [id: string]: RawThreadInfo }, + +inconsistencyReports: $ReadOnlyArray, |}; export type ThreadDeletionRequest = {| - threadID: string, - accountPassword: string, + +threadID: string, + +accountPassword: string, |}; export type RemoveMembersRequest = {| - threadID: string, - memberIDs: $ReadOnlyArray, + +threadID: string, + +memberIDs: $ReadOnlyArray, |}; export type RoleChangeRequest = {| - threadID: string, - memberIDs: $ReadOnlyArray, - role: string, + +threadID: string, + +memberIDs: $ReadOnlyArray, + +role: string, |}; export type ChangeThreadSettingsResult = {| - threadInfo?: RawThreadInfo, - threadInfos?: { [id: string]: RawThreadInfo }, - updatesResult: { + +threadInfo?: RawThreadInfo, + +threadInfos?: { [id: string]: RawThreadInfo }, + +updatesResult: { newUpdates: $ReadOnlyArray, }, - newMessageInfos: $ReadOnlyArray, + +newMessageInfos: $ReadOnlyArray, |}; export type ChangeThreadSettingsPayload = {| - threadID: string, - updatesResult: { + +threadID: string, + +updatesResult: { newUpdates: $ReadOnlyArray, }, - newMessageInfos: $ReadOnlyArray, + +newMessageInfos: $ReadOnlyArray, |}; export type LeaveThreadRequest = {| - threadID: string, + +threadID: string, |}; export type LeaveThreadResult = {| - threadInfos?: { [id: string]: RawThreadInfo }, - updatesResult: { + +threadInfos?: { [id: string]: RawThreadInfo }, + +updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type LeaveThreadPayload = {| - updatesResult: { + +updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type ThreadChanges = Shape<{| - type: ThreadType, - name: string, - description: string, - color: string, - parentThreadID: string, - newMemberIDs: $ReadOnlyArray, + +type: ThreadType, + +name: string, + +description: string, + +color: string, + +parentThreadID: string, + +newMemberIDs: $ReadOnlyArray, |}>; export type UpdateThreadRequest = {| - threadID: string, - changes: ThreadChanges, + +threadID: string, + +changes: ThreadChanges, |}; export type BaseNewThreadRequest = {| +name?: ?string, +description?: ?string, +color?: ?string, +parentThreadID?: ?string, +initialMemberIDs?: ?$ReadOnlyArray, +ghostMemberIDs?: ?$ReadOnlyArray, |}; export type NewThreadRequest = | {| +type: 3 | 4 | 6 | 7, ...BaseNewThreadRequest, |} | {| +type: 5, +sourceMessageID: string, ...BaseNewThreadRequest, |}; export type NewThreadResponse = {| +updatesResult: {| +newUpdates: $ReadOnlyArray, |}, +newMessageInfos: $ReadOnlyArray, +newThreadInfo?: RawThreadInfo, +userInfos: { [string]: AccountUserInfo }, +newThreadID?: string, |}; export type NewThreadResult = {| +updatesResult: {| +newUpdates: $ReadOnlyArray, |}, +newMessageInfos: $ReadOnlyArray, +userInfos: { [string]: AccountUserInfo }, +newThreadID: string, |}; export type ServerThreadJoinRequest = {| - threadID: string, - calendarQuery?: ?CalendarQuery, + +threadID: string, + +calendarQuery?: ?CalendarQuery, |}; export type ClientThreadJoinRequest = {| - threadID: string, - calendarQuery: CalendarQuery, + +threadID: string, + +calendarQuery: CalendarQuery, |}; export type ThreadJoinResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: { [string]: AccountUserInfo }, rawEntryInfos?: ?$ReadOnlyArray, |}; export type ThreadJoinPayload = {| - updatesResult: { + +updatesResult: { newUpdates: $ReadOnlyArray, }, - rawMessageInfos: RawMessageInfo[], - truncationStatuses: MessageTruncationStatuses, - userInfos: $ReadOnlyArray, - calendarResult: CalendarResult, + +rawMessageInfos: $ReadOnlyArray, + +truncationStatuses: MessageTruncationStatuses, + +userInfos: $ReadOnlyArray, + +calendarResult: CalendarResult, |}; export type SidebarInfo = {| +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, |}; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index 401ad9c61..4cf54745b 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,105 +1,69 @@ // @flow -import PropTypes from 'prop-types'; - import type { UserRelationshipStatus } from './relationship-types'; import type { UserInconsistencyReportCreationRequest } from './report-types'; export type GlobalUserInfo = {| +id: string, +username: ?string, |}; export type GlobalAccountUserInfo = {| +id: string, +username: string, |}; export type UserInfo = {| +id: string, +username: ?string, +relationshipStatus?: UserRelationshipStatus, |}; export type UserInfos = { +[id: string]: UserInfo }; -export const userInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - username: PropTypes.string, - relationshipStatus: PropTypes.number, -}); - export type AccountUserInfo = {| +id: string, +username: string, +relationshipStatus?: UserRelationshipStatus, |}; -export const accountUserInfoPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - username: PropTypes.string.isRequired, - relationshipStatus: PropTypes.number, -}); - export type UserStore = {| +userInfos: UserInfos, +inconsistencyReports: $ReadOnlyArray, |}; export type RelativeUserInfo = {| +id: string, +username: ?string, +isViewer: boolean, |}; export type LoggedInUserInfo = {| +id: string, +username: string, +email: string, +emailVerified: boolean, |}; export type LoggedOutUserInfo = {| +id: string, +anonymous: true, |}; export type CurrentUserInfo = LoggedInUserInfo | LoggedOutUserInfo; -export const currentUserPropType = PropTypes.oneOfType([ - PropTypes.shape({ - id: PropTypes.string.isRequired, - username: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - emailVerified: PropTypes.bool.isRequired, - }), - PropTypes.shape({ - id: PropTypes.string.isRequired, - anonymous: PropTypes.oneOf([true]).isRequired, - }), -]); - export type AccountUpdate = {| +updatedFields: {| +email?: ?string, +password?: ?string, |}, +currentPassword: string, |}; export type UserListItem = {| +id: string, +username: string, +disabled?: boolean, +notice?: string, +alertText?: string, +alertTitle?: string, |}; - -export const userListItemPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - username: PropTypes.string.isRequired, - disabled: PropTypes.bool, - notice: PropTypes.string, - alertText: PropTypes.string, - alertTitle: PropTypes.string, -}); diff --git a/lib/types/verify-types.js b/lib/types/verify-types.js index c3952f7ad..b7af449b8 100644 --- a/lib/types/verify-types.js +++ b/lib/types/verify-types.js @@ -1,60 +1,44 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; export const verifyField = Object.freeze({ EMAIL: 0, RESET_PASSWORD: 1, }); export type VerifyField = $Values; export function assertVerifyField(ourVerifyField: number): VerifyField { invariant( ourVerifyField === 0 || ourVerifyField === 1, 'number is not VerifyField enum', ); return ourVerifyField; } export type CodeVerificationRequest = {| - code: string, + +code: string, |}; export type HandleVerificationCodeResult = {| - verifyField: VerifyField, - resetPasswordUsername?: string, + +verifyField: VerifyField, + +resetPasswordUsername?: string, |}; type FailedVerificationResult = {| - success: false, + +success: false, |}; type EmailServerVerificationResult = {| - success: true, - field: 0, + +success: true, + +field: 0, |}; type ResetPasswordServerVerificationResult = {| - success: true, - field: 1, - username: string, + +success: true, + +field: 1, + +username: string, |}; export type ServerSuccessfulVerificationResult = | EmailServerVerificationResult | ResetPasswordServerVerificationResult; export type ServerVerificationResult = | FailedVerificationResult | ServerSuccessfulVerificationResult; - -export const serverVerificationResultPropType = PropTypes.oneOfType([ - PropTypes.shape({ - success: PropTypes.oneOf([false]).isRequired, - }), - PropTypes.shape({ - success: PropTypes.oneOf([true]).isRequired, - field: PropTypes.oneOf([verifyField.EMAIL]).isRequired, - }), - PropTypes.shape({ - success: PropTypes.oneOf([true]).isRequired, - field: PropTypes.oneOf([verifyField.RESET_PASSWORD]).isRequired, - username: PropTypes.string.isRequired, - }), -]); diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 5a6730887..e9c440adc 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,915 +1,878 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, Alert, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { Easing } from 'react-native-reanimated'; import FAIcon from 'react-native-vector-icons/FontAwesome'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; import { joinThreadActionTypes, joinThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { trimMessage } from 'lib/shared/message-utils'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, useRealThreadCreator, } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; -import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { PhotoPaste } from 'lib/types/media-types'; import { messageTypes } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, - threadInfoPropType, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; -import { type UserInfos, userInfoPropType } from 'lib/types/user-types'; +import { type UserInfos } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import ClearableTextInput from '../components/clearable-text-input.react'; -import { - type InputState, - inputStatePropType, - InputStateContext, -} from '../input/input-state'; +import { type InputState, InputStateContext } from '../input/input-state'; import { getKeyboardHeight } from '../keyboard/keyboard'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import { type KeyboardState, - keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { type NavigationRoute, CameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; -import { - type Colors, - colorsPropType, - useStyles, - useColors, -} from '../themes/colors'; +import { type Colors, useStyles, useColors } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; import { runTiming } from '../utils/animation-utils'; import type { ChatNavigationProp } from './chat.react'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolate, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 500, easing: Easing.inOut(Easing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), }; const draftKeyFromThreadID = (threadID: string) => `${threadID}/message_composer`; type BaseProps = {| +threadInfo: ThreadInfo, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, +getServerThreadID: () => Promise, // withInputState +inputState: ?InputState, |}; type State = {| +text: string, +buttonsExpanded: boolean, |}; class ChatInputBar extends React.PureComponent { - static propTypes = { - threadInfo: threadInfoPropType.isRequired, - navigation: messageListNavPropType.isRequired, - route: messageListRoutePropType.isRequired, - isActive: PropTypes.bool.isRequired, - viewerID: PropTypes.string, - draft: PropTypes.string.isRequired, - joinThreadLoadingStatus: loadingStatusPropType.isRequired, - calendarQuery: PropTypes.func.isRequired, - nextLocalID: PropTypes.number.isRequired, - userInfos: PropTypes.objectOf(userInfoPropType).isRequired, - colors: colorsPropType.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - keyboardState: keyboardStatePropType, - dispatch: PropTypes.func.isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - joinThread: PropTypes.func.isRequired, - inputState: inputStatePropType, - getServerThreadID: PropTypes.func.isRequired, - }; textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: ViewStyle; cameraRollIconStyle: ViewStyle; cameraIconStyle: ViewStyle; expandIconStyle: ViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, buttonsExpanded: true, }; this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolate(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [22, 60], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; const initialSendButtonContainerOpen = trimMessage(props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolate(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( prevProps, ); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( Join Thread ); } joinButton = ( ); } let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else { const defaultRoleID = Object.keys(this.props.threadInfo.roles).find( (roleID) => this.props.threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = this.props.threadInfo.roles[defaultRoleID]; const membersAreVoiced = !!defaultRole.permissions[ threadPermissions.VOICED ]; if (membersAreVoiced && canJoin) { content = ( Join this thread to send messages. ); } else { content = ( You don't have permission to send messages. ); } } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; updateText = (text: string) => { this.setState({ text }); this.saveDraft(text); }; saveDraft = _throttle((text: string) => { this.props.dispatch({ type: saveDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), draft: text, }, }); }, 400); focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } invariant(this.textInput, 'textInput should be set in focusAndUpdateText'); this.textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `local${this.props.nextLocalID}`; const creatorID = this.props.viewerID; const threadID = await this.props.getServerThreadID(); invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); if (threadID) { this.props.inputState.sendTextMessage({ type: messageTypes.TEXT, localID, threadID, text, creatorID, time: Date.now(), }); } }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } openCamera = async () => { this.dismissKeyboard(); this.props.navigation.navigate({ name: CameraModalRouteName, params: { presentedFrom: this.props.route.key, thread: { threadInfo: this.props.threadInfo, sourceMessageID: this.props.route.params.sourceMessageID, }, }, }); }; showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery({ threadInfo: this.props.threadInfo, sourceMessageID: this.props.route.params.sourceMessageID, }); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 10, paddingRight: 3, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 8 : 7, paddingRight: 8, }, container: { backgroundColor: 'listBackground', }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 12 : 10, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { backgroundColor: 'mintButton', borderRadius: 5, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, paddingBottom: 5, paddingTop: 3, }, joinButtonContainer: { flexDirection: 'row', height: 36, }, joinButtonText: { color: 'listBackground', fontSize: 20, textAlign: 'center', }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: Platform.OS === 'android' ? 4 : 3, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 5, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 10, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginVertical: 5, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const showErrorAlert = () => Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); export default React.memo(function ConnectedChatInputBar( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const draftKey = draftKeyFromThreadID(props.threadInfo.id); const draft = useSelector((state) => state.drafts[draftKey] || ''); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const calendarQuery = useSelector((state) => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector((state) => state.nextLocalID); const userInfos = useSelector((state) => state.userStore.userInfos); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const imagePastedCallback = React.useCallback( (imagePastedEvent) => { if (props.threadInfo.id !== imagePastedEvent['threadID']) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; props.navigation.navigate({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: { threadInfo: props.threadInfo, sourceMessageID: props.route.params.sourceMessageID, }, }, }); }, [props.navigation, props.route.params.sourceMessageID, props.threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const getServerThreadID = useRealThreadCreator( { threadInfo: props.threadInfo, sourceMessageID: props.route.params.sourceMessageID, }, showErrorAlert, ); return ( ); }); diff --git a/native/chat/message-list-types.js b/native/chat/message-list-types.js index f95358d31..0940b8012 100644 --- a/native/chat/message-list-types.js +++ b/native/chat/message-list-types.js @@ -1,60 +1,34 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; -import { threadInfoPropType } from 'lib/types/thread-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import { type UserInfo, userInfoPropType } from 'lib/types/user-types'; +import { type UserInfo } from 'lib/types/user-types'; import type { MarkdownRules } from '../markdown/rules.react'; import { useTextMessageRulesFunc } from '../markdown/rules.react'; export type MessageListParams = {| +threadInfo: ThreadInfo, +sourceMessageID?: string, +pendingPersonalThreadUserInfo?: UserInfo, +searching?: boolean, |}; -const messageListRoutePropType = PropTypes.shape({ - key: PropTypes.string.isRequired, - params: PropTypes.shape({ - threadInfo: threadInfoPropType.isRequired, - sourceMessageID: PropTypes.string, - pendingPersonalThreadUserInfo: userInfoPropType, - searching: PropTypes.bool, - }).isRequired, -}); - -const messageListNavPropType = PropTypes.shape({ - navigate: PropTypes.func.isRequired, - setParams: PropTypes.func.isRequired, - setOptions: PropTypes.func.isRequired, - dangerouslyGetParent: PropTypes.func.isRequired, - isFocused: PropTypes.func.isRequired, - popToTop: PropTypes.func.isRequired, -}); - export type MessageListContextType = {| +getTextMessageMarkdownRules: (useDarkStyle: boolean) => MarkdownRules, |}; const MessageListContext = React.createContext(); function useMessageListContext(threadID: string) { const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadID); return React.useMemo( () => ({ getTextMessageMarkdownRules, }), [getTextMessageMarkdownRules], ); } -export { - messageListRoutePropType, - messageListNavPropType, - MessageListContext, - useMessageListContext, -}; +export { MessageListContext, useMessageListContext }; diff --git a/native/chat/settings/thread-settings-color.react.js b/native/chat/settings/thread-settings-color.react.js index fa0cbfe05..f8ae716f7 100644 --- a/native/chat/settings/thread-settings-color.react.js +++ b/native/chat/settings/thread-settings-color.react.js @@ -1,140 +1,121 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, ActivityIndicator, View, Platform } from 'react-native'; import { changeThreadSettingsActionTypes } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { type ThreadInfo } from 'lib/types/thread-types'; import ColorSplotch from '../../components/color-splotch.react'; import EditSettingButton from '../../components/edit-setting-button.react'; import { ColorPickerModalRouteName } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; -import { - type Colors, - colorsPropType, - useColors, - useStyles, -} from '../../themes/colors'; +import { type Colors, useColors, useStyles } from '../../themes/colors'; import type { ThreadSettingsNavigate } from './thread-settings.react'; type BaseProps = {| +threadInfo: ThreadInfo, +colorEditValue: string, +setColorEditValue: (color: string) => void, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, |}; class ThreadSettingsColor extends React.PureComponent { - static propTypes = { - threadInfo: threadInfoPropType.isRequired, - colorEditValue: PropTypes.string.isRequired, - setColorEditValue: PropTypes.func.isRequired, - canChangeSettings: PropTypes.bool.isRequired, - navigate: PropTypes.func.isRequired, - threadSettingsRouteKey: PropTypes.string.isRequired, - loadingStatus: loadingStatusPropType.isRequired, - colors: colorsPropType.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - }; - render() { let colorButton; if (this.props.loadingStatus !== 'loading') { colorButton = ( ); } else { colorButton = ( ); } return ( Color {colorButton} ); } onPressEditColor = () => { this.props.navigate({ name: ColorPickerModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, color: this.props.colorEditValue, threadInfo: this.props.threadInfo, setColor: this.props.setColorEditValue, }, }); }; } const unboundStyles = { colorLine: { lineHeight: Platform.select({ android: 22, default: 25 }), }, colorRow: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingBottom: 8, paddingHorizontal: 24, paddingTop: 4, }, currentValue: { flex: 1, paddingLeft: 4, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); export default React.memo(function ConnectedThreadSettingsColor( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); }); diff --git a/native/chat/settings/thread-settings-home-notifs.react.js b/native/chat/settings/thread-settings-home-notifs.react.js index b87a70748..f67707bd9 100644 --- a/native/chat/settings/thread-settings-home-notifs.react.js +++ b/native/chat/settings/thread-settings-home-notifs.react.js @@ -1,121 +1,113 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, View, Switch } from 'react-native'; import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types'; -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { type ThreadInfo } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { useStyles } from '../../themes/colors'; type BaseProps = {| +threadInfo: ThreadInfo, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, |}; type State = {| +currentValue: boolean, |}; class ThreadSettingsHomeNotifs extends React.PureComponent { - static propTypes = { - threadInfo: threadInfoPropType.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - updateSubscription: PropTypes.func.isRequired, - }; - constructor(props: Props) { super(props); this.state = { currentValue: !props.threadInfo.currentUser.subscription.home, }; } render() { return ( Background ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { home: !value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', flex: 1, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; export default React.memo(function ConnectedThreadSettingsHomeNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); }); diff --git a/native/chat/settings/thread-settings-member-tooltip-button.react.js b/native/chat/settings/thread-settings-member-tooltip-button.react.js index 3a7201916..3f250a4eb 100644 --- a/native/chat/settings/thread-settings-member-tooltip-button.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-button.react.js @@ -1,34 +1,27 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { TouchableOpacity } from 'react-native'; import PencilIcon from '../../components/pencil-icon.react'; import type { AppNavigationProp } from '../../navigation/app-navigator.react'; type Props = { +navigation: AppNavigationProp<'ThreadSettingsMemberTooltipModal'>, ... }; class ThreadSettingsMemberTooltipButton extends React.PureComponent { - static propTypes = { - navigation: PropTypes.shape({ - goBackOnce: PropTypes.func.isRequired, - }).isRequired, - }; - render() { return ( ); } onPress = () => { this.props.navigation.goBackOnce(); }; } export default ThreadSettingsMemberTooltipButton; diff --git a/native/chat/settings/thread-settings-push-notifs.react.js b/native/chat/settings/thread-settings-push-notifs.react.js index 994f399b8..bdddf752e 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,121 +1,113 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, View, Switch } from 'react-native'; import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from 'lib/types/subscription-types'; -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import { type ThreadInfo } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { useStyles } from '../../themes/colors'; type BaseProps = {| +threadInfo: ThreadInfo, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, |}; type State = {| +currentValue: boolean, |}; class ThreadSettingsPushNotifs extends React.PureComponent { - static propTypes = { - threadInfo: threadInfoPropType.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - updateSubscription: PropTypes.func.isRequired, - }; - constructor(props: Props) { super(props); this.state = { currentValue: props.threadInfo.currentUser.subscription.pushNotifs, }; } render() { return ( Push notifs ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { pushNotifs: value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', flex: 1, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; export default React.memo(function ConnectedThreadSettingsPushNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); }); diff --git a/native/components/button.react.js b/native/components/button.react.js index 502ae38c1..e6a604d9a 100644 --- a/native/components/button.react.js +++ b/native/components/button.react.js @@ -1,118 +1,104 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { Platform, View, TouchableNativeFeedback, TouchableHighlight, - ViewPropTypes, TouchableOpacity, } from 'react-native'; import type { ViewStyle } from '../types/styles'; const ANDROID_VERSION_LOLLIPOP = 21; type Props = { - onPress: () => *, - disabled?: boolean, - style?: ViewStyle, + +onPress: () => *, + +disabled?: boolean, + +style?: ViewStyle, // style and topStyle just get merged in most cases. The separation only // matters in the case of iOS and iosFormat = "highlight", where the // topStyle is necessary for layout, and the bottom style is necessary for // colors etc. - topStyle?: ViewStyle, - children?: React.Node, - androidBorderlessRipple: boolean, - iosFormat: 'highlight' | 'opacity', - androidFormat: 'ripple' | 'highlight' | 'opacity', - iosHighlightUnderlayColor?: string, - iosActiveOpacity: number, + +topStyle?: ViewStyle, + +children?: React.Node, + +androidBorderlessRipple: boolean, + +iosFormat: 'highlight' | 'opacity', + +androidFormat: 'ripple' | 'highlight' | 'opacity', + +iosHighlightUnderlayColor?: string, + +iosActiveOpacity: number, }; class Button extends React.PureComponent { - static propTypes = { - onPress: PropTypes.func.isRequired, - disabled: PropTypes.bool, - style: ViewPropTypes.style, - topStyle: ViewPropTypes.style, - children: PropTypes.node, - androidBorderlessRipple: PropTypes.bool, - iosFormat: PropTypes.oneOf(['highlight', 'opacity']), - androidFormat: PropTypes.oneOf(['ripple', 'highlight', 'opacity']), - iosHighlightUnderlayColor: PropTypes.string, - iosActiveOpacity: PropTypes.number, - }; static defaultProps = { androidBorderlessRipple: false, iosFormat: 'opacity', androidFormat: 'ripple', iosActiveOpacity: 0.2, }; render() { if ( Platform.OS === 'android' && this.props.androidFormat === 'ripple' && Platform.Version >= ANDROID_VERSION_LOLLIPOP ) { return ( {this.props.children} ); } let format = 'opacity'; if (Platform.OS === 'ios') { format = this.props.iosFormat; } else if ( Platform.OS === 'android' && this.props.androidFormat !== 'ripple' ) { format = this.props.androidFormat; } if (format === 'highlight') { const underlayColor = this.props.iosHighlightUnderlayColor; invariant( underlayColor, 'iosHighlightUnderlayColor should be specified to Button in ' + "format='highlight'", ); return ( {this.props.children} ); } else { return ( {this.props.children} ); } } } export default Button; diff --git a/native/components/node-height-measurer.react.js b/native/components/node-height-measurer.react.js index 647981bc2..240f53f0b 100644 --- a/native/components/node-height-measurer.react.js +++ b/native/components/node-height-measurer.react.js @@ -1,461 +1,452 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import shallowequal from 'shallowequal'; import type { Shape } from 'lib/types/core'; import type { LayoutEvent } from '../types/react-native'; const measureBatchSize = 50; type MergedItemPair = {| +item: Item, +mergedItem: MergedItem, |}; type Props = { // What we want to render +listData: ?$ReadOnlyArray, // Every item should have an ID. We use this ID to cache the result of calling // mergeItemWithHeight below, and only update it if the input item changes, // mergeItemWithHeight changes, or any extra props we get passed change +itemToID: (Item) => string, // Only measurable items should return a measureKey. // Falsey keys won't get measured, but will still get passed through // mergeItemWithHeight with height undefined // Make sure that if an item's height changes, its measure key does too! +itemToMeasureKey: (Item) => ?string, // The "dummy" is the component whose height we will be measuring // We will only call this with items for which itemToMeasureKey returns truthy +itemToDummy: (Item) => React.Element, // Once we have the height, we need to merge it into the item +mergeItemWithHeight: (item: Item, height: ?number) => MergedItem, // We'll pass our results here when we're done +allHeightsMeasured: (items: $ReadOnlyArray) => mixed, ... }; type State = {| // These are the dummies currently being rendered +currentlyMeasuring: $ReadOnlyArray<{| +measureKey: string, +dummy: React.Element, |}>, // When certain parameters change we need to remeasure everything. In order to // avoid considering any onLayouts that got queued before we issued the // remeasure, we increment the "iteration" and only count onLayouts with the // right value +iteration: number, // We cache the measured heights here, keyed by measure key +measuredHeights: Map, // We cache the results of calling mergeItemWithHeight on measured items after // measuring their height, keyed by ID +measurableItems: Map>, // We cache the results of calling mergeItemWithHeight on items that aren't // measurable (eg. itemToKey reurns falsey), keyed by ID +unmeasurableItems: Map>, |}; class NodeHeightMeasurer extends React.PureComponent< Props, State, > { - static propTypes = { - listData: PropTypes.arrayOf(PropTypes.object), - itemToID: PropTypes.func.isRequired, - itemToMeasureKey: PropTypes.func.isRequired, - itemToDummy: PropTypes.func.isRequired, - mergeItemWithHeight: PropTypes.func.isRequired, - allHeightsMeasured: PropTypes.func.isRequired, - }; containerWidth: ?number; constructor(props: Props) { super(props); const { listData, itemToID, itemToMeasureKey, mergeItemWithHeight } = props; const unmeasurableItems = new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { continue; } const mergedItem = mergeItemWithHeight(item, undefined); unmeasurableItems.set(itemToID(item), { item, mergedItem }); } } this.state = { currentlyMeasuring: [], iteration: 0, measuredHeights: new Map(), measurableItems: new Map(), unmeasurableItems, }; } static getDerivedStateFromProps( props: Props, state: State, ) { return NodeHeightMeasurer.getPossibleStateUpdateForNextBatch< Item, MergedItem, >(props, state); } static getPossibleStateUpdateForNextBatch( props: Props, state: State, ): ?Shape> { const { currentlyMeasuring, measuredHeights } = state; let stillMeasuring = false; for (const { measureKey } of currentlyMeasuring) { const height = measuredHeights.get(measureKey); if (height === null || height === undefined) { stillMeasuring = true; break; } } if (stillMeasuring) { return null; } const { listData, itemToMeasureKey, itemToDummy } = props; const toMeasure = new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = measuredHeights.get(measureKey); if (height !== null && height !== undefined) { continue; } const dummy = itemToDummy(item); toMeasure.set(measureKey, dummy); if (toMeasure.size === measureBatchSize) { break; } } } if (currentlyMeasuring.length === 0 && toMeasure.size === 0) { return null; } const nextCurrentlyMeasuring = []; for (const [measureKey, dummy] of toMeasure) { nextCurrentlyMeasuring.push({ measureKey, dummy }); } return { currentlyMeasuring: nextCurrentlyMeasuring, measuredHeights: new Map(measuredHeights), }; } possiblyIssueNewBatch() { const stateUpdate = NodeHeightMeasurer.getPossibleStateUpdateForNextBatch( this.props, this.state, ); if (stateUpdate) { this.setState(stateUpdate); } } componentDidMount() { this.triggerCallback( this.state.measurableItems, this.state.unmeasurableItems, false, ); } triggerCallback( measurableItems: Map>, unmeasurableItems: Map>, mustTrigger: boolean, ) { const { listData, itemToID, itemToMeasureKey, allHeightsMeasured, } = this.props; if (!listData) { return; } const result = []; for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { const measurableItem = measurableItems.get(id); if (!measurableItem && !mustTrigger) { return; } invariant( measurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(measurableItem.mergedItem); } else { const unmeasurableItem = unmeasurableItems.get(id); if (!unmeasurableItem && !mustTrigger) { return; } invariant( unmeasurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(unmeasurableItem.mergedItem); } } allHeightsMeasured(result); } componentDidUpdate( prevProps: Props, prevState: State, ) { const { listData, itemToID, itemToMeasureKey, itemToDummy, mergeItemWithHeight, allHeightsMeasured, ...rest } = this.props; const { listData: prevListData, itemToID: prevItemToID, itemToMeasureKey: prevItemToMeasureKey, itemToDummy: prevItemToDummy, mergeItemWithHeight: prevMergeItemWithHeight, allHeightsMeasured: prevAllHeightsMeasured, ...prevRest } = prevProps; const restShallowEqual = shallowequal(rest, prevRest); const measurementJustCompleted = this.state.currentlyMeasuring.length === 0 && prevState.currentlyMeasuring.length !== 0; let incrementIteration = false; const nextMeasuredHeights = new Map(this.state.measuredHeights); let measuredHeightsChanged = false; const nextMeasurableItems = new Map(this.state.measurableItems); let measurableItemsChanged = false; const nextUnmeasurableItems = new Map(this.state.unmeasurableItems); let unmeasurableItemsChanged = false; if ( itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy ) { incrementIteration = true; nextMeasuredHeights.clear(); measuredHeightsChanged = true; } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextMeasurableItems.size > 0) { nextMeasurableItems.clear(); measurableItemsChanged = true; } } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextUnmeasurableItems.size > 0) { nextUnmeasurableItems.clear(); unmeasurableItemsChanged = true; } } if ( measurementJustCompleted || listData !== prevListData || measuredHeightsChanged || measurableItemsChanged || unmeasurableItemsChanged ) { const currentMeasurableItems = new Map(); const currentUnmeasurableItems = new Map(); if (listData) { for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { currentMeasurableItems.set(id, item); } else { currentUnmeasurableItems.set(id, item); } } } for (const [id, { item }] of nextMeasurableItems) { const currentItem = currentMeasurableItems.get(id); if (!currentItem) { measurableItemsChanged = true; nextMeasurableItems.delete(id); } else if (currentItem !== item) { measurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey === null || measureKey === undefined) { nextMeasurableItems.delete(id); continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { nextMeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, height); nextMeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentMeasurableItems) { if (nextMeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { continue; } const mergedItem = mergeItemWithHeight(item, height); nextMeasurableItems.set(id, { item, mergedItem }); measurableItemsChanged = true; } for (const [id, { item }] of nextUnmeasurableItems) { const currentItem = currentUnmeasurableItems.get(id); if (!currentItem) { unmeasurableItemsChanged = true; nextUnmeasurableItems.delete(id); } else if (currentItem !== item) { unmeasurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey !== null && measureKey !== undefined) { nextUnmeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, undefined); nextUnmeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentUnmeasurableItems) { if (nextUnmeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { continue; } const mergedItem = mergeItemWithHeight(item, undefined); nextUnmeasurableItems.set(id, { item, mergedItem }); unmeasurableItemsChanged = true; } } const stateUpdate = {}; if (incrementIteration) { stateUpdate.iteration = this.state.iteration + 1; } if (measuredHeightsChanged) { stateUpdate.measuredHeights = nextMeasuredHeights; } if (measurableItemsChanged) { stateUpdate.measurableItems = nextMeasurableItems; } if (unmeasurableItemsChanged) { stateUpdate.unmeasurableItems = nextUnmeasurableItems; } if (Object.keys(stateUpdate).length > 0) { this.setState(stateUpdate); } if (measurementJustCompleted || !shallowequal(this.props, prevProps)) { this.triggerCallback( nextMeasurableItems, nextUnmeasurableItems, measurementJustCompleted, ); } } onContainerLayout = (event: LayoutEvent) => { const { width, height } = event.nativeEvent.layout; if (width > height) { // We currently only use NodeHeightMeasurer on interfaces that are // portrait-locked. If we expand beyond that we'll need to rethink this return; } if (this.containerWidth === undefined) { this.containerWidth = width; } else if (this.containerWidth !== width) { this.containerWidth = width; this.setState((innerPrevState) => ({ iteration: innerPrevState.iteration + 1, measuredHeights: new Map(), measurableItems: new Map(), })); } }; onDummyLayout(measureKey: string, iteration: number, event: LayoutEvent) { if (iteration !== this.state.iteration) { return; } const { height } = event.nativeEvent.layout; this.state.measuredHeights.set(measureKey, height); this.possiblyIssueNewBatch(); } render() { const { currentlyMeasuring, iteration } = this.state; const dummies = currentlyMeasuring.map(({ measureKey, dummy }) => { const { children } = dummy.props; const style = [dummy.props.style, styles.dummy]; const onLayout = (event) => this.onDummyLayout(measureKey, iteration, event); const node = React.cloneElement(dummy, { style, onLayout, children, }); return {node}; }); return {dummies}; } } const styles = StyleSheet.create({ dummy: { opacity: 0, position: 'absolute', }, }); export default NodeHeightMeasurer; diff --git a/native/input/input-state.js b/native/input/input-state.js index 0f5459f1b..c7a114e62 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,77 +1,44 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import type { NativeMediaSelection } from 'lib/types/media-types'; import type { RawTextMessageInfo } from 'lib/types/messages/text'; export type MultimediaProcessingStep = 'transcoding' | 'uploading'; export type PendingMultimediaUpload = {| +failed: ?string, +progressPercent: number, +processingStep: ?MultimediaProcessingStep, |}; -const pendingMultimediaUploadPropType = PropTypes.shape({ - failed: PropTypes.string, - progressPercent: PropTypes.number.isRequired, - processingStep: PropTypes.oneOf(['transcoding', 'uploading']), -}); - -export type MessagePendingUploads = { +export type MessagePendingUploads = {| [localUploadID: string]: PendingMultimediaUpload, -}; - -const messagePendingUploadsPropType = PropTypes.objectOf( - pendingMultimediaUploadPropType, -); +|}; export type PendingMultimediaUploads = { [localMessageID: string]: MessagePendingUploads, }; -const pendingMultimediaUploadsPropType = PropTypes.objectOf( - messagePendingUploadsPropType, -); - export type InputState = {| - pendingUploads: PendingMultimediaUploads, - sendTextMessage: (messageInfo: RawTextMessageInfo) => void, - sendMultimediaMessage: ( + +pendingUploads: PendingMultimediaUploads, + +sendTextMessage: (messageInfo: RawTextMessageInfo) => void, + +sendMultimediaMessage: ( threadID: string, selections: $ReadOnlyArray, ) => Promise, - addReply: (text: string) => void, - addReplyListener: ((message: string) => void) => void, - removeReplyListener: ((message: string) => void) => void, - messageHasUploadFailure: (localMessageID: string) => boolean, - retryMessage: (localMessageID: string) => Promise, - registerSendCallback: (() => void) => void, - unregisterSendCallback: (() => void) => void, - uploadInProgress: () => boolean, - reportURIDisplayed: (uri: string, loaded: boolean) => void, + +addReply: (text: string) => void, + +addReplyListener: ((message: string) => void) => void, + +removeReplyListener: ((message: string) => void) => void, + +messageHasUploadFailure: (localMessageID: string) => boolean, + +retryMessage: (localMessageID: string) => Promise, + +registerSendCallback: (() => void) => void, + +unregisterSendCallback: (() => void) => void, + +uploadInProgress: () => boolean, + +reportURIDisplayed: (uri: string, loaded: boolean) => void, |}; -const inputStatePropType = PropTypes.shape({ - pendingUploads: pendingMultimediaUploadsPropType.isRequired, - sendTextMessage: PropTypes.func.isRequired, - sendMultimediaMessage: PropTypes.func.isRequired, - addReply: PropTypes.func.isRequired, - addReplyListener: PropTypes.func.isRequired, - removeReplyListener: PropTypes.func.isRequired, - messageHasUploadFailure: PropTypes.func.isRequired, - retryMessage: PropTypes.func.isRequired, - uploadInProgress: PropTypes.func.isRequired, - reportURIDisplayed: PropTypes.func.isRequired, -}); - const InputStateContext = React.createContext(null); -export { - messagePendingUploadsPropType, - pendingMultimediaUploadPropType, - inputStatePropType, - InputStateContext, -}; +export { InputStateContext }; diff --git a/native/keyboard/keyboard-input-host.react.js b/native/keyboard/keyboard-input-host.react.js index 02368bb55..9ba379fbe 100644 --- a/native/keyboard/keyboard-input-host.react.js +++ b/native/keyboard/keyboard-input-host.react.js @@ -1,141 +1,123 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { Alert, TextInput } from 'react-native'; import { KeyboardAccessoryView } from 'react-native-keyboard-input'; import { useRealThreadCreator } from 'lib/shared/thread-utils'; import type { MediaLibrarySelection } from 'lib/types/media-types'; -import { - type InputState, - inputStatePropType, - InputStateContext, -} from '../input/input-state'; +import { type InputState, InputStateContext } from '../input/input-state'; import { mediaGalleryKeyboardName } from '../media/media-gallery-keyboard.react'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { useStyles } from '../themes/colors'; -import { - type KeyboardState, - keyboardStatePropType, - KeyboardContext, -} from './keyboard-state'; +import { type KeyboardState, KeyboardContext } from './keyboard-state'; type BaseProps = {| +textInputRef?: React.ElementRef, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, +activeMessageList: ?string, // withKeyboardState +keyboardState: KeyboardState, // withInputState +inputState: ?InputState, +getServerThreadID: () => Promise, |}; class KeyboardInputHost extends React.PureComponent { - static propTypes = { - textInputRef: PropTypes.object, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - activeMessageList: PropTypes.string, - keyboardState: keyboardStatePropType, - inputState: inputStatePropType, - getServerThreadID: PropTypes.func.isRequired, - }; - componentDidUpdate(prevProps: Props) { if ( prevProps.activeMessageList && this.props.activeMessageList !== prevProps.activeMessageList ) { this.hideMediaGallery(); } } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } render() { const kbComponent = KeyboardInputHost.mediaGalleryOpen(this.props) ? mediaGalleryKeyboardName : null; return ( ); } onMediaGalleryItemSelected = async ( keyboardName: string, selections: $ReadOnlyArray, ) => { const { keyboardState, getServerThreadID } = this.props; keyboardState.dismissKeyboard(); const mediaGalleryThreadID = await getServerThreadID(); if (mediaGalleryThreadID === null || mediaGalleryThreadID === undefined) { return; } const { inputState } = this.props; invariant( inputState, 'inputState should be set in onMediaGalleryItemSelected', ); inputState.sendMultimediaMessage(mediaGalleryThreadID, selections); }; hideMediaGallery = () => { const { keyboardState } = this.props; keyboardState.hideMediaGallery(); }; } const unboundStyles = { kbInitialProps: { backgroundColor: 'listBackground', }, }; const showErrorAlert = () => Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); export default React.memo(function ConnectedKeyboardInputHost( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const activeMessageList = activeMessageListSelector(navContext); invariant(keyboardState, 'keyboardState should be initialized'); const getServerThreadID = useRealThreadCreator( keyboardState.getMediaGalleryThread(), showErrorAlert, ); return ( ); }); diff --git a/native/keyboard/keyboard-state-container.react.js b/native/keyboard/keyboard-state-container.react.js index bf0b92dbc..03dc5088a 100644 --- a/native/keyboard/keyboard-state-container.react.js +++ b/native/keyboard/keyboard-state-container.react.js @@ -1,159 +1,155 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import { Platform } from 'react-native'; import { KeyboardUtils } from 'react-native-keyboard-input'; import type { Shape } from 'lib/types/core'; import type { OptimisticThreadInfo } from 'lib/types/thread-types'; import sleep from 'lib/utils/sleep'; import { tabBarAnimationDuration } from '../navigation/tab-bar.react'; import { waitForInteractions } from '../utils/timers'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, androidKeyboardResizesFrame, } from './keyboard'; import KeyboardInputHost from './keyboard-input-host.react'; import { KeyboardContext } from './keyboard-state'; type Props = {| - children: React.Node, + +children: React.Node, |}; type State = {| - systemKeyboardShowing: boolean, - mediaGalleryOpen: boolean, - mediaGalleryThread: ?OptimisticThreadInfo, - renderKeyboardInputHost: boolean, + +systemKeyboardShowing: boolean, + +mediaGalleryOpen: boolean, + +mediaGalleryThread: ?OptimisticThreadInfo, + +renderKeyboardInputHost: boolean, |}; class KeyboardStateContainer extends React.PureComponent { - static propTypes = { - children: PropTypes.node.isRequired, - }; state: State = { systemKeyboardShowing: false, mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }; keyboardShowListener: ?Object; keyboardDismissListener: ?Object; keyboardShow = () => { this.setState({ systemKeyboardShowing: true }); }; keyboardDismiss = () => { this.setState({ systemKeyboardShowing: false }); }; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } } componentDidUpdate(prevProps: Props, prevState: State) { if (Platform.OS !== 'android' || androidKeyboardResizesFrame) { return; } if (this.state.mediaGalleryOpen && !prevState.mediaGalleryOpen) { (async () => { await sleep(tabBarAnimationDuration); await waitForInteractions(); this.setState({ renderKeyboardInputHost: true }); })(); } } dismissKeyboard = () => { KeyboardUtils.dismiss(); this.hideMediaGallery(); }; dismissKeyboardIfShowing = () => { if (!this.keyboardShowing) { return false; } this.dismissKeyboard(); return true; }; get keyboardShowing() { const { systemKeyboardShowing, mediaGalleryOpen } = this.state; return systemKeyboardShowing || mediaGalleryOpen; } showMediaGallery = (thread: OptimisticThreadInfo) => { let updates: Shape = { mediaGalleryOpen: true, mediaGalleryThread: thread, }; if (androidKeyboardResizesFrame) { updates = { ...updates, renderKeyboardInputHost: true }; } this.setState(updates); }; hideMediaGallery = () => { this.setState({ mediaGalleryOpen: false, mediaGalleryThread: null, renderKeyboardInputHost: false, }); }; getMediaGalleryThread = () => this.state.mediaGalleryThread; render() { const { systemKeyboardShowing, mediaGalleryOpen, renderKeyboardInputHost, } = this.state; const { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, showMediaGallery, hideMediaGallery, getMediaGalleryThread, } = this; const keyboardState = { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, systemKeyboardShowing, mediaGalleryOpen, showMediaGallery, hideMediaGallery, getMediaGalleryThread, }; const keyboardInputHost = renderKeyboardInputHost ? ( ) : null; return ( {this.props.children} {keyboardInputHost} ); } } export default KeyboardStateContainer; diff --git a/native/keyboard/keyboard-state.js b/native/keyboard/keyboard-state.js index 3a41affaf..4d812ad22 100644 --- a/native/keyboard/keyboard-state.js +++ b/native/keyboard/keyboard-state.js @@ -1,32 +1,20 @@ // @flow -import PropTypes from 'prop-types'; import * as React from 'react'; import type { OptimisticThreadInfo } from 'lib/types/thread-types'; export type KeyboardState = {| - keyboardShowing: boolean, - dismissKeyboard: () => void, - dismissKeyboardIfShowing: () => boolean, - systemKeyboardShowing: boolean, - mediaGalleryOpen: boolean, - showMediaGallery: (thread: OptimisticThreadInfo) => void, - hideMediaGallery: () => void, - getMediaGalleryThread: () => ?OptimisticThreadInfo, + +keyboardShowing: boolean, + +dismissKeyboard: () => void, + +dismissKeyboardIfShowing: () => boolean, + +systemKeyboardShowing: boolean, + +mediaGalleryOpen: boolean, + +showMediaGallery: (thread: OptimisticThreadInfo) => void, + +hideMediaGallery: () => void, + +getMediaGalleryThread: () => ?OptimisticThreadInfo, |}; -const keyboardStatePropType = PropTypes.shape({ - keyboardShowing: PropTypes.bool.isRequired, - dismissKeyboard: PropTypes.func.isRequired, - dismissKeyboardIfShowing: PropTypes.func.isRequired, - systemKeyboardShowing: PropTypes.bool.isRequired, - mediaGalleryOpen: PropTypes.bool.isRequired, - showMediaGallery: PropTypes.func.isRequired, - hideMediaGallery: PropTypes.func.isRequired, - getMediaGalleryThread: PropTypes.func.isRequired, -}); - const KeyboardContext = React.createContext(null); -export { keyboardStatePropType, KeyboardContext }; +export { KeyboardContext }; diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js index 71ac1b4e3..f17422930 100644 --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -1,1248 +1,1217 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Image, Animated, Easing, Alert, } from 'react-native'; import { RNCamera } from 'react-native-camera'; import filesystem from 'react-native-fs'; import { PinchGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import type { Orientations } from 'react-native-orientation-locker'; import Reanimated, { Easing as ReanimatedEasing, } from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import { useRealThreadCreator } from 'lib/shared/thread-utils'; import type { PhotoCapture } from 'lib/types/media-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { OptimisticThreadInfo } from 'lib/types/thread-types'; -import { optimisticThreadInfoPropType } from 'lib/types/thread-types'; import ContentLoading from '../components/content-loading.react'; import ConnectedStatusBar from '../connected-status-bar.react'; -import { - type InputState, - inputStatePropType, - InputStateContext, -} from '../input/input-state'; +import { type InputState, InputStateContext } from '../input/input-state'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, - overlayContextPropType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { updateDeviceCameraInfoActionType } from '../redux/action-types'; -import { - type DimensionsInfo, - dimensionsInfoPropType, -} from '../redux/dimensions-updater.react'; +import { type DimensionsInfo } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { colors } from '../themes/colors'; -import { - type DeviceCameraInfo, - deviceCameraInfoPropType, -} from '../types/camera'; +import { type DeviceCameraInfo } from '../types/camera'; import type { NativeMethods } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import { clamp, gestureJustEnded } from '../utils/animation-utils'; import SendMediaButton from './send-media-button.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, event, Extrapolate, block, set, call, cond, not, and, or, eq, greaterThan, lessThan, add, sub, multiply, divide, abs, interpolate, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Reanimated; /* eslint-enable import/no-named-as-default-member */ const maxZoom = 16; const zoomUpdateFactor = (() => { if (Platform.OS === 'ios') { return 0.002; } if (Platform.OS === 'android' && Platform.Version > 26) { return 0.005; } if (Platform.OS === 'android' && Platform.Version > 23) { return 0.01; } return 0.03; })(); const stagingModeAnimationConfig = { duration: 150, easing: ReanimatedEasing.inOut(ReanimatedEasing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const indicatorSpringConfig = { ...SpringUtils.makeDefaultConfig(), damping: 0, mass: 0.6, toValue: 1, }; const indicatorTimingConfig = { duration: 500, easing: ReanimatedEasing.out(ReanimatedEasing.ease), toValue: 0, }; function runIndicatorAnimation( // Inputs springClock: Clock, delayClock: Clock, timingClock: Clock, animationRunning: Value, // Outputs scale: Value, opacity: Value, ): Value { const delayStart = new Value(0); const springScale = new Value(0.75); const delayScale = new Value(0); const timingScale = new Value(0.75); const animatedScale = cond( clockRunning(springClock), springScale, cond(clockRunning(delayClock), delayScale, timingScale), ); const lastAnimatedScale = new Value(0.75); const numScaleLoops = new Value(0); const springState = { finished: new Value(1), velocity: new Value(0), time: new Value(0), position: springScale, }; const timingState = { finished: new Value(1), frameTime: new Value(0), time: new Value(0), position: timingScale, }; return block([ cond(not(animationRunning), [ set(springState.finished, 0), set(springState.velocity, 0), set(springState.time, 0), set(springScale, 0.75), set(lastAnimatedScale, 0.75), set(numScaleLoops, 0), set(opacity, 1), startClock(springClock), ]), [ cond( clockRunning(springClock), spring(springClock, springState, indicatorSpringConfig), ), timing(timingClock, timingState, indicatorTimingConfig), ], [ cond( and( greaterThan(animatedScale, 1.2), not(greaterThan(lastAnimatedScale, 1.2)), ), [ set(numScaleLoops, add(numScaleLoops, 1)), cond(greaterThan(numScaleLoops, 1), [ set(springState.finished, 1), stopClock(springClock), set(delayScale, springScale), set(delayStart, delayClock), startClock(delayClock), ]), ], ), set(lastAnimatedScale, animatedScale), ], cond( and( clockRunning(delayClock), greaterThan(delayClock, add(delayStart, 400)), ), [ stopClock(delayClock), set(timingState.finished, 0), set(timingState.frameTime, 0), set(timingState.time, 0), set(timingScale, delayScale), startClock(timingClock), ], ), cond( and(springState.finished, timingState.finished), stopClock(timingClock), ), set(scale, animatedScale), cond(clockRunning(timingClock), set(opacity, clamp(animatedScale, 0, 1))), ]); } export type CameraModalParams = {| +presentedFrom: string, +thread: OptimisticThreadInfo, |}; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type BaseProps = {| +navigation: AppNavigationProp<'CameraModal'>, +route: NavigationRoute<'CameraModal'>, |}; type Props = {| ...BaseProps, // Redux state +dimensions: DimensionsInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +foreground: boolean, // Redux dispatch functions +dispatch: Dispatch, +getServerThreadID: () => Promise, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +zoom: number, +useFrontCamera: boolean, +hasCamerasOnBothSides: boolean, +flashMode: number, +autoFocusPointOfInterest: ?{| x: number, y: number, autoExposure?: boolean, |}, +stagingMode: boolean, +pendingPhotoCapture: ?PhotoCapture, |}; class CameraModal extends React.PureComponent { - static propTypes = { - navigation: PropTypes.shape({ - goBackOnce: PropTypes.func.isRequired, - }).isRequired, - route: PropTypes.shape({ - params: PropTypes.shape({ - thread: optimisticThreadInfoPropType.isRequired, - }).isRequired, - }).isRequired, - dimensions: dimensionsInfoPropType.isRequired, - deviceCameraInfo: deviceCameraInfoPropType.isRequired, - deviceOrientation: PropTypes.string.isRequired, - foreground: PropTypes.bool.isRequired, - dispatch: PropTypes.func.isRequired, - getServerThreadID: PropTypes.func.isRequired, - inputState: inputStatePropType, - overlayContext: overlayContextPropType, - }; camera: ?RNCamera; pinchEvent; pinchHandler = React.createRef(); tapEvent; tapHandler = React.createRef(); animationCode: Value; closeButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); photoButton: ?React.ElementRef; photoButtonX = new Value(-1); photoButtonY = new Value(-1); photoButtonWidth = new Value(0); photoButtonHeight = new Value(0); switchCameraButton: ?React.ElementRef; switchCameraButtonX = new Value(-1); switchCameraButtonY = new Value(-1); switchCameraButtonWidth = new Value(0); switchCameraButtonHeight = new Value(0); flashButton: ?React.ElementRef; flashButtonX = new Value(-1); flashButtonY = new Value(-1); flashButtonWidth = new Value(0); flashButtonHeight = new Value(0); focusIndicatorX = new Value(-1); focusIndicatorY = new Value(-1); focusIndicatorScale = new Value(0); focusIndicatorOpacity = new Value(0); cancelIndicatorAnimation = new Value(0); cameraIDsFetched = false; stagingModeProgress = new Value(0); sendButtonProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; overlayStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { zoom: 0, useFrontCamera: props.deviceCameraInfo.defaultUseFrontCamera, hasCamerasOnBothSides: props.deviceCameraInfo.hasCamerasOnBothSides, flashMode: RNCamera.Constants.FlashMode.off, autoFocusPointOfInterest: undefined, stagingMode: false, pendingPhotoCapture: undefined, }; const sendButtonScale = this.sendButtonProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.1, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.sendButtonProgress, transform: [{ scale: sendButtonScale }], }; const overlayOpacity = interpolate(this.stagingModeProgress, { inputRange: [0, 0.01, 1], outputRange: [0, 0.5, 0], extrapolate: Extrapolate.CLAMP, }); this.overlayStyle = { ...styles.overlay, opacity: overlayOpacity, }; const pinchState = new Value(-1); const pinchScale = new Value(1); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, }, }, ]); const tapState = new Value(-1); const tapX = new Value(0); const tapY = new Value(0); this.tapEvent = event([ { nativeEvent: { state: tapState, x: tapX, y: tapY, }, }, ]); this.animationCode = block([ this.zoomAnimationCode(pinchState, pinchScale), this.focusAnimationCode(tapState, tapX, tapY), ]); } zoomAnimationCode(pinchState: Value, pinchScale: Value): Value { const pinchJustEnded = gestureJustEnded(pinchState); const zoomBase = new Value(1); const zoomReported = new Value(1); const currentZoom = interpolate(multiply(zoomBase, pinchScale), { inputRange: [1, 8], outputRange: [1, 8], extrapolate: Extrapolate.CLAMP, }); const cameraZoomFactor = interpolate(zoomReported, { inputRange: [1, 8], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const resolvedZoom = cond( eq(pinchState, GestureState.ACTIVE), currentZoom, zoomBase, ); return [ cond(pinchJustEnded, set(zoomBase, currentZoom)), cond( or( pinchJustEnded, greaterThan( abs(sub(divide(resolvedZoom, zoomReported), 1)), zoomUpdateFactor, ), ), [ set(zoomReported, resolvedZoom), call([cameraZoomFactor], this.updateZoom), ], ), ]; } focusAnimationCode(tapState: Value, tapX: Value, tapY: Value): Value { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(tapState), this.outsideButtons(lastTapX, lastTapY), ); const indicatorSpringClock = new Clock(); const indicatorDelayClock = new Clock(); const indicatorTimingClock = new Clock(); const indicatorAnimationRunning = or( clockRunning(indicatorSpringClock), clockRunning(indicatorDelayClock), clockRunning(indicatorTimingClock), ); return [ cond(fingerJustReleased, [ call([tapX, tapY], this.focusOnPoint), set(this.focusIndicatorX, tapX), set(this.focusIndicatorY, tapY), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), ]), cond(this.cancelIndicatorAnimation, [ set(this.cancelIndicatorAnimation, 0), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), set(this.focusIndicatorOpacity, 0), ]), cond( or(fingerJustReleased, indicatorAnimationRunning), runIndicatorAnimation( indicatorSpringClock, indicatorDelayClock, indicatorTimingClock, indicatorAnimationRunning, this.focusIndicatorScale, this.focusIndicatorOpacity, ), ), set(lastTapX, tapX), set(lastTapY, tapY), ]; } outsideButtons(x: Value, y: Value) { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, photoButtonX, photoButtonY, photoButtonWidth, photoButtonHeight, switchCameraButtonX, switchCameraButtonY, switchCameraButtonWidth, switchCameraButtonHeight, flashButtonX, flashButtonY, flashButtonWidth, flashButtonHeight, } = this; return and( or( lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( lessThan(x, photoButtonX), greaterThan(x, add(photoButtonX, photoButtonWidth)), lessThan(y, photoButtonY), greaterThan(y, add(photoButtonY, photoButtonHeight)), ), or( lessThan(x, switchCameraButtonX), greaterThan(x, add(switchCameraButtonX, switchCameraButtonWidth)), lessThan(y, switchCameraButtonY), greaterThan(y, add(switchCameraButtonY, switchCameraButtonHeight)), ), or( lessThan(x, flashButtonX), greaterThan(x, add(flashButtonX, flashButtonWidth)), lessThan(y, flashButtonY), greaterThan(y, add(flashButtonY, flashButtonHeight)), ), ); } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return !overlayContext.isDismissing; } componentDidMount() { if (CameraModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (CameraModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props, prevState: State) { const isActive = CameraModal.isActive(this.props); const wasActive = CameraModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } if (!this.state.hasCamerasOnBothSides && prevState.hasCamerasOnBothSides) { this.switchCameraButtonX.setValue(-1); this.switchCameraButtonY.setValue(-1); this.switchCameraButtonWidth.setValue(0); this.switchCameraButtonHeight.setValue(0); } if (this.props.deviceOrientation !== prevProps.deviceOrientation) { this.setState({ autoFocusPointOfInterest: null }); this.cancelIndicatorAnimation.setValue(1); } if (this.props.foreground && !prevProps.foreground && this.camera) { this.camera.refreshAuthorizationStatus(); } if (this.state.stagingMode && !prevState.stagingMode) { this.cancelIndicatorAnimation.setValue(1); this.focusIndicatorOpacity.setValue(0); timing(this.stagingModeProgress, { ...stagingModeAnimationConfig, toValue: 1, }).start(); } else if (!this.state.stagingMode && prevState.stagingMode) { this.stagingModeProgress.setValue(0); } if (this.state.pendingPhotoCapture && !prevState.pendingPhotoCapture) { Animated.timing(this.sendButtonProgress, { ...sendButtonAnimationConfig, toValue: 1, }).start(); } else if ( !this.state.pendingPhotoCapture && prevState.pendingPhotoCapture ) { CameraModal.cleanUpPendingPhotoCapture(prevState.pendingPhotoCapture); this.sendButtonProgress.setValue(0); } } static async cleanUpPendingPhotoCapture(pendingPhotoCapture: PhotoCapture) { const path = pathFromURI(pendingPhotoCapture.uri); if (!path) { return; } try { await filesystem.unlink(path); } catch (e) {} } get containerStyle() { const { overlayContext } = this.props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return { ...styles.container, opacity: overlayContext.position, }; } get focusIndicatorStyle() { return { ...styles.focusIndicator, opacity: this.focusIndicatorOpacity, transform: [ { translateX: this.focusIndicatorX }, { translateY: this.focusIndicatorY }, { scale: this.focusIndicatorScale }, ], }; } renderCamera = ({ camera, status }) => { if (camera && camera._cameraHandle) { this.fetchCameraIDs(camera); } if (this.state.stagingMode) { return this.renderStagingView(); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset, 6), }; return ( <> {this.renderCameraContent(status)} × ); }; renderStagingView() { let image = null; const { pendingPhotoCapture } = this.state; if (pendingPhotoCapture) { const imageSource = { uri: pendingPhotoCapture.uri }; image = ; } else { image = ; } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const sendButtonContainerStyle = { bottom: this.props.dimensions.bottomInset + 22, }; return ( <> {image} ); } renderCameraContent(status) { if (status === 'PENDING_AUTHORIZATION') { return ; } else if (status === 'NOT_AUTHORIZED') { return ( {"don't have permission :("} ); } let switchCameraButton = null; if (this.state.hasCamerasOnBothSides) { switchCameraButton = ( ); } let flashIcon; if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { flashIcon = ; } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { flashIcon = ; } else { flashIcon = ( <> A ); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const bottomButtonsContainerStyle = { bottom: this.props.dimensions.bottomInset + 20, }; return ( {flashIcon} {switchCameraButton} ); } render() { const statusBar = CameraModal.isActive(this.props) ? (