diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index 5d976c0cf..a2368f171 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -30,15 +30,19 @@ const micCellEl = new DOMParser().parseFromString( ).body.firstElementChild as HTMLElement; const createProps = (overrideProps: Partial = {}): Props => ({ + conversationId: '123', i18n, micCellEl, - onChooseAttachment: action('onChooseAttachment'), + + addAttachment: action('addAttachment'), + addPendingAttachment: action('addPendingAttachment'), + processAttachments: action('processAttachments'), + removeAttachment: action('removeAttachment'), + // AttachmentList draftAttachments: overrideProps.draftAttachments || [], - onAddAttachment: action('onAddAttachment'), onClearAttachments: action('onClearAttachments'), onClickAttachment: action('onClickAttachment'), - onCloseAttachment: action('onCloseAttachment'), // StagedLinkPreview linkPreviewLoading: Boolean(overrideProps.linkPreviewLoading), linkPreviewResult: overrideProps.linkPreviewResult, diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 5717e14a9..187276fe0 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -1,7 +1,14 @@ // Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import * as React from 'react'; +import React, { + MutableRefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { get, noop } from 'lodash'; import classNames from 'classnames'; import { Spinner } from './Spinner'; @@ -39,52 +46,61 @@ import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { LinkPreviewWithDomain } from '../types/LinkPreview'; import { ConversationType } from '../state/ducks/conversations'; import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner'; +import { CompositionUpload } from './CompositionUpload'; +import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing'; export type CompositionAPIType = { focusInput: () => void; isDirty: () => boolean; setDisabled: (disabled: boolean) => void; - setShowMic: (showMic: boolean) => void; setMicActive: (micActive: boolean) => void; reset: InputApi['reset']; resetEmojiResults: InputApi['resetEmojiResults']; }; export type OwnProps = Readonly<{ - i18n: LocalizerType; - areWePending?: boolean; - areWePendingApproval?: boolean; + acceptedMessageRequest?: boolean; + addAttachment: ( + conversationId: string, + attachment: AttachmentType + ) => unknown; + addPendingAttachment: ( + conversationId: string, + pendingAttachment: AttachmentType + ) => unknown; announcementsOnly?: boolean; areWeAdmin?: boolean; + areWePending?: boolean; + areWePendingApproval?: boolean; + compositionApi?: MutableRefObject; + conversationId: string; + draftAttachments: ReadonlyArray; groupAdmins: Array; groupVersion?: 1 | 2; + i18n: LocalizerType; + isFetchingUUID?: boolean; isGroupV1AndDisabled?: boolean; isMissingMandatoryProfileSharing?: boolean; isSMSOnly?: boolean; - isFetchingUUID?: boolean; left?: boolean; + linkPreviewLoading: boolean; + linkPreviewResult?: LinkPreviewWithDomain; messageRequestsEnabled?: boolean; - acceptedMessageRequest?: boolean; - compositionApi?: React.MutableRefObject; micCellEl?: HTMLElement; - draftAttachments: ReadonlyArray; - shouldSendHighQualityAttachments: boolean; - onChooseAttachment(): unknown; - onAddAttachment(): unknown; - onClickAttachment(): unknown; - onCloseAttachment(): unknown; onClearAttachments(): unknown; + onClickAttachment(): unknown; + onClickQuotedMessage(): unknown; + onCloseLinkPreview(): unknown; + processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown; onSelectMediaQuality(isHQ: boolean): unknown; + openConversation(conversationId: string): unknown; quotedMessageProps?: Omit< QuoteProps, 'i18n' | 'onClick' | 'onClose' | 'withContentAbove' >; - onClickQuotedMessage(): unknown; + removeAttachment: (conversationId: string, filePath: string) => unknown; setQuotedMessage(message: undefined): unknown; - linkPreviewLoading: boolean; - linkPreviewResult?: LinkPreviewWithDomain; - onCloseLinkPreview(): unknown; - openConversation(conversationId: string): unknown; + shouldSendHighQualityAttachments: boolean; }>; export type Props = Pick< @@ -129,15 +145,19 @@ const emptyElement = (el: HTMLElement) => { }; export const CompositionArea = ({ + // Base props + addAttachment, + addPendingAttachment, + conversationId, i18n, micCellEl, - onChooseAttachment, + processAttachments, + removeAttachment, + // AttachmentList draftAttachments, - onAddAttachment, onClearAttachments, onClickAttachment, - onCloseAttachment, // StagedLinkPreview linkPreviewLoading, linkPreviewResult, @@ -206,21 +226,21 @@ export const CompositionArea = ({ isSMSOnly, isFetchingUUID, }: Props): JSX.Element => { - const [disabled, setDisabled] = React.useState(false); - const [showMic, setShowMic] = React.useState(!draftText); - const [micActive, setMicActive] = React.useState(false); - const [dirty, setDirty] = React.useState(false); - const [large, setLarge] = React.useState(false); - const inputApiRef = React.useRef(); + const [disabled, setDisabled] = useState(false); + const [micActive, setMicActive] = useState(false); + const [dirty, setDirty] = useState(false); + const [large, setLarge] = useState(false); + const inputApiRef = useRef(); + const fileInputRef = useRef(null); - const handleForceSend = React.useCallback(() => { + const handleForceSend = useCallback(() => { setLarge(false); if (inputApiRef.current) { inputApiRef.current.submit(); } }, [inputApiRef, setLarge]); - const handleSubmit = React.useCallback( + const handleSubmit = useCallback( (...args) => { setLarge(false); onSubmit(...args); @@ -228,7 +248,17 @@ export const CompositionArea = ({ [setLarge, onSubmit] ); - const focusInput = React.useCallback(() => { + const launchAttachmentPicker = () => { + const fileInput = fileInputRef.current; + if (fileInput) { + // Setting the value to empty so that onChange always fires in case + // you add multiple photos. + fileInput.value = ''; + fileInput.click(); + } + }; + + const focusInput = useCallback(() => { if (inputApiRef.current) { inputApiRef.current.focus(); } @@ -249,7 +279,6 @@ export const CompositionArea = ({ isDirty: () => dirty, focusInput, setDisabled, - setShowMic, setMicActive, reset: () => { if (inputApiRef.current) { @@ -264,7 +293,7 @@ export const CompositionArea = ({ }; } - const insertEmoji = React.useCallback( + const insertEmoji = useCallback( (e: EmojiPickDataType) => { if (inputApiRef.current) { inputApiRef.current.insertEmoji(e); @@ -274,14 +303,16 @@ export const CompositionArea = ({ [inputApiRef, onPickEmoji] ); - const handleToggleLarge = React.useCallback(() => { + const handleToggleLarge = useCallback(() => { setLarge(l => !l); }, [setLarge]); + const shouldShowMicrophone = !draftAttachments.length && !draftText; + // The following is a work-around to allow react to lay-out backbone-managed // dom nodes until those functions are in React - const micCellRef = React.useRef(null); - React.useLayoutEffect(() => { + const micCellRef = useRef(null); + useLayoutEffect(() => { const { current: micCellContainer } = micCellRef; if (micCellContainer && micCellEl) { emptyElement(micCellContainer); @@ -289,7 +320,7 @@ export const CompositionArea = ({ } return noop; - }, [micCellRef, micCellEl, large, dirty, showMic]); + }, [micCellRef, micCellEl, large, dirty, shouldShowMicrophone]); const showMediaQualitySelector = draftAttachments.some(isImageAttachment); @@ -318,7 +349,7 @@ export const CompositionArea = ({ ); - const micButtonFragment = showMic ? ( + const micButtonFragment = shouldShowMicrophone ? (
@@ -384,7 +415,7 @@ export const CompositionArea = ({ ) : null; // Listen for cmd/ctrl-shift-x to toggle large composition mode - React.useEffect(() => { + useEffect(() => { const handler = (e: KeyboardEvent) => { const { key, shiftKey, ctrlKey, metaKey } = e; // When using the ctrl key, `key` is `'X'`. When using the cmd key, `key` is `'x'` @@ -557,10 +588,14 @@ export const CompositionArea = ({ { + if (attachment.path) { + removeAttachment(conversationId, attachment.path); + } + }} /> ) : null} @@ -610,9 +645,19 @@ export const CompositionArea = ({ {stickerButtonFragment} {attButton} {!dirty ? micButtonFragment : null} - {dirty || !showMic ? sendButtonFragment : null} + {dirty || !shouldShowMicrophone ? sendButtonFragment : null} ) : null} + ); }; diff --git a/ts/components/CompositionUpload.tsx b/ts/components/CompositionUpload.tsx new file mode 100644 index 000000000..182c85048 --- /dev/null +++ b/ts/components/CompositionUpload.tsx @@ -0,0 +1,113 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ChangeEventHandler, forwardRef, useState } from 'react'; + +import { AttachmentType } from '../types/Attachment'; +import { AttachmentToastType } from '../types/AttachmentToastType'; +import { LocalizerType } from '../types/Util'; + +import { ToastCannotMixImageAndNonImageAttachments } from './ToastCannotMixImageAndNonImageAttachments'; +import { ToastDangerousFileType } from './ToastDangerousFileType'; +import { ToastFileSize } from './ToastFileSize'; +import { ToastMaxAttachments } from './ToastMaxAttachments'; +import { ToastOneNonImageAtATime } from './ToastOneNonImageAtATime'; +import { ToastUnableToLoadAttachment } from './ToastUnableToLoadAttachment'; +import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing'; + +export type PropsType = { + addAttachment: ( + conversationId: string, + attachment: AttachmentType + ) => unknown; + addPendingAttachment: ( + conversationId: string, + pendingAttachment: AttachmentType + ) => unknown; + conversationId: string; + draftAttachments: ReadonlyArray; + i18n: LocalizerType; + processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown; + removeAttachment: (conversationId: string, filePath: string) => unknown; +}; + +export const CompositionUpload = forwardRef( + ( + { + addAttachment, + addPendingAttachment, + conversationId, + draftAttachments, + i18n, + processAttachments, + removeAttachment, + }, + ref + ) => { + const [toastType, setToastType] = useState< + AttachmentToastType | undefined + >(); + + const onFileInputChange: ChangeEventHandler = 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.ToastOneNonImageAtATime) { + toast = ; + } else if ( + toastType === + AttachmentToastType.ToastCannotMixImageAndNonImageAttachments + ) { + toast = ( + + ); + } else if (toastType === AttachmentToastType.ToastUnableToLoadAttachment) { + toast = ; + } + + return ( + <> + {toast} + + + ); + } +); diff --git a/ts/components/Toast.tsx b/ts/components/Toast.tsx index cf5a59c8e..b63739796 100644 --- a/ts/components/Toast.tsx +++ b/ts/components/Toast.tsx @@ -23,7 +23,7 @@ export const Toast = ({ disableCloseOnClick = false, onClick, onClose, - timeout = 2000, + timeout = 8000, }: PropsType): JSX.Element | null => { const [root, setRoot] = React.useState(null); diff --git a/ts/components/conversation/AttachmentList.tsx b/ts/components/conversation/AttachmentList.tsx index 759bfb7ab..6c9b6914b 100644 --- a/ts/components/conversation/AttachmentList.tsx +++ b/ts/components/conversation/AttachmentList.tsx @@ -61,7 +61,7 @@ export const AttachmentList = ({ {(attachments || []).map((attachment, index) => { const url = getUrl(attachment); - const key = url || attachment.fileName || index; + const key = url || attachment.path || attachment.fileName || index; const isImage = isImageAttachment(attachment); const isVideo = isVideoAttachment(attachment); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index e090ff486..9664d6bb0 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -22,11 +22,7 @@ import { } from './messages/MessageSendState'; import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions'; import { ConversationColorType } from './types/Colors'; -import { - AttachmentType, - ThumbnailType, - OnDiskAttachmentDraftType, -} from './types/Attachment'; +import { AttachmentType, ThumbnailType } from './types/Attachment'; import { EmbeddedContactType } from './types/EmbeddedContact'; import { SignalService as Proto } from './protobuf'; import { AvatarDataType } from './types/Avatar'; @@ -214,7 +210,7 @@ export type ConversationAttributesType = { customColorId?: string; discoveredUnregisteredAt?: number; draftChanged?: boolean; - draftAttachments?: Array; + draftAttachments?: Array; draftBodyRanges?: Array; draftTimestamp?: number | null; inbox_position: number; diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index 5983716fb..af740b780 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -3,6 +3,8 @@ import { ThunkAction } from 'redux-thunk'; +import * as log from '../../logging/log'; +import { NoopActionType } from './noop'; import { StateType as RootStateType } from '../reducer'; import { AttachmentType } from '../../types/Attachment'; import { MessageAttributesType } from '../../model-types.d'; @@ -12,6 +14,13 @@ import { REMOVE_PREVIEW as REMOVE_LINK_PREVIEW, RemoveLinkPreviewActionType, } from './linkPreviews'; +import { writeDraftAttachment } from '../../util/writeDraftAttachment'; +import { replaceIndex } from '../../util/replaceIndex'; +import { resolveAttachmentOnDisk } from '../../util/resolveAttachmentOnDisk'; +import { + handleAttachmentsProcessing, + HandleAttachmentsProcessingArgsType, +} from '../../util/handleAttachmentsProcessing'; // State @@ -25,12 +34,18 @@ export type ComposerStateType = { // Actions +const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT'; const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS'; const RESET_COMPOSER = 'composer/RESET_COMPOSER'; const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING'; const SET_LINK_PREVIEW_RESULT = 'composer/SET_LINK_PREVIEW_RESULT'; const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE'; +type AddPendingAttachmentActionType = { + type: typeof ADD_PENDING_ATTACHMENT; + payload: AttachmentType; +}; + type ReplaceAttachmentsActionType = { type: typeof REPLACE_ATTACHMENTS; payload: ReadonlyArray; @@ -59,16 +74,21 @@ type SetQuotedMessageActionType = { }; type ComposerActionType = + | AddPendingAttachmentActionType + | RemoveLinkPreviewActionType | ReplaceAttachmentsActionType | ResetComposerActionType | SetHighQualitySettingActionType | SetLinkPreviewResultActionType - | RemoveLinkPreviewActionType | SetQuotedMessageActionType; // Action Creators export const actions = { + addAttachment, + addPendingAttachment, + processAttachments, + removeAttachment, replaceAttachments, resetComposer, setLinkPreviewResult, @@ -76,9 +96,137 @@ export const actions = { setQuotedMessage, }; +// Not cool that we have to pull from ConversationModel here +// but if the current selected conversation isn't the one that we're operating +// on then we won't be able to grab attachments from state so we resort to the +// next in-memory store. +function getAttachmentsFromConversationModel( + conversationId: string +): Array { + const conversation = window.ConversationController.get(conversationId); + return conversation?.get('draftAttachments') || []; +} + +function addAttachment( + conversationId: string, + attachment: AttachmentType +): ThunkAction { + return async (dispatch, getState) => { + const isSelectedConversation = + getState().conversations.selectedConversationId === conversationId; + + const draftAttachments = isSelectedConversation + ? getState().composer.attachments + : getAttachmentsFromConversationModel(conversationId); + + const hasDraftAttachmentPending = draftAttachments.some( + draftAttachment => + draftAttachment.pending && draftAttachment.path === attachment.path + ); + + // User has canceled the draft so we don't need to continue processing + if (!hasDraftAttachmentPending) { + return; + } + + const onDisk = await writeDraftAttachment(attachment); + + // Remove any pending attachments that were transcoding + const index = draftAttachments.findIndex( + draftAttachment => draftAttachment.path === attachment.path + ); + let nextAttachments = draftAttachments; + if (index < 0) { + log.warn( + `addAttachment: Failed to find pending attachment with path ${attachment.path}` + ); + nextAttachments = [...draftAttachments, onDisk]; + } else { + nextAttachments = replaceIndex(draftAttachments, index, onDisk); + } + + replaceAttachments(conversationId, nextAttachments)( + dispatch, + getState, + null + ); + + const conversation = window.ConversationController.get(conversationId); + if (conversation) { + conversation.attributes.draftAttachments = nextAttachments; + window.Signal.Data.updateConversation(conversation.attributes); + } + }; +} + +function addPendingAttachment( + conversationId: string, + pendingAttachment: AttachmentType +): ThunkAction { + return (dispatch, getState) => { + const isSelectedConversation = + getState().conversations.selectedConversationId === conversationId; + + const draftAttachments = isSelectedConversation + ? getState().composer.attachments + : getAttachmentsFromConversationModel(conversationId); + + const nextAttachments = [...draftAttachments, pendingAttachment]; + + dispatch({ + type: REPLACE_ATTACHMENTS, + payload: nextAttachments, + }); + + const conversation = window.ConversationController.get(conversationId); + if (conversation) { + conversation.attributes.draftAttachments = nextAttachments; + window.Signal.Data.updateConversation(conversation.attributes); + } + }; +} + +function processAttachments( + options: HandleAttachmentsProcessingArgsType +): ThunkAction { + return async dispatch => { + await handleAttachmentsProcessing(options); + dispatch({ + type: 'NOOP', + payload: null, + }); + }; +} + +function removeAttachment( + conversationId: string, + filePath: string +): ThunkAction { + return (dispatch, getState) => { + const { attachments } = getState().composer; + + const nextAttachments = attachments.filter( + attachment => attachment.path !== filePath + ); + + const conversation = window.ConversationController.get(conversationId); + if (conversation) { + conversation.attributes.draftAttachments = nextAttachments; + conversation.attributes.draftChanged = true; + window.Signal.Data.updateConversation(conversation.attributes); + } + + replaceAttachments(conversationId, nextAttachments)( + dispatch, + getState, + null + ); + }; +} + function replaceAttachments( conversationId: string, - payload: ReadonlyArray + attachments: ReadonlyArray ): ThunkAction { return (dispatch, getState) => { // If the call came from a conversation we are no longer in we do not @@ -89,7 +237,7 @@ function replaceAttachments( dispatch({ type: REPLACE_ATTACHMENTS, - payload, + payload: attachments.map(resolveAttachmentOnDisk), }); }; } @@ -189,5 +337,12 @@ export function reducer( }); } + if (action.type === ADD_PENDING_ATTACHMENT) { + return { + ...state, + attachments: [...state.attachments, action.payload], + }; + } + return state; } diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 72e3a12ef..3eaeceaaa 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -78,6 +78,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { return { // Base + conversationId: id, i18n: getIntl(state), // AttachmentsList draftAttachments, diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts index 877e06917..33dce1398 100644 --- a/ts/test-both/state/ducks/composer_test.ts +++ b/ts/test-both/state/ducks/composer_test.ts @@ -39,7 +39,9 @@ describe('both/state/ducks/composer', () => { const { replaceAttachments } = actions; const dispatch = sinon.spy(); - const attachments: Array = [{ contentType: IMAGE_JPEG }]; + const attachments: Array = [ + { contentType: IMAGE_JPEG, pending: false, url: '' }, + ]; replaceAttachments('123', attachments)( dispatch, getRootStateFunction('123'), diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 5f1ec9fc6..66749cea8 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -55,6 +55,7 @@ export type AttachmentType = { contentType: MIME.MIMEType; path: string; }; + screenshotData?: Uint8Array; screenshotPath?: string; flags?: number; thumbnail?: ThumbnailType; @@ -96,19 +97,6 @@ export type InMemoryAttachmentDraftType = pending: true; }; -export type OnDiskAttachmentDraftType = - | ({ - caption?: string; - pending: false; - screenshotPath?: string; - } & BaseAttachmentDraftType) - | { - contentType: MIME.MIMEType; - fileName: string; - path: string; - pending: true; - }; - export type AttachmentDraftType = | ({ url: string; diff --git a/ts/types/AttachmentToastType.ts b/ts/types/AttachmentToastType.ts new file mode 100644 index 000000000..d4d15eb93 --- /dev/null +++ b/ts/types/AttachmentToastType.ts @@ -0,0 +1,11 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export enum AttachmentToastType { + ToastCannotMixImageAndNonImageAttachments, + ToastDangerousFileType, + ToastFileSize, + ToastMaxAttachments, + ToastOneNonImageAtATime, + ToastUnableToLoadAttachment, +} diff --git a/ts/util/deleteDraftAttachment.ts b/ts/util/deleteDraftAttachment.ts new file mode 100644 index 000000000..18ec32f25 --- /dev/null +++ b/ts/util/deleteDraftAttachment.ts @@ -0,0 +1,15 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { AttachmentType } from '../types/Attachment'; + +export async function deleteDraftAttachment( + attachment: Pick +): Promise { + if (attachment.screenshotPath) { + await window.Signal.Migrations.deleteDraftFile(attachment.screenshotPath); + } + if (attachment.path) { + await window.Signal.Migrations.deleteDraftFile(attachment.path); + } +} diff --git a/ts/util/fileToBytes.ts b/ts/util/fileToBytes.ts new file mode 100644 index 000000000..f0483de63 --- /dev/null +++ b/ts/util/fileToBytes.ts @@ -0,0 +1,18 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function fileToBytes(file: Blob): Promise { + return new Promise((resolve, rejectPromise) => { + const FR = new FileReader(); + FR.onload = () => { + if (!FR.result || typeof FR.result === 'string') { + rejectPromise(new Error('bytesFromFile: No result!')); + return; + } + resolve(new Uint8Array(FR.result)); + }; + FR.onerror = rejectPromise; + FR.onabort = rejectPromise; + FR.readAsArrayBuffer(file); + }); +} diff --git a/ts/util/handleAttachmentsProcessing.ts b/ts/util/handleAttachmentsProcessing.ts new file mode 100644 index 000000000..8eced891b --- /dev/null +++ b/ts/util/handleAttachmentsProcessing.ts @@ -0,0 +1,82 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + getPendingAttachment, + preProcessAttachment, + processAttachment, +} from './processAttachment'; +import { AttachmentType } from '../types/Attachment'; +import { AttachmentToastType } from '../types/AttachmentToastType'; + +export type AddAttachmentActionType = ( + conversationId: string, + attachment: AttachmentType +) => unknown; +export type AddPendingAttachmentActionType = ( + conversationId: string, + pendingAttachment: AttachmentType +) => 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) { + 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) { + removeAttachment(conversationId, file.path); + onShowToast(AttachmentToastType.ToastUnableToLoadAttachment); + } + }) + ); +} diff --git a/ts/util/handleVideoAttachment.ts b/ts/util/handleVideoAttachment.ts new file mode 100644 index 000000000..04589753a --- /dev/null +++ b/ts/util/handleVideoAttachment.ts @@ -0,0 +1,43 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { blobToArrayBuffer } from 'blob-util'; + +import * as log from '../logging/log'; +import { makeVideoScreenshot } from '../types/VisualAttachment'; +import { IMAGE_PNG, stringToMIMEType } from '../types/MIME'; +import { InMemoryAttachmentDraftType } from '../types/Attachment'; +import { fileToBytes } from './fileToBytes'; + +export async function handleVideoAttachment( + file: Readonly +): Promise { + const objectUrl = URL.createObjectURL(file); + if (!objectUrl) { + throw new Error('Failed to create object url for video!'); + } + try { + const screenshotContentType = IMAGE_PNG; + const screenshotBlob = await makeVideoScreenshot({ + objectUrl, + contentType: screenshotContentType, + logger: log, + }); + const screenshotData = await blobToArrayBuffer(screenshotBlob); + const data = await fileToBytes(file); + + return { + contentType: stringToMIMEType(file.type), + data, + fileName: file.name, + path: file.name, + pending: false, + screenshotContentType, + screenshotData: new Uint8Array(screenshotData), + screenshotSize: screenshotData.byteLength, + size: data.byteLength, + }; + } finally { + URL.revokeObjectURL(objectUrl); + } +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 337c96677..e07ee21a3 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -12289,22 +12289,6 @@ "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Our code, no user input, only clearing out the dom" }, - { - "rule": "React-useRef", - "path": "ts/components/CompositionArea.js", - "line": " const inputApiRef = React.useRef();", - "reasonCategory": "falseMatch", - "updated": "2020-10-26T19:12:24.410Z", - "reasonDetail": "Doesn't refer to a DOM element." - }, - { - "rule": "React-useRef", - "path": "ts/components/CompositionArea.js", - "line": " const micCellRef = React.useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2020-10-26T19:12:24.410Z", - "reasonDetail": "Needed for the composition area." - }, { "rule": "DOM-innerHTML", "path": "ts/components/CompositionArea.tsx", @@ -12316,16 +12300,23 @@ { "rule": "React-useRef", "path": "ts/components/CompositionArea.tsx", - "line": " const inputApiRef = React.useRef();", + "line": " const micCellRef = useRef(null);", "reasonCategory": "usageTrusted", "updated": "2021-07-30T16:57:33.618Z" }, { "rule": "React-useRef", "path": "ts/components/CompositionArea.tsx", - "line": " const micCellRef = React.useRef(null);", + "line": " const inputApiRef = useRef();", "reasonCategory": "usageTrusted", - "updated": "2021-07-30T16:57:33.618Z" + "updated": "2021-09-23T00:07:11.885Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CompositionArea.tsx", + "line": " const fileInputRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-09-23T00:07:11.885Z" }, { "rule": "React-useRef", @@ -14283,4 +14274,4 @@ "reasonCategory": "usageTrusted", "updated": "2021-09-17T21:02:59.414Z" } -] \ No newline at end of file +] diff --git a/ts/util/processAttachment.ts b/ts/util/processAttachment.ts new file mode 100644 index 000000000..fd4326280 --- /dev/null +++ b/ts/util/processAttachment.ts @@ -0,0 +1,122 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import path from 'path'; + +import * as log from '../logging/log'; +import { AttachmentType } from '../types/Attachment'; +import { AttachmentToastType } from '../types/AttachmentToastType'; +import { fileToBytes } from './fileToBytes'; +import { handleImageAttachment } from './handleImageAttachment'; +import { handleVideoAttachment } from './handleVideoAttachment'; +import { isAttachmentSizeOkay } from './isAttachmentSizeOkay'; +import { isFileDangerous } from './isFileDangerous'; +import { isHeic, isImage, stringToMIMEType } from '../types/MIME'; +import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome'; + +export function getPendingAttachment(file: File): AttachmentType | undefined { + if (!file) { + return; + } + + const fileType = stringToMIMEType(file.type); + const { name: fileName } = path.parse(file.name); + + return { + contentType: fileType, + fileName, + path: file.name, + pending: true, + }; +} + +export function preProcessAttachment( + file: File, + draftAttachments: Array +): AttachmentToastType | undefined { + if (!file) { + return; + } + + const MB = 1000 * 1024; + if (file.size > 100 * MB) { + return AttachmentToastType.ToastFileSize; + } + + if (isFileDangerous(file.name)) { + return AttachmentToastType.ToastDangerousFileType; + } + + if (draftAttachments.length >= 32) { + return AttachmentToastType.ToastMaxAttachments; + } + + const haveNonImage = draftAttachments.some( + (attachment: AttachmentType) => !isImage(attachment.contentType) + ); + // You can't add another attachment if you already have a non-image staged + if (haveNonImage) { + return AttachmentToastType.ToastOneNonImageAtATime; + } + + const fileType = stringToMIMEType(file.type); + + // You can't add a non-image attachment if you already have attachments staged + if (!isImage(fileType) && draftAttachments.length > 0) { + return AttachmentToastType.ToastCannotMixImageAndNonImageAttachments; + } + + return undefined; +} + +export async function processAttachment( + file: File +): Promise { + const fileType = stringToMIMEType(file.type); + + let attachment: AttachmentType; + try { + if (isImageTypeSupported(fileType) || isHeic(fileType)) { + attachment = await handleImageAttachment(file); + } else if (isVideoTypeSupported(fileType)) { + attachment = await handleVideoAttachment(file); + } else { + const data = await fileToBytes(file); + attachment = { + contentType: fileType, + data, + fileName: file.name, + path: file.name, + pending: false, + size: data.byteLength, + }; + } + } catch (e) { + log.error( + `Was unable to generate thumbnail for fileType ${fileType}`, + e && e.stack ? e.stack : e + ); + const data = await fileToBytes(file); + attachment = { + contentType: fileType, + data, + fileName: file.name, + path: file.name, + pending: false, + size: data.byteLength, + }; + } + + try { + if (isAttachmentSizeOkay(attachment)) { + return attachment; + } + } catch (error) { + log.error( + 'Error ensuring that image is properly sized:', + error && error.stack ? error.stack : error + ); + + throw error; + } +} diff --git a/ts/util/resolveAttachmentOnDisk.ts b/ts/util/resolveAttachmentOnDisk.ts new file mode 100644 index 000000000..ed9ba232a --- /dev/null +++ b/ts/util/resolveAttachmentOnDisk.ts @@ -0,0 +1,40 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { pick } from 'lodash'; + +import * as log from '../logging/log'; +import { AttachmentType } from '../types/Attachment'; + +export function resolveAttachmentOnDisk( + attachment: AttachmentType +): AttachmentType { + let url = ''; + if (attachment.pending) { + return attachment; + } + + if (attachment.screenshotPath) { + url = window.Signal.Migrations.getAbsoluteDraftPath( + attachment.screenshotPath + ); + } else if (attachment.path) { + url = window.Signal.Migrations.getAbsoluteDraftPath(attachment.path); + } else { + log.warn( + 'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields' + ); + } + return { + ...pick(attachment, [ + 'blurHash', + 'caption', + 'contentType', + 'fileName', + 'path', + 'size', + ]), + pending: false, + url, + }; +} diff --git a/ts/util/writeDraftAttachment.ts b/ts/util/writeDraftAttachment.ts new file mode 100644 index 000000000..cd0c6ced8 --- /dev/null +++ b/ts/util/writeDraftAttachment.ts @@ -0,0 +1,29 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { omit } from 'lodash'; +import { AttachmentType } from '../types/Attachment'; + +export async function writeDraftAttachment( + attachment: AttachmentType +): Promise { + if (attachment.pending) { + throw new Error('writeDraftAttachment: Cannot write pending attachment'); + } + + const result: AttachmentType = { + ...omit(attachment, ['data', 'screenshotData']), + pending: false, + }; + if (attachment.data) { + result.path = await window.Signal.Migrations.writeNewDraftData( + attachment.data + ); + } + if (attachment.screenshotData) { + result.screenshotPath = await window.Signal.Migrations.writeNewDraftData( + attachment.screenshotData + ); + } + return result; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 1efa127b2..3c6167afe 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -1,16 +1,13 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import nodePath from 'path'; import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; -import { debounce, flatten, omit, pick, reject, throttle } from 'lodash'; +import { debounce, flatten, omit, throttle } from 'lodash'; import { render } from 'mustache'; import { AttachmentDraftType, AttachmentType, - InMemoryAttachmentDraftType, - OnDiskAttachmentDraftType, isGIF, } from '../types/Attachment'; import * as Attachment from '../types/Attachment'; @@ -20,8 +17,6 @@ import { BodyRangeType, BodyRangesType } from '../types/Util'; import { IMAGE_JPEG, IMAGE_WEBP, - IMAGE_PNG, - isHeic, MIMEType, stringToMIMEType, } from '../types/MIME'; @@ -41,7 +36,6 @@ import { import { MessageModel } from '../models/messages'; import { strictAssert } from '../util/assert'; import { maybeParseUrl } from '../util/url'; -import { replaceIndex } from '../util/replaceIndex'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; @@ -72,17 +66,13 @@ import { } from '../types/LinkPreview'; import * as LinkPreview from '../types/LinkPreview'; import { SignalService as Proto } from '../protobuf'; -import { - autoScale, - handleImageAttachment, -} from '../util/handleImageAttachment'; +import { autoScale } from '../util/handleImageAttachment'; import { ReadStatus } from '../messages/MessageReadStatus'; import { markViewed } from '../services/MessageUpdater'; import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue'; import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue'; import type { EmbeddedContactType } from '../types/EmbeddedContact'; import * as VisualAttachment from '../types/VisualAttachment'; -import * as MIME from '../types/MIME'; import type { AnyViewClass, BasicReactWrapperViewClass } from '../window.d'; import { isNotNil } from '../util/isNotNil'; import { dropNull } from '../util/dropNull'; @@ -93,7 +83,6 @@ import { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCa import { showToast } from '../util/showToast'; import { ToastBlocked } from '../components/ToastBlocked'; import { ToastBlockedGroup } from '../components/ToastBlockedGroup'; -import { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCannotMixImageAndNonImageAttachments'; import { ToastConversationArchived } from '../components/ToastConversationArchived'; import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread'; import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived'; @@ -101,23 +90,26 @@ import { ToastDangerousFileType } from '../components/ToastDangerousFileType'; import { ToastDeleteForEveryoneFailed } from '../components/ToastDeleteForEveryoneFailed'; import { ToastExpired } from '../components/ToastExpired'; import { ToastFileSaved } from '../components/ToastFileSaved'; -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 { ToastOneNonImageAtATime } from '../components/ToastOneNonImageAtATime'; import { ToastOriginalMessageNotFound } from '../components/ToastOriginalMessageNotFound'; import { ToastPinnedConversationsFull } from '../components/ToastPinnedConversationsFull'; import { ToastReactionFailed } from '../components/ToastReactionFailed'; import { ToastReportedSpamAndBlocked } from '../components/ToastReportedSpamAndBlocked'; import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming'; import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing'; -import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment'; import { ToastVoiceNoteLimit } from '../components/ToastVoiceNoteLimit'; import { ToastVoiceNoteMustBeOnlyAttachment } from '../components/ToastVoiceNoteMustBeOnlyAttachment'; import { copyGroupLink } from '../util/copyGroupLink'; -import { isAttachmentSizeOkay } from '../util/isAttachmentSizeOkay'; +import { fileToBytes } from '../util/fileToBytes'; +import { AttachmentToastType } from '../types/AttachmentToastType'; +import { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCannotMixImageAndNonImageAttachments'; +import { ToastFileSize } from '../components/ToastFileSize'; +import { ToastMaxAttachments } from '../components/ToastMaxAttachments'; +import { ToastOneNonImageAtATime } from '../components/ToastOneNonImageAtATime'; +import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment'; +import { deleteDraftAttachment } from '../util/deleteDraftAttachment'; type AttachmentOptions = { messageId: string; @@ -134,10 +126,8 @@ const { Message } = window.Signal.Types; const { copyIntoTempDirectory, - deleteDraftFile, deleteTempFile, getAbsoluteAttachmentPath, - getAbsoluteDraftPath, getAbsoluteTempPath, loadAttachmentData, loadPreviewData, @@ -147,7 +137,6 @@ const { readDraftData, saveAttachmentToDisk, upgradeMessageSchema, - writeNewDraftData, } = window.Signal.Migrations; const { @@ -543,7 +532,6 @@ export class ConversationView extends window.Backbone.View { caretLocation?: number ) => this.onEditorStateChange(msg, bodyRanges, caretLocation), onTextTooLong: () => showToast(ToastMessageBodyTooLong), - onChooseAttachment: this.onChooseAttachment.bind(this), getQuotedMessage: () => this.model.get('quotedMessageId'), clearQuotedMessage: () => this.setQuoteMessage(null), micCellEl, @@ -595,9 +583,7 @@ export class ConversationView extends window.Backbone.View { }); }, - onAddAttachment: this.onChooseAttachment.bind(this), onClickAttachment: this.onClickAttachment.bind(this), - onCloseAttachment: this.removeDraftAttachment.bind(this), onClearAttachments: this.clearAttachments.bind(this), onSelectMediaQuality: (isHQ: boolean) => { window.reduxActions.composer.setMediaQualitySetting(isHQ); @@ -1320,20 +1306,59 @@ export class ConversationView extends window.Backbone.View { } onChooseAttachment(): void { + // TODO: DESKTOP-2425 this.$('input.file-input').click(); } + async onChoseAttachment(): Promise { const fileField = this.$('input.file-input'); - const files = fileField.prop('files'); - - for (let i = 0, max = files.length; i < max; i += 1) { - const file = files[i]; - // eslint-disable-next-line no-await-in-loop - await this.maybeAddAttachment(file); - this.toggleMicrophone(); - } + const files: Array = Array.from(fileField.prop('files')); fileField.val([]); + + await this.processAttachments(files); + } + + // TODO DESKTOP-2426 + async processAttachments(files: Array): Promise { + 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.ToastOneNonImageAtATime) { + showToast(ToastOneNonImageAtATime); + } else if ( + toastType === + AttachmentToastType.ToastCannotMixImageAndNonImageAttachments + ) { + showToast(ToastCannotMixImageAndNonImageAttachments); + } else if ( + toastType === AttachmentToastType.ToastUnableToLoadAttachment + ) { + showToast(ToastUnableToLoadAttachment); + } + }, + removeAttachment, + }); } unload(reason: string): void { @@ -1419,10 +1444,7 @@ export class ConversationView extends window.Backbone.View { e.preventDefault(); const { files } = event.dataTransfer; - for (let i = 0, max = files.length; i < max; i += 1) { - const file = files[i]; - this.maybeAddAttachment(file); - } + this.processAttachments(Array.from(files)); } onPaste(e: JQuery.TriggeredEvent): void { @@ -1445,14 +1467,17 @@ export class ConversationView extends window.Backbone.View { 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) { - this.maybeAddAttachment(file); + files.push(file); } } } + + this.processAttachments(files); } syncMessageRequestResponse( @@ -1511,7 +1536,7 @@ export class ConversationView extends window.Backbone.View { const onSave = (caption?: string) => { const attachments = this.model.get('draftAttachments') || []; this.model.set({ - draftAttachments: attachments.map((item: OnDiskAttachmentDraftType) => { + draftAttachments: attachments.map((item: AttachmentType) => { if (item.pending || attachment.pending) { return item; } @@ -1551,98 +1576,10 @@ export class ConversationView extends window.Backbone.View { window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el); } - // eslint-disable-next-line class-methods-use-this - async deleteDraftAttachment( - attachment: Pick - ): Promise { - if (attachment.screenshotPath) { - await deleteDraftFile(attachment.screenshotPath); - } - if (attachment.path) { - await deleteDraftFile(attachment.path); - } - } - async saveModel(): Promise { window.Signal.Data.updateConversation(this.model.attributes); } - async addAttachment(attachment: InMemoryAttachmentDraftType): Promise { - const onDisk = await this.writeDraftAttachment(attachment); - - // Remove any pending attachments that were transcoding - const draftAttachments = this.model.get('draftAttachments') || []; - const index = draftAttachments.findIndex( - draftAttachment => draftAttachment.path === attachment.path - ); - if (index < 0) { - log.warn( - `addAttachment: Failed to find pending attachment with path ${attachment.path}` - ); - this.model.set({ - draftAttachments: [...draftAttachments, onDisk], - }); - } else { - this.model.set({ - draftAttachments: replaceIndex(draftAttachments, index, onDisk), - }); - } - this.updateAttachmentsView(); - - await this.saveModel(); - } - - // eslint-disable-next-line class-methods-use-this - resolveOnDiskAttachment( - attachment: OnDiskAttachmentDraftType - ): AttachmentDraftType { - let url = ''; - if (attachment.pending) { - return attachment; - } - - if (attachment.screenshotPath) { - url = getAbsoluteDraftPath(attachment.screenshotPath); - } else if (attachment.path) { - url = getAbsoluteDraftPath(attachment.path); - } else { - log.warn( - 'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields' - ); - } - return { - ...pick(attachment, [ - 'blurHash', - 'caption', - 'contentType', - 'fileName', - 'path', - 'size', - ]), - pending: false, - url, - }; - } - - async removeDraftAttachment( - attachment: Pick - ): Promise { - const draftAttachments = this.model.get('draftAttachments') || []; - - this.model.set({ - draftAttachments: reject( - draftAttachments, - item => item.path === attachment.path - ), - draftChanged: true, - }); - - this.updateAttachmentsView(); - - await this.saveModel(); - await this.deleteDraftAttachment(attachment); - } - async clearAttachments(): Promise { this.voiceNoteAttachment = undefined; @@ -1658,9 +1595,7 @@ export class ConversationView extends window.Backbone.View { await Promise.all([ this.saveModel(), Promise.all( - draftAttachments.map(attachment => - this.deleteDraftAttachment(attachment) - ) + draftAttachments.map(attachment => deleteDraftAttachment(attachment)) ), ]); } @@ -1690,12 +1625,16 @@ export class ConversationView extends window.Backbone.View { // eslint-disable-next-line class-methods-use-this async getFile( - attachment?: OnDiskAttachmentDraftType + attachment?: AttachmentType ): Promise { if (!attachment || attachment.pending) { return; } + if (!attachment.path) { + return; + } + const data = await readDraftData(attachment.path); if (data.byteLength !== attachment.size) { log.error( @@ -1710,231 +1649,17 @@ export class ConversationView extends window.Backbone.View { }; } - // eslint-disable-next-line class-methods-use-this - bytesFromFile(file: Blob): Promise { - return new Promise((resolve, rejectPromise) => { - const FR = new FileReader(); - FR.onload = () => { - if (!FR.result || typeof FR.result === 'string') { - rejectPromise(new Error('bytesFromFile: No result!')); - return; - } - resolve(new Uint8Array(FR.result)); - }; - FR.onerror = rejectPromise; - FR.onabort = rejectPromise; - FR.readAsArrayBuffer(file); - }); - } - updateAttachmentsView(): void { const draftAttachments = this.model.get('draftAttachments') || []; window.reduxActions.composer.replaceAttachments( this.model.get('id'), - draftAttachments.map((att: OnDiskAttachmentDraftType) => - this.resolveOnDiskAttachment(att) - ) + draftAttachments ); - this.toggleMicrophone(); if (this.hasFiles({ includePending: true })) { this.removeLinkPreview(); } } - // eslint-disable-next-line class-methods-use-this - async writeDraftAttachment( - attachment: InMemoryAttachmentDraftType - ): Promise { - if (attachment.pending) { - throw new Error('writeDraftAttachment: Cannot write pending attachment'); - } - - const result: OnDiskAttachmentDraftType = { - ...omit(attachment, ['data', 'screenshotData']), - pending: false, - }; - if (attachment.data) { - result.path = await writeNewDraftData(attachment.data); - } - if (attachment.screenshotData) { - result.screenshotPath = await writeNewDraftData( - attachment.screenshotData - ); - } - return result; - } - - async maybeAddAttachment(file: File): Promise { - if (!file) { - return; - } - - const MB = 1000 * 1024; - if (file.size > 100 * MB) { - showToast(ToastFileSize, { - limit: 100, - units: 'MB', - }); - return; - } - - if (window.Signal.Util.isFileDangerous(file.name)) { - showToast(ToastDangerousFileType); - return; - } - - const draftAttachments = this.model.get('draftAttachments') || []; - if (draftAttachments.length >= 32) { - showToast(ToastMaxAttachments); - return; - } - - const haveNonImage = draftAttachments.some( - (attachment: OnDiskAttachmentDraftType) => - !MIME.isImage(attachment.contentType) - ); - // You can't add another attachment if you already have a non-image staged - if (haveNonImage) { - showToast(ToastOneNonImageAtATime); - return; - } - - const fileType = stringToMIMEType(file.type); - - // You can't add a non-image attachment if you already have attachments staged - if (!MIME.isImage(fileType) && draftAttachments.length > 0) { - showToast(ToastCannotMixImageAndNonImageAttachments); - return; - } - - // Add a pending attachment since async processing happens below - const path = file.name; - const fileName = nodePath.parse(file.name).name; - this.model.set({ - draftAttachments: [ - ...draftAttachments, - { - contentType: fileType, - fileName, - path, - pending: true, - }, - ], - }); - this.updateAttachmentsView(); - - let attachment: InMemoryAttachmentDraftType; - try { - if ( - window.Signal.Util.GoogleChrome.isImageTypeSupported(fileType) || - isHeic(fileType) - ) { - attachment = await handleImageAttachment(file); - - const hasDraftAttachmentPending = ( - this.model.get('draftAttachments') || [] - ).some( - draftAttachment => - draftAttachment.pending && draftAttachment.path === path - ); - - // User has canceled the draft so we don't need to continue processing - if (!hasDraftAttachmentPending) { - return; - } - } else if ( - window.Signal.Util.GoogleChrome.isVideoTypeSupported(fileType) - ) { - attachment = await this.handleVideoAttachment(file); - } else { - const data = await this.bytesFromFile(file); - attachment = { - contentType: fileType, - data, - fileName: file.name, - path: file.name, - pending: false, - size: data.byteLength, - }; - } - } catch (e) { - log.error( - `Was unable to generate thumbnail for fileType ${fileType}`, - e && e.stack ? e.stack : e - ); - const data = await this.bytesFromFile(file); - attachment = { - contentType: fileType, - data, - fileName: file.name, - path: file.name, - pending: false, - size: data.byteLength, - }; - } - - try { - if (!isAttachmentSizeOkay(attachment)) { - this.removeDraftAttachment(attachment); - } - } catch (error) { - log.error( - 'Error ensuring that image is properly sized:', - error && error.stack ? error.stack : error - ); - - this.removeDraftAttachment(attachment); - showToast(ToastUnableToLoadAttachment); - return; - } - - try { - await this.addAttachment(attachment); - } catch (error) { - log.error( - 'Error saving draft attachment:', - error && error.stack ? error.stack : error - ); - - showToast(ToastUnableToLoadAttachment); - } - } - - async handleVideoAttachment( - file: Readonly - ): Promise { - const objectUrl = URL.createObjectURL(file); - if (!objectUrl) { - throw new Error('Failed to create object url for video!'); - } - try { - const screenshotContentType = IMAGE_PNG; - const screenshotBlob = await VisualAttachment.makeVideoScreenshot({ - objectUrl, - contentType: screenshotContentType, - logger: log, - }); - const screenshotData = await VisualAttachment.blobToArrayBuffer( - screenshotBlob - ); - const data = await this.bytesFromFile(file); - - return { - contentType: stringToMIMEType(file.type), - data, - fileName: file.name, - path: file.name, - pending: false, - screenshotContentType, - screenshotData: new Uint8Array(screenshotData), - screenshotSize: screenshotData.byteLength, - size: data.byteLength, - }; - } finally { - URL.revokeObjectURL(objectUrl); - } - } - // eslint-disable-next-line class-methods-use-this async markAllAsVerifiedDefault( unverified: ReadonlyArray @@ -1957,12 +1682,6 @@ export class ConversationView extends window.Backbone.View { await Promise.all(untrusted.map(contact => contact.setApproved())); } - toggleMicrophone(): void { - this.compositionApi.current?.setShowMic( - !this.hasFiles({ includePending: true }) - ); - } - captureAudio(e?: Event): void { if (e) { e.preventDefault(); @@ -2019,7 +1738,7 @@ export class ConversationView extends window.Backbone.View { throw new Error('A voice note cannot be sent with other attachments'); } - const data = await this.bytesFromFile(blob); + const data = await fileToBytes(blob); // These aren't persisted to disk; they are meant to be sent immediately this.voiceNoteAttachment = { @@ -4097,7 +3816,7 @@ export class ConversationView extends window.Backbone.View { fileName: title, }); - const data = await this.bytesFromFile(withBlob.file); + const data = await fileToBytes(withBlob.file); objectUrl = URL.createObjectURL(withBlob.file); const blurHash = await window.imageToBlurHash(withBlob.file);