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