diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index a721f977d..123ef47f2 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,1053 +1,1053 @@ // @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 { pendingToRealizedThreadIDsSelector } from '../selectors/thread-selectors'; import { messageID, combineTruncationStatuses, sortMessageInfoList, } from '../shared/message-utils'; import { threadHasPermission, threadInChatList, threadIsPending, } 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, type ThreadMessageInfo, type LocallyComposedMessageInfo, type RawMultimediaMessageInfo, messageTruncationStatus, messageTypes, defaultNumberPerThread, } from '../types/message-types'; import type { RawImagesMessageInfo } from '../types/messages/images'; import type { RawMediaMessageInfo } from '../types/messages/media'; import type { RawTextMessageInfo } from '../types/messages/text'; 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 (const 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( threadID: string, threadInfo: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadIsPending(threadID) || (threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadID))) ); } function freshMessageStore( 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 (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( threads[threadID] || !threadIsWatched(threadID, 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 }; } function reassignMessagesToRealizedThreads( messageStore: MessageStore, threadInfos: { +[threadID: string]: RawThreadInfo }, ): MessageStore { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector( threadInfos, ); const messages = {}; for (const storeMessageID in messageStore.messages) { const message = messageStore.messages[storeMessageID]; const newThreadID = pendingToRealizedThreadIDs.get(message.threadID); messages[storeMessageID] = newThreadID ? { ...message, threadID: newThreadID, } : message; } const threads = {}; for (const threadID in messageStore.threads) { const threadMessageInfo = messageStore.threads[threadID]; const newThreadID = pendingToRealizedThreadIDs.get(threadID); if (!newThreadID) { threads[threadID] = threadMessageInfo; continue; } const realizedThread = messageStore.threads[newThreadID]; if (!realizedThread) { threads[newThreadID] = threadMessageInfo; continue; } // This is the first time we see a thread, and yet it's already present // in message store. It means that something went wrong. invariant(false, 'Message store should not contain a new thread'); } return { ...messageStore, threads, messages, }; } // 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 messageStoreAfterReassignment = reassignMessagesToRealizedThreads( oldMessageStore, threadInfos, ); 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'); invariant( !threadIsPending(messageInfo.threadID), 'new messageInfos should have realized thread id', ); const currentMessageInfo = messageStoreAfterReassignment.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? messageStoreAfterReassignment.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); } } } else if ( currentMessageInfo && messageInfo.time > currentMessageInfo.time ) { // When thick threads will be introduced it will be possible for two // clients to create the same message (e.g. when they create the same // sidebar at the same time). We're going to use deterministic ids for // messages which should be unique within a thread and we have to find // a way for clients to agree which message to keep. We can't rely on // always choosing incoming messages nor messages from the store, // because a message that is in one user's store, will be send to // another user. One way to deal with it is to always choose a message // which is older, according to its timestamp. We can use this strategy // only for messages that can start a thread, because for other types // it might break the "contiguous" property of message ids (we can // consider selecting younger messages in that case, but for now we use // an invariant). invariant( messageInfo.type === messageTypes.CREATE_SIDEBAR || messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.SIDEBAR_SOURCE, `Two different messages of type ${messageInfo.type} with the same ` + 'id found', ); return currentMessageInfo; } 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(threadID, threadInfos[threadID], watchedIDs), ), _mapValuesWithKeys((messageIDs: string[], threadID: string) => { const oldThread = messageStoreAfterReassignment.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 (const id of oldNotInNew) { const oldMessageInfo = messageStoreAfterReassignment.messages[id]; invariant(oldMessageInfo, `could not find ${id} in messageStore`); oldMessageInfosToCombine.push(oldMessageInfo); const localInfo = messageStoreAfterReassignment.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 (const threadID in messageStoreAfterReassignment.threads) { if ( threads[threadID] || !threadIsWatched(threadID, threadInfos[threadID], watchedIDs) ) { continue; } let thread = messageStoreAfterReassignment.threads[threadID]; const truncate = truncationStatus[threadID]; if (truncate === messageTruncationStatus.EXHAUSTIVE) { thread = { ...thread, startReached: true, }; } threads[threadID] = thread; for (const id of thread.messageIDs) { const messageInfo = messageStoreAfterReassignment.messages[id]; if (messageInfo) { oldMessageInfosToCombine.push(messageInfo); } const localInfo = messageStoreAfterReassignment.local[id]; if (localInfo) { local[id] = localInfo; } } } for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if ( threads[threadID] || !threadIsWatched(threadID, 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 (const threadID of mustResortThreadMessageIDs) { threads[threadID].messageIDs = sortMessageIDs(messages)( threads[threadID].messageIDs, ); } const currentAsOf = Math.max( orderedNewMessageInfos.length > 0 ? orderedNewMessageInfos[0].time : 0, messageStoreAfterReassignment.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.id, threadInfo, watchedIDs), )(threadInfos); const messageIDsToRemove = []; for (const threadID in messageStore.threads) { if (watchedThreadInfos[threadID]) { continue; } for (const 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 ensureRealizedThreadIDIsUsedWhenPossible< T: RawTextMessageInfo | RawMultimediaMessageInfo | LocallyComposedMessageInfo, >(payload: T, threadInfos: { [id: string]: RawThreadInfo }): T { const pendingToRealizedThreadIDs = pendingToRealizedThreadIDsSelector( threadInfos, ); const realizedThreadID = pendingToRealizedThreadIDs.get(payload.threadID); return realizedThreadID ? { ...payload, threadID: realizedThreadID } : payload; } 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 (const updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (const 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 (const 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 (const 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 = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); 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]?.messageIDs ?? []; for (const existingMessageID of messageIDs) { const existingMessageInfo = messageStore.messages[existingMessageID]; if (existingMessageInfo && existingMessageInfo.localID === localID) { return messageStore; } } const now = Date.now(); const threadState: ThreadMessageInfo = messageStore.threads[threadID] ? { ...messageStore.threads[threadID], messageIDs: [localID, ...messageStore.threads[threadID].messageIDs], } : { messageIDs: [localID], startReached: true, lastNavigatedTo: now, lastPruned: now, }; return { messages: { ...messageStore.messages, [localID]: payload, }, threads: { ...messageStore.threads, [threadID]: threadState, }, 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; invariant( !threadIsPending(payload.threadID), 'Successful message action should have realized thread id', ); 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 (const 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 = []; const newThreads = { ...messageStore.threads }; for (const 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 (const 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 = ensureRealizedThreadIDIsUsedWhenPossible( action.payload, newThreadInfos, ); 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, + ...(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 (const 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 (const updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (const 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/types/redux-types.js b/lib/types/redux-types.js index 77cd19bb9..8721fc5ce 100644 --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1,829 +1,829 @@ // @flow import type { LogOutResult, LogInStartingPayload, LogInResult, RegisterResult, } from './account-types'; import type { ActivityUpdateSuccessPayload, QueueActivityUpdatesPayload, SetThreadUnreadStatusPayload, } from './activity-types'; import type { RawEntryInfo, EntryStore, CalendarQuery, SaveEntryPayload, CreateEntryPayload, DeleteEntryResponse, RestoreEntryPayload, FetchEntryInfosResult, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from './entry-types'; import type { CalendarFilter, CalendarThreadFilter, SetCalendarDeletedFilterPayload, } from './filter-types'; import type { LifecycleState } from './lifecycle-state-types'; import type { LoadingStatus, LoadingInfo } from './loading-types'; import type { UpdateMultimediaMessageMediaPayload } from './media-types'; import type { MessageStore, RawMultimediaMessageInfo, FetchMessageInfosPayload, SendMessagePayload, SaveMessagesPayload, NewMessagesPayload, MessageStorePrunePayload, LocallyComposedMessageInfo, } from './message-types'; import type { RawTextMessageInfo } from './messages/text'; import type { BaseNavInfo } from './nav-types'; import type { RelationshipErrors } from './relationship-types'; import type { ClearDeliveredReportsPayload, ClientReportCreationRequest, QueueReportsPayload, } from './report-types'; import type { ProcessServerRequestsPayload } from './request-types'; import type { UserSearchResult } from './search-types'; import type { SetSessionPayload } from './session-types'; import type { ConnectionInfo, StateSyncFullActionPayload, StateSyncIncrementalActionPayload, UpdateConnectionStatusPayload, SetLateResponsePayload, UpdateDisconnectedBarPayload, } from './socket-types'; import type { SubscriptionUpdateResult } from './subscription-types'; import type { ThreadStore, ChangeThreadSettingsPayload, LeaveThreadPayload, NewThreadResult, ThreadJoinPayload, } from './thread-types'; import type { UpdatesResultWithUserInfos } from './update-types'; import type { CurrentUserInfo, UserStore } from './user-types'; export type BaseAppState = { navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, // millisecond timestamp loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, dataLoaded: boolean, }; // Web JS runtime doesn't have access to the cookie for security reasons. // Native JS doesn't have a sessionID because the cookieID is used instead. // Web JS doesn't have a device token because it's not a device... export type NativeAppState = BaseAppState<*> & { sessionID?: void, deviceToken: ?string, cookie: ?string, }; export type WebAppState = BaseAppState<*> & { sessionID: ?string, deviceToken?: void, cookie?: void, }; export type AppState = NativeAppState | WebAppState; export type BaseAction = | {| +type: '@@redux/INIT', +payload?: void, |} | {| +type: 'FETCH_ENTRIES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_SUCCESS', +payload: FetchEntryInfosResult, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_LOCAL_ENTRY', +payload: RawEntryInfo, |} | {| +type: 'CREATE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_SUCCESS', +payload: CreateEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_SUCCESS', +payload: SaveEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CONCURRENT_MODIFICATION_RESET', +payload: {| +id: string, +dbText: string, |}, |} | {| +type: 'DELETE_ENTRY_STARTED', +loadingInfo: LoadingInfo, +payload: {| +localID: ?string, +serverID: ?string, |}, |} | {| +type: 'DELETE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ENTRY_SUCCESS', +payload: ?DeleteEntryResponse, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'LOG_IN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'REGISTER_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_SUCCESS', +payload: RegisterResult, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_STARTED', +payload: {| calendarQuery: CalendarQuery |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_SUCCESS', +payload: {| +email: string, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_SUCCESS', +payload: NewThreadResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', +payload: {| +entryID: string, +text: string, +deleted: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_SUCCESS', +payload: RestoreEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_SUCCESS', +payload: ThreadJoinPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_NEW_SESSION', +payload: SetSessionPayload, |} | {| +type: 'persist/REHYDRATE', +payload: ?BaseAppState<*>, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_STARTED', +loadingInfo?: LoadingInfo, +payload: RawTextMessageInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, +loadingInfo?: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_STARTED', - +loadingInfo: LoadingInfo, + +loadingInfo?: LoadingInfo, +payload: RawMultimediaMessageInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, - +loadingInfo: LoadingInfo, + +loadingInfo?: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_SUCCESS', +payload: UserSearchResult, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_DRAFT', +payload: { +key: string, +draft: string, }, |} | {| +type: 'UPDATE_ACTIVITY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_SUCCESS', +payload: ActivityUpdateSuccessPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_STARTED', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_SUCCESS', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'QUEUE_REPORTS', +payload: QueueReportsPayload, |} | {| +type: 'SET_URL_PREFIX', +payload: string, |} | {| +type: 'SAVE_MESSAGES', +payload: SaveMessagesPayload, |} | {| +type: 'UPDATE_CALENDAR_THREAD_FILTER', +payload: CalendarThreadFilter, |} | {| +type: 'CLEAR_CALENDAR_THREAD_FILTER', +payload?: void, |} | {| +type: 'SET_CALENDAR_DELETED_FILTER', +payload: SetCalendarDeletedFilterPayload, |} | {| +type: 'UPDATE_SUBSCRIPTION_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_SUCCESS', +payload: SubscriptionUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_STARTED', +loadingInfo: LoadingInfo, +payload?: CalendarQueryUpdateStartingPayload, |} | {| +type: 'UPDATE_CALENDAR_QUERY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_SUCCESS', +payload: CalendarQueryUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FULL_STATE_SYNC', +payload: StateSyncFullActionPayload, |} | {| +type: 'INCREMENTAL_STATE_SYNC', +payload: StateSyncIncrementalActionPayload, |} | {| +type: 'PROCESS_SERVER_REQUESTS', +payload: ProcessServerRequestsPayload, |} | {| +type: 'UPDATE_CONNECTION_STATUS', +payload: UpdateConnectionStatusPayload, |} | {| +type: 'QUEUE_ACTIVITY_UPDATES', +payload: QueueActivityUpdatesPayload, |} | {| +type: 'UNSUPERVISED_BACKGROUND', +payload?: void, |} | {| +type: 'UPDATE_LIFECYCLE_STATE', +payload: LifecycleState, |} | {| +type: 'PROCESS_UPDATES', +payload: UpdatesResultWithUserInfos, |} | {| +type: 'PROCESS_MESSAGES', +payload: NewMessagesPayload, |} | {| +type: 'MESSAGE_STORE_PRUNE', +payload: MessageStorePrunePayload, |} | {| +type: 'SET_LATE_RESPONSE', +payload: SetLateResponsePayload, |} | {| +type: 'UPDATE_DISCONNECTED_BAR', +payload: UpdateDisconnectedBarPayload, |} | {| +type: 'REQUEST_ACCESS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', +payload: UpdateMultimediaMessageMediaPayload, |} | {| +type: 'CREATE_LOCAL_MESSAGE', +payload: LocallyComposedMessageInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_SUCCESS', +payload: RelationshipErrors, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_STARTED', +payload: {| +threadID: string, +unread: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_SUCCESS', +payload: SetThreadUnreadStatusPayload, |}; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); export type SuperAction = { type: string, payload?: ActionPayload, loadingInfo?: LoadingInfo, error?: boolean, }; type ThunkedAction = (dispatch: Dispatch) => void; export type PromisedAction = (dispatch: Dispatch) => Promise; export type Dispatch = ((promisedAction: PromisedAction) => Promise) & ((thunkedAction: ThunkedAction) => void) & ((action: SuperAction) => boolean); // This is lifted from redux-persist/lib/constants.js // I don't want to add redux-persist to the web/server bundles... // import { REHYDRATE } from 'redux-persist'; export const rehydrateActionType = 'persist/REHYDRATE'; diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index f56dfaa92..e66fdd0bb 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,451 +1,454 @@ // @flow import { faFileImage } from '@fortawesome/free-regular-svg-icons'; import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import invariant from 'invariant'; import _difference from 'lodash/fp/difference'; import * as React from 'react'; 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, threadIsPending, } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; import { type UserInfos } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { type InputState, type PendingMultimediaUpload, } from '../input/input-state'; import LoadingIndicator from '../loading-indicator.react'; import { allowedMimeTypeString } from '../media/file-utils'; import Multimedia from '../media/multimedia.react'; import { useSelector } from '../redux/redux-utils'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors'; import css from './chat-message-list.css'; type BaseProps = {| +threadInfo: ThreadInfo, +inputState: InputState, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, +joinThreadLoadingStatus: LoadingStatus, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +isThreadActive: boolean, +userInfos: UserInfos, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, |}; class ChatInputBar extends React.PureComponent { textarea: ?HTMLTextAreaElement; multimediaInput: ?HTMLInputElement; componentDidMount() { this.updateHeight(); if (this.props.isThreadActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isThreadActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props) { if (this.props.isThreadActive && !prevProps.isThreadActive) { this.addReplyListener(); } else if (!this.props.isThreadActive && prevProps.isThreadActive) { this.removeReplyListener(); } const { inputState } = this.props; const prevInputState = prevProps.inputState; if (inputState.draft !== prevInputState.draft) { this.updateHeight(); } const curUploadIDs = ChatInputBar.unassignedUploadIDs( inputState.pendingUploads, ); const prevUploadIDs = ChatInputBar.unassignedUploadIDs( prevInputState.pendingUploads, ); if ( this.multimediaInput && _difference(prevUploadIDs)(curUploadIDs).length > 0 ) { // Whenever a pending upload is removed, we reset the file // HTMLInputElement's value field, so that if the same upload occurs again // the onChange call doesn't get filtered this.multimediaInput.value = ''; } else if ( this.textarea && _difference(curUploadIDs)(prevUploadIDs).length > 0 ) { // Whenever a pending upload is added, we focus the textarea this.textarea.focus(); return; } if (this.props.threadInfo.id !== prevProps.threadInfo.id && this.textarea) { this.textarea.focus(); } } static unassignedUploadIDs( pendingUploads: $ReadOnlyArray, ) { return pendingUploads .filter( (pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID, ) .map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID); } updateHeight() { const textarea = this.textarea; if (textarea) { textarea.style.height = 'auto'; const newHeight = Math.min(textarea.scrollHeight, 150); textarea.style.height = `${newHeight}px`; } } 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); } 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 = ( ); } const { pendingUploads, cancelPendingUpload } = this.props.inputState; const multimediaPreviews = pendingUploads.map((pendingUpload) => ( )); const previews = multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : null; let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { const sendIconStyle = { color: `#${this.props.threadInfo.color}` }; let multimediaInput = null; if (!threadIsPending(this.props.threadInfo.id)) { multimediaInput = ( ); } content = (