diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5232a90adc..d891fd0529 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -690,7 +690,12 @@ "description": "An error popup when the user has attempted to add an attachment" }, "fileSizeWarning": { - "message": "Sorry, the selected file exceeds message size restrictions." + "message": "Sorry, the selected file exceeds message size restrictions.", + "description": "(deleted 2022/12/07) Shown in a toast if the user tries to attach too-large file" + }, + "icu:fileSizeWarning": { + "messageformat": "Sorry, the selected file exceeds message size restrictions. {limit}{units}", + "description": "Shown in a toast if the user tries to attach too-large file" }, "unableToLoadAttachment": { "message": "Unable to load selected attachment." diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index e8154754ce..b607e986bd 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -33,7 +33,6 @@ export default { const useProps = (overrideProps: Partial = {}): Props => ({ addAttachment: action('addAttachment'), - addPendingAttachment: action('addPendingAttachment'), conversationId: '123', i18n, onSendMessage: action('onSendMessage'), diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 9a35707e0f..fefd5a3566 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -12,7 +12,6 @@ import type { } from '../types/Util'; import type { ErrorDialogAudioRecorderType } from '../state/ducks/audioRecorder'; import { RecordingState } from '../state/ducks/audioRecorder'; -import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing'; import type { imageToBlurHash } from '../util/imageToBlurHash'; import { Spinner } from './Spinner'; import type { @@ -76,10 +75,6 @@ export type OwnProps = Readonly<{ conversationId: string, attachment: InMemoryAttachmentDraftType ) => unknown; - addPendingAttachment: ( - conversationId: string, - pendingAttachment: AttachmentDraftType - ) => unknown; announcementsOnly?: boolean; areWeAdmin?: boolean; areWePending?: boolean; @@ -112,7 +107,10 @@ export type OwnProps = Readonly<{ onClearAttachments(): unknown; onClickQuotedMessage(): unknown; onCloseLinkPreview(): unknown; - processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown; + processAttachments: (options: { + conversationId: string; + files: ReadonlyArray; + }) => unknown; onSelectMediaQuality(isHQ: boolean): unknown; onSendMessage(options: { draftAttachments?: ReadonlyArray; @@ -171,7 +169,6 @@ export type Props = Pick< export function CompositionArea({ // Base props addAttachment, - addPendingAttachment, conversationId, i18n, onSendMessage, @@ -733,13 +730,10 @@ export function CompositionArea({ ) : null} diff --git a/ts/components/CompositionUpload.tsx b/ts/components/CompositionUpload.tsx index 59ec141547..e9086741a9 100644 --- a/ts/components/CompositionUpload.tsx +++ b/ts/components/CompositionUpload.tsx @@ -2,116 +2,43 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ChangeEventHandler } from 'react'; -import React, { forwardRef, useState } from 'react'; +import React, { forwardRef } from 'react'; -import type { - InMemoryAttachmentDraftType, - AttachmentDraftType, -} from '../types/Attachment'; +import type { AttachmentDraftType } from '../types/Attachment'; import { isVideoAttachment, isImageAttachment } from '../types/Attachment'; -import { AttachmentToastType } from '../types/AttachmentToastType'; import type { LocalizerType } from '../types/Util'; -import { ToastCannotMixMultiAndNonMultiAttachments } from './ToastCannotMixMultiAndNonMultiAttachments'; -import { ToastDangerousFileType } from './ToastDangerousFileType'; -import { ToastFileSize } from './ToastFileSize'; -import { ToastMaxAttachments } from './ToastMaxAttachments'; -import { ToastUnsupportedMultiAttachment } from './ToastUnsupportedMultiAttachment'; -import { ToastUnableToLoadAttachment } from './ToastUnableToLoadAttachment'; -import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing'; import { getSupportedImageTypes, getSupportedVideoTypes, } from '../util/GoogleChrome'; export type PropsType = { - addAttachment: ( - conversationId: string, - attachment: InMemoryAttachmentDraftType - ) => unknown; - addPendingAttachment: ( - conversationId: string, - pendingAttachment: AttachmentDraftType - ) => unknown; conversationId: string; draftAttachments: ReadonlyArray; i18n: LocalizerType; - processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown; - removeAttachment: (conversationId: string, filePath: string) => unknown; + processAttachments: (options: { + conversationId: string; + files: ReadonlyArray; + }) => unknown; }; export const CompositionUpload = forwardRef( function CompositionUploadInner( - { - addAttachment, - addPendingAttachment, - conversationId, - draftAttachments, - i18n, - processAttachments, - removeAttachment, - }, + { conversationId, draftAttachments, processAttachments }, ref ) { - const [toastType, setToastType] = useState< - AttachmentToastType | undefined - >(); - const onFileInputChange: ChangeEventHandler< HTMLInputElement > = async event => { const files = event.target.files || []; await processAttachments({ - addAttachment, - addPendingAttachment, conversationId, files: Array.from(files), - draftAttachments, - onShowToast: setToastType, - removeAttachment, }); }; - function closeToast() { - setToastType(undefined); - } - - let toast; - - if (toastType === AttachmentToastType.ToastFileSize) { - toast = ( - - ); - } else if (toastType === AttachmentToastType.ToastDangerousFileType) { - toast = ; - } else if (toastType === AttachmentToastType.ToastMaxAttachments) { - toast = ; - } else if ( - toastType === AttachmentToastType.ToastUnsupportedMultiAttachment - ) { - toast = ( - - ); - } else if ( - toastType === - AttachmentToastType.ToastCannotMixMultiAndNonMultiAttachments - ) { - toast = ( - - ); - } else if (toastType === AttachmentToastType.ToastUnableToLoadAttachment) { - toast = ; - } - const anyVideoOrImageAttachments = draftAttachments.some(attachment => { return isImageAttachment(attachment) || isVideoAttachment(attachment); }); @@ -121,17 +48,14 @@ export const CompositionUpload = forwardRef( : null; return ( - <> - {toast} - - + ); } ); diff --git a/ts/components/ToastCannotMixMultiAndNonMultiAttachments.stories.tsx b/ts/components/ToastCannotMixMultiAndNonMultiAttachments.stories.tsx deleted file mode 100644 index 1c4b13549f..0000000000 --- a/ts/components/ToastCannotMixMultiAndNonMultiAttachments.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { ToastCannotMixMultiAndNonMultiAttachments } from './ToastCannotMixMultiAndNonMultiAttachments'; - -import { setupI18n } from '../util/setupI18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const defaultProps = { - i18n, - onClose: action('onClose'), -}; - -export default { - title: 'Components/ToastCannotMixMultiAndNonMultiAttachments', -}; - -export const _ToastCannotMixMultiAndNonMultiAttachments = (): JSX.Element => ( - -); - -_ToastCannotMixMultiAndNonMultiAttachments.story = { - name: 'ToastCannotMixMultiAndNonMultiAttachments', -}; diff --git a/ts/components/ToastCannotMixMultiAndNonMultiAttachments.tsx b/ts/components/ToastCannotMixMultiAndNonMultiAttachments.tsx deleted file mode 100644 index a402c18916..0000000000 --- a/ts/components/ToastCannotMixMultiAndNonMultiAttachments.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import type { LocalizerType } from '../types/Util'; -import { Toast } from './Toast'; - -type PropsType = { - i18n: LocalizerType; - onClose: () => unknown; -}; - -export function ToastCannotMixMultiAndNonMultiAttachments({ - i18n, - onClose, -}: PropsType): JSX.Element { - return ( - - {i18n('cannotSelectPhotosAndVideosAlongWithFiles')} - - ); -} diff --git a/ts/components/ToastFileSize.stories.tsx b/ts/components/ToastFileSize.stories.tsx index 4641897d8d..9835d69d90 100644 --- a/ts/components/ToastFileSize.stories.tsx +++ b/ts/components/ToastFileSize.stories.tsx @@ -20,7 +20,7 @@ export default { }; export const _ToastFileSize = (): JSX.Element => ( - + ); _ToastFileSize.story = { diff --git a/ts/components/ToastFileSize.tsx b/ts/components/ToastFileSize.tsx index a3954f1dfb..50e5ee464b 100644 --- a/ts/components/ToastFileSize.tsx +++ b/ts/components/ToastFileSize.tsx @@ -6,7 +6,7 @@ import type { LocalizerType } from '../types/Util'; import { Toast } from './Toast'; export type ToastPropsType = { - limit: number; + limit: string; units: string; }; @@ -23,8 +23,7 @@ export function ToastFileSize({ }: PropsType): JSX.Element { return ( - {i18n('fileSizeWarning')} {limit} - {units} + {i18n('icu:fileSizeWarning', { limit, units })} ); } diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 5328c4bd61..181a8f279e 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -49,6 +49,13 @@ AddingUserToGroup.args = { }, }; +export const CannotMixMultiAndNonMultiAttachments = Template.bind({}); +CannotMixMultiAndNonMultiAttachments.args = { + toast: { + toastType: ToastType.CannotMixMultiAndNonMultiAttachments, + }, +}; + export const CannotStartGroupCall = Template.bind({}); CannotStartGroupCall.args = { toast: { @@ -70,6 +77,13 @@ CopiedUsernameLink.args = { }, }; +export const DangerousFileType = Template.bind({}); +DangerousFileType.args = { + toast: { + toastType: ToastType.DangerousFileType, + }, +}; + export const DeleteForEveryoneFailed = Template.bind({}); DeleteForEveryoneFailed.args = { toast: { @@ -91,6 +105,24 @@ FailedToDeleteUsername.args = { }, }; +export const FileSize = Template.bind({}); +FileSize.args = { + toast: { + toastType: ToastType.FileSize, + parameters: { + limit: '100', + units: 'MB', + }, + }, +}; + +export const MaxAttachments = Template.bind({}); +MaxAttachments.args = { + toast: { + toastType: ToastType.MaxAttachments, + }, +}; + export const MessageBodyTooLong = Template.bind({}); MessageBodyTooLong.args = { toast: { @@ -105,13 +137,6 @@ PinnedConversationsFull.args = { }, }; -export const StoryMuted = Template.bind({}); -StoryMuted.args = { - toast: { - toastType: ToastType.StoryMuted, - }, -}; - export const ReportedSpamAndBlocked = Template.bind({}); ReportedSpamAndBlocked.args = { toast: { @@ -119,6 +144,13 @@ ReportedSpamAndBlocked.args = { }, }; +export const StoryMuted = Template.bind({}); +StoryMuted.args = { + toast: { + toastType: ToastType.StoryMuted, + }, +}; + export const StoryReact = Template.bind({}); StoryReact.args = { toast: { @@ -154,6 +186,20 @@ StoryVideoUnsupported.args = { }, }; +export const UnableToLoadAttachment = Template.bind({}); +UnableToLoadAttachment.args = { + toast: { + toastType: ToastType.UnableToLoadAttachment, + }, +}; + +export const UnsupportedMultiAttachment = Template.bind({}); +UnsupportedMultiAttachment.args = { + toast: { + toastType: ToastType.UnsupportedMultiAttachment, + }, +}; + export const UserAddedToGroup = Template.bind({}); UserAddedToGroup.args = { toast: { diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index dbe1cbea53..d050d72744 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -42,6 +42,14 @@ export function ToastManager({ ); } + if (toastType === ToastType.CannotMixMultiAndNonMultiAttachments) { + return ( + + {i18n('cannotSelectPhotosAndVideosAlongWithFiles')} + + ); + } + if (toastType === ToastType.CannotStartGroupCall) { return ( @@ -66,6 +74,10 @@ export function ToastManager({ ); } + if (toastType === ToastType.DangerousFileType) { + return {i18n('dangerousFileType')}; + } + if (toastType === ToastType.DeleteForEveryoneFailed) { return {i18n('deleteForEveryoneFailed')}; } @@ -93,6 +105,18 @@ export function ToastManager({ ); } + if (toastType === ToastType.FileSize) { + return ( + + {i18n('icu:fileSizeWarning', toast?.parameters)} + + ); + } + + if (toastType === ToastType.MaxAttachments) { + return {i18n('maximumAttachments')}; + } + if (toastType === ToastType.MessageBodyTooLong) { return ; } @@ -157,6 +181,18 @@ export function ToastManager({ ); } + if (toastType === ToastType.UnableToLoadAttachment) { + return {i18n('unableToLoadAttachment')}; + } + + if (toastType === ToastType.UnsupportedMultiAttachment) { + return ( + + {i18n('cannotSelectPhotosAndVideosAlongWithFiles')} + + ); + } + if (toastType === ToastType.UserAddedToGroup) { return ( diff --git a/ts/components/ToastMaxAttachments.stories.tsx b/ts/components/ToastMaxAttachments.stories.tsx deleted file mode 100644 index 0230e7417d..0000000000 --- a/ts/components/ToastMaxAttachments.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { ToastMaxAttachments } from './ToastMaxAttachments'; - -import { setupI18n } from '../util/setupI18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const defaultProps = { - i18n, - onClose: action('onClose'), -}; - -export default { - title: 'Components/ToastMaxAttachments', -}; - -export const _ToastMaxAttachments = (): JSX.Element => ( - -); - -_ToastMaxAttachments.story = { - name: 'ToastMaxAttachments', -}; diff --git a/ts/components/ToastMaxAttachments.tsx b/ts/components/ToastMaxAttachments.tsx deleted file mode 100644 index 5b3acb47b5..0000000000 --- a/ts/components/ToastMaxAttachments.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import type { LocalizerType } from '../types/Util'; -import { Toast } from './Toast'; - -type PropsType = { - i18n: LocalizerType; - onClose: () => unknown; -}; - -export function ToastMaxAttachments({ i18n, onClose }: PropsType): JSX.Element { - return {i18n('maximumAttachments')}; -} diff --git a/ts/components/ToastUnableToLoadAttachment.stories.tsx b/ts/components/ToastUnableToLoadAttachment.stories.tsx deleted file mode 100644 index 62cb1f777d..0000000000 --- a/ts/components/ToastUnableToLoadAttachment.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { ToastUnableToLoadAttachment } from './ToastUnableToLoadAttachment'; - -import { setupI18n } from '../util/setupI18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const defaultProps = { - i18n, - onClose: action('onClose'), -}; - -export default { - title: 'Components/ToastUnableToLoadAttachment', -}; - -export const _ToastUnableToLoadAttachment = (): JSX.Element => ( - -); - -_ToastUnableToLoadAttachment.story = { - name: 'ToastUnableToLoadAttachment', -}; diff --git a/ts/components/ToastUnsupportedMultiAttachment.stories.tsx b/ts/components/ToastUnsupportedMultiAttachment.stories.tsx deleted file mode 100644 index b0f07d3811..0000000000 --- a/ts/components/ToastUnsupportedMultiAttachment.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import { action } from '@storybook/addon-actions'; -import { ToastUnsupportedMultiAttachment } from './ToastUnsupportedMultiAttachment'; - -import { setupI18n } from '../util/setupI18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const defaultProps = { - i18n, - onClose: action('onClose'), -}; - -export default { - title: 'Components/ToastUnsupportedMultiAttachment', -}; - -export const _ToastUnsupportedMultiAttachment = (): JSX.Element => ( - -); - -_ToastUnsupportedMultiAttachment.story = { - name: 'ToastUnsupportedMultiAttachment', -}; diff --git a/ts/components/ToastUnsupportedMultiAttachment.tsx b/ts/components/ToastUnsupportedMultiAttachment.tsx deleted file mode 100644 index 56ed86c175..0000000000 --- a/ts/components/ToastUnsupportedMultiAttachment.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; -import type { LocalizerType } from '../types/Util'; -import { Toast } from './Toast'; - -type PropsType = { - i18n: LocalizerType; - onClose: () => unknown; -}; - -export function ToastUnsupportedMultiAttachment({ - i18n, - onClose, -}: PropsType): JSX.Element { - return ( - - {i18n('cannotSelectPhotosAndVideosAlongWithFiles')} - - ); -} diff --git a/ts/components/conversation/ConversationView.tsx b/ts/components/conversation/ConversationView.tsx index c024e12ff7..eb22801e36 100644 --- a/ts/components/conversation/ConversationView.tsx +++ b/ts/components/conversation/ConversationView.tsx @@ -4,18 +4,82 @@ import React from 'react'; export type PropsType = { + conversationId: string; + processAttachments: (options: { + conversationId: string; + files: ReadonlyArray; + }) => void; renderCompositionArea: () => JSX.Element; renderConversationHeader: () => JSX.Element; renderTimeline: () => JSX.Element; }; export function ConversationView({ + conversationId, + processAttachments, renderCompositionArea, renderConversationHeader, renderTimeline, }: PropsType): JSX.Element { + const onDrop = React.useCallback( + (event: React.DragEvent) => { + if (!event.dataTransfer) { + return; + } + + if (event.dataTransfer.types[0] !== 'Files') { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const { files } = event.dataTransfer; + processAttachments({ + conversationId, + files: Array.from(files), + }); + }, + [conversationId, processAttachments] + ); + + const onPaste = React.useCallback( + (event: React.ClipboardEvent) => { + if (!event.clipboardData) { + return; + } + const { items } = event.clipboardData; + + const anyImages = [...items].some( + item => item.type.split('/')[0] === 'image' + ); + if (!anyImages) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const files: Array = []; + for (let i = 0; i < items.length; i += 1) { + if (items[i].type.split('/')[0] === 'image') { + const file = items[i].getAsFile(); + if (file) { + files.push(file); + } + } + } + + processAttachments({ + conversationId, + files, + }); + }, + [conversationId, processAttachments] + ); + return ( -
+
{renderConversationHeader()}
diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index d95bcf5bce..d26a9f6803 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -1,6 +1,8 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import path from 'path'; + import type { ThunkAction } from 'redux-thunk'; import * as log from '../../logging/log'; @@ -25,9 +27,19 @@ import { writeDraftAttachment } from '../../util/writeDraftAttachment'; import { deleteDraftAttachment } from '../../util/deleteDraftAttachment'; import { replaceIndex } from '../../util/replaceIndex'; import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk'; -import type { HandleAttachmentsProcessingArgsType } from '../../util/handleAttachmentsProcessing'; -import { handleAttachmentsProcessing } from '../../util/handleAttachmentsProcessing'; import { LinkPreviewSourceType } from '../../types/LinkPreview'; +import { RecordingState } from './audioRecorder'; +import { hasLinkPreviewLoaded } from '../../services/LinkPreview'; +import { SHOW_TOAST, ToastType } from './toast'; +import type { ShowToastActionType } from './toast'; +import { getMaximumAttachmentSize } from '../../util/attachments'; +import { isFileDangerous } from '../../util/isFileDangerous'; +import { isImage, isVideo, stringToMIMEType } from '../../types/MIME'; +import { + getRenderDetailsForLimit, + processAttachment, +} from '../../util/processAttachment'; +import type { ReplacementValuesType } from '../../types/Util'; // State @@ -187,11 +199,112 @@ function addPendingAttachment( }; } -function processAttachments( - options: HandleAttachmentsProcessingArgsType -): ThunkAction { - return async dispatch => { - await handleAttachmentsProcessing(options); +function processAttachments({ + conversationId, + files, +}: { + conversationId: string; + files: ReadonlyArray; +}): ThunkAction< + void, + RootStateType, + unknown, + NoopActionType | ShowToastActionType +> { + return async (dispatch, getState) => { + if (!files.length) { + return; + } + + // If the call came from a conversation we are no longer in we do not + // update the state. + if (getState().conversations.selectedConversationId !== conversationId) { + return; + } + + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('processAttachments: Unable to find conv'); + } + + const state = getState(); + const isRecording = + state.audioRecorder.recordingState === RecordingState.Recording; + + if (hasLinkPreviewLoaded() || isRecording) { + return; + } + + let toastToShow: + | { toastType: ToastType; parameters?: ReplacementValuesType } + | undefined; + + const nextDraftAttachments = ( + conversation.get('draftAttachments') || [] + ).slice(); + const filesToProcess: Array = []; + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + const processingResult = preProcessAttachment(file, nextDraftAttachments); + if (processingResult != null) { + toastToShow = processingResult; + } else { + const pendingAttachment = getPendingAttachment(file); + if (pendingAttachment) { + addPendingAttachment(conversationId, pendingAttachment)( + dispatch, + getState, + undefined + ); + filesToProcess.push(file); + // we keep a running count of the draft attachments so we can show a + // toast in case we add too many attachments at once + nextDraftAttachments.push(pendingAttachment); + } + } + } + + await Promise.all( + filesToProcess.map(async file => { + try { + const attachment = await processAttachment(file); + if (!attachment) { + removeAttachment(conversationId, file.path)( + dispatch, + getState, + undefined + ); + return; + } + addAttachment(conversationId, attachment)( + dispatch, + getState, + undefined + ); + } catch (err) { + log.error( + 'handleAttachmentsProcessing: failed to process attachment:', + err.stack + ); + removeAttachment(conversationId, file.path)( + dispatch, + getState, + undefined + ); + toastToShow = { toastType: ToastType.UnableToLoadAttachment }; + } + }) + ); + + if (toastToShow) { + dispatch({ + type: SHOW_TOAST, + payload: toastToShow, + }); + + return; + } + dispatch({ type: 'NOOP', payload: null, @@ -199,6 +312,70 @@ function processAttachments( }; } +function preProcessAttachment( + file: File, + draftAttachments: Array +): { toastType: ToastType; parameters?: ReplacementValuesType } | undefined { + if (!file) { + return; + } + + const limitKb = getMaximumAttachmentSize(); + if (file.size > limitKb) { + return { + toastType: ToastType.FileSize, + parameters: getRenderDetailsForLimit(limitKb), + }; + } + + if (isFileDangerous(file.name)) { + return { toastType: ToastType.DangerousFileType }; + } + + if (draftAttachments.length >= 32) { + return { toastType: ToastType.MaxAttachments }; + } + + const haveNonImageOrVideo = draftAttachments.some( + (attachment: AttachmentDraftType) => { + return ( + !isImage(attachment.contentType) && !isVideo(attachment.contentType) + ); + } + ); + // You can't add another attachment if you already have a non-image staged + if (haveNonImageOrVideo) { + return { toastType: ToastType.UnsupportedMultiAttachment }; + } + + const fileType = stringToMIMEType(file.type); + const imageOrVideo = isImage(fileType) || isVideo(fileType); + + // You can't add a non-image attachment if you already have attachments staged + if (!imageOrVideo && draftAttachments.length > 0) { + return { toastType: ToastType.CannotMixMultiAndNonMultiAttachments }; + } + + return undefined; +} + +function getPendingAttachment(file: File): AttachmentDraftType | undefined { + if (!file) { + return; + } + + const fileType = stringToMIMEType(file.type); + const { name: fileName } = path.parse(file.name); + + return { + contentType: fileType, + fileName, + size: file.size, + path: file.name, + pending: true, + }; +} + function removeAttachment( conversationId: string, filePath: string diff --git a/ts/state/ducks/toast.ts b/ts/state/ducks/toast.ts index ad53e94441..950045cc6a 100644 --- a/ts/state/ducks/toast.ts +++ b/ts/state/ducks/toast.ts @@ -6,12 +6,16 @@ import type { ReplacementValuesType } from '../../types/Util'; export enum ToastType { AddingUserToGroup = 'AddingUserToGroup', + CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments', CannotStartGroupCall = 'CannotStartGroupCall', CopiedUsername = 'CopiedUsername', CopiedUsernameLink = 'CopiedUsernameLink', + DangerousFileType = 'DangerousFileType', DeleteForEveryoneFailed = 'DeleteForEveryoneFailed', Error = 'Error', FailedToDeleteUsername = 'FailedToDeleteUsername', + FileSize = 'FileSize', + MaxAttachments = 'MaxAttachments', MessageBodyTooLong = 'MessageBodyTooLong', PinnedConversationsFull = 'PinnedConversationsFull', ReportedSpamAndBlocked = 'ReportedSpamAndBlocked', @@ -21,6 +25,8 @@ export enum ToastType { StoryVideoError = 'StoryVideoError', StoryVideoTooLong = 'StoryVideoTooLong', StoryVideoUnsupported = 'StoryVideoUnsupported', + UnableToLoadAttachment = 'UnableToLoadAttachment', + UnsupportedMultiAttachment = 'UnsupportedMultiAttachment', UserAddedToGroup = 'UserAddedToGroup', } diff --git a/ts/state/smart/ConversationView.tsx b/ts/state/smart/ConversationView.tsx index 06ff007c4a..517b53617f 100644 --- a/ts/state/smart/ConversationView.tsx +++ b/ts/state/smart/ConversationView.tsx @@ -14,6 +14,7 @@ import type { TimelinePropsType } from './Timeline'; import { SmartTimeline } from './Timeline'; export type PropsType = { + conversationId: string; compositionAreaProps: Pick< CompositionAreaPropsType, | 'clearQuotedMessage' @@ -38,10 +39,15 @@ export type PropsType = { }; const mapStateToProps = (_state: StateType, props: PropsType) => { - const { compositionAreaProps, conversationHeaderProps, timelineProps } = - props; + const { + compositionAreaProps, + conversationHeaderProps, + conversationId, + timelineProps, + } = props; return { + conversationId, renderCompositionArea: () => ( ), diff --git a/ts/types/AttachmentToastType.ts b/ts/types/AttachmentToastType.ts deleted file mode 100644 index f378dfae7b..0000000000 --- a/ts/types/AttachmentToastType.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -export enum AttachmentToastType { - ToastCannotMixMultiAndNonMultiAttachments, - ToastDangerousFileType, - ToastFileSize, - ToastMaxAttachments, - ToastUnsupportedMultiAttachment, - ToastUnableToLoadAttachment, -} diff --git a/ts/util/handleAttachmentsProcessing.ts b/ts/util/handleAttachmentsProcessing.ts deleted file mode 100644 index 8b16ab5836..0000000000 --- a/ts/util/handleAttachmentsProcessing.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { - getPendingAttachment, - preProcessAttachment, - processAttachment, -} from './processAttachment'; -import type { - AttachmentDraftType, - InMemoryAttachmentDraftType, -} from '../types/Attachment'; -import { AttachmentToastType } from '../types/AttachmentToastType'; -import * as log from '../logging/log'; - -export type AddAttachmentActionType = ( - conversationId: string, - attachment: InMemoryAttachmentDraftType -) => unknown; -export type AddPendingAttachmentActionType = ( - conversationId: string, - pendingAttachment: AttachmentDraftType -) => unknown; -export type RemoveAttachmentActionType = ( - conversationId: string, - filePath: string -) => unknown; - -export type HandleAttachmentsProcessingArgsType = { - addAttachment: AddAttachmentActionType; - addPendingAttachment: AddPendingAttachmentActionType; - conversationId: string; - draftAttachments: ReadonlyArray; - files: ReadonlyArray; - onShowToast: (toastType: AttachmentToastType) => unknown; - removeAttachment: RemoveAttachmentActionType; -}; - -export async function handleAttachmentsProcessing({ - addAttachment, - addPendingAttachment, - conversationId, - draftAttachments, - files, - onShowToast, - removeAttachment, -}: HandleAttachmentsProcessingArgsType): Promise { - if (!files.length) { - return; - } - - const nextDraftAttachments = [...draftAttachments]; - const filesToProcess: Array = []; - for (let i = 0; i < files.length; i += 1) { - const file = files[i]; - const processingResult = preProcessAttachment(file, nextDraftAttachments); - if (processingResult != null) { - onShowToast(processingResult); - } else { - const pendingAttachment = getPendingAttachment(file); - if (pendingAttachment) { - addPendingAttachment(conversationId, pendingAttachment); - filesToProcess.push(file); - // we keep a running count of the draft attachments so we can show a - // toast in case we add too many attachments at once - nextDraftAttachments.push(pendingAttachment); - } - } - } - - await Promise.all( - filesToProcess.map(async file => { - try { - const attachment = await processAttachment(file); - if (!attachment) { - removeAttachment(conversationId, file.path); - return; - } - addAttachment(conversationId, attachment); - } catch (err) { - log.error( - 'handleAttachmentsProcessing: failed to process attachment:', - err.stack - ); - removeAttachment(conversationId, file.path); - onShowToast(AttachmentToastType.ToastUnableToLoadAttachment); - } - }) - ); -} diff --git a/ts/util/isAttachmentSizeOkay.ts b/ts/util/isAttachmentSizeOkay.ts deleted file mode 100644 index adcacc01ee..0000000000 --- a/ts/util/isAttachmentSizeOkay.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { AttachmentType } from '../types/Attachment'; -import { getMaximumAttachmentSize } from './attachments'; -import { showToast } from './showToast'; -import { ToastFileSize } from '../components/ToastFileSize'; - -export function isAttachmentSizeOkay( - attachment: Readonly -): boolean { - const limitKb = getMaximumAttachmentSize(); - // this needs to be cast properly - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if ((attachment.data.byteLength / 1024).toFixed(4) >= limitKb) { - const units = ['kB', 'MB', 'GB']; - let u = -1; - let limit = limitKb * 1000; - do { - limit /= 1000; - u += 1; - } while (limit >= 1000 && u < units.length - 1); - showToast(ToastFileSize, { - limit, - units: units[u], - }); - return false; - } - - return true; -} diff --git a/ts/util/processAttachment.ts b/ts/util/processAttachment.ts index 13739303c2..f6d0a5667e 100644 --- a/ts/util/processAttachment.ts +++ b/ts/util/processAttachment.ts @@ -1,85 +1,20 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import path from 'path'; - import * as log from '../logging/log'; import type { - AttachmentDraftType, + AttachmentType, InMemoryAttachmentDraftType, } from '../types/Attachment'; -import * as Errors from '../types/errors'; import { getMaximumAttachmentSize } from './attachments'; -import { AttachmentToastType } from '../types/AttachmentToastType'; +import * as Errors from '../types/errors'; import { fileToBytes } from './fileToBytes'; import { handleImageAttachment } from './handleImageAttachment'; import { handleVideoAttachment } from './handleVideoAttachment'; -import { isAttachmentSizeOkay } from './isAttachmentSizeOkay'; -import { isFileDangerous } from './isFileDangerous'; -import { isHeic, isImage, isVideo, stringToMIMEType } from '../types/MIME'; +import { isHeic, stringToMIMEType } from '../types/MIME'; import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome'; - -export function getPendingAttachment( - file: File -): AttachmentDraftType | undefined { - if (!file) { - return; - } - - const fileType = stringToMIMEType(file.type); - const { name: fileName } = path.parse(file.name); - - return { - contentType: fileType, - fileName, - size: file.size, - path: file.name, - pending: true, - }; -} - -export function preProcessAttachment( - file: File, - draftAttachments: Array -): AttachmentToastType | undefined { - if (!file) { - return; - } - - if (file.size > getMaximumAttachmentSize()) { - return AttachmentToastType.ToastFileSize; - } - - if (isFileDangerous(file.name)) { - return AttachmentToastType.ToastDangerousFileType; - } - - if (draftAttachments.length >= 32) { - return AttachmentToastType.ToastMaxAttachments; - } - - const haveNonImageOrVideo = draftAttachments.some( - (attachment: AttachmentDraftType) => { - return ( - !isImage(attachment.contentType) && !isVideo(attachment.contentType) - ); - } - ); - // You can't add another attachment if you already have a non-image staged - if (haveNonImageOrVideo) { - return AttachmentToastType.ToastUnsupportedMultiAttachment; - } - - const fileType = stringToMIMEType(file.type); - const imageOrVideo = isImage(fileType) || isVideo(fileType); - - // You can't add a non-image attachment if you already have attachments staged - if (!imageOrVideo && draftAttachments.length > 0) { - return AttachmentToastType.ToastCannotMixMultiAndNonMultiAttachments; - } - - return undefined; -} +import { showToast } from './showToast'; +import { ToastFileSize } from '../components/ToastFileSize'; export async function processAttachment( file: File @@ -132,3 +67,34 @@ export async function processAttachment( throw error; } } + +export function getRenderDetailsForLimit(limitKb: number): { + limit: string; + units: string; +} { + const units = ['kB', 'MB', 'GB']; + let u = -1; + let limit = limitKb * 1000; + do { + limit /= 1000; + u += 1; + } while (limit >= 1000 && u < units.length - 1); + + return { + limit: limit.toFixed(0), + units: units[u], + }; +} + +function isAttachmentSizeOkay(attachment: Readonly): boolean { + const limitKb = getMaximumAttachmentSize(); + // this needs to be cast properly + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if ((attachment.data.byteLength / 1024).toFixed(4) >= limitKb) { + showToast(ToastFileSize, getRenderDetailsForLimit(limitKb)); + return false; + } + + return true; +} diff --git a/ts/util/showToast.tsx b/ts/util/showToast.tsx index e6d445805a..5b6005c30e 100644 --- a/ts/util/showToast.tsx +++ b/ts/util/showToast.tsx @@ -8,7 +8,6 @@ import type { ToastAlreadyGroupMember } from '../components/ToastAlreadyGroupMem import type { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequestedToJoin'; import type { ToastBlocked } from '../components/ToastBlocked'; import type { ToastBlockedGroup } from '../components/ToastBlockedGroup'; -import type { ToastUnsupportedMultiAttachment } from '../components/ToastUnsupportedMultiAttachment'; import type { ToastCannotOpenGiftBadge, ToastPropsType as ToastCannotOpenGiftBadgePropsType, @@ -21,7 +20,6 @@ import type { } from '../components/ToastConversationArchived'; import type { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import type { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; -import type { ToastDangerousFileType } from '../components/ToastDangerousFileType'; import type { ToastInternalError, ToastPropsType as ToastInternalErrorPropsType, @@ -40,7 +38,6 @@ import type { ToastInvalidConversation } from '../components/ToastInvalidConvers import type { ToastLeftGroup } from '../components/ToastLeftGroup'; import type { ToastLinkCopied } from '../components/ToastLinkCopied'; import type { ToastLoadingFullLogs } from '../components/ToastLoadingFullLogs'; -import type { ToastMaxAttachments } from '../components/ToastMaxAttachments'; import type { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong'; import type { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound'; @@ -56,7 +53,6 @@ export function showToast(Toast: typeof ToastAlreadyGroupMember): void; export function showToast(Toast: typeof ToastAlreadyRequestedToJoin): void; export function showToast(Toast: typeof ToastBlocked): void; export function showToast(Toast: typeof ToastBlockedGroup): void; -export function showToast(Toast: typeof ToastUnsupportedMultiAttachment): void; export function showToast( Toast: typeof ToastCannotOpenGiftBadge, props: Omit @@ -69,7 +65,6 @@ export function showToast( ): void; export function showToast(Toast: typeof ToastConversationMarkedUnread): void; export function showToast(Toast: typeof ToastConversationUnarchived): void; -export function showToast(Toast: typeof ToastDangerousFileType): void; export function showToast( Toast: typeof ToastInternalError, props: ToastInternalErrorPropsType @@ -88,9 +83,7 @@ export function showToast(Toast: typeof ToastInvalidConversation): void; export function showToast(Toast: typeof ToastLeftGroup): void; export function showToast(Toast: typeof ToastLinkCopied): void; export function showToast(Toast: typeof ToastLoadingFullLogs): void; -export function showToast(Toast: typeof ToastMaxAttachments): void; export function showToast(Toast: typeof ToastMessageBodyTooLong): void; -export function showToast(Toast: typeof ToastUnsupportedMultiAttachment): void; export function showToast(Toast: typeof ToastOriginalMessageNotFound): void; export function showToast(Toast: typeof ToastReactionFailed): void; export function showToast(Toast: typeof ToastStickerPackInstallFailed): void; diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 8446a2475c..c761b00eb5 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -19,10 +19,8 @@ import type { ConversationModel } from '../models/conversations'; import type { GroupV2PendingMemberType, MessageAttributesType, - QuotedMessageType, } from '../model-types.d'; import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem'; -import type { MessageModel } from '../models/messages'; import { getMessageById } from '../messages/getMessageById'; import { getContactId } from '../messages/helpers'; import { strictAssert } from '../util/assert'; @@ -53,22 +51,17 @@ import { ConversationDetailsMembershipList } from '../components/conversation/co import * as log from '../logging/log'; import type { EmbeddedContactType } from '../types/EmbeddedContact'; import { createConversationView } from '../state/roots/createConversationView'; -import { AttachmentToastType } from '../types/AttachmentToastType'; import type { CompositionAPIType } from '../components/CompositionArea'; import { ToastBlocked } from '../components/ToastBlocked'; import { ToastBlockedGroup } from '../components/ToastBlockedGroup'; -import { ToastCannotMixMultiAndNonMultiAttachments } from '../components/ToastCannotMixMultiAndNonMultiAttachments'; import { ToastConversationArchived } from '../components/ToastConversationArchived'; import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; import { ToastDangerousFileType } from '../components/ToastDangerousFileType'; import { ToastExpired } from '../components/ToastExpired'; -import { ToastFileSize } from '../components/ToastFileSize'; import { ToastInvalidConversation } from '../components/ToastInvalidConversation'; import { ToastLeftGroup } from '../components/ToastLeftGroup'; -import { ToastMaxAttachments } from '../components/ToastMaxAttachments'; import { ToastMessageBodyTooLong } from '../components/ToastMessageBodyTooLong'; -import { ToastUnsupportedMultiAttachment } from '../components/ToastUnsupportedMultiAttachment'; import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound'; import { ToastReactionFailed } from '../components/ToastReactionFailed'; import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming'; @@ -81,7 +74,6 @@ import { isNotNil } from '../util/isNotNil'; import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser'; import { resolveAttachmentDraftData } from '../util/resolveAttachmentDraftData'; import { showToast } from '../util/showToast'; -import { RecordingState } from '../state/ducks/audioRecorder'; import { UUIDKind } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID'; import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone'; @@ -90,7 +82,6 @@ import { MediaGallery } from '../components/conversation/media-gallery/MediaGall import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent'; import { getLinkPreviewForSend, - hasLinkPreviewLoaded, maybeGrabLinkPreview, removeLinkPreview, resetLinkPreview, @@ -200,10 +191,6 @@ export class ConversationView extends window.Backbone.View { } = { current: undefined }; private sendStart?: number; - // Quotes - private quote?: QuotedMessageType; - private quotedMessage?: MessageModel; - // Sub-views private contactModalView?: Backbone.View; private conversationView?: Backbone.View; @@ -241,8 +228,12 @@ export class ConversationView extends window.Backbone.View { this.model, 'toggle-reply', (messageId: string | undefined) => { - const target = this.quote || !messageId ? null : messageId; - this.setQuoteMessage(target); + const composerState = window.reduxStore + ? window.reduxStore.getState().composer + : undefined; + const quote = composerState?.quotedMessage?.quote; + + this.setQuoteMessage(quote ? undefined : messageId); } ); this.listenTo( @@ -458,7 +449,7 @@ export class ConversationView extends window.Backbone.View { ) => this.onEditorStateChange(msg, bodyRanges, caretLocation), onTextTooLong: () => showToast(ToastMessageBodyTooLong), getQuotedMessage: () => this.model.get('quotedMessageId'), - clearQuotedMessage: () => this.setQuoteMessage(null), + clearQuotedMessage: () => this.setQuoteMessage(undefined), onStartGroupMigration: () => this.startMigrationToGV2(), onCancelJoinRequest: async () => { await window.showConfirmationDialog({ @@ -516,6 +507,7 @@ export class ConversationView extends window.Backbone.View { // createConversationView root const JSX = createConversationView(window.reduxStore, { + conversationId: this.model.id, compositionAreaProps, conversationHeaderProps, timelineProps, @@ -750,59 +742,6 @@ export class ConversationView extends window.Backbone.View { }); } - // TODO DESKTOP-2426 - async processAttachments(files: Array): Promise { - const state = window.reduxStore.getState(); - - const isRecording = - state.audioRecorder.recordingState === RecordingState.Recording; - - if (hasLinkPreviewLoaded() || isRecording) { - return; - } - - const { - addAttachment, - addPendingAttachment, - processAttachments, - removeAttachment, - } = window.reduxActions.composer; - - await processAttachments({ - addAttachment, - addPendingAttachment, - conversationId: this.model.id, - draftAttachments: this.model.get('draftAttachments') || [], - files, - onShowToast: (toastType: AttachmentToastType) => { - if (toastType === AttachmentToastType.ToastFileSize) { - showToast(ToastFileSize, { - limit: 100, - units: 'MB', - }); - } else if (toastType === AttachmentToastType.ToastDangerousFileType) { - showToast(ToastDangerousFileType); - } else if (toastType === AttachmentToastType.ToastMaxAttachments) { - showToast(ToastMaxAttachments); - } else if ( - toastType === AttachmentToastType.ToastUnsupportedMultiAttachment - ) { - showToast(ToastUnsupportedMultiAttachment); - } else if ( - toastType === - AttachmentToastType.ToastCannotMixMultiAndNonMultiAttachments - ) { - showToast(ToastCannotMixMultiAndNonMultiAttachments); - } else if ( - toastType === AttachmentToastType.ToastUnableToLoadAttachment - ) { - showToast(ToastUnableToLoadAttachment); - } - }, - removeAttachment, - }); - } - unload(reason: string): void { log.info( 'unloading conversation', @@ -865,101 +804,10 @@ export class ConversationView extends window.Backbone.View { this.remove(); } - async onDrop(e: JQuery.TriggeredEvent): Promise { - if (!e.originalEvent) { - return; - } - const event = e.originalEvent as DragEvent; - if (!event.dataTransfer) { - return; - } - - if (event.dataTransfer.types[0] !== 'Files') { - return; - } - - e.stopPropagation(); - e.preventDefault(); - - const { files } = event.dataTransfer; - this.processAttachments(Array.from(files)); - } - - onPaste(e: JQuery.TriggeredEvent): void { - if (!e.originalEvent) { - return; - } - const event = e.originalEvent as ClipboardEvent; - if (!event.clipboardData) { - return; - } - const { items } = event.clipboardData; - - const anyImages = [...items].some( - item => item.type.split('/')[0] === 'image' - ); - if (!anyImages) { - return; - } - - e.stopPropagation(); - e.preventDefault(); - - const files: Array = []; - for (let i = 0; i < items.length; i += 1) { - if (items[i].type.split('/')[0] === 'image') { - const file = items[i].getAsFile(); - if (file) { - files.push(file); - } - } - } - - this.processAttachments(files); - } - async saveModel(): Promise { window.Signal.Data.updateConversation(this.model.attributes); } - async clearAttachments(): Promise { - const draftAttachments = this.model.get('draftAttachments') || []; - this.model.set({ - draftAttachments: [], - draftChanged: true, - }); - - this.updateAttachmentsView(); - - // We're fine doing this all at once; at most it should be 32 attachments - await Promise.all([ - this.saveModel(), - Promise.all( - draftAttachments.map(attachment => deleteDraftAttachment(attachment)) - ), - ]); - } - - hasFiles(options: { includePending: boolean }): boolean { - const draftAttachments = this.model.get('draftAttachments') || []; - if (options.includePending) { - return draftAttachments.length > 0; - } - - return draftAttachments.some(item => !item.pending); - } - - updateAttachmentsView(): void { - const draftAttachments = this.model.get('draftAttachments') || []; - window.reduxActions.composer.replaceAttachments( - this.model.get('id'), - draftAttachments - ); - if (this.hasFiles({ includePending: true })) { - removeLinkPreview(); - } - } - async onOpened(messageId: string): Promise { this.model.onOpenStart(); @@ -1219,26 +1067,6 @@ export class ConversationView extends window.Backbone.View { update(); } - focusMessageField(): void { - if (this.panels && this.panels.length) { - return; - } - - this.compositionApi.current?.focusInput(); - } - - disableMessageField(): void { - this.compositionApi.current?.setDisabled(true); - } - - enableMessageField(): void { - this.compositionApi.current?.setDisabled(false); - } - - resetEmojiResults(): void { - this.compositionApi.current?.resetEmojiResults(); - } - showGV1Members(): void { const { contactCollection, id } = this.model; @@ -1922,74 +1750,6 @@ export class ConversationView extends window.Backbone.View { } } - async setQuoteMessage(messageId: null | string): Promise { - const { model } = this; - const message = messageId ? await getMessageById(messageId) : undefined; - - if ( - message && - !canReply( - message.attributes, - window.ConversationController.getOurConversationIdOrThrow(), - findAndFormatContact - ) - ) { - return; - } - - if (message && !message.isNormalBubble()) { - return; - } - - this.quote = undefined; - this.quotedMessage = undefined; - - const existing = model.get('quotedMessageId'); - if (existing !== messageId) { - const now = Date.now(); - let active_at = this.model.get('active_at'); - let timestamp = this.model.get('timestamp'); - - if (!active_at && messageId) { - active_at = now; - timestamp = now; - } - - this.model.set({ - active_at, - draftChanged: true, - quotedMessageId: messageId, - timestamp, - }); - - await this.saveModel(); - } - - if (message) { - this.quotedMessage = message; - this.quote = await model.makeQuote(this.quotedMessage); - - this.enableMessageField(); - this.focusMessageField(); - } - - this.renderQuotedMessage(); - } - - renderQuotedMessage(): void { - const { model }: { model: ConversationModel } = this; - - if (!this.quotedMessage) { - window.reduxActions.composer.setQuotedMessage(undefined); - return; - } - - window.reduxActions.composer.setQuotedMessage({ - conversationId: model.id, - quote: this.quote, - }); - } - showInvalidMessageToast(messageText?: string): boolean { const { model }: { model: ConversationModel } = this; @@ -2105,9 +1865,12 @@ export class ConversationView extends window.Backbone.View { ).filter(isNotNil); } - const shouldSendHighQualityAttachments = window.reduxStore - ? window.reduxStore.getState().composer.shouldSendHighQualityAttachments + const composerState = window.reduxStore + ? window.reduxStore.getState().composer : undefined; + const shouldSendHighQualityAttachments = + composerState?.shouldSendHighQualityAttachments; + const quote = composerState?.quotedMessage?.quote; const sendHQImages = shouldSendHighQualityAttachments !== undefined @@ -2122,7 +1885,7 @@ export class ConversationView extends window.Backbone.View { { body: message, attachments, - quote: this.quote, + quote, preview: getLinkPreviewForSend(message), mentions, }, @@ -2132,7 +1895,7 @@ export class ConversationView extends window.Backbone.View { extraReduxActions: () => { this.compositionApi.current?.reset(); this.model.setMarkedUnread(false); - this.setQuoteMessage(null); + this.setQuoteMessage(undefined); resetLinkPreview(); this.clearAttachments(); window.reduxActions.composer.resetComposer(); @@ -2149,12 +1912,35 @@ export class ConversationView extends window.Backbone.View { } } + focusMessageField(): void { + if (this.panels && this.panels.length) { + return; + } + + this.compositionApi.current?.focusInput(); + } + + disableMessageField(): void { + this.compositionApi.current?.setDisabled(true); + } + + enableMessageField(): void { + this.compositionApi.current?.setDisabled(false); + } + + resetEmojiResults(): void { + this.compositionApi.current?.resetEmojiResults(); + } + onEditorStateChange( messageText: string, bodyRanges: DraftBodyRangesType, caretLocation?: number ): void { - this.maybeBumpTyping(messageText); + if (messageText.length && this.model.throttledBumpTyping) { + this.model.throttledBumpTyping(); + } + this.debouncedSaveDraft(messageText, bodyRanges); // If we have attachments, don't add link preview @@ -2206,11 +1992,95 @@ export class ConversationView extends window.Backbone.View { } } - // Called whenever the user changes the message composition field. But only - // fires if there's content in the message field after the change. - maybeBumpTyping(messageText: string): void { - if (messageText.length && this.model.throttledBumpTyping) { - this.model.throttledBumpTyping(); + async setQuoteMessage(messageId: string | undefined): Promise { + const { model } = this; + const message = messageId ? await getMessageById(messageId) : undefined; + + if ( + message && + !canReply( + message.attributes, + window.ConversationController.getOurConversationIdOrThrow(), + findAndFormatContact + ) + ) { + return; + } + + if (message && !message.isNormalBubble()) { + return; + } + + const existing = model.get('quotedMessageId'); + if (existing !== messageId) { + const now = Date.now(); + let active_at = this.model.get('active_at'); + let timestamp = this.model.get('timestamp'); + + if (!active_at && messageId) { + active_at = now; + timestamp = now; + } + + this.model.set({ + active_at, + draftChanged: true, + quotedMessageId: messageId, + timestamp, + }); + + await this.saveModel(); + } + + if (message) { + const quote = await model.makeQuote(message); + window.reduxActions.composer.setQuotedMessage({ + conversationId: model.id, + quote, + }); + + this.enableMessageField(); + this.focusMessageField(); + } else { + window.reduxActions.composer.setQuotedMessage(undefined); + } + } + + async clearAttachments(): Promise { + const draftAttachments = this.model.get('draftAttachments') || []; + this.model.set({ + draftAttachments: [], + draftChanged: true, + }); + + this.updateAttachmentsView(); + + // We're fine doing this all at once; at most it should be 32 attachments + await Promise.all([ + this.saveModel(), + Promise.all( + draftAttachments.map(attachment => deleteDraftAttachment(attachment)) + ), + ]); + } + + hasFiles(options: { includePending: boolean }): boolean { + const draftAttachments = this.model.get('draftAttachments') || []; + if (options.includePending) { + return draftAttachments.length > 0; + } + + return draftAttachments.some(item => !item.pending); + } + + updateAttachmentsView(): void { + const draftAttachments = this.model.get('draftAttachments') || []; + window.reduxActions.composer.replaceAttachments( + this.model.get('id'), + draftAttachments + ); + if (this.hasFiles({ includePending: true })) { + removeLinkPreview(); } } }