diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 444ed2052c3..6d823ed9723 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1720,6 +1720,12 @@ } } }, + + "ConversationListItem--draft-prefix": { + "message": "Draft:", + "description": + "Prefix shown in italic in conversation view when a draft is saved" + }, "message--getNotificationText--stickers": { "message": "Sticker message", "description": @@ -1906,5 +1912,20 @@ "message": "View Photo", "description": "Text shown on messages with with individual timers, before user has viewed it" + }, + "Conversation--getDraftPreview--attachment": { + "message": "(attachment)", + "description": + "Text shown in left pane as preview for conversation with saved a saved draft message" + }, + "Conversation--getDraftPreview--quote": { + "message": "(quote)", + "description": + "Text shown in left pane as preview for conversation with saved a saved draft message" + }, + "Conversation--getDraftPreview--draft": { + "message": "(draft)", + "description": + "Text shown in left pane as preview for conversation with saved a saved draft message" } } diff --git a/app/attachment_channel.js b/app/attachment_channel.js index e32279e8c41..2c921b1b37c 100644 --- a/app/attachment_channel.js +++ b/app/attachment_channel.js @@ -13,6 +13,7 @@ let initialized = false; const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; const ERASE_STICKERS_KEY = 'erase-stickers'; const ERASE_TEMP_KEY = 'erase-temp'; +const ERASE_DRAFTS_KEY = 'erase-drafts'; const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; async function initialize({ configDir, cleanupOrphanedAttachments }) { @@ -24,6 +25,7 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) { const attachmentsDir = Attachments.getPath(configDir); const stickersDir = Attachments.getStickersPath(configDir); const tempDir = Attachments.getTempPath(configDir); + const draftDir = Attachments.getDraftPath(configDir); ipcMain.on(ERASE_TEMP_KEY, event => { try { @@ -58,6 +60,17 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) { } }); + ipcMain.on(ERASE_DRAFTS_KEY, event => { + try { + rimraf.sync(draftDir); + event.sender.send(`${ERASE_DRAFTS_KEY}-done`); + } catch (error) { + const errorForDisplay = error && error.stack ? error.stack : error; + console.log(`erase drafts error: ${errorForDisplay}`); + event.sender.send(`${ERASE_DRAFTS_KEY}-done`, error); + } + }); + ipcMain.on(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async event => { try { await cleanupOrphanedAttachments(); diff --git a/app/attachments.js b/app/attachments.js index c83c028a4a9..6174b2540d1 100644 --- a/app/attachments.js +++ b/app/attachments.js @@ -10,6 +10,7 @@ const { map, isArrayBuffer, isString } = require('lodash'); const PATH = 'attachments.noindex'; const STICKER_PATH = 'stickers.noindex'; const TEMP_PATH = 'temp'; +const DRAFT_PATH = 'drafts.noindex'; exports.getAllAttachments = async userDataPath => { const dir = exports.getPath(userDataPath); @@ -27,6 +28,14 @@ exports.getAllStickers = async userDataPath => { return map(files, file => path.relative(dir, file)); }; +exports.getAllDraftAttachments = async userDataPath => { + const dir = exports.getDraftPath(userDataPath); + const pattern = path.join(dir, '**', '*'); + + const files = await pify(glob)(pattern, { nodir: true }); + return map(files, file => path.relative(dir, file)); +}; + // getPath :: AbsolutePath -> AbsolutePath exports.getPath = userDataPath => { if (!isString(userDataPath)) { @@ -51,6 +60,14 @@ exports.getTempPath = userDataPath => { return path.join(userDataPath, TEMP_PATH); }; +// getDraftPath :: AbsolutePath -> AbsolutePath +exports.getDraftPath = userDataPath => { + if (!isString(userDataPath)) { + throw new TypeError("'userDataPath' must be a string"); + } + return path.join(userDataPath, DRAFT_PATH); +}; + // clearTempPath :: AbsolutePath -> AbsolutePath exports.clearTempPath = userDataPath => { const tempPath = exports.getTempPath(userDataPath); @@ -204,6 +221,20 @@ exports.deleteAllStickers = async ({ userDataPath, stickers }) => { console.log(`deleteAllStickers: deleted ${stickers.length} files`); }; +exports.deleteAllDraftAttachments = async ({ userDataPath, stickers }) => { + const deleteFromDisk = exports.createDeleter( + exports.getDraftPath(userDataPath) + ); + + for (let index = 0, max = stickers.length; index < max; index += 1) { + const file = stickers[index]; + // eslint-disable-next-line no-await-in-loop + await deleteFromDisk(file); + } + + console.log(`deleteAllDraftAttachments: deleted ${stickers.length} files`); +}; + // createName :: Unit -> IO String exports.createName = () => { const buffer = crypto.randomBytes(32); diff --git a/app/sql.js b/app/sql.js index 3c2a09f95d2..82c59ee7736 100644 --- a/app/sql.js +++ b/app/sql.js @@ -140,6 +140,7 @@ module.exports = { removeKnownAttachments, removeKnownStickers, + removeKnownDraftAttachments, }; function generateUUID() { @@ -2867,6 +2868,24 @@ function getExternalFilesForConversation(conversation) { return files; } +function getExternalDraftFilesForConversation(conversation) { + const draftAttachments = conversation.draftAttachments || []; + const files = []; + + forEach(draftAttachments, attachment => { + const { path: file, screenshotPath } = attachment; + if (file) { + files.push(file); + } + + if (screenshotPath) { + files.push(screenshotPath); + } + }); + + return files; +} + async function removeKnownAttachments(allAttachments) { const lookup = fromPairs(map(allAttachments, file => [file, true])); const chunkSize = 50; @@ -2999,3 +3018,54 @@ async function removeKnownStickers(allStickers) { return Object.keys(lookup); } + +async function removeKnownDraftAttachments(allStickers) { + const lookup = fromPairs(map(allStickers, file => [file, true])); + const chunkSize = 50; + + const total = await getConversationCount(); + console.log( + `removeKnownDraftAttachments: About to iterate through ${total} conversations` + ); + + let complete = false; + let count = 0; + // Though conversations.id is a string, this ensures that, when coerced, this + // value is still a string but it's smaller than every other string. + let id = 0; + + while (!complete) { + // eslint-disable-next-line no-await-in-loop + const rows = await db.all( + `SELECT json FROM conversations + WHERE id > $id + ORDER BY id ASC + LIMIT $chunkSize;`, + { + $id: id, + $chunkSize: chunkSize, + } + ); + + const conversations = map(rows, row => jsonToObject(row.json)); + forEach(conversations, conversation => { + const externalFiles = getExternalDraftFilesForConversation(conversation); + forEach(externalFiles, file => { + delete lookup[file]; + }); + }); + + const lastMessage = last(conversations); + if (lastMessage) { + ({ id } = lastMessage); + } + complete = conversations.length < chunkSize; + count += conversations.length; + } + + console.log( + `removeKnownDraftAttachments: Done processing ${count} conversations` + ); + + return Object.keys(lookup); +} diff --git a/background.html b/background.html index be982096932..6aaf3cf3221 100644 --- a/background.html +++ b/background.html @@ -471,7 +471,6 @@ - diff --git a/js/models/conversations.js b/js/models/conversations.js index d3b3c697d56..d481ad98708 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -150,6 +150,34 @@ return this.id === this.ourNumber; }, + hasDraft() { + const draftAttachments = this.get('draftAttachments') || []; + return ( + this.get('draft') || + this.get('quotedMessageId') || + draftAttachments.length > 0 + ); + }, + + getDraftPreview() { + const draft = this.get('draft'); + if (draft) { + return draft; + } + + const draftAttachments = this.get('draftAttachments') || []; + if (draftAttachments.length > 0) { + return i18n('Conversation--getDraftPreview--attachment'); + } + + const quotedMessageId = this.get('quotedMessageId'); + if (quotedMessageId) { + return i18n('Conversation--getDraftPreview--quote'); + } + + return i18n('Conversation--getDraftPreview--draft'); + }, + bumpTyping() { // We don't send typing messages if the setting is disabled if (!storage.get('typingIndicators')) { @@ -327,6 +355,13 @@ ? ConversationController.getOrCreate(typingMostRecent.sender, 'private') : null; + const timestamp = this.get('timestamp'); + const draftTimestamp = this.get('draftTimestamp'); + const draftPreview = this.getDraftPreview(); + const draftText = this.get('draft'); + const shouldShowDraft = + this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp; + const result = { id: this.id, @@ -340,10 +375,14 @@ lastUpdated: this.get('timestamp'), name: this.getName(), profileName: this.getProfileName(), - timestamp: this.get('timestamp'), + timestamp, title: this.getTitle(), unreadCount: this.get('unreadCount') || 0, + shouldShowDraft, + draftPreview, + draftText, + phoneNumber: format(this.id, { ourRegionCode: regionCode, }), @@ -970,6 +1009,8 @@ active_at: now, timestamp: now, isArchived: false, + draft: null, + draftTimestamp: null, }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, @@ -1226,6 +1267,15 @@ ); const lastMessageModel = messages.at(0); + if ( + this.hasDraft() && + this.get('draftTimestamp') && + (!lastMessageModel || + lastMessageModel.get('sent_at') < this.get('draftTimestamp')) + ) { + return; + } + const lastMessageJSON = lastMessageModel ? lastMessageModel.toJSON() : null; diff --git a/js/modules/data.js b/js/modules/data.js index 6b5ffa11f1b..0e8d2ebb298 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -9,7 +9,6 @@ const { isFunction, isObject, map, - merge, set, } = require('lodash'); @@ -29,6 +28,7 @@ const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; const ERASE_STICKERS_KEY = 'erase-stickers'; const ERASE_TEMP_KEY = 'erase-temp'; +const ERASE_DRAFTS_KEY = 'erase-drafts'; const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const _jobs = Object.create(null); @@ -598,7 +598,10 @@ async function updateConversation(id, data, { Conversation }) { throw new Error(`Conversation ${id} does not exist!`); } - const merged = merge({}, existing.attributes, data); + const merged = { + ...existing.attributes, + ...data, + }; await channels.updateConversation(merged); } @@ -1007,6 +1010,7 @@ async function removeOtherData() { callChannel(ERASE_ATTACHMENTS_KEY), callChannel(ERASE_STICKERS_KEY), callChannel(ERASE_TEMP_KEY), + callChannel(ERASE_DRAFTS_KEY), ]); } diff --git a/js/modules/signal.js b/js/modules/signal.js index 5d610075bbc..233b053b882 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -103,20 +103,21 @@ function initializeMigrations({ return null; } const { + createAbsolutePathGetter, + createReader, + createWriterForExisting, + createWriterForNew, + getDraftPath, getPath, getStickersPath, getTempPath, - createReader, - createAbsolutePathGetter, - createWriterForNew, - createWriterForExisting, } = Attachments; const { - makeObjectUrl, - revokeObjectUrl, getImageDimensions, makeImageThumbnail, + makeObjectUrl, makeVideoScreenshot, + revokeObjectUrl, } = VisualType; const attachmentsPath = getPath(userDataPath); @@ -147,11 +148,18 @@ function initializeMigrations({ tempPath ); + const draftPath = getDraftPath(userDataPath); + const getAbsoluteDraftPath = createAbsolutePathGetter(draftPath); + const writeNewDraftData = createWriterForNew(draftPath); + const deleteDraftFile = Attachments.createDeleter(draftPath); + const readDraftData = createReader(draftPath); + return { attachmentsPath, copyIntoAttachmentsDirectory, copyIntoTempDirectory, deleteAttachmentData: deleteOnDisk, + deleteDraftFile, deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({ deleteAttachmentData: Type.deleteData(deleteOnDisk), deleteOnDisk, @@ -159,6 +167,7 @@ function initializeMigrations({ deleteSticker, deleteTempFile, getAbsoluteAttachmentPath, + getAbsoluteDraftPath, getAbsoluteStickerPath, getAbsoluteTempPath, getPlaceholderMigrations, @@ -169,6 +178,7 @@ function initializeMigrations({ loadQuoteData, loadStickerData, readAttachmentData, + readDraftData, readStickerData, readTempData, run, @@ -218,6 +228,7 @@ function initializeMigrations({ logger, }), writeNewAttachmentData: createWriterForNew(attachmentsPath), + writeNewDraftData, }; } diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 667d387604f..a517d74369e 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -2,9 +2,10 @@ $, _, ConversationController, - MessageController, extension, i18n, + loadImage, + MessageController, Signal, storage, textsecure, @@ -16,13 +17,17 @@ 'use strict'; window.Whisper = window.Whisper || {}; - const { Message } = window.Signal.Types; + const { Message, MIME, VisualAttachment } = window.Signal.Types; const { upgradeMessageSchema, getAbsoluteAttachmentPath, + getAbsoluteDraftPath, copyIntoTempDirectory, getAbsoluteTempPath, + deleteDraftFile, deleteTempFile, + readDraftData, + writeNewDraftData, } = window.Signal.Migrations; const { getOlderMessagesByConversation, @@ -80,6 +85,35 @@ }, }); + Whisper.FileSizeToast = Whisper.ToastView.extend({ + templateName: 'file-size-modal', + render_attributes() { + return { + 'file-size-warning': i18n('fileSizeWarning'), + limit: this.model.limit, + units: this.model.units, + }; + }, + }); + Whisper.UnableToLoadToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: i18n('unableToLoadAttachment') }; + }, + }); + + Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ + template: i18n('dangerousFileType'), + }); + Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({ + template: i18n('oneNonImageAtATimeToast'), + }); + Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({ + template: i18n('cannotMixImageAdnNonImageAttachments'), + }); + Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({ + template: i18n('maximumAttachments'), + }); + Whisper.ConversationLoadingScreen = Whisper.View.extend({ templateName: 'conversation-loading-screen', className: 'conversation-loading-screen', @@ -152,6 +186,7 @@ this.maybeGrabLinkPreview.bind(this), 200 ); + this.debouncedSaveDraft = _.debounce(this.saveDraft.bind(this), 200); this.render(); @@ -163,41 +198,26 @@ const attachmentListEl = $( '
' ); - this.fileInput = new Whisper.FileInputView({ + + this.attachmentListView = new Whisper.ReactWrapperView({ el: attachmentListEl, - }); - this.listenTo( - this.fileInput, - 'choose-attachment', - this.onChooseAttachment - ); - this.listenTo(this.fileInput, 'staged-attachments-changed', () => { - this.toggleMicrophone(); - if (this.fileInput.hasFiles()) { - this.removeLinkPreview(); - } + Component: window.Signal.Components.AttachmentList, + props: this.getPropsForAttachmentList(), }); extension.windows.onClosed(() => { this.unload('windows closed'); }); - this.$('.send-message').focus(this.focusBottomBar.bind(this)); - this.$('.send-message').blur(this.unfocusBottomBar.bind(this)); - this.setupHeader(); this.setupTimeline(); this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] }); }, events: { - click: 'onClick', 'click .composition-area-placeholder': 'onClickPlaceholder', 'click .bottom-bar': 'focusMessageField', 'click .capture-audio .microphone': 'captureAudio', - 'focus .send-message': 'focusBottomBar', - 'blur .send-message': 'unfocusBottomBar', - 'click button.paperclip': 'onChooseAttachment', 'change input.file-input': 'onChoseAttachment', dragover: 'onDragOver', @@ -284,13 +304,9 @@ `)[0]; - const attCellEl = $(` -
- -
- `)[0]; const props = { + id: this.model.id, compositionApi, onClickAddPack: () => this.showStickerManager(), onPickSticker: (packId, stickerId) => @@ -298,8 +314,8 @@ onSubmit: message => this.sendMessage(message), onEditorStateChange: (msg, caretLocation) => this.onEditorStateChange(msg, caretLocation), + onChooseAttachment: this.onChooseAttachment.bind(this), micCellEl, - attCellEl, attachmentListEl, }; @@ -352,6 +368,7 @@ const downloadNewVersion = () => { this.downloadNewVersion(); }; + const scrollToQuotedMessage = async options => { const { author, sentAt } = options; @@ -366,9 +383,7 @@ ); if (!message) { - const toast = new Whisper.OriginalNotFoundToast(); - toast.$el.appendTo(this.$el); - toast.render(); + this.showToast(Whisper.OriginalNotFoundToast); return; } @@ -477,8 +492,10 @@ finish(); } }; - const markMessageRead = async messageId => { - if (!document.hasFocus()) { + const markMessageRead = async (messageId, forceFocus) => { + // We need a forceFocus parameter because the BrowserWindow focus event fires + // before the document realizes that it has focus. + if (!document.hasFocus() && !forceFocus) { return; } @@ -523,6 +540,12 @@ this.$('.timeline-placeholder').append(this.timelineView.el); }, + showToast(ToastView) { + const toast = new ToastView(); + toast.$el.appendTo(this.$el); + toast.render(); + }, + async cleanModels(collection) { const result = collection .filter(message => Boolean(message.id)) @@ -701,12 +724,7 @@ e.preventDefault(); }, - onChooseAttachment(e) { - if (e) { - e.stopPropagation(); - e.preventDefault(); - } - + onChooseAttachment() { this.$('input.file-input').click(); }, async onChoseAttachment() { @@ -716,26 +734,13 @@ 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.fileInput.maybeAddAttachment(file); + await this.maybeAddAttachment(file); this.toggleMicrophone(); } fileField.val(null); }, - onDragOver(e) { - this.fileInput.onDragOver(e); - }, - onDragLeave(e) { - this.fileInput.onDragLeave(e); - }, - onDrop(e) { - this.fileInput.onDrop(e); - }, - onPaste(e) { - this.fileInput.onPaste(e); - }, - unload(reason) { window.log.info( 'unloading conversation', @@ -749,10 +754,15 @@ conversationUnloaded(this.model.id); } - this.fileInput.remove(); this.titleView.remove(); this.timelineView.remove(); + if (this.attachmentListView) { + this.attachmentListView.remove(); + } + if (this.captionEditorView) { + this.captionEditorView.remove(); + } if (this.stickerButtonView) { this.stickerButtonView.remove(); } @@ -787,8 +797,6 @@ } } - this.window.removeEventListener('focus', this.onFocus); - this.remove(); this.model.messageCollection.reset([]); @@ -802,6 +810,562 @@ window.location = 'https://signal.org/download'; }, + onDragOver(e) { + if (e.originalEvent.dataTransfer.types[0] !== 'Files') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + this.$el.addClass('dropoff'); + }, + + onDragLeave(e) { + if (e.originalEvent.dataTransfer.types[0] !== 'Files') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + }, + + async onDrop(e) { + if (e.originalEvent.dataTransfer.types[0] !== 'Files') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + + const { files } = e.originalEvent.dataTransfer; + 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); + } + }, + + onPaste(e) { + const { items } = e.originalEvent.clipboardData; + let imgBlob = null; + for (let i = 0; i < items.length; i += 1) { + if (items[i].type.split('/')[0] === 'image') { + imgBlob = items[i].getAsFile(); + } + } + if (imgBlob !== null) { + const file = imgBlob; + this.maybeAddAttachment(file); + + e.stopPropagation(); + e.preventDefault(); + } + }, + + getPropsForAttachmentList() { + const draftAttachments = this.model.get('draftAttachments') || []; + + return { + // In conversation model/redux + attachments: draftAttachments.map(attachment => ({ + ...attachment, + url: attachment.screenshotPath + ? getAbsoluteDraftPath(attachment.screenshotPath) + : getAbsoluteDraftPath(attachment.path), + })), + // Passed in from ConversationView + onAddAttachment: this.onChooseAttachment.bind(this), + onClickAttachment: this.onClickAttachment.bind(this), + onCloseAttachment: this.onCloseAttachment.bind(this), + onClose: this.clearAttachments.bind(this), + }; + }, + + onClickAttachment(attachment) { + const getProps = () => ({ + url: attachment.url, + caption: attachment.caption, + attachment, + onSave, + }); + + const onSave = caption => { + // eslint-disable-next-line no-param-reassign + attachment.caption = caption; + this.captionEditorView.remove(); + Signal.Backbone.Views.Lightbox.hide(); + this.attachmentListView.update(this.getPropsForAttachmentList()); + }; + + this.captionEditorView = new Whisper.ReactWrapperView({ + className: 'attachment-list-wrapper', + Component: window.Signal.Components.CaptionEditor, + props: getProps(), + onClose: () => Signal.Backbone.Views.Lightbox.hide(), + }); + Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el); + }, + + async deleteDraftAttachment(attachment) { + if (attachment.screenshotPath) { + await deleteDraftFile(attachment.screenshotPath); + } + if (attachment.path) { + await deleteDraftFile(attachment.path); + } + }, + + async saveModel() { + await window.Signal.Data.updateConversation( + this.model.id, + this.model.attributes, + { + Conversation: Whisper.Conversation, + } + ); + }, + + async addAttachment(attachment) { + const onDisk = await this.writeDraftAttachment(attachment); + + const draftAttachments = this.model.get('draftAttachments') || []; + this.model.set({ + draftAttachments: [...draftAttachments, onDisk], + draftTimestamp: Date.now(), + timestamp: Date.now(), + }); + await this.saveModel(); + + this.updateAttachmentsView(); + }, + + async onCloseAttachment(attachment) { + const draftAttachments = this.model.get('draftAttachments') || []; + + this.model.set({ + draftAttachments: _.reject( + draftAttachments, + item => item.path === attachment.path + ), + }); + + this.updateAttachmentsView(); + + await this.saveModel(); + await this.deleteDraftAttachment(attachment); + + this.model.updateLastMessage(); + }, + + async clearAttachments() { + this.voiceNoteAttachment = null; + + const draftAttachments = this.model.get('draftAttachments') || []; + this.model.set({ + draftAttachments: [], + }); + + this.updateAttachmentsView(); + + this.model.updateLastMessage(); + + // 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 => + this.deleteDraftAttachment(attachment) + ) + ), + ]); + }, + + hasFiles() { + const draftAttachments = this.model.get('draftAttachments') || []; + return draftAttachments.length > 0; + }, + + async getFiles() { + if (this.voiceNoteAttachment) { + // We don't need to pull these off disk; we return them as-is + return [this.voiceNoteAttachment]; + } + + const draftAttachments = this.model.get('draftAttachments') || []; + const files = _.compact( + await Promise.all( + draftAttachments.map(attachment => this.getFile(attachment)) + ) + ); + return files; + }, + + async getFile(attachment) { + if (!attachment) { + return Promise.resolve(); + } + + const data = await readDraftData(attachment.path); + if (data.byteLength !== attachment.size) { + window.log.error( + `Attachment size from disk ${ + data.byteLength + } did not match attachment size ${attachment.size}` + ); + return null; + } + + return { + ..._.pick(attachment, ['contentType', 'fileName', 'size']), + data, + }; + }, + + arrayBufferFromFile(file) { + return new Promise((resolve, reject) => { + const FR = new FileReader(); + FR.onload = e => { + resolve(e.target.result); + }; + FR.onerror = reject; + FR.onabort = reject; + FR.readAsArrayBuffer(file); + }); + }, + + showFileSizeError({ limit, units, u }) { + const toast = new Whisper.FileSizeToast({ + model: { limit, units: units[u] }, + }); + toast.$el.insertAfter(this.$el); + toast.render(); + }, + + updateAttachmentsView() { + this.attachmentListView.update(this.getPropsForAttachmentList()); + this.toggleMicrophone(); + if (this.hasFiles()) { + this.removeLinkPreview(); + } + }, + + async writeDraftAttachment(attachment) { + let toWrite = attachment; + + if (toWrite.data) { + const path = await writeNewDraftData(toWrite.data); + toWrite = { + ..._.omit(toWrite, ['data']), + path, + }; + } + if (toWrite.screenshotData) { + const screenshotPath = await writeNewDraftData(toWrite.screenshotData); + toWrite = { + ..._.omit(toWrite, ['screenshotData']), + screenshotPath, + }; + } + + return toWrite; + }, + + async maybeAddAttachment(file) { + if (!file) { + return; + } + + const MB = 1000 * 1024; + if (file.size > 100 * MB) { + this.showFileSizeError({ limit: 100, units: ['MB'], u: 0 }); + return; + } + + if (window.Signal.Util.isFileDangerous(file.name)) { + this.showToast(Whisper.DangerousFileTypeToast); + return; + } + + const draftAttachments = this.model.get('draftAttachments') || []; + if (draftAttachments.length >= 32) { + this.showToast(Whisper.MaxAttachmentsToast); + return; + } + + const haveNonImage = _.any( + draftAttachments, + attachment => !MIME.isImage(attachment.contentType) + ); + // You can't add another attachment if you already have a non-image staged + if (haveNonImage) { + this.showToast(Whisper.OneNonImageAtATimeToast); + return; + } + + // You can't add a non-image attachment if you already have attachments staged + if (!MIME.isImage(file.type) && draftAttachments.length > 0) { + this.showToast(Whisper.CannotMixImageAndNonImageAttachmentsToast); + return; + } + + let attachment; + + try { + if (Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) { + attachment = await this.handleImageAttachment(file); + } else if (Signal.Util.GoogleChrome.isVideoTypeSupported(file.type)) { + attachment = await this.handleVideoAttachment(file); + } else { + const data = await this.arrayBufferFromFile(file); + attachment = { + data, + size: data.byteLength, + contentType: file.type, + fileName: file.name, + }; + } + } catch (e) { + window.log.error( + `Was unable to generate thumbnail for file type ${file.type}`, + e && e.stack ? e.stack : e + ); + const data = await this.arrayBufferFromFile(file); + attachment = { + data, + size: data.byteLength, + contentType: file.type, + fileName: file.name, + }; + } + + try { + if (!this.isSizeOkay(attachment)) { + return; + } + } catch (error) { + window.log.error( + 'Error ensuring that image is properly sized:', + error && error.stack ? error.stack : error + ); + + this.showToast(Whisper.UnableToLoadToast); + return; + } + + this.addAttachment(attachment); + }, + + isSizeOkay(attachment) { + let limitKb = 1000000; + const type = + attachment.contentType === 'image/gif' + ? 'gif' + : attachment.contentType.split('/')[0]; + + switch (type) { + case 'image': + limitKb = 6000; + break; + case 'gif': + limitKb = 25000; + break; + case 'audio': + limitKb = 100000; + break; + case 'video': + limitKb = 100000; + break; + default: + limitKb = 100000; + break; + } + 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); + this.showFileSizeError({ limit, units, u }); + return false; + } + + return true; + }, + + async handleVideoAttachment(file) { + 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: window.log, + }); + const screenshotData = await VisualAttachment.blobToArrayBuffer( + screenshotBlob + ); + const data = await this.arrayBufferFromFile(file); + + return { + fileName: file.name, + screenshotContentType, + screenshotData, + screenshotSize: screenshotData.byteLength, + contentType: file.type, + data, + size: data.byteLength, + }; + } finally { + URL.revokeObjectURL(objectUrl); + } + }, + + async handleImageAttachment(file) { + if (MIME.isJPEG(file.type)) { + const rotatedDataUrl = await window.autoOrientImage(file); + const rotatedBlob = VisualAttachment.dataURLToBlobSync(rotatedDataUrl); + const { contentType, file: resizedBlob } = await this.autoScale({ + contentType: file.type, + rotatedBlob, + }); + const data = await await VisualAttachment.blobToArrayBuffer( + resizedBlob + ); + + return { + fileName: file.name, + contentType, + data, + size: data.byteLength, + }; + } + + const { contentType, file: resizedBlob } = await this.autoScale({ + contentType: file.type, + file, + }); + const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob); + return { + fileName: file.name, + contentType, + data, + size: data.byteLength, + }; + }, + + autoScale(attachment) { + 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 targetContentType = 'image/jpeg'; + const canvas = loadImage.scale(img, { + canvas: true, + maxWidth, + maxHeight, + }); + + let quality = 0.95; + let i = 4; + let blob; + do { + i -= 1; + blob = window.dataURLToBlobSync( + canvas.toDataURL(targetContentType, 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, + fileName: this.fixExtension(attachment.fileName, targetContentType), + contentType: targetContentType, + file: blob, + }); + }; + img.src = url; + }); + }, + + getFileName(fileName) { + if (!fileName) { + return ''; + } + + if (!fileName.includes('.')) { + return fileName; + } + + return fileName + .split('.') + .slice(0, -1) + .join('.'); + }, + + getType(contentType) { + if (!contentType) { + return ''; + } + + if (!contentType.includes('/')) { + return contentType; + } + + return contentType.split('/')[1]; + }, + + fixExtension(fileName, contentType) { + const extension = this.getType(contentType); + const name = this.getFileName(fileName); + return `${name}.${extension}`; + }, + markAllAsVerifiedDefault(unverified) { return Promise.all( unverified.map(contact => { @@ -865,16 +1429,14 @@ }, toggleMicrophone() { - this.compositionApi.current.setShowMic(!this.fileInput.hasFiles()); + this.compositionApi.current.setShowMic(!this.hasFiles()); }, captureAudio(e) { e.preventDefault(); - if (this.fileInput.hasFiles()) { - const toast = new Whisper.VoiceNoteMustBeOnlyAttachmentToast(); - toast.$el.appendTo(this.$el); - toast.render(); + if (this.hasFiles()) { + this.showToast(Whisper.VoiceNoteMustBeOnlyAttachmentToast); return; } @@ -898,12 +1460,21 @@ this.disableMessageField(); this.$('.microphone').hide(); }, - handleAudioCapture(blob) { - this.fileInput.addAttachment({ + async handleAudioCapture(blob) { + if (this.hasFiles()) { + throw new Error('A voice note cannot be sent with other attachments'); + } + + const data = await this.arrayBufferFromFile(blob); + + // These aren't persisted to disk; they are meant to be sent immediately + this.voiceNoteAttachment = { contentType: blob.type, - file: blob, - isVoiceNote: true, - }); + data, + size: data.byteLength, + flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + }; + this.sendMessage(); }, endCaptureAudio() { @@ -913,13 +1484,6 @@ this.compositionApi.current.setMicActive(false); }, - unfocusBottomBar() { - this.$('.bottom-bar form').removeClass('active'); - }, - focusBottomBar() { - this.$('.bottom-bar form').addClass('active'); - }, - async onOpened(messageId) { this.openStart = Date.now(); this.lastActivity = Date.now(); @@ -953,6 +1517,11 @@ } this.loadNewestMessages(); + + const quotedMessageId = this.model.get('quotedMessageId'); + if (quotedMessageId) { + this.setQuoteMessage(quotedMessageId); + } }, async retrySend(messageId) { @@ -1220,9 +1789,7 @@ downloadAttachment({ attachment, timestamp, isDangerous }) { if (isDangerous) { - const toast = new Whisper.DangerousFileTypeToast(); - toast.$el.appendTo(this.$el); - toast.render(); + this.showToast(Whisper.DangerousFileTypeToast); return; } @@ -1676,20 +2243,36 @@ this.quote = null; this.quotedMessage = null; + const existing = this.model.get('quotedMessageId'); + if (existing !== messageId) { + const timestamp = messageId ? Date.now() : null; + this.model.set({ + quotedMessageId: messageId, + draftTimestamp: timestamp, + timestamp, + }); + await this.saveModel(); + } + if (this.quoteHolder) { this.quoteHolder.unload(); this.quoteHolder = null; } - const message = this.model.messageCollection.get(messageId); - if (message) { - this.quotedMessage = message; + if (messageId) { + const model = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (model) { + const message = MessageController.register(model.id, model); + this.quotedMessage = message; - if (message) { - const quote = await this.model.makeQuote(this.quotedMessage); - this.quote = quote; + if (message) { + const quote = await this.model.makeQuote(this.quotedMessage); + this.quote = quote; - this.focusMessageFieldAndClearDisabled(); + this.focusMessageFieldAndClearDisabled(); + } } } @@ -1765,36 +2348,35 @@ this.model.clearTypingTimers(); - let toast; + let ToastView; if (extension.expired()) { - toast = new Whisper.ExpiredToast(); + ToastView = Whisper.ExpiredToast; } if (this.model.isPrivate() && storage.isBlocked(this.model.id)) { - toast = new Whisper.BlockedToast(); + ToastView = Whisper.BlockedToast; } if (!this.model.isPrivate() && storage.isGroupBlocked(this.model.id)) { - toast = new Whisper.BlockedGroupToast(); + ToastView = Whisper.BlockedGroupToast; } if (!this.model.isPrivate() && this.model.get('left')) { - toast = new Whisper.LeftGroupToast(); + ToastView = Whisper.LeftGroupToast; } if (message.length > MAX_MESSAGE_BODY_LENGTH) { - toast = new Whisper.MessageBodyTooLongToast(); + ToastView = Whisper.MessageBodyTooLongToast; } - if (toast) { - toast.$el.appendTo(this.$el); - toast.render(); + if (ToastView) { + this.showToast(ToastView); this.focusMessageFieldAndClearDisabled(); return; } try { - if (!message.length && !this.fileInput.hasFiles()) { + if (!message.length && !this.hasFiles() && !this.voiceNoteAttachment) { return; } - const attachments = await this.fileInput.getFiles(); + const attachments = await this.getFiles(); const sendDelta = Date.now() - this.sendStart; window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); @@ -1808,7 +2390,7 @@ this.compositionApi.current.reset(); this.setQuoteMessage(null); this.resetLinkPreview(); - this.fileInput.clearAttachments(); + this.clearAttachments(); } catch (error) { window.log.error( 'Error pulling attached files before send', @@ -1821,9 +2403,32 @@ onEditorStateChange(messageText, caretLocation) { this.maybeBumpTyping(messageText); + this.debouncedSaveDraft(messageText); this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); }, + async saveDraft(messageText) { + if ( + (this.model.get('draft') && !messageText) || + messageText.length === 0 + ) { + this.model.set({ + draft: null, + }); + await this.saveModel(); + + this.model.updateLastMessage(); + return; + } + + this.model.set({ + draft: messageText, + draftTimestamp: Date.now(), + timestamp: Date.now(), + }); + await this.saveModel(); + }, + maybeGrabLinkPreview(message, caretLocation) { // Don't generate link previews if user has turned them off if (!storage.get('linkPreviews', false)) { @@ -1834,7 +2439,7 @@ return; } // If we have attachments, don't add link preview - if (this.fileInput.hasFiles()) { + if (this.hasFiles()) { return; } // If we're behind a user-configured proxy, we don't support link previews @@ -2053,14 +2658,14 @@ // Ensure that this file is either small enough or is resized to meet our // requirements for attachments - const withBlob = await this.fileInput.autoScale({ + const withBlob = await this.autoScale({ contentType: data.contentType, file: new Blob([data.data], { type: data.contentType, }), }); - const attachment = await this.fileInput.readFile(withBlob); + const attachment = await this.arrayBufferFromFile(withBlob); objectUrl = URL.createObjectURL(withBlob.file); const dimensions = await Signal.Types.VisualAttachment.getImageDimensions( diff --git a/js/views/file_input_view.js b/js/views/file_input_view.js deleted file mode 100644 index 04d6d3b7555..00000000000 --- a/js/views/file_input_view.js +++ /dev/null @@ -1,575 +0,0 @@ -/* global textsecure: false */ -/* global Whisper: false */ -/* global i18n: false */ -/* global loadImage: false */ -/* global Backbone: false */ -/* global _: false */ -/* global Signal: false */ - -// eslint-disable-next-line func-names -(function() { - 'use strict'; - - window.Whisper = window.Whisper || {}; - - const { MIME, VisualAttachment } = window.Signal.Types; - - Whisper.FileSizeToast = Whisper.ToastView.extend({ - templateName: 'file-size-modal', - render_attributes() { - return { - 'file-size-warning': i18n('fileSizeWarning'), - limit: this.model.limit, - units: this.model.units, - }; - }, - }); - Whisper.UnableToLoadToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('unableToLoadAttachment') }; - }, - }); - - Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ - template: i18n('dangerousFileType'), - }); - Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({ - template: i18n('oneNonImageAtATimeToast'), - }); - Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({ - template: i18n('cannotMixImageAdnNonImageAttachments'), - }); - Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({ - template: i18n('maximumAttachments'), - }); - - Whisper.FileInputView = Backbone.View.extend({ - tagName: 'span', - className: 'file-input', - initialize() { - this.attachments = []; - - this.attachmentListView = new Whisper.ReactWrapperView({ - el: this.el, - Component: window.Signal.Components.AttachmentList, - props: this.getPropsForAttachmentList(), - }); - }, - - remove() { - if (this.attachmentListView) { - this.attachmentListView.remove(); - } - if (this.captionEditorView) { - this.captionEditorView.remove(); - } - - Backbone.View.prototype.remove.call(this); - }, - - render() { - this.attachmentListView.update(this.getPropsForAttachmentList()); - this.trigger('staged-attachments-changed'); - }, - - getPropsForAttachmentList() { - const { attachments } = this; - - // We never want to display voice notes in our attachment list - if (_.any(attachments, attachment => Boolean(attachment.isVoiceNote))) { - return { - attachments: [], - }; - } - - return { - attachments, - onAddAttachment: this.onAddAttachment.bind(this), - onClickAttachment: this.onClickAttachment.bind(this), - onCloseAttachment: this.onCloseAttachment.bind(this), - onClose: this.onClose.bind(this), - }; - }, - - onClickAttachment(attachment) { - const getProps = () => ({ - url: attachment.videoUrl || attachment.url, - caption: attachment.caption, - attachment, - onSave, - }); - - const onSave = caption => { - // eslint-disable-next-line no-param-reassign - attachment.caption = caption; - this.captionEditorView.remove(); - Signal.Backbone.Views.Lightbox.hide(); - this.render(); - }; - - this.captionEditorView = new Whisper.ReactWrapperView({ - className: 'attachment-list-wrapper', - Component: window.Signal.Components.CaptionEditor, - props: getProps(), - onClose: () => Signal.Backbone.Views.Lightbox.hide(), - }); - Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el); - }, - - onCloseAttachment(attachment) { - this.attachments = _.without(this.attachments, attachment); - this.render(); - }, - - onAddAttachment() { - this.trigger('choose-attachment'); - }, - - onClose() { - this.attachments = []; - this.render(); - }, - - // These event handlers are called by ConversationView, which listens for these events - - onDragOver(e) { - if (e.originalEvent.dataTransfer.types[0] !== 'Files') { - return; - } - - e.stopPropagation(); - e.preventDefault(); - this.$el.addClass('dropoff'); - }, - - onDragLeave(e) { - if (e.originalEvent.dataTransfer.types[0] !== 'Files') { - return; - } - - e.stopPropagation(); - e.preventDefault(); - this.$el.removeClass('dropoff'); - }, - - async onDrop(e) { - if (e.originalEvent.dataTransfer.types[0] !== 'Files') { - return; - } - - e.stopPropagation(); - e.preventDefault(); - - const { files } = e.originalEvent.dataTransfer; - 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.$el.removeClass('dropoff'); - }, - - onPaste(e) { - const { items } = e.originalEvent.clipboardData; - let imgBlob = null; - for (let i = 0; i < items.length; i += 1) { - if (items[i].type.split('/')[0] === 'image') { - imgBlob = items[i].getAsFile(); - } - } - if (imgBlob !== null) { - const file = imgBlob; - this.maybeAddAttachment(file); - - e.stopPropagation(); - e.preventDefault(); - } - }, - - // Public interface - - hasFiles() { - return this.attachments.length > 0; - }, - - async getFiles() { - const files = await Promise.all( - this.attachments.map(attachment => this.getFile(attachment)) - ); - this.clearAttachments(); - return files; - }, - - clearAttachments() { - this.attachments.forEach(attachment => { - if (attachment.url) { - URL.revokeObjectURL(attachment.url); - } - if (attachment.videoUrl) { - URL.revokeObjectURL(attachment.videoUrl); - } - }); - - this.attachments = []; - this.render(); - this.$el.trigger('force-resize'); - }, - - // Show errors - - showLoadFailure() { - const toast = new Whisper.UnableToLoadToast(); - toast.$el.insertAfter(this.$el); - toast.render(); - }, - - showDangerousError() { - const toast = new Whisper.DangerousFileTypeToast(); - toast.$el.insertAfter(this.$el); - toast.render(); - }, - - showFileSizeError({ limit, units, u }) { - const toast = new Whisper.FileSizeToast({ - model: { limit, units: units[u] }, - }); - toast.$el.insertAfter(this.$el); - toast.render(); - }, - - showCannotMixError() { - const toast = new Whisper.CannotMixImageAndNonImageAttachmentsToast(); - toast.$el.insertAfter(this.$el); - toast.render(); - }, - - showMultipleNonImageError() { - const toast = new Whisper.OneNonImageAtATimeToast(); - toast.$el.insertAfter(this.$el); - toast.render(); - }, - - showMaximumAttachmentsError() { - const toast = new Whisper.MaxAttachmentsToast(); - toast.$el.insertAfter(this.$el); - toast.render(); - }, - - // Housekeeping - - addAttachment(attachment) { - if (attachment.isVoiceNote && this.attachments.length > 0) { - throw new Error('A voice note cannot be sent with other attachments'); - } - - this.attachments.push(attachment); - this.render(); - }, - - async maybeAddAttachment(file) { - if (!file) { - return; - } - - const fileName = file.name; - const contentType = file.type; - - if (window.Signal.Util.isFileDangerous(fileName)) { - this.showDangerousError(); - return; - } - - if (this.attachments.length >= 32) { - this.showMaximumAttachmentsError(); - return; - } - - const haveNonImage = _.any( - this.attachments, - attachment => !MIME.isImage(attachment.contentType) - ); - // You can't add another attachment if you already have a non-image staged - if (haveNonImage) { - this.showMultipleNonImageError(); - return; - } - - // You can't add a non-image attachment if you already have attachments staged - if (!MIME.isImage(contentType) && this.attachments.length > 0) { - this.showCannotMixError(); - return; - } - - 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 = Signal.Util.arrayBufferToObjectURL({ - data, - type, - }); - this.addAttachment({ - file, - size: file.size, - fileName, - contentType, - videoUrl: objectUrl, - url, - }); - } catch (error) { - URL.revokeObjectURL(objectUrl); - } - }; - - const renderImagePreview = async () => { - if (!MIME.isJPEG(contentType)) { - const url = URL.createObjectURL(file); - if (!url) { - throw new Error('Failed to create object url for image!'); - } - this.addAttachment({ - file, - size: file.size, - fileName, - contentType, - url, - }); - return; - } - - const url = await window.autoOrientImage(file); - this.addAttachment({ - file, - size: file.size, - fileName, - contentType, - url, - }); - }; - - try { - const blob = await this.autoScale({ - contentType, - file, - }); - let limitKb = 1000000; - const blobType = - file.type === 'image/gif' ? 'gif' : contentType.split('/')[0]; - - switch (blobType) { - case 'image': - limitKb = 6000; - break; - case 'gif': - limitKb = 25000; - break; - case 'audio': - limitKb = 100000; - break; - case 'video': - limitKb = 100000; - break; - default: - limitKb = 100000; - break; - } - 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 - ); - - this.showLoadFailure(); - return; - } - - try { - if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) { - await renderImagePreview(); - } else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) { - await renderVideoPreview(); - } else { - this.addAttachment({ - file, - size: file.size, - contentType, - fileName, - }); - } - } 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, - }); - } - }, - - autoScale(attachment) { - 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 targetContentType = 'image/jpeg'; - const canvas = loadImage.scale(img, { - canvas: true, - maxWidth, - maxHeight, - }); - - let quality = 0.95; - let i = 4; - let blob; - do { - i -= 1; - blob = window.dataURLToBlobSync( - canvas.toDataURL(targetContentType, 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, - fileName: this.fixExtension(attachment.fileName, targetContentType), - contentType: targetContentType, - file: blob, - }); - }; - img.src = url; - }); - }, - - getFileName(fileName) { - if (!fileName) { - return ''; - } - - if (!fileName.includes('.')) { - return fileName; - } - - return fileName - .split('.') - .slice(0, -1) - .join('.'); - }, - - getType(contentType) { - if (!contentType) { - return ''; - } - - if (!contentType.includes('/')) { - return contentType; - } - - return contentType.split('/')[1]; - }, - - fixExtension(fileName, contentType) { - const extension = this.getType(contentType); - const name = this.getFileName(fileName); - return `${name}.${extension}`; - }, - - async getFile(attachment) { - if (!attachment) { - return Promise.resolve(); - } - - const attachmentFlags = attachment.isVoiceNote - ? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE - : null; - - const scaled = await this.autoScale(attachment); - const fileRead = await this.readFile(scaled); - return { - ...fileRead, - url: undefined, - videoUrl: undefined, - flags: attachmentFlags || null, - }; - }, - - readFile(attachment) { - return new Promise((resolve, reject) => { - const FR = new FileReader(); - FR.onload = e => { - const data = e.target.result; - resolve({ - ...attachment, - data, - size: data.byteLength, - }); - }; - FR.onerror = reject; - FR.onabort = reject; - FR.readAsArrayBuffer(attachment.file); - }); - }, - }); -})(); diff --git a/main.js b/main.js index 621edf70658..dfdce726139 100644 --- a/main.js +++ b/main.js @@ -724,6 +724,17 @@ app.on('ready', async () => { userDataPath, stickers: orphanedStickers, }); + + const allDraftAttachments = await attachments.getAllDraftAttachments( + userDataPath + ); + const orphanedDraftAttachments = await sql.removeKnownDraftAttachments( + allDraftAttachments + ); + await attachments.deleteAllDraftAttachments({ + userDataPath, + stickers: orphanedDraftAttachments, + }); } try { diff --git a/preload.js b/preload.js index c0776cc24ca..dc09f245188 100644 --- a/preload.js +++ b/preload.js @@ -5,8 +5,17 @@ const semver = require('semver'); const { deferredToPromise } = require('./js/modules/deferred_to_promise'); -const { app } = electron.remote; -const { systemPreferences } = electron.remote.require('electron'); +const { remote } = electron; +const { app } = remote; +const { systemPreferences } = remote.require('electron'); + +const browserWindow = remote.getCurrentWindow(); +let focusHandlers = []; +browserWindow.on('focus', () => focusHandlers.forEach(handler => handler())); +window.registerForFocus = handler => focusHandlers.push(handler); +window.unregisterForFocus = handler => { + focusHandlers = focusHandlers.filter(item => item !== handler); +}; // Waiting for clients to implement changes on receive side window.ENABLE_STICKER_SEND = true; @@ -308,6 +317,7 @@ const userDataPath = app.getPath('userData'); window.baseAttachmentsPath = Attachments.getPath(userDataPath); window.baseStickersPath = Attachments.getStickersPath(userDataPath); window.baseTempPath = Attachments.getTempPath(userDataPath); +window.baseDraftPath = Attachments.getDraftPath(userDataPath); window.Signal = Signal.setup({ Attachments, userDataPath, diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 03a0611f565..72c4d7aa159 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -14,7 +14,7 @@ width: 100%; margin-top: 10px; - &:after { + &::after { visibility: hidden; display: block; font-size: 0; @@ -1689,7 +1689,7 @@ padding-top: 20px; padding-bottom: 20px; - &:after { + &::after { content: '.'; visibility: hidden; display: block; @@ -2146,6 +2146,11 @@ color: $color-gray-90; } +.module-conversation-list-item__message__draft-prefix { + font-style: italic; + margin-right: 3px; +} + .module-conversation-list-item__message__status-icon { flex-shrink: 0; @@ -2387,7 +2392,7 @@ color: $color-gray-90; font-size: 14px; - &::placeholder { + &:placeholder { color: $color-gray-45; } @@ -2898,7 +2903,7 @@ padding-left: 12px; padding-right: 65px; - &::placeholder { + &:placeholder { color: $color-white-07; } &:focus { @@ -3861,7 +3866,7 @@ background: none; border: 0; &--menu { - &:after { + &::after { content: ''; display: block; min-width: 24px; @@ -4123,7 +4128,7 @@ opacity: 1; } - &:after { + &::after { display: block; content: ''; width: 24px; @@ -4447,7 +4452,7 @@ border-color: $color-signal-blue; } - &::placeholder { + &:placeholder { color: $color-gray-45; } } @@ -4461,7 +4466,7 @@ border-color: $color-signal-blue; } - &::placeholder { + &:placeholder { color: $color-gray-45; } } @@ -4642,7 +4647,7 @@ opacity: 1; } - &:after { + &::after { display: block; content: ''; width: 24px; @@ -4987,7 +4992,7 @@ align-items: center; background: none; border: none; - &:after { + &::after { display: block; content: ''; width: 24px; diff --git a/test/index.html b/test/index.html index 0baf2f4a9e5..e51e22adc9f 100644 --- a/test/index.html +++ b/test/index.html @@ -484,7 +484,6 @@ - diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index b42cfb952cc..9edf148d843 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -33,11 +33,12 @@ export type OwnProps = { readonly micCellEl?: HTMLElement; readonly attCellEl?: HTMLElement; readonly attachmentListEl?: HTMLElement; + onChooseAttachment(): unknown; }; export type Props = Pick< CompositionInputProps, - 'onSubmit' | 'onEditorSizeChange' | 'onEditorStateChange' + 'onSubmit' | 'onEditorSizeChange' | 'onEditorStateChange' | 'startingText' > & Pick< EmojiButtonProps, @@ -69,12 +70,13 @@ export const CompositionArea = ({ i18n, attachmentListEl, micCellEl, - attCellEl, + onChooseAttachment, // CompositionInput onSubmit, compositionApi, onEditorSizeChange, onEditorStateChange, + startingText, // EmojiButton onPickEmoji, onSetSkinTone, @@ -94,7 +96,7 @@ export const CompositionArea = ({ clearShowPickerHint, }: Props) => { const [disabled, setDisabled] = React.useState(false); - const [showMic, setShowMic] = React.useState(true); + const [showMic, setShowMic] = React.useState(!startingText); const [micActive, setMicActive] = React.useState(false); const [dirty, setDirty] = React.useState(false); const [large, setLarge] = React.useState(false); @@ -179,23 +181,17 @@ export const CompositionArea = ({ // 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); - const attCellRef = React.useRef(null); React.useLayoutEffect( () => { const { current: micCellContainer } = micCellRef; - const { current: attCellContainer } = attCellRef; if (micCellContainer && micCellEl) { emptyElement(micCellContainer); micCellContainer.appendChild(micCellEl); } - if (attCellContainer && attCellEl) { - emptyElement(attCellContainer); - attCellContainer.appendChild(attCellEl); - } return noop; }, - [micCellRef, attCellRef, micCellEl, attCellEl, large, dirty, showMic] + [micCellRef, micCellEl, large, dirty, showMic] ); React.useLayoutEffect( @@ -235,8 +231,12 @@ export const CompositionArea = ({ /> ) : null; - const attButtonFragment = ( -
+ const attButton = ( +
+
+
+
); const sendButtonFragment = ( @@ -318,13 +318,14 @@ export const CompositionArea = ({ onEditorStateChange={onEditorStateChange} onDirtyChange={setDirty} skinTone={skinTone} + startingText={startingText} />
{!large ? ( <> {stickerButtonFragment} {!dirty ? micButtonFragment : null} - {attButtonFragment} + {attButton} ) : null} @@ -337,7 +338,7 @@ export const CompositionArea = ({ > {emojiButtonFragment} {stickerButtonFragment} - {attButtonFragment} + {attButton} {!dirty ? micButtonFragment : null} {dirty || !showMic ? sendButtonFragment : null} diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index aadb6e6f5bc..10c06ebf55c 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -38,6 +38,7 @@ export type Props = { readonly editorRef?: React.RefObject; readonly inputApi?: React.MutableRefObject; readonly skinTone?: EmojiPickDataType['skinTone']; + readonly startingText?: string; onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?(messageText: string, caretLocation: number): unknown; onEditorSizeChange?(rect: ContentRect): unknown; @@ -141,6 +142,25 @@ const combineRefs = createSelector( } ); +const getInitialEditorState = (startingText?: string) => { + if (!startingText) { + return EditorState.createEmpty(compositeDecorator); + } + + const end = startingText.length; + const state = EditorState.createWithContent( + ContentState.createFromText(startingText), + compositeDecorator + ); + const selection = state.getSelection(); + const selectionAtEnd = selection.merge({ + anchorOffset: end, + focusOffset: end, + }) as SelectionState; + + return EditorState.forceSelection(state, selectionAtEnd); +}; + // tslint:disable-next-line max-func-body-length export const CompositionInput = ({ i18n, @@ -154,9 +174,10 @@ export const CompositionInput = ({ onPickEmoji, onSubmit, skinTone, + startingText, }: Props) => { const [editorRenderState, setEditorRenderState] = React.useState( - EditorState.createEmpty(compositeDecorator) + getInitialEditorState(startingText) ); const [searchText, setSearchText] = React.useState(''); const [emojiResults, setEmojiResults] = React.useState>([]); diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 157536ed640..90ba454206c 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -23,6 +23,9 @@ export type PropsData = { unreadCount: number; isSelected: boolean; + draftPreview?: string; + shouldShowDraft?: boolean; + typingContact?: Object; lastMessage?: { status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; @@ -134,11 +137,23 @@ export class ConversationListItem extends React.PureComponent { } public renderMessage() { - const { lastMessage, typingContact, unreadCount, i18n } = this.props; + const { + draftPreview, + i18n, + lastMessage, + shouldShowDraft, + typingContact, + unreadCount, + } = this.props; if (!lastMessage && !typingContact) { return null; } - const text = lastMessage && lastMessage.text ? lastMessage.text : ''; + const text = + shouldShowDraft && draftPreview + ? draftPreview + : lastMessage && lastMessage.text + ? lastMessage.text + : ''; return (
diff --git a/ts/components/conversation/AttachmentList.tsx b/ts/components/conversation/AttachmentList.tsx index b282c546a5a..e1a7b43978e 100644 --- a/ts/components/conversation/AttachmentList.tsx +++ b/ts/components/conversation/AttachmentList.tsx @@ -85,6 +85,9 @@ export class AttachmentList extends React.Component { closeButton={true} onClick={clickCallback} onClickClose={onCloseAttachment} + onError={() => { + onCloseAttachment(attachment); + }} /> ); } diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 17c017179dd..e1e9c0391a5 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -60,7 +60,7 @@ type PropsActionsType = { loadOlderMessages: (messageId: string) => unknown; loadNewerMessages: (messageId: string) => unknown; loadNewestMessages: (messageId: string) => unknown; - markMessageRead: (messageId: string) => unknown; + markMessageRead: (messageId: string, forceFocus?: boolean) => unknown; } & MessageActionsType & SafetyNumberActionsType; @@ -397,7 +397,7 @@ export class Timeline extends React.PureComponent { // tslint:disable-next-line member-ordering cyclomatic-complexity public updateWithVisibleRows = debounce( - () => { + (forceFocus?: boolean) => { const { unreadCount, haveNewest, @@ -421,7 +421,7 @@ export class Timeline extends React.PureComponent { return; } - markMessageRead(newest.id); + markMessageRead(newest.id, forceFocus); const rowCount = this.getRowCount(); @@ -699,6 +699,22 @@ export class Timeline extends React.PureComponent { } }; + public componentDidMount() { + this.updateWithVisibleRows(); + // @ts-ignore + window.registerForFocus(this.forceFocusVisibleRowUpdate); + } + + public componentWillUnmount() { + // @ts-ignore + window.unregisterForFocus(this.forceFocusVisibleRowUpdate); + } + + public forceFocusVisibleRowUpdate = () => { + const forceFocus = true; + this.updateWithVisibleRows(forceFocus); + }; + public componentDidUpdate(prevProps: Props) { const { id, @@ -732,8 +748,6 @@ export class Timeline extends React.PureComponent { if (prevProps.items && prevProps.items.length > 0) { this.resizeAll(); } - - return; } else if (!typingContact && prevProps.typingContact) { this.resizeAll(); } else if (oldestUnreadIndex !== prevProps.oldestUnreadIndex) { @@ -784,6 +798,8 @@ export class Timeline extends React.PureComponent { clearChangedMessages(id); } else if (this.resizeAllFlag) { this.resizeAll(); + } else { + this.updateWithVisibleRows(); } } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 6ac35bcf32f..3799cbb066c 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -63,6 +63,10 @@ export type ConversationType = { phoneNumber: string; profileName?: string; }; + + shouldShowDraft?: boolean; + draftText?: string; + draftPreview?: string; }; export type ConversationLookupType = { [key: string]: ConversationType; diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 7d64e91f8da..c4365d7a459 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -7,6 +7,7 @@ import { StateType } from '../reducer'; import { isShortName } from '../../components/emoji/lib'; import { getIntl } from '../selectors/user'; +import { getConversationSelector } from '../selectors/conversations'; import { getBlessedStickerPacks, getInstalledStickerPacks, @@ -16,12 +17,25 @@ import { getRecentStickers, } from '../selectors/stickers'; +type ExternalProps = { + id: string; +}; + const selectRecentEmojis = createSelector( ({ emojis }: StateType) => emojis.recents, recents => recents.filter(isShortName) ); -const mapStateToProps = (state: StateType) => { +const mapStateToProps = (state: StateType, props: ExternalProps) => { + const { id } = props; + + const conversation = getConversationSelector(state)(id); + if (!conversation) { + throw new Error(`Conversation id ${id} not found!`); + } + + const { draftText } = conversation; + const receivedPacks = getReceivedStickerPacks(state); const installedPacks = getInstalledStickerPacks(state); const blessedPacks = getBlessedStickerPacks(state); @@ -43,6 +57,7 @@ const mapStateToProps = (state: StateType) => { return { // Base i18n: getIntl(state), + startingText: draftText, // Emojis recentEmojis, skinTone: get(state, ['items', 'skinTone'], 0), diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 39f3b1f10bd..1e6f02222ad 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -7862,7 +7862,7 @@ "rule": "DOM-innerHTML", "path": "ts/components/CompositionArea.tsx", "line": " el.innerHTML = '';", - "lineNumber": 64, + "lineNumber": 65, "reasonCategory": "usageTrusted", "updated": "2019-08-01T14:10:37.481Z", "reasonDetail": "Our code, no user input, only clearing out the dom" diff --git a/ts/util/lint/linter.ts b/ts/util/lint/linter.ts index 5bfa0425688..44ca10c03e7 100644 --- a/ts/util/lint/linter.ts +++ b/ts/util/lint/linter.ts @@ -53,7 +53,6 @@ const excludedFiles = [ '^js/models/messages.js', '^js/modules/crypto.js', '^js/views/conversation_view.js', - '^js/views/file_input_view.js', '^js/background.js', // Generated files