diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 0515d01ca..5dc9a009c 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,905 +1,905 @@ // @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 } 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, - type RawMediaMessageInfo, - type RawImagesMessageInfo, messageTruncationStatus, messageTypes, defaultNumberPerThread, } from '../types/message-types'; +import type { RawImagesMessageInfo } from '../types/message/images'; +import type { RawMediaMessageInfo } from '../types/message/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 }); // Input must already be ordered! function threadsToMessageIDsFromMessageInfos( orderedMessageInfos: RawMessageInfo[], ): { [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[], truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: number, threadInfos: { [threadID: string]: RawThreadInfo }, ): MessageStore { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = _orderBy('time')('desc')(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; }), _orderBy('time')('desc'), )(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( _orderBy('time')('desc'), _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); for (let threadID of mustResortThreadMessageIDs) { threads[threadID].messageIDs = _orderBy([ (id: string) => messages[id].time, ])('desc')(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: _orderBy([(id: string) => messages[id].time])('desc')( 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, _orderBy([(id: string) => newMessages[id].time])('desc'), )(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) { const thread = newThreads[threadID]; if (!thread) { continue; } const removed = thread.messageIDs.splice(defaultNumberPerThread); for (let id of removed) { messageIDsToPrune.push(id); } thread.lastPruned = now; if (removed.length > 0) { thread.startReached = false; } } 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/shared/message-utils.js b/lib/shared/message-utils.js index a99f3c8f6..67adc4561 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,1080 +1,1084 @@ // @flow import invariant from 'invariant'; import _maxBy from 'lodash/fp/maxBy'; import { shimUploadURI, multimediaMessagePreview } from '../media/media-utils'; import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; import type { PlatformDetails } from '../types/device-types'; import type { Media, Image, Video } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, - type TextMessageInfo, - type MediaMessageInfo, - type ImagesMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, - type RawImagesMessageInfo, - type RawMediaMessageInfo, type MultimediaMessageData, - type MediaMessageData, - type ImagesMessageData, type MessageStore, messageTypes, messageTruncationStatus, } from '../types/message-types'; +import type { + ImagesMessageData, + ImagesMessageInfo, + RawImagesMessageInfo, +} from '../types/message/images'; +import type { + MediaMessageData, + MediaMessageInfo, + RawMediaMessageInfo, +} from '../types/message/media'; +import type { TextMessageInfo } from '../types/message/text'; import { type ThreadInfo, threadTypes } from '../types/thread-types'; import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; import { codeBlockRegex } from './markdown'; import { stringForUser } from './user-utils'; import { hasMinCodeVersion } from './version-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 newThreadRobotext(messageInfo: RobotextMessageInfo, creator: string) { invariant( messageInfo.type === messageTypes.CREATE_THREAD || messageInfo.type === messageTypes.CREATE_SIDEBAR, `Expected CREATE_THREAD or CREATE_SIDEBAR message type, but received ${messageInfo.type}`, ); const threadTypeText = messageInfo.type === messageTypes.CREATE_SIDEBAR ? 'sidebar' : 'thread'; let text = `created ${encodedThreadEntity( messageInfo.threadID, `this ${threadTypeText}`, )}`; const parentThread = messageInfo.initialThreadState.parentThreadInfo; if (parentThread) { text += ' as a child of ' + `<${encodeURI(parentThread.uiName)}|t${parentThread.id}>`; } if (messageInfo.initialThreadState.name) { text += ` with the name "${encodeURI( messageInfo.initialThreadState.name, )}"`; } const users = messageInfo.initialThreadState.otherMembers; if (users.length !== 0) { const initialUsersString = robotextForUsers(users); text += ` and added ${initialUsersString}`; } return `${creator} ${text}`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); if (messageInfo.type === messageTypes.CREATE_THREAD) { return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.ADD_MEMBERS) { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const addedUsersString = robotextForUsers(users); return `${creator} added ${addedUsersString}`; } else if (messageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childName = messageInfo.childThreadInfo.name; const childNoun = messageInfo.childThreadInfo.type === threadTypes.SIDEBAR ? 'sidebar' : 'child thread'; if (childName) { return ( `${creator} created a ${childNoun}` + ` named "<${encodeURI(childName)}|t${messageInfo.childThreadInfo.id}>"` ); } else { return ( `${creator} created a ` + `<${childNoun}|t${messageInfo.childThreadInfo.id}>` ); } } else if (messageInfo.type === messageTypes.CHANGE_SETTINGS) { let value; if (messageInfo.field === 'color') { value = `<#${messageInfo.value}|c${messageInfo.threadID}>`; } else { value = messageInfo.value; } return ( `${creator} updated ` + `${encodedThreadEntity(messageInfo.threadID, 'the thread')}'s ` + `${messageInfo.field} to "${value}"` ); } else if (messageInfo.type === messageTypes.REMOVE_MEMBERS) { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'removed who??'); const removedUsersString = robotextForUsers(users); return `${creator} removed ${removedUsersString}`; } else if (messageInfo.type === messageTypes.CHANGE_ROLE) { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const usersString = robotextForUsers(users); const verb = threadInfo.roles[messageInfo.newRole].isDefault ? 'removed' : 'added'; const noun = users.length === 1 ? 'an admin' : 'admins'; return `${creator} ${verb} ${usersString} as ${noun}`; } else if (messageInfo.type === messageTypes.LEAVE_THREAD) { return ( `${creator} left ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.JOIN_THREAD) { return ( `${creator} joined ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.CREATE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} created an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.EDIT_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} updated the text of an event scheduled for ` + `${date}: "${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.DELETE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} deleted an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.RESTORE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} restored an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = robotextForUser(messageInfo.target); if (messageInfo.operation === 'request_sent') { return `${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = messageInfo.target.isViewer ? 'your' : `${target}'s`; return `${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); } else if (messageInfo.type === messageTypes.CREATE_SIDEBAR) { return newThreadRobotext(messageInfo, creator); } else if (messageInfo.type === messageTypes.UNSUPPORTED) { return `${creator} ${messageInfo.robotext}`; } invariant(false, `we're not aware of messageType ${messageInfo.type}`); } 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; } if (rawMessageInfo.type === messageTypes.TEXT) { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; let parentThreadInfo = null; if (initialParentThreadID) { parentThreadInfo = threadInfos[initialParentThreadID]; } return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } else if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { const addedMembers = userIDsToRelativeUserInfos( rawMessageInfo.addedUserIDs, viewerID, userInfos, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, addedMembers, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, childThreadInfo, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { const removedMembers = userIDsToRelativeUserInfos( rawMessageInfo.removedUserIDs, viewerID, userInfos, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, removedMembers, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { const members = userIDsToRelativeUserInfos( rawMessageInfo.userIDs, viewerID, userInfos, ); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, }; } else if (rawMessageInfo.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.UNSUPPORTED) { return { type: messageTypes.UNSUPPORTED, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, robotext: rawMessageInfo.robotext, unsupportedMessageInfo: rawMessageInfo.unsupportedMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = userInfos[rawMessageInfo.targetID]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, target: { id: target.id, username: target.username, isViewer: target.id === viewerID, }, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { const initialMessage = createMessageInfo( rawMessageInfo.initialMessage, viewerID, userInfos, threadInfos, ); invariant( initialMessage && initialMessage.type !== messageTypes.SIDEBAR_SOURCE, 'Sidebars can not be created from SIDEBAR SOURCE', ); return { type: messageTypes.SIDEBAR_SOURCE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialMessage, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) { const parentThreadInfo = threadInfos[rawMessageInfo.initialThreadState.parentThreadID]; return { type: messageTypes.CREATE_SIDEBAR, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } invariant(false, `we're not aware of messageType ${rawMessageInfo.type}`); } function sortMessageInfoList( messageInfos: T[], ): T[] { return messageInfos.sort((a: T, b: T) => b.time - a.time); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { if (messageData.type === messageTypes.TEXT) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.ADD_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SUB_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_SETTINGS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.REMOVE_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_ROLE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.IMAGES) { return ({ ...messageData, id }: RawImagesMessageInfo); } else if (messageData.type === messageTypes.MULTIMEDIA) { return ({ ...messageData, id }: RawMediaMessageInfo); } else if (messageData.type === messageTypes.UPDATE_RELATIONSHIP) { return { ...messageData, id }; } else if (messageData.type === messageTypes.SIDEBAR_SOURCE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SIDEBAR) { return { ...messageData, id }; } else { invariant(false, `we're not aware of messageType ${messageData.type}`); } } function mostRecentMessageTimestamp( messageInfos: RawMessageInfo[], previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return ( type !== messageTypes.JOIN_THREAD && type !== messageTypes.LEAVE_THREAD && type !== messageTypes.ADD_MEMBERS && type !== messageTypes.REMOVE_MEMBERS && type !== messageTypes.SIDEBAR_SOURCE ); } 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) => { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); if (hasMinCodeVersion(platformDetails, 30)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback if (hasMinCodeVersion(platformDetails, 62)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { if (hasMinCodeVersion(platformDetails, 71)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'performed a relationship action', unsupportedMessageInfo: rawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE) { // TODO determine min code version if ( hasMinCodeVersion(platformDetails, 75) && rawMessageInfo.initialMessage ) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'first message in sidebar', unsupportedMessageInfo: rawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SIDEBAR) { // TODO determine min code version if (hasMinCodeVersion(platformDetails, 75)) { return rawMessageInfo; } const { id } = rawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: rawMessageInfo.threadID, creatorID: rawMessageInfo.creatorID, time: rawMessageInfo.time, robotext: 'created a sidebar', unsupportedMessageInfo: rawMessageInfo, }; } return rawMessageInfo; }); } function shimMediaMessageInfo( rawMessageInfo: RawMultimediaMessageInfo, platformDetails: ?PlatformDetails, ): RawMultimediaMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { let uriChanged = false; const newMedia: Image[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawImagesMessageInfo); } else { let uriChanged = false; const newMedia: Media[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else if (singleMedia.type === 'photo') { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Video)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawMediaMessageInfo); } } 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); // This conditional is for Flow let rawMessageInfo; if (messageData.type === messageTypes.IMAGES) { rawMessageInfo = ({ ...messageData, type: messageTypes.IMAGES, }: RawImagesMessageInfo); } else { rawMessageInfo = ({ ...messageData, type: messageTypes.MULTIMEDIA, }: RawMediaMessageInfo); } if (input.id) { rawMessageInfo.id = input.id; } return rawMessageInfo; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (let rawMessageInfo of input) { if ( rawMessageInfo.localID === null || rawMessageInfo.localID === undefined ) { output.push(rawMessageInfo); continue; } invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); if (rawMessageInfo.type === messageTypes.TEXT) { const { localID, ...rest } = rawMessageInfo; output.push({ ...rest }); } else if (rawMessageInfo.type === messageTypes.IMAGES) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawImagesMessageInfo)); } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawMediaMessageInfo)); } else { invariant( false, `message ${rawMessageInfo.id} of type ${rawMessageInfo.type} ` + `unexpectedly has localID`, ); } } 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( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadInfo.id]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, }; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index aeb7bb4d2..4241e35de 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,717 +1,447 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { FetchResultInfoInterface } from '../utils/fetch-json'; -import { type Media, type Image } from './media-types'; -import { type ThreadInfo, type ThreadType } from './thread-types'; +import type { + AddMembersMessageData, + AddMembersMessageInfo, + RawAddMembersMessageInfo, +} from './message/add-members'; +import type { + ChangeRoleMessageData, + ChangeRoleMessageInfo, + RawChangeRoleMessageInfo, +} from './message/change-role'; +import type { + ChangeSettingsMessageData, + ChangeSettingsMessageInfo, + RawChangeSettingsMessageInfo, +} from './message/change-settings'; +import type { + CreateEntryMessageData, + CreateEntryMessageInfo, + RawCreateEntryMessageInfo, +} from './message/create-entry'; +import type { + CreateSidebarMessageData, + CreateSidebarMessageInfo, + RawCreateSidebarMessageInfo, +} from './message/create-sidebar'; +import type { + CreateSubthreadMessageData, + CreateSubthreadMessageInfo, + RawCreateSubthreadMessageInfo, +} from './message/create-subthread'; +import type { + CreateThreadMessageData, + CreateThreadMessageInfo, + RawCreateThreadMessageInfo, +} from './message/create-thread'; +import type { + DeleteEntryMessageData, + DeleteEntryMessageInfo, + RawDeleteEntryMessageInfo, +} from './message/delete-entry'; +import type { + EditEntryMessageData, + EditEntryMessageInfo, + RawEditEntryMessageInfo, +} from './message/edit-entry'; +import type { + ImagesMessageData, + ImagesMessageInfo, + RawImagesMessageInfo, +} from './message/images'; +import type { + JoinThreadMessageData, + JoinThreadMessageInfo, + RawJoinThreadMessageInfo, +} from './message/join-thread'; +import type { + LeaveThreadMessageData, + LeaveThreadMessageInfo, + RawLeaveThreadMessageInfo, +} from './message/leave-thread'; +import type { + MediaMessageData, + MediaMessageInfo, + RawMediaMessageInfo, +} from './message/media'; +import type { + RawRemoveMembersMessageInfo, + RemoveMembersMessageData, + RemoveMembersMessageInfo, +} from './message/remove-members'; +import type { + RawRestoreEntryMessageInfo, + RestoreEntryMessageData, + RestoreEntryMessageInfo, +} from './message/restore-entry'; +import type { + RawTextMessageInfo, + TextMessageData, + TextMessageInfo, +} from './message/text'; +import type { + RawUnsupportedMessageInfo, + UnsupportedMessageInfo, +} from './message/unsupported'; +import type { + RawUpdateRelationshipMessageInfo, + UpdateRelationshipMessageData, + UpdateRelationshipMessageInfo, +} from './message/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 messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code -export type TextMessageData = {| - type: 0, - localID?: string, // for optimistic creations. included by new clients - threadID: string, - creatorID: string, - time: number, - text: string, -|}; -type CreateThreadMessageData = {| - type: 1, - threadID: string, - creatorID: string, - time: number, - initialThreadState: {| - type: ThreadType, - name: ?string, - parentThreadID: ?string, - color: string, - memberIDs: string[], - |}, -|}; -type AddMembersMessageData = {| - type: 2, - threadID: string, - creatorID: string, - time: number, - addedUserIDs: string[], -|}; -type CreateSubthreadMessageData = {| - type: 3, - threadID: string, - creatorID: string, - time: number, - childThreadID: string, -|}; -type ChangeSettingsMessageData = {| - type: 4, - threadID: string, - creatorID: string, - time: number, - field: string, - value: string | number, -|}; -type RemoveMembersMessageData = {| - type: 5, - threadID: string, - creatorID: string, - time: number, - removedUserIDs: string[], -|}; -type ChangeRoleMessageData = {| - type: 6, - threadID: string, - creatorID: string, - time: number, - userIDs: string[], - newRole: string, -|}; -type LeaveThreadMessageData = {| - type: 7, - threadID: string, - creatorID: string, - time: number, -|}; -type JoinThreadMessageData = {| - type: 8, - threadID: string, - creatorID: string, - time: number, -|}; -type CreateEntryMessageData = {| - type: 9, - threadID: string, - creatorID: string, - time: number, - entryID: string, - date: string, - text: string, -|}; -type EditEntryMessageData = {| - type: 10, - threadID: string, - creatorID: string, - time: number, - entryID: string, - date: string, - text: string, -|}; -type DeleteEntryMessageData = {| - type: 11, - threadID: string, - creatorID: string, - time: number, - entryID: string, - date: string, - text: string, -|}; -type RestoreEntryMessageData = {| - type: 12, - threadID: string, - creatorID: string, - time: number, - entryID: string, - date: string, - text: string, -|}; -export type ImagesMessageData = {| - type: 14, - localID?: string, // for optimistic creations. included by new clients - threadID: string, - creatorID: string, - time: number, - media: $ReadOnlyArray, -|}; -export type MediaMessageData = {| - type: 15, - localID?: string, // for optimistic creations. included by new clients - threadID: string, - creatorID: string, - time: number, - media: $ReadOnlyArray, -|}; -export type UpdateRelationshipMessageData = {| - +type: 16, - +threadID: string, - +creatorID: string, - +targetID: string, - +time: number, - +operation: 'request_sent' | 'request_accepted', -|}; export type SidebarSourceMessageData = {| +type: 17, +threadID: string, +creatorID: string, +time: number, +initialMessage: RawComposableMessageInfo | RawRobotextMessageInfo, |}; -export type CreateSidebarMessageData = {| - +type: 18, - +threadID: string, - +creatorID: string, - +time: number, - +initialThreadState: {| - +name: ?string, - +parentThreadID: string, - +color: string, - +memberIDs: string[], - |}, -|}; 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 RawTextMessageInfo = {| - ...TextMessageData, - id?: string, // null if local copy without ID yet -|}; -export type RawImagesMessageInfo = {| - ...ImagesMessageData, - id?: string, // null if local copy without ID yet -|}; -export type RawMediaMessageInfo = {| - ...MediaMessageData, - id?: string, // null if local copy without ID yet -|}; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; type RawRobotextMessageInfo = - | {| - ...CreateThreadMessageData, - id: string, - |} - | {| - ...AddMembersMessageData, - id: string, - |} - | {| - ...CreateSubthreadMessageData, - id: string, - |} - | {| - ...ChangeSettingsMessageData, - id: string, - |} - | {| - ...RemoveMembersMessageData, - id: string, - |} - | {| - ...ChangeRoleMessageData, - id: string, - |} - | {| - ...LeaveThreadMessageData, - id: string, - |} - | {| - ...JoinThreadMessageData, - id: string, - |} - | {| - ...CreateEntryMessageData, - id: string, - |} - | {| - ...EditEntryMessageData, - id: string, - |} - | {| - ...DeleteEntryMessageData, - id: string, - |} - | {| - ...RestoreEntryMessageData, - id: string, - |} - | {| - ...UpdateRelationshipMessageData, - id: string, - |} - | {| - ...CreateSidebarMessageData, - id: string, - |} - | {| - type: 13, - id: string, - threadID: string, - creatorID: string, - time: number, - robotext: string, - unsupportedMessageInfo: Object, - |}; + | 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, ... }; -export type TextMessageInfo = {| - type: 0, - id?: string, // null if local copy without ID yet - localID?: string, // for optimistic creations - threadID: string, - creator: RelativeUserInfo, - time: number, // millisecond timestamp - text: string, -|}; -export type ImagesMessageInfo = {| - type: 14, - id?: string, // null if local copy without ID yet - localID?: string, // for optimistic creations - threadID: string, - creator: RelativeUserInfo, - time: number, // millisecond timestamp - media: $ReadOnlyArray, -|}; -export type MediaMessageInfo = {| - type: 15, - id?: string, // null if local copy without ID yet - localID?: string, // for optimistic creations - threadID: string, - creator: RelativeUserInfo, - time: number, // millisecond timestamp - media: $ReadOnlyArray, -|}; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = - | {| - type: 1, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - initialThreadState: {| - type: ThreadType, - name: ?string, - parentThreadInfo: ?ThreadInfo, - color: string, - otherMembers: RelativeUserInfo[], - |}, - |} - | {| - type: 2, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - addedMembers: RelativeUserInfo[], - |} - | {| - type: 3, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - childThreadInfo: ThreadInfo, - |} - | {| - type: 4, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - field: string, - value: string | number, - |} - | {| - type: 5, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - removedMembers: RelativeUserInfo[], - |} - | {| - type: 6, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - members: RelativeUserInfo[], - newRole: string, - |} - | {| - type: 7, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - |} - | {| - type: 8, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - |} - | {| - type: 9, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - entryID: string, - date: string, - text: string, - |} - | {| - type: 10, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - entryID: string, - date: string, - text: string, - |} - | {| - type: 11, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - entryID: string, - date: string, - text: string, - |} - | {| - type: 12, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - entryID: string, - date: string, - text: string, - |} - | {| - type: 13, - id: string, - threadID: string, - creator: RelativeUserInfo, - time: number, - robotext: string, - unsupportedMessageInfo: Object, - |} - | {| - +type: 16, - +id: string, - +threadID: string, - +creator: RelativeUserInfo, - +target: RelativeUserInfo, - +time: number, - +operation: 'request_sent' | 'request_accepted', - |} - | {| - +type: 18, - +id: string, - +threadID: string, - +creator: RelativeUserInfo, - +time: number, - +initialThreadState: {| - +name: ?string, - +parentThreadInfo: ThreadInfo, - +color: string, - +otherMembers: RelativeUserInfo[], - |}, - |}; + | 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, +initialMessage: 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, |}; export const localMessageInfoPropType = PropTypes.shape({ sendFailed: PropTypes.bool, }); export type MessageStore = {| messages: { [id: string]: RawMessageInfo }, threads: { [threadID: string]: ThreadMessageInfo }, local: { [id: string]: LocalMessageInfo }, currentAsOf: number, |}; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export type ThreadCursors = { [threadID: string]: ?string }; export type ThreadSelectionCriteria = {| threadCursors?: ?ThreadCursors, joinedThreads?: ?boolean, |}; export type FetchMessageInfosRequest = {| cursors: ThreadCursors, numberPerThread?: ?number, |}; export type FetchMessageInfosResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, |}; export type FetchMessageInfosResult = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, |}; export type FetchMessageInfosPayload = {| threadID: string, rawMessageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatus, |}; export type MessagesResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, currentAsOf: number, |}; export const defaultNumberPerThread = 20; export type SendMessageResponse = {| +newMessageInfo: RawMessageInfo, |}; export type SendMessageResult = {| +id: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendMessagePayload = {| +localID: string, +serverID: string, +threadID: string, +time: number, +interface: FetchResultInfoInterface, |}; export type SendTextMessageRequest = {| threadID: string, localID?: string, text: string, |}; export type SendMultimediaMessageRequest = {| 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, |}; export type SaveMessagesPayload = {| rawMessageInfos: $ReadOnlyArray, updatesCurrentAsOf: number, |}; export type NewMessagesPayload = {| messagesResult: MessagesResponse, |}; export type MessageStorePrunePayload = {| threadIDs: $ReadOnlyArray, |}; diff --git a/lib/types/message/add-members.js b/lib/types/message/add-members.js new file mode 100644 index 000000000..86e3941f0 --- /dev/null +++ b/lib/types/message/add-members.js @@ -0,0 +1,25 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type AddMembersMessageData = {| + type: 2, + threadID: string, + creatorID: string, + time: number, + addedUserIDs: string[], +|}; + +export type RawAddMembersMessageInfo = {| + ...AddMembersMessageData, + id: string, +|}; + +export type AddMembersMessageInfo = {| + type: 2, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + addedMembers: RelativeUserInfo[], +|}; diff --git a/lib/types/message/change-role.js b/lib/types/message/change-role.js new file mode 100644 index 000000000..497e48438 --- /dev/null +++ b/lib/types/message/change-role.js @@ -0,0 +1,27 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type ChangeRoleMessageData = {| + type: 6, + threadID: string, + creatorID: string, + time: number, + userIDs: string[], + newRole: string, +|}; + +export type RawChangeRoleMessageInfo = {| + ...ChangeRoleMessageData, + id: string, +|}; + +export type ChangeRoleMessageInfo = {| + type: 6, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + members: RelativeUserInfo[], + newRole: string, +|}; diff --git a/lib/types/message/change-settings.js b/lib/types/message/change-settings.js new file mode 100644 index 000000000..887669aa4 --- /dev/null +++ b/lib/types/message/change-settings.js @@ -0,0 +1,27 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type ChangeSettingsMessageData = {| + type: 4, + threadID: string, + creatorID: string, + time: number, + field: string, + value: string | number, +|}; + +export type RawChangeSettingsMessageInfo = {| + ...ChangeSettingsMessageData, + id: string, +|}; + +export type ChangeSettingsMessageInfo = {| + type: 4, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + field: string, + value: string | number, +|}; diff --git a/lib/types/message/create-entry.js b/lib/types/message/create-entry.js new file mode 100644 index 000000000..40d6d3a3f --- /dev/null +++ b/lib/types/message/create-entry.js @@ -0,0 +1,29 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type CreateEntryMessageData = {| + type: 9, + threadID: string, + creatorID: string, + time: number, + entryID: string, + date: string, + text: string, +|}; + +export type RawCreateEntryMessageInfo = {| + ...CreateEntryMessageData, + id: string, +|}; + +export type CreateEntryMessageInfo = {| + type: 9, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + entryID: string, + date: string, + text: string, +|}; diff --git a/lib/types/message/create-sidebar.js b/lib/types/message/create-sidebar.js new file mode 100644 index 000000000..5ea1eb11c --- /dev/null +++ b/lib/types/message/create-sidebar.js @@ -0,0 +1,36 @@ +// @flow + +import type { ThreadInfo } from '../thread-types'; +import type { RelativeUserInfo } from '../user-types'; + +export type CreateSidebarMessageData = {| + +type: 18, + +threadID: string, + +creatorID: string, + +time: number, + +initialThreadState: {| + +name: ?string, + +parentThreadID: string, + +color: string, + +memberIDs: string[], + |}, +|}; + +export type RawCreateSidebarMessageInfo = {| + ...CreateSidebarMessageData, + id: string, +|}; + +export type CreateSidebarMessageInfo = {| + +type: 18, + +id: string, + +threadID: string, + +creator: RelativeUserInfo, + +time: number, + +initialThreadState: {| + +name: ?string, + +parentThreadInfo: ThreadInfo, + +color: string, + +otherMembers: RelativeUserInfo[], + |}, +|}; diff --git a/lib/types/message/create-subthread.js b/lib/types/message/create-subthread.js new file mode 100644 index 000000000..c583a90a1 --- /dev/null +++ b/lib/types/message/create-subthread.js @@ -0,0 +1,26 @@ +// @flow + +import type { ThreadInfo } from '../thread-types'; +import type { RelativeUserInfo } from '../user-types'; + +export type CreateSubthreadMessageData = {| + type: 3, + threadID: string, + creatorID: string, + time: number, + childThreadID: string, +|}; + +export type RawCreateSubthreadMessageInfo = {| + ...CreateSubthreadMessageData, + id: string, +|}; + +export type CreateSubthreadMessageInfo = {| + type: 3, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + childThreadInfo: ThreadInfo, +|}; diff --git a/lib/types/message/create-thread.js b/lib/types/message/create-thread.js new file mode 100644 index 000000000..1110bc581 --- /dev/null +++ b/lib/types/message/create-thread.js @@ -0,0 +1,38 @@ +// @flow + +import type { ThreadInfo, ThreadType } from '../thread-types'; +import type { RelativeUserInfo } from '../user-types'; + +export type CreateThreadMessageData = {| + type: 1, + threadID: string, + creatorID: string, + time: number, + initialThreadState: {| + type: ThreadType, + name: ?string, + parentThreadID: ?string, + color: string, + memberIDs: string[], + |}, +|}; + +export type RawCreateThreadMessageInfo = {| + ...CreateThreadMessageData, + id: string, +|}; + +export type CreateThreadMessageInfo = {| + type: 1, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + initialThreadState: {| + type: ThreadType, + name: ?string, + parentThreadInfo: ?ThreadInfo, + color: string, + otherMembers: RelativeUserInfo[], + |}, +|}; diff --git a/lib/types/message/delete-entry.js b/lib/types/message/delete-entry.js new file mode 100644 index 000000000..9473d6586 --- /dev/null +++ b/lib/types/message/delete-entry.js @@ -0,0 +1,29 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type DeleteEntryMessageData = {| + type: 11, + threadID: string, + creatorID: string, + time: number, + entryID: string, + date: string, + text: string, +|}; + +export type RawDeleteEntryMessageInfo = {| + ...DeleteEntryMessageData, + id: string, +|}; + +export type DeleteEntryMessageInfo = {| + type: 11, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + entryID: string, + date: string, + text: string, +|}; diff --git a/lib/types/message/edit-entry.js b/lib/types/message/edit-entry.js new file mode 100644 index 000000000..673c01fd4 --- /dev/null +++ b/lib/types/message/edit-entry.js @@ -0,0 +1,29 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type EditEntryMessageData = {| + type: 10, + threadID: string, + creatorID: string, + time: number, + entryID: string, + date: string, + text: string, +|}; + +export type RawEditEntryMessageInfo = {| + ...EditEntryMessageData, + id: string, +|}; + +export type EditEntryMessageInfo = {| + type: 10, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + entryID: string, + date: string, + text: string, +|}; diff --git a/lib/types/message/images.js b/lib/types/message/images.js new file mode 100644 index 000000000..d0e63593d --- /dev/null +++ b/lib/types/message/images.js @@ -0,0 +1,28 @@ +// @flow + +import type { Image } from '../media-types'; +import type { RelativeUserInfo } from '../user-types'; + +export type ImagesMessageData = {| + type: 14, + localID?: string, // for optimistic creations. included by new clients + threadID: string, + creatorID: string, + time: number, + media: $ReadOnlyArray, +|}; + +export type RawImagesMessageInfo = {| + ...ImagesMessageData, + id?: string, // null if local copy without ID yet +|}; + +export type ImagesMessageInfo = {| + type: 14, + id?: string, // null if local copy without ID yet + localID?: string, // for optimistic creations + threadID: string, + creator: RelativeUserInfo, + time: number, // millisecond timestamp + media: $ReadOnlyArray, +|}; diff --git a/lib/types/message/join-thread.js b/lib/types/message/join-thread.js new file mode 100644 index 000000000..90e32c20f --- /dev/null +++ b/lib/types/message/join-thread.js @@ -0,0 +1,23 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type JoinThreadMessageData = {| + type: 8, + threadID: string, + creatorID: string, + time: number, +|}; + +export type RawJoinThreadMessageInfo = {| + ...JoinThreadMessageData, + id: string, +|}; + +export type JoinThreadMessageInfo = {| + type: 8, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, +|}; diff --git a/lib/types/message/leave-thread.js b/lib/types/message/leave-thread.js new file mode 100644 index 000000000..c1c1fdb35 --- /dev/null +++ b/lib/types/message/leave-thread.js @@ -0,0 +1,23 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type LeaveThreadMessageData = {| + type: 7, + threadID: string, + creatorID: string, + time: number, +|}; + +export type RawLeaveThreadMessageInfo = {| + ...LeaveThreadMessageData, + id: string, +|}; + +export type LeaveThreadMessageInfo = {| + type: 7, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, +|}; diff --git a/lib/types/message/media.js b/lib/types/message/media.js new file mode 100644 index 000000000..f0d39c539 --- /dev/null +++ b/lib/types/message/media.js @@ -0,0 +1,28 @@ +// @flow + +import type { Media } from '../media-types'; +import type { RelativeUserInfo } from '../user-types'; + +export type MediaMessageData = {| + type: 15, + localID?: string, // for optimistic creations. included by new clients + threadID: string, + creatorID: string, + time: number, + media: $ReadOnlyArray, +|}; + +export type RawMediaMessageInfo = {| + ...MediaMessageData, + id?: string, // null if local copy without ID yet +|}; + +export type MediaMessageInfo = {| + type: 15, + id?: string, // null if local copy without ID yet + localID?: string, // for optimistic creations + threadID: string, + creator: RelativeUserInfo, + time: number, // millisecond timestamp + media: $ReadOnlyArray, +|}; diff --git a/lib/types/message/remove-members.js b/lib/types/message/remove-members.js new file mode 100644 index 000000000..bb0720d10 --- /dev/null +++ b/lib/types/message/remove-members.js @@ -0,0 +1,25 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type RemoveMembersMessageData = {| + type: 5, + threadID: string, + creatorID: string, + time: number, + removedUserIDs: string[], +|}; + +export type RawRemoveMembersMessageInfo = {| + ...RemoveMembersMessageData, + id: string, +|}; + +export type RemoveMembersMessageInfo = {| + type: 5, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + removedMembers: RelativeUserInfo[], +|}; diff --git a/lib/types/message/restore-entry.js b/lib/types/message/restore-entry.js new file mode 100644 index 000000000..9327ee7ef --- /dev/null +++ b/lib/types/message/restore-entry.js @@ -0,0 +1,29 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type RestoreEntryMessageData = {| + type: 12, + threadID: string, + creatorID: string, + time: number, + entryID: string, + date: string, + text: string, +|}; + +export type RawRestoreEntryMessageInfo = {| + ...RestoreEntryMessageData, + id: string, +|}; + +export type RestoreEntryMessageInfo = {| + type: 12, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + entryID: string, + date: string, + text: string, +|}; diff --git a/lib/types/message/text.js b/lib/types/message/text.js new file mode 100644 index 000000000..11b4bdbfb --- /dev/null +++ b/lib/types/message/text.js @@ -0,0 +1,27 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type TextMessageData = {| + type: 0, + localID?: string, // for optimistic creations. included by new clients + threadID: string, + creatorID: string, + time: number, + text: string, +|}; + +export type RawTextMessageInfo = {| + ...TextMessageData, + id?: string, // null if local copy without ID yet +|}; + +export type TextMessageInfo = {| + type: 0, + id?: string, // null if local copy without ID yet + localID?: string, // for optimistic creations + threadID: string, + creator: RelativeUserInfo, + time: number, // millisecond timestamp + text: string, +|}; diff --git a/lib/types/message/unsupported.js b/lib/types/message/unsupported.js new file mode 100644 index 000000000..c195ae392 --- /dev/null +++ b/lib/types/message/unsupported.js @@ -0,0 +1,23 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type RawUnsupportedMessageInfo = {| + type: 13, + id: string, + threadID: string, + creatorID: string, + time: number, + robotext: string, + unsupportedMessageInfo: Object, +|}; + +export type UnsupportedMessageInfo = {| + type: 13, + id: string, + threadID: string, + creator: RelativeUserInfo, + time: number, + robotext: string, + unsupportedMessageInfo: Object, +|}; diff --git a/lib/types/message/update-relationship.js b/lib/types/message/update-relationship.js new file mode 100644 index 000000000..2f92cccd1 --- /dev/null +++ b/lib/types/message/update-relationship.js @@ -0,0 +1,27 @@ +// @flow + +import type { RelativeUserInfo } from '../user-types'; + +export type UpdateRelationshipMessageData = {| + +type: 16, + +threadID: string, + +creatorID: string, + +targetID: string, + +time: number, + +operation: 'request_sent' | 'request_accepted', +|}; + +export type RawUpdateRelationshipMessageInfo = {| + ...UpdateRelationshipMessageData, + id: string, +|}; + +export type UpdateRelationshipMessageInfo = {| + +type: 16, + +id: string, + +threadID: string, + +creator: RelativeUserInfo, + +target: RelativeUserInfo, + +time: number, + +operation: 'request_sent' | 'request_accepted', +|}; diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js index 35a866120..d2ea1cffa 100644 --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1,831 +1,831 @@ // @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 { LoadingStatus, LoadingInfo } from './loading-types'; import type { UpdateMultimediaMessageMediaPayload } from './media-types'; import type { MessageStore, - RawTextMessageInfo, RawMultimediaMessageInfo, FetchMessageInfosPayload, SendMessagePayload, SaveMessagesPayload, NewMessagesPayload, MessageStorePrunePayload, LocallyComposedMessageInfo, } from './message-types'; +import type { RawTextMessageInfo } from './message/text'; import type { BaseNavInfo } from './nav-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, foreground: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, dataLoaded: boolean, }; // Web JS runtime doesn't have access to the cookie for security reasons. // Native JS doesn't have a sessionID because the cookieID is used instead. // Web JS doesn't have a device token because it's not a device... export type NativeAppState = BaseAppState<*> & { sessionID?: void, deviceToken: ?string, cookie: ?string, }; export type WebAppState = BaseAppState<*> & { sessionID: ?string, deviceToken?: void, cookie?: void, }; export type AppState = NativeAppState | WebAppState; export type BaseAction = | {| type: '@@redux/INIT', payload?: void, |} | {| type: 'FETCH_ENTRIES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_ENTRIES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_ENTRIES_SUCCESS', payload: FetchEntryInfosResult, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_SUCCESS', payload: LogOutResult, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_SUCCESS', payload: LogOutResult, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_LOCAL_ENTRY', payload: RawEntryInfo, |} | {| type: 'CREATE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_ENTRY_SUCCESS', payload: CreateEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_SUCCESS', payload: SaveEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'CONCURRENT_MODIFICATION_RESET', payload: {| id: string, dbText: string, |}, |} | {| type: 'DELETE_ENTRY_STARTED', loadingInfo: LoadingInfo, payload: {| localID: ?string, serverID: ?string, |}, |} | {| type: 'DELETE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ENTRY_SUCCESS', payload: ?DeleteEntryResponse, loadingInfo: LoadingInfo, |} | {| type: 'LOG_IN_STARTED', loadingInfo: LoadingInfo, payload: LogInStartingPayload, |} | {| type: 'LOG_IN_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LOG_IN_SUCCESS', payload: LogInResult, loadingInfo: LoadingInfo, |} | {| type: 'REGISTER_STARTED', loadingInfo: LoadingInfo, payload: LogInStartingPayload, |} | {| type: 'REGISTER_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REGISTER_SUCCESS', payload: RegisterResult, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_STARTED', payload: {| calendarQuery: CalendarQuery |}, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_SUCCESS', payload: LogInResult, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_SUCCESS', payload: {| email: string, |}, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_SUCCESS', payload: LeaveThreadPayload, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_SUCCESS', payload: NewThreadResult, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', payload: {| entryID: string, text: string, deleted: boolean, |}, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_SUCCESS', payload: RestoreEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_SUCCESS', payload: ThreadJoinPayload, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_SUCCESS', payload: LeaveThreadPayload, loadingInfo: LoadingInfo, |} | {| type: 'SET_NEW_SESSION', payload: SetSessionPayload, |} | {| type: 'persist/REHYDRATE', payload: ?BaseAppState<*>, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', payload: FetchMessageInfosPayload, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', payload: FetchMessageInfosPayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_TEXT_MESSAGE_STARTED', loadingInfo: LoadingInfo, payload: RawTextMessageInfo, |} | {| type: 'SEND_TEXT_MESSAGE_FAILED', error: true, payload: Error & { localID: string, threadID: string, }, loadingInfo: LoadingInfo, |} | {| type: 'SEND_TEXT_MESSAGE_SUCCESS', payload: SendMessagePayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_STARTED', loadingInfo: LoadingInfo, payload: RawMultimediaMessageInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_FAILED', error: true, payload: Error & { localID: string, threadID: string, }, loadingInfo: LoadingInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', payload: SendMessagePayload, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_SUCCESS', payload: UserSearchResult, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_DRAFT', payload: { key: string, draft: string, }, |} | {| type: 'UPDATE_ACTIVITY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_ACTIVITY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_ACTIVITY_SUCCESS', payload: ActivityUpdateSuccessPayload, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_STARTED', payload: string, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_SUCCESS', payload: string, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_SUCCESS', payload?: ClearDeliveredReportsPayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_SUCCESS', payload?: ClearDeliveredReportsPayload, loadingInfo: LoadingInfo, |} | {| type: 'QUEUE_REPORTS', payload: QueueReportsPayload, |} | {| type: 'SET_URL_PREFIX', payload: string, |} | {| type: 'SAVE_MESSAGES', payload: SaveMessagesPayload, |} | {| type: 'UPDATE_CALENDAR_THREAD_FILTER', payload: CalendarThreadFilter, |} | {| type: 'CLEAR_CALENDAR_THREAD_FILTER', payload?: void, |} | {| type: 'SET_CALENDAR_DELETED_FILTER', payload: SetCalendarDeletedFilterPayload, |} | {| type: 'UPDATE_SUBSCRIPTION_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_SUBSCRIPTION_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_SUBSCRIPTION_SUCCESS', payload: SubscriptionUpdateResult, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_CALENDAR_QUERY_STARTED', loadingInfo: LoadingInfo, payload?: CalendarQueryUpdateStartingPayload, |} | {| type: 'UPDATE_CALENDAR_QUERY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_CALENDAR_QUERY_SUCCESS', payload: CalendarQueryUpdateResult, loadingInfo: LoadingInfo, |} | {| type: 'FULL_STATE_SYNC', payload: StateSyncFullActionPayload, |} | {| type: 'INCREMENTAL_STATE_SYNC', payload: StateSyncIncrementalActionPayload, |} | {| type: 'PROCESS_SERVER_REQUESTS', payload: ProcessServerRequestsPayload, |} | {| type: 'UPDATE_CONNECTION_STATUS', payload: UpdateConnectionStatusPayload, |} | {| type: 'QUEUE_ACTIVITY_UPDATES', payload: QueueActivityUpdatesPayload, |} | {| type: 'FOREGROUND', payload?: void, |} | {| type: 'BACKGROUND', payload?: void, |} | {| type: 'UNSUPERVISED_BACKGROUND', payload?: void, |} | {| type: 'PROCESS_UPDATES', payload: UpdatesResultWithUserInfos, |} | {| type: 'PROCESS_MESSAGES', payload: NewMessagesPayload, |} | {| type: 'MESSAGE_STORE_PRUNE', payload: MessageStorePrunePayload, |} | {| type: 'SET_LATE_RESPONSE', payload: SetLateResponsePayload, |} | {| type: 'UPDATE_DISCONNECTED_BAR', payload: UpdateDisconnectedBarPayload, |} | {| type: 'REQUEST_ACCESS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'REQUEST_ACCESS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REQUEST_ACCESS_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: UpdateMultimediaMessageMediaPayload, |} | {| type: 'CREATE_LOCAL_MESSAGE', payload: LocallyComposedMessageInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_SUCCESS', payload?: void, 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/native/chat/text-message.react.js b/native/chat/text-message.react.js index d68e739e6..9882123a0 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,226 +1,224 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; import { messageKey } from 'lib/shared/message-utils'; import { threadHasPermission } from 'lib/shared/thread-utils'; -import type { - TextMessageInfo, - LocalMessageInfo, -} from 'lib/types/message-types'; +import type { LocalMessageInfo } from 'lib/types/message-types'; +import type { TextMessageInfo } from 'lib/types/message/text'; import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { MarkdownLinkContext } from '../markdown/markdown-link-context'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { TextMessageTooltipModalRouteName } from '../navigation/route-names'; import type { VerticalBounds } from '../types/layout-types'; import type { ChatNavigationProp } from './chat.react'; import { ComposedMessage, clusterEndHeight } from './composed-message.react'; import { failedSendHeight } from './failed-send.react'; import { InnerTextMessage } from './inner-text-message.react'; import { authorNameHeight } from './message-header.react'; import textMessageSendFailed from './text-message-send-failed'; import { textMessageTooltipHeight } from './text-message-tooltip-modal.react'; export type ChatTextMessageInfoItemWithHeight = {| itemType: 'message', messageShapeType: 'text', messageInfo: TextMessageInfo, localMessageInfo: ?LocalMessageInfo, threadInfo: ThreadInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, contentHeight: number, |}; function textMessageItemHeight(item: ChatTextMessageInfoItemWithHeight) { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } return height; } type BaseProps = {| ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, |}; type Props = {| ...BaseProps, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, // MarkdownLinkContext +linkPressActive: boolean, |}; class TextMessage extends React.PureComponent { message: ?React.ElementRef; render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, keyboardState, overlayContext, linkPressActive, ...viewProps } = this.props; const canSwipe = threadHasPermission( item.threadInfo, threadPermissions.VOICED, ); return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; visibleEntryIDs() { const result = ['copy']; if ( threadHasPermission(this.props.item.threadInfo, threadPermissions.VOICED) ) { result.push('reply'); } return result; } onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { message, props: { verticalBounds, linkPressActive }, } = this; if (!message || !verticalBounds || linkPressActive) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = textMessageTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = textMessageTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), location, margin, item, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const ConnectedTextMessage = React.memo( function ConnectedTextMessage(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const [linkPressActive, setLinkPressActive] = React.useState(false); const markdownLinkContext = React.useMemo( () => ({ setLinkPressActive, }), [setLinkPressActive], ); return ( ); }, ); export { ConnectedTextMessage as TextMessage, textMessageItemHeight }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 443430534..f434b856e 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1091 +1,1091 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Platform } from 'react-native'; import * as Upload from 'react-native-background-upload'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { pathFromURI } from 'lib/media/file-utils'; import { videoDurationLimit } from 'lib/media/video-utils'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { createMediaMessageInfo } from 'lib/shared/message-utils'; import { isStaff } from 'lib/shared/user-utils'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, - type RawImagesMessageInfo, - type RawMediaMessageInfo, - type RawTextMessageInfo, } from 'lib/types/message-types'; +import type { RawImagesMessageInfo } from 'lib/types/message/images'; +import type { RawMediaMessageInfo } from 'lib/types/message/media'; +import type { RawTextMessageInfo } from 'lib/types/message/text'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import type { DispatchActionPayload, DispatchActionPromise, } from 'lib/utils/action-utils'; import { getConfig } from 'lib/utils/config'; import { getMessageForException, cloneError } from 'lib/utils/errors'; import type { FetchJSONOptions, FetchJSONServerResponse, } from 'lib/utils/fetch-json'; import { connect } from 'lib/utils/redux-utils'; import { disposeTempFile } from '../media/file-utils'; import { processMedia } from '../media/media-utils'; import { displayActionResultModal } from '../navigation/action-result-modal'; import type { AppState } from '../redux/redux-setup'; import { InputStateContext, type PendingMultimediaUploads, } from './input-state'; let nextLocalUploadID = 0; function getNewLocalID() { return `localUpload${nextLocalUploadID++}`; } type SelectionWithID = {| selection: NativeMediaSelection, localID: string, |}; type CompletedUploads = { [localMessageID: string]: ?Set }; type Props = {| children: React.Node, // Redux state viewerID: ?string, nextLocalID: number, messageStoreMessages: { [id: string]: RawMessageInfo }, ongoingMessageCreation: boolean, hasWiFi: boolean, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, |}; type State = {| pendingUploads: PendingMultimediaUploads, |}; class InputStateContainer extends React.PureComponent { static propTypes = { children: PropTypes.node.isRequired, viewerID: PropTypes.string, nextLocalID: PropTypes.number.isRequired, messageStoreMessages: PropTypes.object.isRequired, ongoingMessageCreation: PropTypes.bool.isRequired, hasWiFi: PropTypes.bool.isRequired, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, uploadMultimedia: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, sendTextMessage: PropTypes.func.isRequired, }; state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); replyCallbacks: Array<(message: string) => void> = []; static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (let localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); const completed = []; let allUploadsComplete = true; for (let localUploadID in messagePendingUploads) { let media; for (let singleMedia of rawMessageInfo.media) { if (singleMedia.id === localUploadID) { media = singleMedia; break; } } if (media) { allUploadsComplete = false; } else { completed.push(localUploadID); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completed.length > 0) { completedUploads[localMessageID] = new Set(completed); } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (let localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (let localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; for (let { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, addReply: this.addReply, addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMultimediaMessage: this.retryMultimediaMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, }), ); uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; for (let localUploadID in messagePendingUploads) { const { failed } = messagePendingUploads[localUploadID]; if (!failed) { return true; } } } return false; }; sendTextMessage = (messageInfo: RawTextMessageInfo) => { this.sendCallbacks.forEach((callback) => callback()); this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); }; async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } sendMultimediaMessage = async ( threadID: string, selections: $ReadOnlyArray, ) => { this.sendCallbacks.forEach((callback) => callback()); const localMessageID = `local${this.props.nextLocalID}`; const selectionsWithIDs = selections.map((selection) => ({ selection, localID: getNewLocalID(), })); const pendingUploads = {}; for (let { localID } of selectionsWithIDs) { pendingUploads[localID] = { failed: null, progressPercent: 0, }; } this.setState( (prevState) => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = selectionsWithIDs.map(({ localID, selection }) => { if (selection.step === 'photo_library') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'photo_capture') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'photo_paste') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'video_library') { return { id: localID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, }; } invariant(false, `invalid selection ${JSON.stringify(selection)}`); }); const messageInfo = createMediaMessageInfo({ localID: localMessageID, threadID, creatorID, media, }); this.props.dispatchActionPayload( createLocalMessageActionType, messageInfo, ); }, ); await this.uploadFiles(localMessageID, selectionsWithIDs); }; async uploadFiles( localMessageID: string, selectionsWithIDs: $ReadOnlyArray, ) { const results = await Promise.all( selectionsWithIDs.map((selectionWithID) => this.uploadFile(localMessageID, selectionWithID), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, selectionWithID: SelectionWithID, ): Promise { const { localID, selection } = selectionWithID; const start = selection.sendTime; let steps = [selection], serverID, userTime, errorMessage; let reportPromise; const finish = async (result: MediaMissionResult) => { if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const fail = (message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, localID, message); userTime = Date.now() - start; }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia( selection, this.mediaProcessConfig(), ); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; fail(message); return await finish(processResult); } processedMedia = processResult; } catch (e) { fail('processing failed'); return await finish({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } const { uploadURI, shouldDisposePath, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, mediaMissionResult; try { uploadResult = await this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.loop }, { onProgress: (percent: number) => this.setProgress(localMessageID, localID, percent), uploadBlob: this.uploadBlob, }, ); mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); fail('upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if (uploadResult) { const { id, mediaType, uri, dimensions, loop } = uploadResult; serverID = id; this.props.dispatchActionPayload(updateMultimediaMessageMediaActionType, { messageID: localMessageID, currentMediaID: localID, mediaUpdate: { id, type: mediaType, uri, dimensions, localMediaSelection: undefined, loop, }, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const promises = []; if (shouldDisposePath) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete promises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); } if (selection.captureTime) { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose const captureURI = selection.uri; promises.push( (async () => { const { steps: clearSteps, result: capturePath, } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(promises); return await finish(mediaMissionResult); } mediaProcessConfig() { const { hasWiFi, viewerID } = this.props; if (__DEV__ || (viewerID && isStaff(viewerID))) { return { hasWiFi, finalFileHeaderCheck: true, }; } return { hasWiFi }; } setProgress( localMessageID: string, localUploadID: string, progressPercent: number, ) { this.setState((prevState) => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (let key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadID = await Upload.startUpload({ url, path, type: 'multipart', headers: { Accept: 'application/json', }, field: 'multimedia', parameters, }); if (options && options.abortHandler) { options.abortHandler(() => { Upload.cancelUpload(uploadID); }); } return await new Promise((resolve, reject) => { Upload.addListener('error', uploadID, (data) => { reject(data.error); }); Upload.addListener('cancelled', uploadID, () => { reject(new Error('request aborted')); }); Upload.addListener('completed', uploadID, (data) => { try { resolve(JSON.parse(data.responseBody)); } catch (e) { reject(e); } }); if (options && options.onProgress) { const { onProgress } = options; Upload.addListener('progress', uploadID, (data) => onProgress(data.progress / 100), ); } }); }; handleUploadFailure( localMessageID: string, localUploadID: string, message: string, ) { this.setState((prevState) => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: message, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: {| localID: string, localMessageID: string, serverID: ?string |}, mediaMission: MediaMission, ) { const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, }; this.props.dispatchActionPayload(queueReportsActionType, { reports: [report], }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } for (let localUploadID in pendingUploads) { const { failed } = pendingUploads[localUploadID]; if (failed) { return true; } } return false; }; addReply = (message: string) => { this.replyCallbacks.forEach((addReplyCallback) => addReplyCallback(message), ); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( (candidate) => candidate !== callbackReply, ); }; retryMultimediaMessage = async (localMessageID: string) => { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); let pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { pendingUploads = {}; } const now = Date.now(); const updateMedia = (media: $ReadOnlyArray): T[] => media.map((singleMedia) => { const oldID = singleMedia.id; if (!oldID.startsWith('localUpload')) { // already uploaded return singleMedia; } if (pendingUploads[oldID] && !pendingUploads[oldID].failed) { // still being uploaded return singleMedia; } // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const id = pendingUploads[oldID] ? oldID : getNewLocalID(); const oldSelection = singleMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_paste') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (singleMedia.type === 'photo') { return { type: 'photo', ...singleMedia, id, localMediaSelection: selection, }; } else { return { type: 'video', ...singleMedia, id, localMediaSelection: selection, }; } }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (let singleMedia of newRawMessageInfo.media) { if (singleMedia.id.startsWith('localUpload')) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatchActionPayload( sendMultimediaMessageActionTypes.started, newRawMessageInfo, ); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (let { id } of retryMedia) { pendingUploads[id] = { failed: null, progressPercent: 0, }; } this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const selectionsWithIDs = retryMedia.map((singleMedia) => { const { id, localMediaSelection } = singleMedia; invariant( localMediaSelection, 'localMediaSelection should be set on locally created Media', ); return { selection: localMediaSelection, localID: id }; }); await this.uploadFiles(localMessageID, selectionsWithIDs); }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( (candidate) => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (let callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise((resolve) => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); export default connect( (state: AppState) => ({ viewerID: state.currentUserInfo && state.currentUserInfo.id, nextLocalID: state.nextLocalID, messageStoreMessages: state.messageStore.messages, ongoingMessageCreation: combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', hasWiFi: state.connectivity.hasWiFi, }), { uploadMultimedia, sendMultimediaMessage, sendTextMessage }, )(InputStateContainer); diff --git a/native/input/input-state.js b/native/input/input-state.js index 155012e92..00b0c3b37 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,73 +1,73 @@ // @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/message-types'; +import type { RawTextMessageInfo } from 'lib/types/message/text'; export type PendingMultimediaUpload = {| failed: ?string, progressPercent: number, |}; const pendingMultimediaUploadPropType = PropTypes.shape({ failed: PropTypes.string, progressPercent: PropTypes.number.isRequired, }); 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: ( threadID: string, selections: $ReadOnlyArray, ) => Promise, addReply: (text: string) => void, addReplyListener: ((message: string) => void) => void, removeReplyListener: ((message: string) => void) => void, messageHasUploadFailure: (localMessageID: string) => boolean, retryMultimediaMessage: (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, retryMultimediaMessage: PropTypes.func.isRequired, uploadInProgress: PropTypes.func.isRequired, reportURIDisplayed: PropTypes.func.isRequired, }); const InputStateContext = React.createContext(null); export { messagePendingUploadsPropType, pendingMultimediaUploadPropType, inputStatePropType, InputStateContext, }; diff --git a/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js index 1a08995c2..5b5c07a42 100644 --- a/server/src/fetchers/message-fetchers.js +++ b/server/src/fetchers/message-fetchers.js @@ -1,786 +1,786 @@ // @flow import invariant from 'invariant'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, createMediaMessageInfo, } from 'lib/shared/message-utils'; import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils'; import { type RawMessageInfo, messageTypes, type MessageType, assertMessageType, type ThreadSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, - type RawTextMessageInfo, } from 'lib/types/message-types'; +import type { RawTextMessageInfo } from 'lib/types/message/text'; import { threadPermissions } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL, mergeOrConditions } from '../database/database'; import type { PushInfo } from '../push/send'; import type { Viewer } from '../session/viewer'; import { creationString, localIDFromCreationString } from '../utils/idempotent'; import { mediaFromRow } from './upload-fetchers'; export type CollapsableNotifInfo = {| collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], |}; export type FetchCollapsableNotifsResult = { [userID: string]: CollapsableNotifInfo[], }; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; for (let userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let rawMessageInfo of pushInfo[userID].messageInfos) { const collapseKey = notifCollapseKeyForRawMessageInfo(rawMessageInfo); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { return usersToCollapsableNotifInfo; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.user AS creatorID, stm.permissions AS subthread_permissions, n.user, n.collapse_key, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visPermissionExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC`); const [collapseResult] = await dbQuery(collapseQuery); const rowsByUser = new Map(); for (const row of collapseResult) { const user = row.user.toString(); const currentRowsForUser = rowsByUser.get(user); if (currentRowsForUser) { currentRowsForUser.push(row); } else { rowsByUser.set(user, [row]); } } const derivedMessages = await fetchDerivedMessages(collapseResult); for (const userRows of rowsByUser.values()) { const messages = parseMessageSQLResult(userRows, derivedMessages); for (const message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } } for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return usersToCollapsableNotifInfo; } type MessageSQLResult = $ReadOnlyArray<{| rawMessageInfo: RawMessageInfo, rows: $ReadOnlyArray, |}>; function parseMessageSQLResult( rows: $ReadOnlyArray, derivedMessages: $ReadOnlyMap, viewer?: Viewer, ): MessageSQLResult { const rowsByID = new Map(); for (let row of rows) { const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; for (let messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows( messageRows, viewer, derivedMessages, ); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } return messages; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map((row) => row.id.toString()); console.warn( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rows: $ReadOnlyArray, viewer?: Viewer, derivedMessages: $ReadOnlyMap, ): ?RawMessageInfo { const type = mostRecentRowType(rows); if (type === messageTypes.TEXT) { const row = assertSingleRow(rows); const rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; const localID = localIDFromCreationString(viewer, row.creation); if (localID) { rawTextMessageInfo.localID = localID; } return rawTextMessageInfo; } else if (type === messageTypes.CREATE_THREAD) { const row = assertSingleRow(rows); return { type: messageTypes.CREATE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), initialThreadState: JSON.parse(row.content), }; } else if (type === messageTypes.ADD_MEMBERS) { const row = assertSingleRow(rows); return { type: messageTypes.ADD_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), addedUserIDs: JSON.parse(row.content), }; } else if (type === messageTypes.CREATE_SUB_THREAD) { const row = assertSingleRow(rows); const subthreadPermissions = row.subthread_permissions; if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), childThreadID: row.content, }; } else if (type === messageTypes.CHANGE_SETTINGS) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); const field = Object.keys(content)[0]; return { type: messageTypes.CHANGE_SETTINGS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), field, value: content[field], }; } else if (type === messageTypes.REMOVE_MEMBERS) { const row = assertSingleRow(rows); return { type: messageTypes.REMOVE_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), removedUserIDs: JSON.parse(row.content), }; } else if (type === messageTypes.CHANGE_ROLE) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.CHANGE_ROLE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), userIDs: content.userIDs, newRole: content.newRole, }; } else if (type === messageTypes.LEAVE_THREAD) { const row = assertSingleRow(rows); return { type: messageTypes.LEAVE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; } else if (type === messageTypes.JOIN_THREAD) { const row = assertSingleRow(rows); return { type: messageTypes.JOIN_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; } else if (type === messageTypes.CREATE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.CREATE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.EDIT_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.EDIT_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.DELETE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.DELETE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.RESTORE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.RESTORE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { const media = rows.filter((row) => row.uploadID).map(mediaFromRow); const [row] = rows; return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID: localIDFromCreationString(viewer, row.creation), time: row.time, }); } else if (type === messageTypes.UPDATE_RELATIONSHIP) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.UPDATE_RELATIONSHIP, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), targetID: content.targetID, operation: content.operation, }; } else if (type === messageTypes.SIDEBAR_SOURCE) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); const initialMessage = derivedMessages.get(content.initialMessageID); if (!initialMessage) { console.warn( `Message with id ${row.id} has a derived message ` + `${content.initialMessageID} which is not present in the database`, ); } return { type: messageTypes.SIDEBAR_SOURCE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), initialMessage, }; } else if (type === messageTypes.CREATE_SIDEBAR) { const row = assertSingleRow(rows); return { type: messageTypes.CREATE_SIDEBAR, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), initialThreadState: JSON.parse(row.content), }; } else { invariant(false, `unrecognized messageType ${type}`); } } const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchMessageInfos( viewer: Viewer, criteria: ThreadSelectionCriteria, numberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` SELECT * FROM ( SELECT x.id, x.content, x.time, x.type, x.user AS creatorID, x.creation, x.subthread_permissions, x.uploadID, x.uploadType, x.uploadSecret, x.uploadExtra, @num := if( @thread = x.thread, if(@message = x.id, @num, @num + 1), 1 ) AS number, @message := x.id AS messageID, @thread := x.thread AS threadID FROM (SELECT @num := 0, @thread := '', @message := '') init JOIN ( SELECT m.id, m.thread, m.user, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC ) x ) y WHERE y.number <= ${numberPerThread} `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (let message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (let [threadID, messageCount] of threadToMessageCount) { // If there are fewer messages returned than the max for a given thread, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageCount < numberPerThread ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.TRUNCATED; } for (let rawMessageInfo of rawMessageInfos) { if ( rawMessageInfo.type === messageTypes.CREATE_THREAD || rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { // If a CREATE_THREAD or SIDEBAR_SOURCE message for a given thread is in // the result set, then our result set includes all messages in the query // range for that thread truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (let threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus === null || truncationStatus === undefined) { // If nothing was returned for a thread that was explicitly queried for, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else if (truncationStatus === messageTruncationStatus.TRUNCATED) { // If a cursor was specified for a given thread, then the result is // guaranteed to be contiguous with what the client has, and as such the // result should never be TRUNCATED truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function threadSelectionCriteriaToSQLClause(criteria: ThreadSelectionCriteria) { const conditions = []; if (criteria.joinedThreads === true) { conditions.push(SQL`mm.role > 0`); } if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { conditions.push(SQL`(m.thread = ${threadID} AND m.id < ${cursor})`); } else { conditions.push(SQL`m.thread = ${threadID}`); } } } if (conditions.length === 0) { throw new ServerError('internal_error'); } return mergeOrConditions(conditions); } function threadSelectionCriteriaToInitialTruncationStatuses( criteria: ThreadSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } async function fetchMessageInfosSince( viewer: Viewer, criteria: ThreadSelectionCriteria, currentAsOf: number, maxNumberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = threadSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.time > ${currentAsOf} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC `); const [result] = await dbQuery(query); const derivedMessages = await fetchDerivedMessages(result, viewer); const messages = parseMessageSQLResult(result, derivedMessages, viewer); const rawMessageInfos = []; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (let message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if ( rawMessageInfo.type === messageTypes.CREATE_THREAD || rawMessageInfo.type === messageTypes.SIDEBAR_SOURCE ) { // If a CREATE_THREAD or SIDEBAR_SOURCE message is here, then we have // all messages truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, ): FetchMessageInfosResult { const truncationStatuses = {}; for (let rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } async function fetchMessageRowsByIDs(messageIDs: $ReadOnlyArray) { const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') WHERE m.id IN (${messageIDs}) `; const [result] = await dbQuery(query); return result; } async function fetchDerivedMessages( rows: $ReadOnlyArray, viewer?: Viewer, ): Promise<$ReadOnlyMap> { const requiredIDs = new Set(); for (const row of rows) { if (row.type === messageTypes.SIDEBAR_SOURCE) { const content = JSON.parse(row.content); requiredIDs.add(content.initialMessageID); } } const messagesByID = new Map(); if (requiredIDs.size === 0) { return messagesByID; } const result = await fetchMessageRowsByIDs([...requiredIDs]); const messages = parseMessageSQLResult(result, new Map(), viewer); for (const message of messages) { const { rawMessageInfo } = message; if (rawMessageInfo.id) { messagesByID.set(rawMessageInfo.id, rawMessageInfo); } } return messagesByID; } async function fetchMessageInfoByID( viewer?: Viewer, messageID: string, ): Promise { const result = await fetchMessageRowsByIDs([messageID]); if (result.length === 0) { return null; } const derivedMessages = await fetchDerivedMessages(result, viewer); return rawMessageInfoFromRows(result, viewer, derivedMessages); } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, fetchMessageInfoByID, }; diff --git a/server/src/responders/message-responders.js b/server/src/responders/message-responders.js index c5af52f4b..5f1a3ff52 100644 --- a/server/src/responders/message-responders.js +++ b/server/src/responders/message-responders.js @@ -1,145 +1,145 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import { createMediaMessageData, trimMessage } from 'lib/shared/message-utils'; import { messageTypes, type SendTextMessageRequest, type SendMultimediaMessageRequest, type FetchMessageInfosResponse, type FetchMessageInfosRequest, defaultNumberPerThread, type SendMessageResponse, - type TextMessageData, } from 'lib/types/message-types'; +import type { TextMessageData } from 'lib/types/message/text'; import { threadPermissions } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import createMessages from '../creators/message-creator'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers'; import { fetchMedia } from '../fetchers/upload-fetchers'; import type { Viewer } from '../session/viewer'; import { assignMedia } from '../updaters/upload-updaters'; import { validateInput, tShape } from '../utils/validation-utils'; const sendTextMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.maybe(t.String), text: t.String, }); async function textMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendTextMessageRequest = input; await validateInput(viewer, sendTextMessageRequestInputValidator, request); const { threadID, localID, text: rawText } = request; const text = trimMessage(rawText); if (!text) { throw new ServerError('invalid_parameters'); } const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const messageData: TextMessageData = { type: messageTypes.TEXT, threadID, creatorID: viewer.id, time: Date.now(), text, }; if (localID) { messageData.localID = localID; } const rawMessageInfos = await createMessages(viewer, [messageData]); return { newMessageInfo: rawMessageInfos[0] }; } const fetchMessageInfosRequestInputValidator = tShape({ cursors: t.dict(t.String, t.maybe(t.String)), numberPerThread: t.maybe(t.Number), }); async function messageFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchMessageInfosRequest = input; await validateInput(viewer, fetchMessageInfosRequestInputValidator, request); const response = await fetchMessageInfos( viewer, { threadCursors: request.cursors }, request.numberPerThread ? request.numberPerThread : defaultNumberPerThread, ); return { ...response, userInfos: {} }; } const sendMultimediaMessageRequestInputValidator = tShape({ threadID: t.String, localID: t.String, mediaIDs: t.list(t.String), }); async function multimediaMessageCreationResponder( viewer: Viewer, input: any, ): Promise { const request: SendMultimediaMessageRequest = input; await validateInput( viewer, sendMultimediaMessageRequestInputValidator, request, ); const { threadID, localID, mediaIDs } = request; if (mediaIDs.length === 0) { throw new ServerError('invalid_parameters'); } const hasPermission = await checkThreadPermission( viewer, threadID, threadPermissions.VOICED, ); if (!hasPermission) { throw new ServerError('invalid_parameters'); } const media = await fetchMedia(viewer, mediaIDs); if (media.length !== mediaIDs.length) { throw new ServerError('invalid_parameters'); } const messageData = createMediaMessageData({ localID, threadID, creatorID: viewer.id, media, }); const [newMessageInfo] = await createMessages(viewer, [messageData]); const { id } = newMessageInfo; invariant( id !== null && id !== undefined, 'serverID should be set in createMessages result', ); await assignMedia(viewer, mediaIDs, id); return { newMessageInfo }; } export { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, }; diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js index e9a3bd693..50d4ef921 100644 --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -1,1041 +1,1041 @@ // @flow import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy'; import _keyBy from 'lodash/fp/keyBy'; import _omit from 'lodash/fp/omit'; import _partition from 'lodash/fp/partition'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; import PropTypes from 'prop-types'; import * as React from 'react'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, deleteUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { createMediaMessageInfo } from 'lib/shared/message-utils'; import type { UploadMultimediaResult, MediaMissionStep, MediaMissionFailure, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, - type RawImagesMessageInfo, - type RawMediaMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, - type RawTextMessageInfo, } from 'lib/types/message-types'; +import type { RawImagesMessageInfo } from 'lib/types/message/images'; +import type { RawMediaMessageInfo } from 'lib/types/message/media'; +import type { RawTextMessageInfo } from 'lib/types/message/text'; import { reportTypes } from 'lib/types/report-types'; import type { DispatchActionPayload, DispatchActionPromise, } from 'lib/utils/action-utils'; import { getConfig } from 'lib/utils/config'; import { getMessageForException, cloneError } from 'lib/utils/errors'; import { connect } from 'lib/utils/redux-utils'; import { validateFile, preloadImage } from '../media/media-utils'; import InvalidUploadModal from '../modals/chat/invalid-upload.react'; import type { AppState } from '../redux/redux-setup'; import { type PendingMultimediaUpload, InputStateContext } from './input-state'; let nextLocalUploadID = 0; type Props = {| children: React.Node, setModal: (modal: ?React.Node) => void, // Redux state activeChatThreadID: ?string, viewerID: ?string, messageStoreMessages: { [id: string]: RawMessageInfo }, exifRotate: boolean, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, deleteUpload: (id: string) => Promise, sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, |}; type State = {| pendingUploads: { [threadID: string]: { [localUploadID: string]: PendingMultimediaUpload }, }, drafts: { [threadID: string]: string }, |}; class InputStateContainer extends React.PureComponent { static propTypes = { children: PropTypes.node.isRequired, setModal: PropTypes.func.isRequired, activeChatThreadID: PropTypes.string, viewerID: PropTypes.string, messageStoreMessages: PropTypes.object.isRequired, exifRotate: PropTypes.bool.isRequired, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, uploadMultimedia: PropTypes.func.isRequired, deleteUpload: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, sendTextMessage: PropTypes.func.isRequired, }; state: State = { pendingUploads: {}, drafts: {}, }; replyCallbacks: Array<(message: string) => void> = []; static completedMessageIDs(state: State) { const completed = new Map(); for (let threadID in state.pendingUploads) { const pendingUploads = state.pendingUploads[threadID]; for (let localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID, serverID, failed } = upload; if (!messageID || !messageID.startsWith('local')) { continue; } if (!serverID || failed) { completed.set(messageID, false); continue; } if (completed.get(messageID) === undefined) { completed.set(messageID, true); } } } const messageIDs = new Set(); for (let [messageID, isCompleted] of completed) { if (isCompleted) { messageIDs.add(messageID); } } return messageIDs; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const previouslyAssignedMessageIDs = new Set(); for (let threadID in prevState.pendingUploads) { const pendingUploads = prevState.pendingUploads[threadID]; for (let localUploadID in pendingUploads) { const { messageID } = pendingUploads[localUploadID]; if (messageID) { previouslyAssignedMessageIDs.add(messageID); } } } const newlyAssignedUploads = new Map(); for (let threadID in this.state.pendingUploads) { const pendingUploads = this.state.pendingUploads[threadID]; for (let localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID } = upload; if ( !messageID || !messageID.startsWith('local') || previouslyAssignedMessageIDs.has(messageID) ) { continue; } let assignedUploads = newlyAssignedUploads.get(messageID); if (!assignedUploads) { assignedUploads = { threadID, uploads: [] }; newlyAssignedUploads.set(messageID, assignedUploads); } assignedUploads.uploads.push(upload); } } const newMessageInfos = new Map(); for (let [messageID, assignedUploads] of newlyAssignedUploads) { const { uploads, threadID } = assignedUploads; const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = uploads.map( ({ localID, serverID, uri, mediaType, dimensions, loop }) => { // We can get into this state where dimensions are null if the user is // uploading a file type that the browser can't render. In that case // we fake the dimensions here while we wait for the server to tell us // the true dimensions. We actually don't use the dimensions on the // web side currently, but if we ever change that (for instance if we // want to render a properly sized loading overlay like we do on // native), 0,0 is probably a good default. const shimmedDimensions = dimensions ? dimensions : { height: 0, width: 0 }; if (mediaType === 'photo') { return { id: serverID ? serverID : localID, uri, type: 'photo', dimensions: shimmedDimensions, }; } else { return { id: serverID ? serverID : localID, uri, type: 'video', dimensions: shimmedDimensions, loop, }; } }, ); const messageInfo = createMediaMessageInfo({ localID: messageID, threadID, creatorID, media, }); newMessageInfos.set(messageID, messageInfo); } const currentlyCompleted = InputStateContainer.completedMessageIDs( this.state, ); const previouslyCompleted = InputStateContainer.completedMessageIDs( prevState, ); for (let messageID of currentlyCompleted) { if (previouslyCompleted.has(messageID)) { continue; } let rawMessageInfo = newMessageInfos.get(messageID); if (rawMessageInfo) { newMessageInfos.delete(messageID); } else { rawMessageInfo = this.getRawMultimediaMessageInfo(messageID); } this.sendMultimediaMessage(rawMessageInfo); } for (let [, messageInfo] of newMessageInfos) { this.props.dispatchActionPayload( createLocalMessageActionType, messageInfo, ); } } getRawMultimediaMessageInfo( localMessageID: string, ): RawMultimediaMessageInfo { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); return rawMessageInfo; } sendMultimediaMessage(messageInfo: RawMultimediaMessageInfo) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; for (let { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); this.setState((prevState) => { const prevUploads = prevState.pendingUploads[threadID]; const newUploads = {}; for (let localUploadID in prevUploads) { const upload = prevUploads[localUploadID]; if (upload.messageID !== localID) { newUploads[localUploadID] = upload; } else if (!upload.uriIsReal) { newUploads[localUploadID] = { ...upload, messageID: result.id, }; } } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newUploads, }, }; }); return { localID, serverID: result.id, threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = _memoize((threadID: string) => createSelector( (state: State) => state.pendingUploads[threadID], (state: State) => state.drafts[threadID], ( pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload }, draft: ?string, ) => { let threadPendingUploads = []; const assignedUploads = {}; if (pendingUploads) { const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] = _partition( 'messageID', )(pendingUploads); threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs); const threadAssignedUploads = _groupBy('messageID')( uploadsWithMessageIDs, ); for (let messageID in threadAssignedUploads) { // lodash libdefs don't return $ReadOnlyArray assignedUploads[messageID] = [...threadAssignedUploads[messageID]]; } } return { pendingUploads: threadPendingUploads, assignedUploads, draft: draft ? draft : '', appendFiles: (files: $ReadOnlyArray) => this.appendFiles(threadID, files), cancelPendingUpload: (localUploadID: string) => this.cancelPendingUpload(threadID, localUploadID), sendTextMessage: (messageInfo: RawTextMessageInfo) => this.sendTextMessage(messageInfo), createMultimediaMessage: (localID: number) => this.createMultimediaMessage(threadID, localID), setDraft: (newDraft: string) => this.setDraft(threadID, newDraft), messageHasUploadFailure: (localMessageID: string) => this.messageHasUploadFailure(assignedUploads[localMessageID]), retryMultimediaMessage: (localMessageID: string) => this.retryMultimediaMessage( threadID, localMessageID, assignedUploads[localMessageID], ), addReply: (message: string) => this.addReply(message), addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, }; }, ), ); async appendFiles( threadID: string, files: $ReadOnlyArray, ): Promise { const selectionTime = Date.now(); const { setModal } = this.props; const appendResults = await Promise.all( files.map((file) => this.appendFile(file, selectionTime)), ); if (appendResults.some(({ result }) => !result.success)) { setModal(); const time = Date.now() - selectionTime; const reports = []; for (let { steps, result } of appendResults) { let uploadLocalID; if (result.success) { uploadLocalID = result.pendingUpload.localID; result = { success: false, reason: 'web_sibling_validation_failed' }; } const mediaMission = { steps, result, userTime: time, totalTime: time }; reports.push({ mediaMission, uploadLocalID }); } this.queueMediaMissionReports(reports); return false; } const newUploads = appendResults.map(({ result }) => { invariant(result.success, 'any failed validation should be caught above'); return result.pendingUpload; }); const newUploadsObject = _keyBy('localID')(newUploads); this.setState( (prevState) => { const prevUploads = prevState.pendingUploads[threadID]; const mergedUploads = prevUploads ? { ...prevUploads, ...newUploadsObject } : newUploadsObject; return { pendingUploads: { ...prevState.pendingUploads, [threadID]: mergedUploads, }, }; }, () => this.uploadFiles(threadID, newUploads), ); return true; } async appendFile( file: File, selectTime: number, ): Promise<{ steps: $ReadOnlyArray, result: | MediaMissionFailure | {| success: true, pendingUpload: PendingMultimediaUpload |}, }> { const steps = [ { step: 'web_selection', filename: file.name, size: file.size, mime: file.type, selectTime, }, ]; let response; const validationStart = Date.now(); try { response = await validateFile(file, this.props.exifRotate); } catch (e) { return { steps, result: { success: false, reason: 'processing_exception', time: Date.now() - validationStart, exceptionMessage: getMessageForException(e), }, }; } const { steps: validationSteps, result } = response; steps.push(...validationSteps); if (!result.success) { return { steps, result }; } const { uri, file: fixedFile, mediaType, dimensions } = result; return { steps, result: { success: true, pendingUpload: { localID: `localUpload${nextLocalUploadID++}`, serverID: null, messageID: null, failed: null, file: fixedFile, mediaType, dimensions, uri, loop: false, uriIsReal: false, progressPercent: 0, abort: null, steps, selectTime, }, }, }; } uploadFiles( threadID: string, uploads: $ReadOnlyArray, ) { return Promise.all( uploads.map((upload) => this.uploadFile(threadID, upload)), ); } async uploadFile(threadID: string, upload: PendingMultimediaUpload) { const { selectTime, localID } = upload; const steps = [...upload.steps]; let userTime; const sendReport = (missionResult: MediaMissionResult) => { const latestUpload = this.state.pendingUploads[threadID][localID]; invariant( latestUpload, `pendingUpload ${localID} for ${threadID} missing in sendReport`, ); const { serverID, messageID } = latestUpload; const totalTime = Date.now() - selectTime; userTime = userTime ? userTime : totalTime; const mission = { steps, result: missionResult, totalTime, userTime }; this.queueMediaMissionReports([ { mediaMission: mission, uploadLocalID: localID, uploadServerID: serverID, messageLocalID: messageID, }, ]); }; let uploadResult, uploadExceptionMessage; const uploadStart = Date.now(); try { uploadResult = await this.props.uploadMultimedia( upload.file, { ...upload.dimensions, loop: false }, { onProgress: (percent: number) => this.setProgress(threadID, localID, percent), abortHandler: (abort: () => void) => this.handleAbortCallback(threadID, localID, abort), }, ); } catch (e) { uploadExceptionMessage = getMessageForException(e); this.handleUploadFailure(threadID, localID, e); } userTime = Date.now() - selectTime; steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: upload.file.name, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, }); if (!uploadResult) { sendReport({ success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }); return; } const result = uploadResult; const uploadAfterSuccess = this.state.pendingUploads[threadID][localID]; invariant( uploadAfterSuccess, `pendingUpload ${localID}/${result.id} for ${threadID} missing ` + `after upload`, ); if (uploadAfterSuccess.messageID) { this.props.dispatchActionPayload(updateMultimediaMessageMediaActionType, { messageID: uploadAfterSuccess.messageID, currentMediaID: localID, mediaUpdate: { id: result.id, }, }); } this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${threadID} ` + `missing while assigning serverID`, ); return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localID]: { ...currentUpload, serverID: result.id, abort: null, }, }, }, }; }); const { steps: preloadSteps } = await preloadImage(result.uri); steps.push(...preloadSteps); sendReport({ success: true }); const uploadAfterPreload = this.state.pendingUploads[threadID][localID]; invariant( uploadAfterPreload, `pendingUpload ${localID}/${result.id} for ${threadID} missing ` + `after preload`, ); if (uploadAfterPreload.messageID) { const { mediaType, uri, dimensions, loop } = result; this.props.dispatchActionPayload(updateMultimediaMessageMediaActionType, { messageID: uploadAfterPreload.messageID, currentMediaID: uploadAfterPreload.serverID ? uploadAfterPreload.serverID : uploadAfterPreload.localID, mediaUpdate: { type: mediaType, uri, dimensions, loop }, }); } this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${threadID} ` + `missing while assigning URI`, ); const { messageID } = currentUpload; if (messageID && !messageID.startsWith('local')) { const newPendingUploads = _omit([localID])(uploads); return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localID]: { ...currentUpload, uri: result.uri, mediaType: result.mediaType, dimensions: result.dimensions, uriIsReal: true, loop: result.loop, }, }, }, }; }); } handleAbortCallback( threadID: string, localUploadID: string, abort: () => void, ) { this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been cancelled before we were even handed the // abort function. We should immediately abort. abort(); } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localUploadID]: { ...upload, abort, }, }, }, }; }); } handleUploadFailure(threadID: string, localUploadID: string, e: any) { this.setState((prevState) => { const uploads = prevState.pendingUploads[threadID]; const upload = uploads[localUploadID]; if (!upload || !upload.abort || upload.serverID) { // The upload has been cancelled or completed before it failed return {}; } const failed = e instanceof Error && e.message ? e.message : 'failed'; return { pendingUploads: { ...prevState.pendingUploads, [threadID]: { ...uploads, [localUploadID]: { ...upload, failed, progressPercent: 0, abort: null, }, }, }, }; }); } queueMediaMissionReports( partials: $ReadOnlyArray<{| mediaMission: MediaMission, uploadLocalID?: ?string, uploadServerID?: ?string, messageLocalID?: ?string, |}>, ) { const reports = partials.map( ({ mediaMission, uploadLocalID, uploadServerID, messageLocalID }) => ({ type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID, uploadLocalID, messageLocalID, }), ); this.props.dispatchActionPayload(queueReportsActionType, { reports }); } cancelPendingUpload(threadID: string, localUploadID: string) { let revokeURL, abortRequest; this.setState( (prevState) => { const currentPendingUploads = prevState.pendingUploads[threadID]; if (!currentPendingUploads) { return {}; } const pendingUpload = currentPendingUploads[localUploadID]; if (!pendingUpload) { return {}; } if (!pendingUpload.uriIsReal) { revokeURL = pendingUpload.uri; } if (pendingUpload.abort) { abortRequest = pendingUpload.abort; } if (pendingUpload.serverID) { this.props.deleteUpload(pendingUpload.serverID); } const newPendingUploads = _omit([localUploadID])(currentPendingUploads); return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }, () => { if (revokeURL) { URL.revokeObjectURL(revokeURL); } if (abortRequest) { abortRequest(); } }, ); } sendTextMessage(messageInfo: RawTextMessageInfo) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); } async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } // Creates a MultimediaMessage from the unassigned pending uploads, // if there are any createMultimediaMessage(threadID: string, localID: number) { const localMessageID = `local${localID}`; this.setState((prevState) => { const currentPendingUploads = prevState.pendingUploads[threadID]; if (!currentPendingUploads) { return {}; } const newPendingUploads = {}; let uploadAssigned = false; for (let localUploadID in currentPendingUploads) { const upload = currentPendingUploads[localUploadID]; if (upload.messageID) { newPendingUploads[localUploadID] = upload; } else { const newUpload = { ...upload, messageID: localMessageID, }; uploadAssigned = true; newPendingUploads[localUploadID] = newUpload; } } if (!uploadAssigned) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }); } setDraft(threadID: string, draft: string) { this.setState((prevState) => ({ drafts: { ...prevState.drafts, [threadID]: draft, }, })); } setProgress( threadID: string, localUploadID: string, progressPercent: number, ) { this.setState((prevState) => { const pendingUploads = prevState.pendingUploads[threadID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }); } messageHasUploadFailure( pendingUploads: ?$ReadOnlyArray, ) { if (!pendingUploads) { return false; } return pendingUploads.some((upload) => upload.failed); } retryMultimediaMessage( threadID: string, localMessageID: string, pendingUploads: ?$ReadOnlyArray, ) { const rawMessageInfo = this.getRawMultimediaMessageInfo(localMessageID); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawMediaMessageInfo); } else { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawImagesMessageInfo); } const completed = InputStateContainer.completedMessageIDs(this.state); if (completed.has(localMessageID)) { this.sendMultimediaMessage(newRawMessageInfo); return; } if (!pendingUploads) { return; } // We're not actually starting the send here, // we just use this action to update the message's timestamp in Redux this.props.dispatchActionPayload( sendMultimediaMessageActionTypes.started, newRawMessageInfo, ); const uploadIDsToRetry = new Set(); const uploadsToRetry = []; for (let pendingUpload of pendingUploads) { const { serverID, messageID, localID, abort } = pendingUpload; if (serverID || messageID !== localMessageID) { continue; } if (abort) { abort(); } uploadIDsToRetry.add(localID); uploadsToRetry.push(pendingUpload); } this.setState((prevState) => { const prevPendingUploads = prevState.pendingUploads[threadID]; if (!prevPendingUploads) { return {}; } const newPendingUploads = {}; let pendingUploadChanged = false; for (let localID in prevPendingUploads) { const pendingUpload = prevPendingUploads[localID]; if (uploadIDsToRetry.has(localID) && !pendingUpload.serverID) { newPendingUploads[localID] = { ...pendingUpload, failed: null, progressPercent: 0, abort: null, }; pendingUploadChanged = true; } else { newPendingUploads[localID] = pendingUpload; } } if (!pendingUploadChanged) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [threadID]: newPendingUploads, }, }; }); this.uploadFiles(threadID, uploadsToRetry); } addReply = (message: string) => { this.replyCallbacks.forEach((addReplyCallback) => addReplyCallback(message), ); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( (candidate) => candidate !== callbackReply, ); }; render() { const { activeChatThreadID } = this.props; const inputState = activeChatThreadID ? this.inputStateSelector(activeChatThreadID)(this.state) : null; return ( {this.props.children} ); } } export default connect( (state: AppState) => { const browser = detectBrowser(state.userAgent); const exifRotate = !browser || (browser.name !== 'safari' && browser.name !== 'chrome'); return { activeChatThreadID: state.navInfo.activeChatThreadID, viewerID: state.currentUserInfo && state.currentUserInfo.id, messageStoreMessages: state.messageStore.messages, exifRotate, }; }, { uploadMultimedia, deleteUpload, sendMultimediaMessage, sendTextMessage }, )(InputStateContainer); diff --git a/web/input/input-state.js b/web/input/input-state.js index 3df876def..fc07fa6cd 100644 --- a/web/input/input-state.js +++ b/web/input/input-state.js @@ -1,99 +1,99 @@ // @flow import PropTypes from 'prop-types'; import * as React from 'react'; import { type MediaType, mediaTypePropType, type Dimensions, dimensionsPropType, type MediaMissionStep, mediaMissionStepPropType, } from 'lib/types/media-types'; -import type { RawTextMessageInfo } from 'lib/types/message-types'; +import type { RawTextMessageInfo } from 'lib/types/message/text'; export type PendingMultimediaUpload = {| localID: string, // Pending uploads are assigned a serverID once they are complete serverID: ?string, // Pending uploads are assigned a messageID once they are sent messageID: ?string, // This is set to truthy if the upload fails for whatever reason failed: ?string, file: File, mediaType: MediaType, dimensions: ?Dimensions, uri: string, loop: boolean, // URLs created with createObjectURL aren't considered "real". The distinction // is required because those "fake" URLs must be disposed properly uriIsReal: boolean, progressPercent: number, // This is set once the network request begins and used if the upload is // cancelled abort: ?() => void, steps: MediaMissionStep[], selectTime: number, |}; const pendingMultimediaUploadPropType = PropTypes.shape({ localID: PropTypes.string.isRequired, serverID: PropTypes.string, messageID: PropTypes.string, failed: PropTypes.string, file: PropTypes.object.isRequired, mediaType: mediaTypePropType.isRequired, dimensions: dimensionsPropType, uri: PropTypes.string.isRequired, loop: PropTypes.bool.isRequired, uriIsReal: PropTypes.bool.isRequired, progressPercent: PropTypes.number.isRequired, abort: PropTypes.func, steps: PropTypes.arrayOf(mediaMissionStepPropType).isRequired, selectTime: PropTypes.number.isRequired, }); // This type represents the input state for a particular thread export type InputState = {| pendingUploads: $ReadOnlyArray, assignedUploads: { [messageID: string]: $ReadOnlyArray, }, draft: string, appendFiles: (files: $ReadOnlyArray) => Promise, cancelPendingUpload: (localUploadID: string) => void, sendTextMessage: (messageInfo: RawTextMessageInfo) => void, createMultimediaMessage: (localID: number) => void, setDraft: (draft: string) => void, messageHasUploadFailure: (localMessageID: string) => boolean, retryMultimediaMessage: (localMessageID: string) => void, addReply: (text: string) => void, addReplyListener: ((message: string) => void) => void, removeReplyListener: ((message: string) => void) => void, |}; const arrayOfUploadsPropType = PropTypes.arrayOf( pendingMultimediaUploadPropType, ); const inputStatePropType = PropTypes.shape({ pendingUploads: arrayOfUploadsPropType.isRequired, assignedUploads: PropTypes.objectOf(arrayOfUploadsPropType).isRequired, draft: PropTypes.string.isRequired, appendFiles: PropTypes.func.isRequired, cancelPendingUpload: PropTypes.func.isRequired, sendTextMessage: PropTypes.func.isRequired, createMultimediaMessage: PropTypes.func.isRequired, setDraft: PropTypes.func.isRequired, messageHasUploadFailure: PropTypes.func.isRequired, retryMultimediaMessage: PropTypes.func.isRequired, addReply: PropTypes.func.isRequired, addReplyListener: PropTypes.func.isRequired, removeReplyListener: PropTypes.func.isRequired, }); const InputStateContext = React.createContext(null); export { pendingMultimediaUploadPropType, inputStatePropType, InputStateContext, };