diff --git a/lib/shared/dm-ops/process-dm-ops.js b/lib/shared/dm-ops/process-dm-ops.js index 1939c1d17..b58dcd8e3 100644 --- a/lib/shared/dm-ops/process-dm-ops.js +++ b/lib/shared/dm-ops/process-dm-ops.js @@ -1,263 +1,296 @@ // @flow import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import uuid from 'uuid'; import { dmOpSpecs } from './dm-op-specs.js'; import type { OutboundDMOperationSpecification, DMOperationSpecification, } from './dm-op-utils.js'; import { createMessagesToPeersFromDMOp, dmOperationSpecificationTypes, } from './dm-op-utils.js'; import { useLoggedInUserInfo } from '../../hooks/account-hooks.js'; import { useGetLatestMessageEdit } from '../../hooks/latest-message-edit.js'; import { useDispatchWithMetadata } from '../../hooks/ops-hooks.js'; import { mergeUpdatesWithMessageInfos } from '../../reducers/message-reducer.js'; import { getAllPeerUserIDAndDeviceIDs } from '../../selectors/user-selectors.js'; import { usePeerToPeerCommunication } from '../../tunnelbroker/peer-to-peer-context.js'; import { processDMOpsActionType, queueDMOpsActionType, sendDMActionTypes, type SendDMOpsSuccessPayload, } from '../../types/dm-ops.js'; +import type { LocalMessageInfo } from '../../types/message-types.js'; import type { RawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { DispatchMetadata } from '../../types/redux-types.js'; import type { OutboundP2PMessage } from '../../types/sqlite-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; import type { LegacyRawThreadInfo } from '../../types/thread-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import { useDispatchActionPromise } from '../../utils/redux-promise-utils.js'; import { useSelector } from '../../utils/redux-utils.js'; import { messageSpecs } from '../messages/message-specs.js'; import { updateSpecs } from '../updates/update-specs.js'; function useProcessDMOperation(): ( dmOperationSpecification: DMOperationSpecification, dmOpID: ?string, ) => Promise { const fetchMessage = useGetLatestMessageEdit(); const threadInfos = useSelector(state => state.threadStore.threadInfos); const utilities = React.useMemo( () => ({ fetchMessage, threadInfos, }), [fetchMessage, threadInfos], ); const dispatchWithMetadata = useDispatchWithMetadata(); const loggedInUserInfo = useLoggedInUserInfo(); const viewerID = loggedInUserInfo?.id; const allPeerUserIDAndDeviceIDs = useSelector(getAllPeerUserIDAndDeviceIDs); const currentUserInfo = useSelector(state => state.currentUserInfo); return React.useCallback( async ( dmOperationSpecification: DMOperationSpecification, dmOpID: ?string, ) => { if (!viewerID) { console.log('ignored DMOperation because logged out'); return; } let outboundP2PMessages: ?$ReadOnlyArray = null; if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND ) { outboundP2PMessages = await createMessagesToPeersFromDMOp( dmOperationSpecification, allPeerUserIDAndDeviceIDs, currentUserInfo, ); } let dispatchMetadata: ?DispatchMetadata = null; if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND && dmOpID ) { dispatchMetadata = { dmOpID, }; } else if ( dmOperationSpecification.type === dmOperationSpecificationTypes.INBOUND ) { dispatchMetadata = dmOperationSpecification.metadata; } const { op: dmOp } = dmOperationSpecification; const processingCheckResult = dmOpSpecs[dmOp.type].canBeProcessed( dmOp, viewerID, utilities, ); if (!processingCheckResult.isProcessingPossible) { if (processingCheckResult.reason.type === 'missing_thread') { dispatchWithMetadata( { type: queueDMOpsActionType, payload: { operation: dmOp, threadID: processingCheckResult.reason.threadID, timestamp: Date.now(), }, }, dispatchMetadata, ); } return; } const { rawMessageInfos, updateInfos } = await dmOpSpecs[ dmOp.type ].processDMOperation(dmOp, viewerID, utilities); const { rawMessageInfos: allNewMessageInfos } = mergeUpdatesWithMessageInfos(rawMessageInfos, updateInfos); const messagesByThreadID = _groupBy(message => message.threadID)( allNewMessageInfos, ); const updatedThreadInfosByThreadID: { [string]: RawThreadInfo | LegacyRawThreadInfo, } = {}; for (const threadID in messagesByThreadID) { updatedThreadInfosByThreadID[threadID] = threadInfos[threadID]; } for (const update of updateInfos) { const updatedThreadInfo = updateSpecs[ update.type ].getUpdatedThreadInfo?.(update, updatedThreadInfosByThreadID); if ( updatedThreadInfo && updatedThreadInfo?.type === threadTypes.THICK_SIDEBAR ) { updatedThreadInfosByThreadID[updatedThreadInfo.id] = updatedThreadInfo; } } for (const threadID in messagesByThreadID) { const repliesCountIncreasingMessages = messagesByThreadID[ threadID ].filter(message => messageSpecs[message.type].includedInRepliesCount); if (repliesCountIncreasingMessages.length > 0) { const threadInfo = updatedThreadInfosByThreadID[threadID]; const repliesCountIncreaseTime = Math.max( repliesCountIncreasingMessages.map(message => message.time), ); updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time: repliesCountIncreaseTime, threadInfo: { ...threadInfo, repliesCount: threadInfo.repliesCount + repliesCountIncreasingMessages.length, }, }); } const messagesFromOtherPeers = messagesByThreadID[threadID].filter( message => message.creatorID !== viewerID, ); if (messagesFromOtherPeers.length === 0) { continue; } // We take the most recent timestamp to make sure that updates older // than it won't flip the status to read. const time = Math.max( messagesFromOtherPeers.map(message => message.time), ); updateInfos.push({ type: updateTypes.UPDATE_THREAD_READ_STATUS, id: uuid.v4(), time, threadID, unread: true, }); } let messageIDWithoutAutoRetry: ?string = null; if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND && !dmOpSpecs[dmOperationSpecification.op.type].supportsAutoRetry ) { messageIDWithoutAutoRetry = dmOperationSpecification.op.messageID; } dispatchWithMetadata( { type: processDMOpsActionType, payload: { rawMessageInfos, updateInfos, outboundP2PMessages, messageIDWithoutAutoRetry, }, }, dispatchMetadata, ); }, [ viewerID, utilities, dispatchWithMetadata, allPeerUserIDAndDeviceIDs, currentUserInfo, threadInfos, ], ); } function useProcessAndSendDMOperation(): ( dmOperationSpecification: OutboundDMOperationSpecification, ) => Promise { const processDMOps = useProcessDMOperation(); const dispatchActionPromise = useDispatchActionPromise(); const { getDMOpsSendingPromise } = usePeerToPeerCommunication(); return React.useCallback( async (dmOperationSpecification: OutboundDMOperationSpecification) => { const { promise, dmOpID } = getDMOpsSendingPromise(); await processDMOps(dmOperationSpecification, dmOpID); if ( dmOperationSpecification.type === dmOperationSpecificationTypes.OUTBOUND && !dmOpSpecs[dmOperationSpecification.op.type].supportsAutoRetry && dmOperationSpecification.op.messageID ) { const messageID: string = dmOperationSpecification.op.messageID; const sendingPromise: Promise = (async () => { const outboundP2PMessageIDs = await promise; return { messageID, outboundP2PMessageIDs, }; })(); void dispatchActionPromise( sendDMActionTypes, sendingPromise, undefined, { messageID, }, ); } }, [dispatchActionPromise, getDMOpsSendingPromise, processDMOps], ); } -export { useProcessDMOperation, useProcessAndSendDMOperation }; +function useRetrySendDMOperation(): ( + messageID: string, + localMessageInfo: LocalMessageInfo, +) => Promise { + const { processOutboundMessages, getDMOpsSendingPromise } = + usePeerToPeerCommunication(); + const dispatchActionPromise = useDispatchActionPromise(); + + return React.useCallback( + async (messageID: string, localMessageInfo: LocalMessageInfo) => { + const { promise, dmOpID } = getDMOpsSendingPromise(); + processOutboundMessages(localMessageInfo.outboundP2PMessageIDs, dmOpID); + + const sendingPromise: Promise = (async () => { + const outboundP2PMessageIDs = await promise; + return { + messageID, + outboundP2PMessageIDs, + }; + })(); + void dispatchActionPromise(sendDMActionTypes, sendingPromise, undefined, { + messageID, + }); + }, + [dispatchActionPromise, getDMOpsSendingPromise, processOutboundMessages], + ); +} + +export { + useProcessDMOperation, + useProcessAndSendDMOperation, + useRetrySendDMOperation, +}; diff --git a/native/chat/failed-send.react.js b/native/chat/failed-send.react.js index a1da3bb00..c738c4a26 100644 --- a/native/chat/failed-send.react.js +++ b/native/chat/failed-send.react.js @@ -1,177 +1,201 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View } from 'react-native'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; +import { useRetrySendDMOperation } from 'lib/shared/dm-ops/process-dm-ops.js'; import { messageID } from 'lib/shared/message-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; -import type { RawComposableMessageInfo } from 'lib/types/message-types.js'; -import { assertComposableRawMessage } from 'lib/types/message-types.js'; +import { + type RawComposableMessageInfo, + assertComposableRawMessage, + type LocalMessageInfo, +} from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; +import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import { multimediaMessageSendFailed } from './multimedia-message-utils.js'; import textMessageSendFailed from './text-message-send-failed.js'; import Button from '../components/button.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; const failedSendHeight = 22; const unboundStyles = { deliveryFailed: { color: 'listSeparatorLabel', paddingHorizontal: 3, }, failedSendInfo: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20, paddingTop: 5, }, retrySend: { paddingHorizontal: 3, }, }; type BaseProps = { +item: ChatMessageInfoItemWithHeight, }; type Props = { ...BaseProps, +rawMessageInfo: ?RawComposableMessageInfo, +styles: $ReadOnly, +inputState: ?InputState, +parentThreadInfo: ?ThreadInfo, + +retrySendDMOperation: ( + messageID: string, + localMessageInfo: LocalMessageInfo, + ) => Promise, }; class FailedSend extends React.PureComponent { retryingText = false; retryingMedia = false; componentDidUpdate(prevProps: Props) { const newItem = this.props.item; const prevItem = prevProps.item; if ( newItem.messageShapeType === 'multimedia' && prevItem.messageShapeType === 'multimedia' ) { const isFailed = multimediaMessageSendFailed(newItem); const wasFailed = multimediaMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingMedia = false; } } else if ( newItem.messageShapeType === 'text' && prevItem.messageShapeType === 'text' ) { const isFailed = textMessageSendFailed(newItem); const wasFailed = textMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingText = false; } } } render(): React.Node { if (!this.props.rawMessageInfo) { return null; } const threadColor = { color: `#${this.props.item.threadInfo.color}`, }; return ( DELIVERY FAILED. ); } retrySend = () => { const { rawMessageInfo } = this.props; if (!rawMessageInfo) { return; } if (rawMessageInfo.type === messageTypes.TEXT) { if (this.retryingText) { return; } this.retryingText = true; } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { if (this.retryingMedia) { return; } this.retryingMedia = true; } + if (threadTypeIsThick(this.props.item.threadInfo.type)) { + const failedMessageID = this.props.rawMessageInfo?.id; + invariant(failedMessageID, 'failedMessageID should be set for DMs'); + const localMessageInfo = this.props.item.localMessageInfo; + invariant( + localMessageInfo, + 'localMessageInfo should be set for failed message', + ); + void this.props.retrySendDMOperation(failedMessageID, localMessageInfo); + return; + } + const { inputState } = this.props; invariant( inputState, 'inputState should be initialized before user can hit retry', ); const { localID } = rawMessageInfo; invariant(localID, 'failed RawMessageInfo should have localID'); void inputState.retryMessage( localID, this.props.item.threadInfo, this.props.parentThreadInfo, ); }; } const ConnectedFailedSend: React.ComponentType = React.memo(function ConnectedFailedSend(props: BaseProps) { const id = messageID(props.item.messageInfo); const rawMessageInfo = useSelector(state => { const message = state.messageStore.messages[id]; return message ? assertComposableRawMessage(message) : null; }); const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); const { parentThreadID } = props.item.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); + const retrySendDMOperation = useRetrySendDMOperation(); + return ( ); }); export { ConnectedFailedSend as FailedSend, failedSendHeight }; diff --git a/web/chat/failed-send.react.js b/web/chat/failed-send.react.js index 17630977e..9fe247a9e 100644 --- a/web/chat/failed-send.react.js +++ b/web/chat/failed-send.react.js @@ -1,162 +1,183 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; +import { useRetrySendDMOperation } from 'lib/shared/dm-ops/process-dm-ops.js'; import { messageID } from 'lib/shared/message-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { assertComposableMessageType, + type LocalMessageInfo, type RawComposableMessageInfo, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; +import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import css from './chat-message-list.css'; import multimediaMessageSendFailed from './multimedia-message-send-failed.js'; import textMessageSendFailed from './text-message-send-failed.js'; import Button from '../components/button.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { useSelector } from '../redux/redux-utils.js'; type BaseProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +rawMessageInfo: RawComposableMessageInfo, +inputState: ?InputState, +parentThreadInfo: ?ThreadInfo, + +retrySendDMOperation: ( + messageID: string, + localMessageInfo: LocalMessageInfo, + ) => Promise, }; class FailedSend extends React.PureComponent { retryingText = false; retryingMedia = false; componentDidUpdate(prevProps: Props) { if ( (this.props.rawMessageInfo.type === messageTypes.IMAGES || this.props.rawMessageInfo.type === messageTypes.MULTIMEDIA) && (prevProps.rawMessageInfo.type === messageTypes.IMAGES || prevProps.rawMessageInfo.type === messageTypes.MULTIMEDIA) ) { const { inputState } = this.props; const prevInputState = prevProps.inputState; invariant( inputState && prevInputState, 'inputState should be set in FailedSend', ); const isFailed = multimediaMessageSendFailed(this.props.item, inputState); const wasFailed = multimediaMessageSendFailed( prevProps.item, prevInputState, ); const isDone = this.props.item.messageInfo.id !== null && this.props.item.messageInfo.id !== undefined; const wasDone = prevProps.item.messageInfo.id !== null && prevProps.item.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingMedia = false; } } else if ( this.props.rawMessageInfo.type === messageTypes.TEXT && prevProps.rawMessageInfo.type === messageTypes.TEXT ) { const isFailed = textMessageSendFailed(this.props.item); const wasFailed = textMessageSendFailed(prevProps.item); const isDone = this.props.item.messageInfo.id !== null && this.props.item.messageInfo.id !== undefined; const wasDone = prevProps.item.messageInfo.id !== null && prevProps.item.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingText = false; } } } render(): React.Node { return (
Delivery failed.
); } retrySend = () => { const { inputState } = this.props; invariant(inputState, 'inputState should be set in FailedSend'); const { rawMessageInfo } = this.props; if (rawMessageInfo.type === messageTypes.TEXT) { if (this.retryingText) { return; } this.retryingText = true; + if (threadTypeIsThick(this.props.threadInfo.type)) { + const failedMessageID = this.props.rawMessageInfo.id; + invariant(failedMessageID, 'failedMessageID should be set for DMs'); + const localMessageInfo = this.props.item.localMessageInfo; + invariant( + localMessageInfo, + 'localMessageInfo should be set for failed message', + ); + void this.props.retrySendDMOperation(failedMessageID, localMessageInfo); + return; + } void inputState.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { const { localID } = rawMessageInfo; invariant(localID, 'failed RawMessageInfo should have localID'); if (this.retryingMedia) { return; } this.retryingMedia = true; inputState.retryMultimediaMessage(localID, this.props.threadInfo); } }; } const ConnectedFailedSend: React.ComponentType = React.memo(function ConnectedFailedSend(props) { const { messageInfo } = props.item; assertComposableMessageType(messageInfo.type); const id = messageID(messageInfo); const rawMessageInfo = useSelector( state => state.messageStore.messages[id], ); assertComposableMessageType(rawMessageInfo.type); invariant( rawMessageInfo.type === messageTypes.TEXT || rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, 'FailedSend should only be used for composable message types', ); const inputState = React.useContext(InputStateContext); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); + const retrySendDMOperation = useRetrySendDMOperation(); + return ( ); }); export default ConnectedFailedSend;