diff --git a/background.html b/background.html index 49f52b134..b5fb39499 100644 --- a/background.html +++ b/background.html @@ -53,7 +53,6 @@ - diff --git a/background_test.html b/background_test.html index 19e98cc31..871fb44a2 100644 --- a/background_test.html +++ b/background_test.html @@ -52,7 +52,6 @@ - diff --git a/js/modules/signal.js b/js/modules/signal.js index fc782f966..e0b35e016 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -1,7 +1,6 @@ // The idea with this file is to make it webpackable for the style guide const { bindActionCreators } = require('redux'); -const Backbone = require('../../ts/backbone'); const Crypto = require('./crypto'); const Data = require('./data'); const Database = require('./database'); @@ -363,7 +362,6 @@ exports.setup = (options = {}) => { return { AttachmentDownloads, - Backbone, Components, Crypto, Data, diff --git a/package.json b/package.json index e9f82bb49..bcbb8111d 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "react-autosize-textarea": "^7.0.0", "react-contextmenu": "2.11.0", "react-dom": "16.8.3", - "react-dropzone": "^11.0.2", "react-emoji": "^0.5.0", "react-emoji-render": "^1.2.4", "react-h5-audio-player": "^3.2.0", diff --git a/test/index.html b/test/index.html index ee8c252b8..c8610c097 100644 --- a/test/index.html +++ b/test/index.html @@ -40,7 +40,6 @@ - diff --git a/ts/backbone/index.ts b/ts/backbone/index.ts deleted file mode 100644 index 26dc5f620..000000000 --- a/ts/backbone/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as Views from './views'; - -export { Views }; diff --git a/ts/backbone/views/Lightbox.ts b/ts/backbone/views/Lightbox.ts deleted file mode 100644 index 04da21003..000000000 --- a/ts/backbone/views/Lightbox.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const show = (element: HTMLElement): void => { - const container: HTMLDivElement | null = document.querySelector( - '.lightbox-container' - ); - if (!container) { - throw new TypeError("'.lightbox-container' is required"); - } - // tslint:disable-next-line:no-inner-html - container.innerHTML = ''; - container.style.display = 'block'; - container.appendChild(element); -}; - -export const hide = (): void => { - const container: HTMLDivElement | null = document.querySelector( - '.lightbox-container' - ); - if (!container) { - return; - } - // tslint:disable-next-line:no-inner-html - container.innerHTML = ''; - container.style.display = 'none'; -}; diff --git a/ts/backbone/views/index.ts b/ts/backbone/views/index.ts deleted file mode 100644 index e3cf49917..000000000 --- a/ts/backbone/views/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import * as Lightbox from './Lightbox'; - -export { Lightbox }; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index eac5884c7..6101ba6cb 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -36,7 +36,6 @@ import { isFileDangerous } from '../../util/isFileDangerous'; import { ColorType, LocalizerType } from '../../types/Util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import { SessionIcon, SessionIconSize, SessionIconType } from '../session/icon'; -import { ReplyingToMessageProps } from '../session/conversation/SessionCompositionBox'; import _ from 'lodash'; import { MessageModel } from '../../../js/models/messages'; diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx index 075885077..e8c226ab3 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/SessionCompositionBox.tsx @@ -9,7 +9,6 @@ import TextareaAutosize from 'react-autosize-textarea'; import { SessionIconButton, SessionIconSize, SessionIconType } from '../icon'; import { SessionEmojiPanel } from './SessionEmojiPanel'; import { SessionRecording } from './SessionRecording'; -import * as GoogleChrome from '../../../util/GoogleChrome'; import { SignalService } from '../../../protobuf'; @@ -20,6 +19,7 @@ import { SessionQuotedMessageComposition } from './SessionQuotedMessageCompositi import { Flex } from '../Flex'; import { AttachmentList } from '../../conversation/AttachmentList'; import { ToastUtils } from '../../../session/utils'; +import { AttachmentUtil } from '../../../util'; export interface ReplyingToMessageProps { convoId: string; @@ -30,7 +30,7 @@ export interface ReplyingToMessageProps { attachments?: Array; } -interface StagedAttachmentType extends AttachmentType { +export interface StagedAttachmentType extends AttachmentType { file: File; } @@ -45,11 +45,14 @@ interface Props { onLoadVoiceNoteView: any; onExitVoiceNoteView: any; - dropZoneFiles: FileList; quotedMessageProps?: ReplyingToMessageProps; removeQuotedMessage: () => void; textarea: React.RefObject; + stagedAttachments: Array; + clearAttachments: () => any; + removeAttachment: (toRemove: AttachmentType) => void; + onChoseAttachments: (newAttachments: FileList) => void; } interface State { @@ -58,7 +61,6 @@ interface State { mediaSetting: boolean | null; showEmojiPanel: boolean; - stagedAttachments: Array; voiceRecording?: Blob; } @@ -71,7 +73,6 @@ export class SessionCompositionBox extends React.Component { super(props); this.state = { message: '', - stagedAttachments: [], voiceRecording: undefined, showRecordingView: false, mediaSetting: null, @@ -101,8 +102,6 @@ export class SessionCompositionBox extends React.Component { // Attachments this.onChoseAttachment = this.onChoseAttachment.bind(this); this.onChooseAttachment = this.onChooseAttachment.bind(this); - this.clearAttachments = this.clearAttachments.bind(this); - this.removeAttachment = this.removeAttachment.bind(this); // On Sending this.onSendMessage = this.onSendMessage.bind(this); @@ -267,7 +266,7 @@ export class SessionCompositionBox extends React.Component { } private renderAttachmentsStaged() { - const { stagedAttachments } = this.state; + const { stagedAttachments } = this.props; if (stagedAttachments && stagedAttachments.length) { return ( { // tslint:disable-next-line: no-empty onClickAttachment={() => {}} onAddAttachment={this.onChooseAttachment} - onCloseAttachment={this.removeAttachment} - onClose={this.clearAttachments} + onCloseAttachment={this.props.removeAttachment} + onClose={this.props.clearAttachments} /> ); } @@ -293,11 +292,7 @@ export class SessionCompositionBox extends React.Component { if (!attachmentsFileList || attachmentsFileList.length === 0) { return; } - - // tslint:disable-next-line: prefer-for-of - for (let i = 0; i < attachmentsFileList.length; i++) { - await this.maybeAddAttachment(attachmentsFileList[i]); - } + this.props.onChoseAttachments(attachmentsFileList); } private async onKeyDown(event: any) { @@ -331,7 +326,7 @@ export class SessionCompositionBox extends React.Component { ToastUtils.pushMessageBodyTooLong(); return; } - if (msgLen === 0 && this.state.stagedAttachments?.length === 0) { + if (msgLen === 0 && this.props.stagedAttachments?.length === 0) { ToastUtils.pushMessageBodyMissing(); return; } @@ -361,13 +356,13 @@ export class SessionCompositionBox extends React.Component { // Message sending sucess this.props.onMessageSuccess(); - // Empty stagedAttachments // Empty composition box this.setState({ message: '', showEmojiPanel: false, }); - this.clearAttachments(); + // Empty stagedAttachments + this.props.clearAttachments(); } catch (e) { // Message sending failed window.log.error(e); @@ -377,11 +372,11 @@ export class SessionCompositionBox extends React.Component { // this function is called right before sending a message, to gather really files bejind attachments. private async getFiles() { - const { stagedAttachments } = this.state; + const { stagedAttachments } = this.props; const files = await Promise.all( - stagedAttachments.map(attachment => this.getFile(attachment)) + stagedAttachments.map(attachment => AttachmentUtil.getFile(attachment)) ); - this.clearAttachments(); + this.props.clearAttachments(); return files; } @@ -488,316 +483,4 @@ export class SessionCompositionBox extends React.Component { // Focus the textarea when user clicks anywhere in the composition box this.textarea.current?.focus(); } - - // tslint:disable: max-func-body-length cyclomatic-complexity - private async maybeAddAttachment(file: any) { - if (!file) { - return; - } - - const fileName = file.name; - const contentType = file.type; - - const { stagedAttachments } = this.state; - - if (window.Signal.Util.isFileDangerous(fileName)) { - ToastUtils.pushDangerousFileError(); - return; - } - - if (stagedAttachments.length >= 32) { - ToastUtils.pushMaximumAttachmentsError(); - return; - } - - const haveNonImage = _.some( - stagedAttachments, - attachment => !MIME.isImage(attachment.contentType) - ); - // You can't add another attachment if you already have a non-image staged - if (haveNonImage) { - ToastUtils.pushMultipleNonImageError(); - return; - } - - // You can't add a non-image attachment if you already have attachments staged - if (!MIME.isImage(contentType) && stagedAttachments.length > 0) { - ToastUtils.pushCannotMixError(); - return; - } - const { VisualAttachment } = window.Signal.Types; - - const renderVideoPreview = async () => { - const objectUrl = URL.createObjectURL(file); - try { - const type = 'image/png'; - - const thumbnail = await VisualAttachment.makeVideoScreenshot({ - objectUrl, - contentType: type, - logger: window.log, - }); - const data = await VisualAttachment.blobToArrayBuffer(thumbnail); - const url = window.Signal.Util.arrayBufferToObjectURL({ - data, - type, - }); - this.addAttachment({ - file, - size: file.size, - fileName, - contentType, - videoUrl: objectUrl, - url, - isVoiceMessage: false, - }); - } catch (error) { - URL.revokeObjectURL(objectUrl); - } - }; - - const renderImagePreview = async () => { - if (!MIME.isJPEG(contentType)) { - const urlImage = URL.createObjectURL(file); - if (!urlImage) { - throw new Error('Failed to create object url for image!'); - } - this.addAttachment({ - file, - size: file.size, - fileName, - contentType, - url: urlImage, - isVoiceMessage: false, - }); - return; - } - - const url = await window.autoOrientImage(file); - this.addAttachment({ - file, - size: file.size, - fileName, - contentType, - url, - isVoiceMessage: false, - }); - }; - - try { - const blob = await this.autoScale({ - contentType, - file, - }); - let limitKb = 10000; - const blobType = - file.type === 'image/gif' ? 'gif' : contentType.split('/')[0]; - - switch (blobType) { - case 'image': - limitKb = 6000; - break; - case 'gif': - limitKb = 10000; - break; - case 'audio': - limitKb = 10000; - break; - case 'video': - limitKb = 10000; - break; - default: - limitKb = 10000; - } - // if ((blob.file.size / 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); - // // this.showFileSizeError(limit, units[u]); - // return; - // } - } catch (error) { - window.log.error( - 'Error ensuring that image is properly sized:', - error && error.stack ? error.stack : error - ); - - ToastUtils.pushLoadAttachmentFailure(); - return; - } - - try { - if (GoogleChrome.isImageTypeSupported(contentType)) { - await renderImagePreview(); - } else if (GoogleChrome.isVideoTypeSupported(contentType)) { - await renderVideoPreview(); - } else { - this.addAttachment({ - file, - size: file.size, - contentType, - fileName, - url: '', - isVoiceMessage: false, - }); - } - } catch (e) { - window.log.error( - `Was unable to generate thumbnail for file type ${contentType}`, - e && e.stack ? e.stack : e - ); - this.addAttachment({ - file, - size: file.size, - contentType, - fileName, - isVoiceMessage: false, - url: '', - }); - } - } - - private addAttachment(attachment: StagedAttachmentType) { - const { stagedAttachments } = this.state; - if (attachment.isVoiceMessage && stagedAttachments.length > 0) { - throw new Error('A voice note cannot be sent with other attachments'); - } - this.setState( - { - stagedAttachments: [...stagedAttachments, { ...attachment }], - }, - this.focusCompositionBox - ); - } - - private async autoScale( - attachment: T - ): Promise { - const { contentType, file } = attachment; - if (contentType.split('/')[0] !== 'image' || contentType === 'image/tiff') { - // nothing to do - return Promise.resolve(attachment); - } - - return new Promise((resolve, reject) => { - const url = URL.createObjectURL(file); - const img = document.createElement('img'); - img.onerror = reject; - img.onload = () => { - URL.revokeObjectURL(url); - - const maxSize = 6000 * 1024; - const maxHeight = 4096; - const maxWidth = 4096; - if ( - img.naturalWidth <= maxWidth && - img.naturalHeight <= maxHeight && - file.size <= maxSize - ) { - resolve(attachment); - return; - } - - const gifMaxSize = 25000 * 1024; - if (file.type === 'image/gif' && file.size <= gifMaxSize) { - resolve(attachment); - return; - } - - if (file.type === 'image/gif') { - reject(new Error('GIF is too large')); - return; - } - - const canvas = window.loadImage.scale(img, { - canvas: true, - maxWidth, - maxHeight, - }); - - let quality = 0.95; - let i = 4; - let blob; - do { - i -= 1; - blob = window.dataURLToBlobSync( - canvas.toDataURL('image/jpeg', quality) - ); - quality = (quality * maxSize) / blob.size; - // NOTE: During testing with a large image, we observed the - // `quality` value being > 1. Should we clamp it to [0.5, 1.0]? - // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax - if (quality < 0.5) { - quality = 0.5; - } - } while (i > 0 && blob.size > maxSize); - - resolve({ - ...attachment, - file: blob, - }); - }; - img.src = url; - }); - } - - private clearAttachments() { - this.state.stagedAttachments.forEach(attachment => { - if (attachment.url) { - URL.revokeObjectURL(attachment.url); - } - if (attachment.videoUrl) { - URL.revokeObjectURL(attachment.videoUrl); - } - }); - this.setState({ stagedAttachments: [] }); - } - - private removeAttachment(attachment: AttachmentType) { - const { stagedAttachments } = this.state; - const updatedStagedAttachments = (stagedAttachments || []).filter( - m => m.fileName !== attachment.fileName - ); - - this.setState({ stagedAttachments: updatedStagedAttachments }); - } - - private async getFile(attachment: any) { - if (!attachment) { - return Promise.resolve(); - } - - const attachmentFlags = attachment.isVoiceMessage - ? SignalService.AttachmentPointer.Flags.VOICE_MESSAGE - : null; - - const scaled = await this.autoScale(attachment); - const fileRead = await this.readFile(scaled); - return { - ...fileRead, - url: undefined, - flags: attachmentFlags || null, - }; - } - - private async readFile(attachment: any): Promise { - return new Promise((resolve, reject) => { - const FR = new FileReader(); - FR.onload = e => { - const data = e?.target?.result as ArrayBuffer; - resolve({ - ...attachment, - data, - size: data.byteLength, - }); - }; - FR.onerror = reject; - FR.onabort = reject; - FR.readAsArrayBuffer(attachment.file); - }); - } } diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index d8cd3162b..7807e369b 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -4,12 +4,15 @@ import React from 'react'; import classNames from 'classnames'; -import { SessionCompositionBox } from './SessionCompositionBox'; +import { + SessionCompositionBox, + StagedAttachmentType, +} from './SessionCompositionBox'; import { Constants } from '../../../session'; import { SessionKeyVerification } from '../SessionKeyVerification'; import _ from 'lodash'; -import { UserUtil } from '../../../util'; +import { AttachmentUtil, GoogleChrome, UserUtil } from '../../../util'; import { MultiDeviceProtocol } from '../../../session/protocols'; import { ConversationHeaderWithDetails } from '../../conversation/ConversationHeader'; import { SessionRightPanelWithDetails } from './SessionRightPanel'; @@ -20,6 +23,8 @@ import { LightboxGallery, MediaItemType } from '../../LightboxGallery'; import { Message } from '../../conversation/media-gallery/types/Message'; import { AttachmentType } from '../../../types/Attachment'; +import { ToastUtils } from '../../../session/utils'; +import * as MIME from '../../../types/MIME'; interface State { conversationKey: string; @@ -50,8 +55,7 @@ interface State { // For displaying `More Info` on messages, and `Safety Number`, etc. infoViewState?: 'safetyNumber' | 'messageDetails'; - // dropZoneFiles?: FileList - dropZoneFiles: any; + stagedAttachments: Array; // quoted message quotedMessageTimestamp?: number; @@ -97,7 +101,7 @@ export class SessionConversation extends React.Component { showRecordingView: false, showOptionsPane: false, infoViewState: undefined, - dropZoneFiles: undefined, // <-- FileList or something else? + stagedAttachments: [], }; this.compositionBoxRef = React.createRef(); @@ -129,6 +133,12 @@ export class SessionConversation extends React.Component { this.renderLightBox = this.renderLightBox.bind(this); + // attachments + this.clearAttachments = this.clearAttachments.bind(this); + this.addAttachments = this.addAttachments.bind(this); + this.removeAttachment = this.removeAttachment.bind(this); + this.onChoseAttachments = this.onChoseAttachments.bind(this); + const conversationModel = window.ConversationController.getOrThrow( this.state.conversationKey ); @@ -238,7 +248,7 @@ export class SessionConversation extends React.Component { {!isRss && ( { void this.replyToMessage(undefined); }} textarea={this.compositionBoxRef} + clearAttachments={this.clearAttachments} + removeAttachment={this.removeAttachment} + onChoseAttachments={this.onChoseAttachments} /> )} @@ -491,6 +504,7 @@ export class SessionConversation extends React.Component { replyToMessage: this.replyToMessage, doneInitialScroll: this.state.doneInitialScroll, onClickAttachment: this.onClickAttachment, + handleFilesDropped: this.onChoseAttachments, }; } @@ -846,6 +860,43 @@ export class SessionConversation extends React.Component { // } } + private clearAttachments() { + this.state.stagedAttachments.forEach(attachment => { + if (attachment.url) { + URL.revokeObjectURL(attachment.url); + } + if (attachment.videoUrl) { + URL.revokeObjectURL(attachment.videoUrl); + } + }); + this.setState({ stagedAttachments: [] }); + } + + private removeAttachment(attachment: AttachmentType) { + const { stagedAttachments } = this.state; + const updatedStagedAttachments = (stagedAttachments || []).filter( + m => m.fileName !== attachment.fileName + ); + + this.setState({ stagedAttachments: updatedStagedAttachments }); + } + + private addAttachments(newAttachments: Array) { + const { stagedAttachments } = this.state; + if (newAttachments?.length > 0) { + if ( + newAttachments.some(a => a.isVoiceMessage) && + stagedAttachments.length > 0 + ) { + throw new Error('A voice note cannot be sent with other attachments'); + } + } + + this.setState({ + stagedAttachments: [...stagedAttachments, ...newAttachments], + }); + } + private renderLightBox({ media, attachment, @@ -891,4 +942,197 @@ export class SessionConversation extends React.Component { timestamp: message?.received_at || Date.now(), }); } + + private async onChoseAttachments(attachmentsFileList: FileList) { + if (!attachmentsFileList || attachmentsFileList.length === 0) { + return; + } + + // tslint:disable-next-line: prefer-for-of + for (let i = 0; i < attachmentsFileList.length; i++) { + await this.maybeAddAttachment(attachmentsFileList[i]); + } + } + + // tslint:disable: max-func-body-length cyclomatic-complexity + private async maybeAddAttachment(file: any) { + if (!file) { + return; + } + + const fileName = file.name; + const contentType = file.type; + + const { stagedAttachments } = this.state; + + if (window.Signal.Util.isFileDangerous(fileName)) { + ToastUtils.pushDangerousFileError(); + return; + } + + if (stagedAttachments.length >= 32) { + ToastUtils.pushMaximumAttachmentsError(); + return; + } + + const haveNonImage = _.some( + stagedAttachments, + attachment => !MIME.isImage(attachment.contentType) + ); + // You can't add another attachment if you already have a non-image staged + if (haveNonImage) { + ToastUtils.pushMultipleNonImageError(); + return; + } + + // You can't add a non-image attachment if you already have attachments staged + if (!MIME.isImage(contentType) && stagedAttachments.length > 0) { + ToastUtils.pushCannotMixError(); + return; + } + const { VisualAttachment } = window.Signal.Types; + + const renderVideoPreview = async () => { + const objectUrl = URL.createObjectURL(file); + try { + const type = 'image/png'; + + const thumbnail = await VisualAttachment.makeVideoScreenshot({ + objectUrl, + contentType: type, + logger: window.log, + }); + const data = await VisualAttachment.blobToArrayBuffer(thumbnail); + const url = window.Signal.Util.arrayBufferToObjectURL({ + data, + type, + }); + this.addAttachments([ + { + file, + size: file.size, + fileName, + contentType, + videoUrl: objectUrl, + url, + isVoiceMessage: false, + }, + ]); + } catch (error) { + URL.revokeObjectURL(objectUrl); + } + }; + + const renderImagePreview = async () => { + if (!MIME.isJPEG(contentType)) { + const urlImage = URL.createObjectURL(file); + if (!urlImage) { + throw new Error('Failed to create object url for image!'); + } + this.addAttachments([ + { + file, + size: file.size, + fileName, + contentType, + url: urlImage, + isVoiceMessage: false, + }, + ]); + return; + } + + const url = await window.autoOrientImage(file); + this.addAttachments([ + { + file, + size: file.size, + fileName, + contentType, + url, + isVoiceMessage: false, + }, + ]); + }; + + try { + const blob = await AttachmentUtil.autoScale({ + contentType, + file, + }); + let limitKb = 10000; + const blobType = + file.type === 'image/gif' ? 'gif' : contentType.split('/')[0]; + + switch (blobType) { + case 'image': + limitKb = 6000; + break; + case 'gif': + limitKb = 10000; + break; + case 'audio': + limitKb = 10000; + break; + case 'video': + limitKb = 10000; + break; + default: + limitKb = 10000; + } + // if ((blob.file.size / 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); + // // this.showFileSizeError(limit, units[u]); + // return; + // } + } catch (error) { + window.log.error( + 'Error ensuring that image is properly sized:', + error && error.stack ? error.stack : error + ); + + ToastUtils.pushLoadAttachmentFailure(); + return; + } + + try { + if (GoogleChrome.isImageTypeSupported(contentType)) { + await renderImagePreview(); + } else if (GoogleChrome.isVideoTypeSupported(contentType)) { + await renderVideoPreview(); + } else { + this.addAttachments([ + { + file, + size: file.size, + contentType, + fileName, + url: '', + isVoiceMessage: false, + }, + ]); + } + } catch (e) { + window.log.error( + `Was unable to generate thumbnail for file type ${contentType}`, + e && e.stack ? e.stack : e + ); + this.addAttachments([ + { + file, + size: file.size, + contentType, + fileName, + isVoiceMessage: false, + url: '', + }, + ]); + } + } } diff --git a/ts/components/session/conversation/SessionConversationMessagesList.tsx b/ts/components/session/conversation/SessionConversationMessagesList.tsx index 60f0685f7..e07711b71 100644 --- a/ts/components/session/conversation/SessionConversationMessagesList.tsx +++ b/ts/components/session/conversation/SessionConversationMessagesList.tsx @@ -8,6 +8,7 @@ import { ResetSessionNotification } from '../../conversation/ResetSessionNotific import { Constants } from '../../../session'; import _ from 'lodash'; import { ConversationModel } from '../../../../js/models/conversations'; +import { SessionFileDropzone } from './SessionFileDropzone'; interface State { isScrolledToBottom: boolean; @@ -30,6 +31,7 @@ interface Props { ) => Promise<{ previousTopMessage: string }>; replyToMessage: (messageId: number) => Promise; onClickAttachment: (attachment: any, message: any) => void; + handleFilesDropped: (droppedFiles: FileList) => void; } export class SessionConversationMessagesList extends React.Component< @@ -111,6 +113,10 @@ export class SessionConversationMessagesList extends React.Component< show={showScrollButton} onClick={this.scrollToBottom} /> + ); } diff --git a/ts/components/session/conversation/SessionFileDropzone.tsx b/ts/components/session/conversation/SessionFileDropzone.tsx new file mode 100644 index 000000000..64b7b063b --- /dev/null +++ b/ts/components/session/conversation/SessionFileDropzone.tsx @@ -0,0 +1,119 @@ +import React, { Component } from 'react'; +import { Flex } from '../Flex'; +import { SessionIcon, SessionIconSize, SessionIconType } from '../icon'; + +interface Props { + handleDrop: (files: FileList) => void; +} + +interface State { + dragging: boolean; +} + +export class SessionFileDropzone extends Component { + private readonly dropRef: React.RefObject; + private dragCounter: number; + + constructor(props: any) { + super(props); + this.state = { + dragging: false, + }; + + this.dragCounter = 0; + this.dropRef = React.createRef(); + } + + public handleDrag = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + }; + + public handleDragIn = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + this.dragCounter++; + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + this.setState({ dragging: true }); + } + }; + + public handleDragOut = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + this.dragCounter--; + if (this.dragCounter === 0) { + this.setState({ dragging: false }); + } + }; + + public handleDrop = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + this.setState({ dragging: false }); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + this.props.handleDrop(e.dataTransfer.files); + e.dataTransfer.clearData(); + this.dragCounter = 0; + } + }; + + public componentDidMount() { + const div = this.dropRef.current; + div.addEventListener('dragenter', this.handleDragIn); + div.addEventListener('dragleave', this.handleDragOut); + div.addEventListener('dragover', this.handleDrag); + div.addEventListener('drop', this.handleDrop); + } + + public componentWillUnmount() { + const div = this.dropRef.current; + div.removeEventListener('dragenter', this.handleDragIn); + div.removeEventListener('dragleave', this.handleDragOut); + div.removeEventListener('dragover', this.handleDrag); + div.removeEventListener('drop', this.handleDrop); + } + + public render() { + return ( +
+
+ + + +
+ + {this.props.children} +
+ ); + } +} diff --git a/ts/util/attachmentsUtil.ts b/ts/util/attachmentsUtil.ts new file mode 100644 index 000000000..bbd7ad3fa --- /dev/null +++ b/ts/util/attachmentsUtil.ts @@ -0,0 +1,108 @@ +import { StagedAttachmentType } from '../components/session/conversation/SessionCompositionBox'; +import { SignalService } from '../protobuf'; + +export async function autoScale( + attachment: T +): Promise { + const { contentType, file } = attachment; + if (contentType.split('/')[0] !== 'image' || contentType === 'image/tiff') { + // nothing to do + return Promise.resolve(attachment); + } + + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = document.createElement('img'); + img.onerror = reject; + img.onload = () => { + URL.revokeObjectURL(url); + + const maxSize = 6000 * 1024; + const maxHeight = 4096; + const maxWidth = 4096; + if ( + img.naturalWidth <= maxWidth && + img.naturalHeight <= maxHeight && + file.size <= maxSize + ) { + resolve(attachment); + return; + } + + const gifMaxSize = 25000 * 1024; + if (file.type === 'image/gif' && file.size <= gifMaxSize) { + resolve(attachment); + return; + } + + if (file.type === 'image/gif') { + reject(new Error('GIF is too large')); + return; + } + + const canvas = window.loadImage.scale(img, { + canvas: true, + maxWidth, + maxHeight, + }); + + let quality = 0.95; + let i = 4; + let blob; + do { + i -= 1; + blob = window.dataURLToBlobSync( + canvas.toDataURL('image/jpeg', quality) + ); + quality = (quality * maxSize) / blob.size; + // NOTE: During testing with a large image, we observed the + // `quality` value being > 1. Should we clamp it to [0.5, 1.0]? + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax + if (quality < 0.5) { + quality = 0.5; + } + } while (i > 0 && blob.size > maxSize); + + resolve({ + ...attachment, + file: blob, + }); + }; + img.src = url; + }); +} + +export async function getFile(attachment: StagedAttachmentType) { + if (!attachment) { + return Promise.resolve(); + } + + const attachmentFlags = attachment.isVoiceMessage + ? SignalService.AttachmentPointer.Flags.VOICE_MESSAGE + : null; + + const scaled = await autoScale(attachment); + const fileRead = await readFile(scaled); + return { + ...fileRead, + url: undefined, + flags: attachmentFlags || null, + }; +} + +async function readFile(attachment: any): Promise { + return new Promise((resolve, reject) => { + const FR = new FileReader(); + FR.onload = e => { + const data = e?.target?.result as ArrayBuffer; + resolve({ + ...attachment, + data, + size: data.byteLength, + }); + }; + FR.onerror = reject; + FR.onabort = reject; + FR.readAsArrayBuffer(attachment.file); + }); +} diff --git a/ts/util/index.ts b/ts/util/index.ts index b9b849e94..a7b20b103 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -7,6 +7,7 @@ import { makeLookup } from './makeLookup'; import { FindMember } from './findMember'; import * as UserUtil from './user'; import * as PasswordUtil from './passwordUtils'; +import * as AttachmentUtil from './attachmentsUtil'; export * from './blockedNumberController'; @@ -20,4 +21,5 @@ export { UserUtil, PasswordUtil, FindMember, + AttachmentUtil, }; diff --git a/yarn.lock b/yarn.lock index f0ead84ef..82d8d0bce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1240,11 +1240,6 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== -attr-accept@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.1.0.tgz#a231a854385d36ff7a99647bb77b33c8a5175aee" - integrity sha512-sLzVM3zCCmmDtDNhI0i96k6PUztkotSOXqE4kDGQt/6iDi5M+H0srjeF+QC6jN581l4X/Zq3Zu/tgcErEssavg== - autoprefixer@^6.3.1: version "6.7.7" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014" @@ -4072,13 +4067,6 @@ file-entry-cache@^2.0.0: flat-cache "^1.2.1" object-assign "^4.0.1" -file-selector@^0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.1.12.tgz#fe726547be219a787a9dcc640575a04a032b1fd0" - integrity sha512-Kx7RTzxyQipHuiqyZGf+Nz4vY9R1XGxuQl/hLoJwq+J4avk/9wxxgZyHKtbyIPJmbD4A66DWGYfyykWNpcYutQ== - dependencies: - tslib "^1.9.0" - file-sync-cmp@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/file-sync-cmp/-/file-sync-cmp-0.1.1.tgz#a5e7a8ffbfa493b43b923bbd4ca89a53b63b612b" @@ -8716,15 +8704,6 @@ react-dom@16.8.3: prop-types "^15.6.2" scheduler "^0.13.3" -react-dropzone@^11.0.2: - version "11.0.2" - resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.0.2.tgz#1a4084f520c2eafbeb24026760b3ee8f3759cfd3" - integrity sha512-/Wde9Il1aJ1FtWllg3N2taIeJh4aftx6UGUG8R1TmLnZit2RnDcEjcKwEEbKwgLXTTh8QQpiZWQJq45jTy1jCA== - dependencies: - attr-accept "^2.0.0" - file-selector "^0.1.12" - prop-types "^15.7.2" - react-emoji-render@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/react-emoji-render/-/react-emoji-render-1.2.4.tgz#fa3542a692e1eed3236f0f12b8e3a61b2818e2c2" @@ -10751,11 +10730,6 @@ tslib@^1.8.0, tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== -tslib@^1.9.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" - integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== - tslint-microsoft-contrib@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/tslint-microsoft-contrib/-/tslint-microsoft-contrib-6.0.0.tgz#7bff73c9ad7a0b7eb5cdb04906de58f42a2bf7a2"