New staged attachments UI, multiple image attachments per message

pull/272/head
Scott Nonnenberg 7 years ago
parent e4babdaef0
commit 985b1d6aa6

@ -172,6 +172,10 @@
"message": "Choose folder", "message": "Choose folder",
"description": "Button to allow the user to find a folder on disk" "description": "Button to allow the user to find a folder on disk"
}, },
"chooseFile": {
"message": "Choose file",
"description": "Button to allow the user to find a file on disk"
},
"loadDataHeader": { "loadDataHeader": {
"message": "Load your data", "message": "Load your data",
"description": "Header shown on the first screen in the data import process" "description": "Header shown on the first screen in the data import process"
@ -542,15 +546,27 @@
"message": "Voice Message", "message": "Voice Message",
"description": "Name for a voice message attachment" "description": "Name for a voice message attachment"
}, },
"unsupportedFileType": {
"message": "Unsupported file type",
"description": "Displayed for outgoing unsupported attachment"
},
"dangerousFileType": { "dangerousFileType": {
"message": "Attachment type not allowed for security reasons", "message": "Attachment type not allowed for security reasons",
"description": "description":
"Shown in toast when user attempts to send .exe file, for example" "Shown in toast when user attempts to send .exe file, for example"
}, },
"oneNonImageAtATimeToast": {
"message":
"When including a non-image attachment, the limit is one attachment per message.",
"description":
"An error popup when the user has attempted to add an attachment"
},
"cannotMixImageAdnNonImageAttachments": {
"message": "You cannot mix non-image and image attachments in one message.",
"description":
"An error popup when the user has attempted to add an attachment"
},
"maximumAttachments": {
"message": "You cannot add any more attachments to this message.",
"description":
"An error popup when the user has attempted to add an attachment"
},
"fileSizeWarning": { "fileSizeWarning": {
"message": "Sorry, the selected file exceeds message size restrictions." "message": "Sorry, the selected file exceeds message size restrictions."
}, },
@ -732,6 +748,12 @@
"description": "description":
"Shown in toast if user clicks on quote references messages not loaded in view, but in database" "Shown in toast if user clicks on quote references messages not loaded in view, but in database"
}, },
"voiceNoteMustBeOnlyAttachment": {
"message":
"A voice note must be the only attachment included in a message.",
"description":
"Shown in toast if tries to record a voice note with any staged attachments"
},
"you": { "you": {
"message": "You", "message": "You",
"description": "description":
@ -910,6 +932,11 @@
"description": "description":
"Used for the icon layered on top of an image in message bubbles" "Used for the icon layered on top of an image in message bubbles"
}, },
"addACaption": {
"message": "Add a caption...",
"descripton":
"Used as the placeholder text in the caption editor text field"
},
"fileIconAlt": { "fileIconAlt": {
"message": "File icon", "message": "File icon",
"description": "description":

@ -118,9 +118,9 @@
<div class='bottom-bar' id='footer'> <div class='bottom-bar' id='footer'>
<div class='emoji-panel-container'></div> <div class='emoji-panel-container'></div>
<div class='attachment-list'></div>
<div class='compose'> <div class='compose'>
<form class='send clearfix'> <form class='send clearfix file-input'>
<div class='attachment-previews'></div>
<div class='flex'> <div class='flex'>
<button class='emoji'></button> <button class='emoji'></button>
<textarea class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto'></textarea> <textarea class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto'></textarea>
@ -160,15 +160,6 @@
</div> </div>
</div> </div>
</script> </script>
<script type='text/x-tmpl-mustache' id='attachment-preview'>
<div class='image-container'>
<img src='{{ source }}' class='preview' />
<div class='outer'>
<div class='play icon'></div>
</div>
</div>
<a class='x close' alt='remove attachment' href='#'></a>
</script>
<script type='text/x-tmpl-mustache' id='file-view'> <script type='text/x-tmpl-mustache' id='file-view'>
<div class='icon {{ mediaType }}'></div> <div class='icon {{ mediaType }}'></div>
<div class='text'> <div class='text'>
@ -619,7 +610,6 @@
<script type='text/javascript' src='js/views/last_seen_indicator_view.js'></script> <script type='text/javascript' src='js/views/last_seen_indicator_view.js'></script>
<script type='text/javascript' src='js/views/scroll_down_button_view.js'></script> <script type='text/javascript' src='js/views/scroll_down_button_view.js'></script>
<script type='text/javascript' src='js/views/toast_view.js'></script> <script type='text/javascript' src='js/views/toast_view.js'></script>
<script type='text/javascript' src='js/views/attachment_preview_view.js'></script>
<script type='text/javascript' src='js/views/file_input_view.js'></script> <script type='text/javascript' src='js/views/file_input_view.js'></script>
<script type='text/javascript' src='js/views/list_view.js'></script> <script type='text/javascript' src='js/views/list_view.js'></script>
<script type='text/javascript' src='js/views/conversation_list_item_view.js'></script> <script type='text/javascript' src='js/views/conversation_list_item_view.js'></script>

@ -0,0 +1 @@
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>add-caption-24</title><rect x="16" y="14" width="7" height="1"/><rect x="16" y="14" width="7" height="1" transform="translate(5 34) rotate(-90)"/><rect x="2" y="11" width="15" height="1"/><rect x="2" y="8" width="18" height="1"/><rect x="2" y="14" width="12" height="1"/></svg>

After

Width:  |  Height:  |  Size: 355 B

@ -0,0 +1 @@
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>x-16</title><polygon points="14.35 2.35 13.65 1.65 8 7.29 2.35 1.65 1.65 2.35 7.29 8 1.65 13.65 2.35 14.35 8 8.71 13.65 14.35 14.35 13.65 8.71 8 14.35 2.35"/></svg>

After

Width:  |  Height:  |  Size: 242 B

@ -0,0 +1 @@
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 12 12"><defs><style>.cls-1{opacity:0.5;filter:url(#shadow_blur_2);}.cls-2{fill:#fff;}</style><filter id="shadow_blur_2" name="shadow_blur_2"><feGaussianBlur stdDeviation="0.5" in="SourceGraphic"/></filter></defs><title>x-shadow-12</title><g class="cls-1"><polygon points="10.6 2.6 9.9 1.9 6 5.79 2.1 1.9 1.4 2.6 5.29 6.5 1.4 10.4 2.1 11.1 6 7.21 9.9 11.1 10.6 10.4 6.71 6.5 10.6 2.6"/></g><polygon class="cls-2" points="10.6 2.1 9.9 1.4 6 5.29 2.1 1.4 1.4 2.1 5.29 6 1.4 9.9 2.1 10.6 6 6.71 9.9 10.6 10.6 9.9 6.71 6 10.6 2.1"/></svg>

After

Width:  |  Height:  |  Size: 641 B

@ -15,6 +15,10 @@ const Metadata = require('./metadata/SecretSessionCipher');
const RefreshSenderCertificate = require('./refresh_sender_certificate'); const RefreshSenderCertificate = require('./refresh_sender_certificate');
// Components // Components
const {
AttachmentList,
} = require('../../ts/components/conversation/AttachmentList');
const { CaptionEditor } = require('../../ts/components/CaptionEditor');
const { const {
ContactDetail, ContactDetail,
} = require('../../ts/components/conversation/ContactDetail'); } = require('../../ts/components/conversation/ContactDetail');
@ -172,6 +176,8 @@ exports.setup = (options = {}) => {
}); });
const Components = { const Components = {
AttachmentList,
CaptionEditor,
ContactDetail, ContactDetail,
ContactListItem, ContactListItem,
ContactName, ContactName,

@ -1,16 +0,0 @@
/* global Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.AttachmentPreviewView = Whisper.View.extend({
className: 'attachment-preview',
templateName: 'attachment-preview',
render_attributes() {
return { source: this.src };
},
});
})();

@ -57,6 +57,11 @@
return { toastMessage: i18n('messageFoundButNotLoaded') }; return { toastMessage: i18n('messageFoundButNotLoaded') };
}, },
}); });
Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') };
},
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({ Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen', templateName: 'conversation-loading-screen',
@ -144,8 +149,7 @@
this.window = options.window; this.window = options.window;
this.fileInput = new Whisper.FileInputView({ this.fileInput = new Whisper.FileInputView({
el: this.$('form.send'), el: this.$('.attachment-list'),
window: this.window,
}); });
const getHeaderProps = () => { const getHeaderProps = () => {
@ -255,15 +259,41 @@
'farFromBottom .message-list': 'addScrollDownButton', 'farFromBottom .message-list': 'addScrollDownButton',
'lazyScroll .message-list': 'onLazyScroll', 'lazyScroll .message-list': 'onLazyScroll',
'force-resize': 'forceUpdateMessageFieldSize', 'force-resize': 'forceUpdateMessageFieldSize',
dragover: 'sendToFileInput',
drop: 'sendToFileInput', 'click button.paperclip': 'onChooseAttachment',
dragleave: 'sendToFileInput', 'change input.file-input': 'onChoseAttachment',
dragover: 'onDragOver',
dragleave: 'onDragLeave',
drop: 'onDrop',
paste: 'onPaste',
}, },
sendToFileInput(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') { onChooseAttachment(e) {
return; e.stopPropagation();
} e.preventDefault();
this.fileInput.$el.trigger(e);
this.$('input.file-input').click();
},
async onChoseAttachment() {
const fileField = this.$('input.file-input');
const file = fileField.prop('files')[0];
await this.fileInput.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);
}, },
onPrune() { onPrune() {
@ -483,6 +513,13 @@
captureAudio(e) { captureAudio(e) {
e.preventDefault(); e.preventDefault();
if (this.fileInput.hasFiles()) {
const toast = new Whisper.VoiceNoteMustBeOnlyAttachmentToast();
toast.$el.appendTo(this.$el);
toast.render();
return;
}
// Note - clicking anywhere will close the audio capture panel, due to // Note - clicking anywhere will close the audio capture panel, due to
// the onClick handler in InboxView, which calls its closeRecording method. // the onClick handler in InboxView, which calls its closeRecording method.
@ -503,9 +540,11 @@
this.$('.microphone').hide(); this.$('.microphone').hide();
}, },
handleAudioCapture(blob) { handleAudioCapture(blob) {
this.fileInput.file = blob; this.fileInput.addAttachment({
this.fileInput.isVoiceNote = true; contentType: blob.type,
this.fileInput.previewImages(); file: blob,
isVoiceNote: true,
});
this.$('.bottom-bar form').submit(); this.$('.bottom-bar form').submit();
}, },
endCaptureAudio() { endCaptureAudio() {
@ -1576,7 +1615,7 @@
this.setQuoteMessage(null); this.setQuoteMessage(null);
this.focusMessageFieldAndClearDisabled(); this.focusMessageFieldAndClearDisabled();
this.forceUpdateMessageFieldSize(e); this.forceUpdateMessageFieldSize(e);
this.fileInput.deleteFiles(); this.fileInput.clearAttachments();
} catch (error) { } catch (error) {
window.log.error( window.log.error(
'Error pulling attached files before send', 'Error pulling attached files before send',

@ -30,225 +30,328 @@
}, },
}); });
Whisper.UnsupportedFileTypeToast = Whisper.ToastView.extend({
template: i18n('unsupportedFileType'),
});
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
template: i18n('dangerousFileType'), 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({ Whisper.FileInputView = Backbone.View.extend({
tagName: 'span', tagName: 'span',
className: 'file-input', className: 'file-input',
initialize(options) { initialize() {
this.$input = this.$('input[type=file]'); this.attachments = [];
this.$input.click(e => {
e.stopPropagation(); this.attachmentListView = new Whisper.ReactWrapperView({
el: this.el,
Component: window.Signal.Components.AttachmentList,
props: this.getPropsForAttachmentList(),
}); });
this.thumb = new Whisper.AttachmentPreviewView();
this.$el.addClass('file-input');
this.window = options.window;
this.previewObjectUrl = null;
}, },
events: { remove() {
'change .choose-file': 'previewImages', if (this.attachmentListView) {
'click .close': 'deleteFiles', this.attachmentListView.remove();
'click .choose-file': 'open', }
drop: 'openDropped', if (this.captionEditorView) {
dragover: 'showArea', this.captionEditorView.remove();
dragleave: 'hideArea', }
paste: 'onPaste',
Backbone.View.prototype.remove.call(this);
}, },
open(e) { render() {
e.preventDefault(); this.attachmentListView.update(this.getPropsForAttachmentList());
// hack
if (this.window && this.window.chrome && this.window.chrome.fileSystem) {
this.window.chrome.fileSystem.chooseEntry(
{ type: 'openFile' },
entry => {
if (!entry) {
return;
}
entry.file(file => {
this.file = file;
this.previewImages();
});
}
);
} else {
this.$input.click();
}
}, },
addThumb(src, options = {}) { getPropsForAttachmentList() {
_.defaults(options, { addPlayIcon: false }); const { attachments } = this;
this.$('.avatar').hide();
this.thumb.src = src;
this.$('.attachment-previews').append(this.thumb.render().el);
if (options.addPlayIcon) { // We never want to display voice notes in our attachment list
this.$el.addClass('video-attachment'); if (_.any(attachments, attachment => Boolean(attachment.isVoiceNote))) {
} else { return {
this.$el.removeClass('video-attachment'); attachments: [],
};
} }
this.thumb.$('img')[0].onload = () => { return {
this.$el.trigger('force-resize'); attachments,
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,
onChangeCaption,
});
const update = () => {
this.captionEditorView.update(getProps());
}; };
this.thumb.$('img')[0].onerror = () => {
this.unableToLoadAttachment(); const onChangeCaption = caption => {
// eslint-disable-next-line no-param-reassign
attachment.caption = caption;
this.render();
update();
}; };
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);
}, },
unableToLoadAttachment() { onCloseAttachment(attachment) {
const toast = new Whisper.UnableToLoadToast(); this.attachments = _.without(this.attachments, attachment);
toast.$el.insertAfter(this.$el); this.render();
toast.render(); },
this.deleteFiles(); onClose() {
this.attachments = [];
this.render();
}, },
autoScale(file) { // These event handlers are called by ConversationView, which listens for these events
if (file.type.split('/')[0] !== 'image' || file.type === 'image/tiff') {
// nothing to do onDragOver(e) {
return Promise.resolve(file); if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
} }
return new Promise((resolve, reject) => { e.stopPropagation();
const url = URL.createObjectURL(file); e.preventDefault();
const img = document.createElement('img'); this.$el.addClass('dropoff');
img.onerror = reject; },
img.onload = () => {
URL.revokeObjectURL(url);
const maxSize = 6000 * 1024; onDragLeave(e) {
const maxHeight = 4096; if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
const maxWidth = 4096; return;
if ( }
img.naturalWidth <= maxWidth &&
img.naturalHeight <= maxHeight &&
file.size <= maxSize
) {
resolve(file);
return;
}
const gifMaxSize = 25000 * 1024; e.stopPropagation();
if (file.type === 'image/gif' && file.size <= gifMaxSize) { e.preventDefault();
resolve(file); this.$el.removeClass('dropoff');
return; },
}
if (file.type === 'image/gif') { onDrop(e) {
reject(new Error('GIF is too large')); if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return; return;
} }
const canvas = loadImage.scale(img, { e.stopPropagation();
canvas: true, e.preventDefault();
maxWidth,
maxHeight,
});
let quality = 0.95; // eslint-disable-next-line prefer-destructuring
let i = 4; const file = e.originalEvent.dataTransfer.files[0];
let blob; this.maybeAddAttachment(file);
do { this.$el.removeClass('dropoff');
i -= 1; },
blob = window.dataURLToBlobSync(
canvas.toDataURL('image/jpeg', quality)
);
quality = quality * maxSize / blob.size;
// NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
if (quality < 0.5) {
quality = 0.5;
}
} while (i > 0 && blob.size > maxSize);
resolve(blob); onPaste(e) {
}; const { items } = e.originalEvent.clipboardData;
img.src = url; 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();
}, },
async previewImages() { // Housekeeping
this.clearForm();
const file = this.file || this.$input.prop('files')[0]; 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) { if (!file) {
return; return;
} }
const { name } = file;
if (window.Signal.Util.isFileDangerous(name)) {
this.deleteFiles();
const toast = new Whisper.DangerousFileTypeToast(); const fileName = file.name;
toast.$el.insertAfter(this.$el); const contentType = file.type;
toast.render();
if (window.Signal.Util.isFileDangerous(fileName)) {
this.showDangerousError();
return; return;
} }
const contentType = file.type; if (this.attachments.length >= 32) {
this.showMaximumAttachmentsError();
return;
}
const renderVideoPreview = async () => { const haveNonImage = _.any(
// we use the variable on this here to ensure cleanup if we're interrupted this.attachments,
this.previewObjectUrl = URL.createObjectURL(file); attachment => !MIME.isImage(attachment.contentType)
const type = 'image/png'; );
const thumbnail = await VisualAttachment.makeVideoThumbnail({ // You can't add another attachment if you already have a non-image staged
size: 100, if (haveNonImage) {
videoObjectUrl: this.previewObjectUrl, this.showMultipleNonImageError();
contentType: type, return;
logger: window.log, }
});
URL.revokeObjectURL(this.previewObjectUrl);
const data = await VisualAttachment.blobToArrayBuffer(thumbnail); // You can't add a non-image attachment if you already have attachments staged
this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({ if (!MIME.isImage(contentType) && this.attachments.length > 0) {
data, this.showCannotMixError();
type, return;
}); }
this.addThumb(this.previewObjectUrl, { addPlayIcon: true });
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 () => { const renderImagePreview = async () => {
if (!MIME.isJPEG(file.type)) { if (!MIME.isJPEG(contentType)) {
this.previewObjectUrl = URL.createObjectURL(file); const url = URL.createObjectURL(file);
if (!this.previewObjectUrl) { if (!url) {
throw new Error('Failed to create object url for image!'); throw new Error('Failed to create object url for image!');
} }
this.addThumb(this.previewObjectUrl); this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url,
});
return; return;
} }
const dataUrl = await window.autoOrientImage(file); const url = await window.autoOrientImage(file);
this.addThumb(dataUrl); this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url,
});
}; };
try { try {
if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) { const blob = await this.autoScale({
await renderImagePreview(); contentType,
} else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) { file,
await renderVideoPreview(); });
} else if (MIME.isAudio(contentType)) {
this.addThumb('images/audio.svg');
} else {
this.addThumb('images/file.svg');
}
} catch (e) {
window.log.error(
`Was unable to generate thumbnail for file type ${contentType}`,
e && e.stack ? e.stack : e
);
this.addThumb('images/file.svg');
}
try {
const blob = await this.autoScale(file);
let limitKb = 1000000; let limitKb = 1000000;
const blobType = const blobType =
file.type === 'image/gif' ? 'gif' : contentType.split('/')[0]; file.type === 'image/gif' ? 'gif' : contentType.split('/')[0];
@ -278,177 +381,150 @@
limit /= 1000; limit /= 1000;
u += 1; u += 1;
} while (limit >= 1000 && u < units.length - 1); } while (limit >= 1000 && u < units.length - 1);
const toast = new Whisper.FileSizeToast({ this.showFileSizeError({ limit, units, u });
model: { limit, units: units[u] }, return;
});
toast.$el.insertAfter(this.$el);
toast.render();
this.deleteFiles();
} }
} catch (error) { } catch (error) {
window.log.error( window.log.error(
'Error ensuring that image is properly sized:', 'Error ensuring that image is properly sized:',
error && error.message ? error.message : error error && error.stack ? error.stack : error
); );
this.unableToLoadAttachment(); this.showLoadFailure();
return;
} }
},
hasFiles() { try {
const files = this.file ? [this.file] : this.$input.prop('files'); if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
return files && files.length && files.length > 0; await renderImagePreview();
}, } else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
await renderVideoPreview();
getFiles() { } else {
const files = this.file this.addAttachment({
? [this.file] file,
: Array.from(this.$input.prop('files')); size: file.size,
const promise = Promise.all(files.map(file => this.getFile(file))); contentType,
this.clearForm(); fileName,
return promise; });
}
} 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,
});
}
}, },
getFile(rawFile) { autoScale(attachment) {
const file = rawFile || this.file || this.$input.prop('files')[0]; const { contentType, file } = attachment;
if (!file) { if (
return Promise.resolve(); contentType.split('/')[0] !== 'image' ||
contentType === 'image/tiff'
) {
// nothing to do
return Promise.resolve(attachment);
} }
const attachmentFlags = this.isVoiceNote return new Promise((resolve, reject) => {
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE const url = URL.createObjectURL(file);
: null; const img = document.createElement('img');
img.onerror = reject;
img.onload = () => {
URL.revokeObjectURL(url);
const setFlags = flags => attachment => { const maxSize = 6000 * 1024;
const newAttachment = Object.assign({}, attachment); const maxHeight = 4096;
if (flags) { const maxWidth = 4096;
newAttachment.flags = flags; if (
} img.naturalWidth <= maxWidth &&
return newAttachment; img.naturalHeight <= maxHeight &&
}; file.size <= maxSize
) {
resolve(attachment);
return;
}
const gifMaxSize = 25000 * 1024;
if (file.type === 'image/gif' && file.size <= gifMaxSize) {
resolve(attachment);
return;
}
if (file.type === 'image/gif') {
reject(new Error('GIF is too large'));
return;
}
const canvas = loadImage.scale(img, {
canvas: true,
maxWidth,
maxHeight,
});
// NOTE: Temporarily allow `then` until we convert the entire file let quality = 0.95;
// to `async` / `await`: let i = 4;
// eslint-disable-next-line more/no-then let blob;
return this.autoScale(file) do {
.then(this.readFile) i -= 1;
.then(setFlags(attachmentFlags)); blob = window.dataURLToBlobSync(
canvas.toDataURL('image/jpeg', quality)
);
quality = quality * maxSize / blob.size;
// NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax
if (quality < 0.5) {
quality = 0.5;
}
} while (i > 0 && blob.size > maxSize);
resolve({
...attachment,
file: blob,
});
};
img.src = url;
});
}, },
async getThumbnail() { async getFile(attachment) {
// Scale and crop an image to 256px square if (!attachment) {
const size = 256;
const file = this.file || this.$input.prop('files')[0];
if (
file === undefined ||
file.type.split('/')[0] !== 'image' ||
file.type === 'image/gif'
) {
// nothing to do
return Promise.resolve(); return Promise.resolve();
} }
const objectUrl = URL.createObjectURL(file); const attachmentFlags = attachment.isVoiceNote
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
const arrayBuffer = await VisualAttachment.makeImageThumbnail({ : null;
size,
objectUrl,
logger: window.log,
});
URL.revokeObjectURL(objectUrl);
return this.readFile(arrayBuffer); const scaled = await this.autoScale(attachment);
const fileRead = await this.readFile(scaled);
return {
...fileRead,
url: undefined,
videoUrl: undefined,
flags: attachmentFlags || null,
};
}, },
// File -> Promise Attachment readFile(attachment) {
readFile(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const FR = new FileReader(); const FR = new FileReader();
FR.onload = e => { FR.onload = e => {
resolve({ resolve({
...attachment,
data: e.target.result, data: e.target.result,
contentType: file.type,
fileName: file.name,
size: file.size,
}); });
}; };
FR.onerror = reject; FR.onerror = reject;
FR.onabort = reject; FR.onabort = reject;
FR.readAsArrayBuffer(file); FR.readAsArrayBuffer(attachment.file);
}); });
}, },
clearForm() {
if (this.previewObjectUrl) {
URL.revokeObjectURL(this.previewObjectUrl);
this.previewObjectUrl = null;
}
this.thumb.remove();
this.$('.avatar').show();
this.$el.trigger('force-resize');
},
deleteFiles(e) {
if (e) {
e.stopPropagation();
}
this.clearForm();
this.$input
.wrap('<form>')
.parent('form')
.trigger('reset');
this.$input.unwrap();
this.file = null;
this.$input.trigger('change');
this.isVoiceNote = false;
},
openDropped(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
// eslint-disable-next-line prefer-destructuring
this.file = e.originalEvent.dataTransfer.files[0];
this.previewImages();
this.$el.removeClass('dropoff');
},
showArea(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
this.$el.addClass('dropoff');
},
hideArea(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
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) {
this.file = imgBlob;
this.previewImages();
}
},
}); });
})(); })();

@ -30,7 +30,7 @@ function OutgoingMessage(
this.failoverNumbers = []; this.failoverNumbers = [];
this.unidentifiedDeliveries = []; this.unidentifiedDeliveries = [];
const { numberInfo, senderCertificate, online } = options; const { numberInfo, senderCertificate, online } = options || {};
this.numberInfo = numberInfo; this.numberInfo = numberInfo;
this.senderCertificate = senderCertificate; this.senderCertificate = senderCertificate;
this.online = online; this.online = online;

@ -2033,6 +2033,7 @@
position: relative; position: relative;
display: inline-block; display: inline-block;
margin: 1px; margin: 1px;
vertical-align: middle;
} }
.module-image__caption-icon { .module-image__caption-icon {
@ -2041,6 +2042,14 @@
left: 6px; left: 6px;
} }
.module-image__with-click-handler {
cursor: pointer;
}
.module-image--soft-corners {
border-radius: 4px;
}
.module-image--curved-top-left { .module-image--curved-top-left {
border-top-left-radius: 16px; border-top-left-radius: 16px;
} }
@ -2143,6 +2152,17 @@
text-align: center; text-align: center;
} }
.module-image__close-button {
cursor: pointer;
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
z-index: 2;
background-image: url('../images/x-shadow-16.svg');
}
// Module: Image Grid // Module: Image Grid
.module-image-grid { .module-image-grid {
@ -2256,6 +2276,220 @@
flex-grow: 1; flex-grow: 1;
} }
// Module: Attachments
.module-attachments {
border-top: 1px solid $color-black-015;
}
.module-attachments__header {
height: 24px;
position: relative;
}
.module-attachments__close-button {
cursor: pointer;
position: absolute;
top: 8px;
right: 16px;
width: 20px;
height: 20px;
z-index: 2;
@include color-svg('../images/x-16.svg', $color-black);
}
.module-attachments__rail {
margin-top: 12px;
margin-left: 16px;
padding-right: 16px;
overflow-x: scroll;
max-height: 142px;
white-space: nowrap;
overflow-y: hidden;
margin-bottom: 6px;
}
// Module: Staged Generic Attachment
.module-staged-generic-attachment {
height: 120px;
width: 120px;
margin: 1px;
display: inline-block;
position: relative;
border-radius: 4px;
box-shadow: inset 0px 0px 0px 1px $color-black-015;
background-color: $color-gray-05;
vertical-align: middle;
}
.module-staged-generic-attachment__close-button {
cursor: pointer;
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
z-index: 2;
@include color-svg('../images/x-16.svg', $color-black);
}
.module-staged-generic-attachment__icon {
margin-top: 30px;
background: url('../images/file-gradient.svg') no-repeat center;
height: 44px;
width: 56px;
margin-left: 32px;
margin-right: 32px;
margin-bottom: -4px;
// So we can center the extension text inside this icon
display: flex;
flex-direction: row;
align-items: center;
}
.module-staged-generic-attachment__icon__extension {
font-size: 10px;
line-height: 13px;
letter-spacing: 0.1px;
text-transform: uppercase;
// Along with flow layout in parent item, centers text
text-align: center;
width: 25px;
margin-left: auto;
margin-right: auto;
// We don't have much room for text here, cut it off without ellipse
overflow-x: hidden;
white-space: nowrap;
text-overflow: clip;
color: $color-gray-90;
}
.module-staged-generic-attachment__filename {
margin: 7px;
margin-top: 5px;
text-align: center;
font-family: Roboto;
font-size: 14px;
overflow: hidden;
height: 2.4em;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
// Module: Caption Editor
.module-caption-editor {
background-color: $color-black;
z-index: 20;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.module-caption-editor__close-button {
z-index: 21;
cursor: pointer;
position: absolute;
top: 12px;
right: 16px;
width: 24px;
height: 24px;
z-index: 2;
@include color-svg('../images/x-16.svg', $color-white);
}
.module-caption-editor__media-container {
flex-grow: 1;
flex-shrink: 1;
background-color: $color-black;
text-align: center;
margin: 50px;
overflow: hidden;
height: 100%;
}
.module-caption-editor__image {
width: 100%;
height: 100%;
object-fit: contain;
flex-grow: 1;
flex-shrink: 1;
}
.module-caption-editor__video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
flex-grow: 1;
flex-shrink: 1;
}
.module-caption-editor__placeholder {
width: 100%;
height: 100%;
object-fit: contain;
flex-grow: 1;
flex-shrink: 1;
}
.module-caption-editor__bottom-bar {
flex-grow: 0;
flex-shrink: 0;
height: 3em;
padding: 0.5em;
display: inline-flex;
flex-direction: row;
align-items: middle;
margin-left: auto;
margin-right: auto;
}
.module-caption-editor__add-caption-button {
display: inline-block;
margin-left: 6px;
height: 24px;
width: 24px;
margin-right: 6px;
@include color-svg('../images/add-caption-24.svg', $color-white);
}
.module-caption-editor__caption-input {
height: 2em;
width: 40em;
border: 1px solid $color-white;
border-radius: 1em;
color: $color-white;
background-color: $color-black;
padding: 0.5em;
&::placeholder {
color: $color-white-07;
}
}
// Third-party module: react-contextmenu // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

@ -0,0 +1,72 @@
## Image
```js
let caption = null;
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url={util.gifObjectUrl}
attachment={{
contentType: 'image/jpeg',
}}
onChangeCaption={caption => console.log('onChangeCaption', caption)}
i18n={util.i18n}
/>
</div>;
```
## Image with caption
```js
let caption =
"This is the user-provided caption. We show it overlaid on the image. If it's really long, then it wraps, but it doesn't get too close to the edges of the image.";
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url="https://placekitten.com/800/600"
attachment={{
contentType: 'image/jpeg',
}}
caption={caption}
contentType="image/jpeg"
onChangeCaption={caption => console.log('onChangeCaption', caption)}
i18n={util.i18n}
/>
</div>;
```
## Video
```js
let caption = null;
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url="fixtures/pixabay-Soap-Bubble-7141.mp4"
attachment={{
contentType: 'video/mp4',
}}
onChangeCaption={caption => console.log('onChangeCaption', caption)}
i18n={util.i18n}
/>
</div>;
```
## Video with caption
```js
let caption =
"This is the user-provided caption. We show it overlaid on the image. If it's really long, then it wraps, but it doesn't get too close to the edges of the image.";
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url="fixtures/pixabay-Soap-Bubble-7141.mp4"
attachment={{
contentType: 'video/mp4',
}}
caption={caption}
onChangeCaption={caption => console.log('onChangeCaption', caption)}
i18n={util.i18n}
/>
</div>;
```

@ -0,0 +1,78 @@
// tslint:disable:react-a11y-anchors
import React from 'react';
import * as GoogleChrome from '../util/GoogleChrome';
import { AttachmentType } from './conversation/types';
import { Localizer } from '../types/Util';
interface Props {
attachment: AttachmentType;
i18n: Localizer;
url: string;
caption?: string;
onChangeCaption?: (caption: string) => void;
close?: () => void;
}
export class CaptionEditor extends React.Component<Props> {
public renderObject() {
const { url, i18n, attachment } = this.props;
const { contentType } = attachment || { contentType: null };
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
if (isImageTypeSupported) {
return (
<img
className="module-caption-editor__image"
alt={i18n('imageAttachmentAlt')}
src={url}
/>
);
}
const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
if (isVideoTypeSupported) {
return (
<video className="module-caption-editor__video" controls={true}>
<source src={url} />
</video>
);
}
return <div className="module-caption-editor__placeholder" />;
}
public render() {
const { caption, i18n, close, onChangeCaption } = this.props;
return (
<div className="module-caption-editor">
<div
role="button"
onClick={close}
className="module-caption-editor__close-button"
/>
<div className="module-caption-editor__media-container">
{this.renderObject()}
</div>
<div className="module-caption-editor__bottom-bar">
<div className="module-caption-editor__add-caption-button" />
<input
type="text"
value={caption || ''}
maxLength={200}
placeholder={i18n('addACaption')}
className="module-caption-editor__caption-input"
onChange={event => {
if (onChangeCaption) {
onChangeCaption(event.target.value);
}
}}
/>
</div>
</div>
);
}
}

@ -0,0 +1,114 @@
### One image
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
];
<AttachmentList
attachments={attachments}
onClose={() => console.log('onClose')}
onClickAttachment={attachment => {
console.log('onClickAttachment', attachment);
}}
onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment);
}}
i18n={util.i18n}
/>;
```
### Four images
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
{
url: util.landscapeObjectUrl,
contentType: 'image/png',
width: 4496,
height: 3000,
},
{
url: util.landscapeGreenObjectUrl,
contentType: 'image/png',
width: 1000,
height: 50,
},
];
<div>
<AttachmentList
attachments={attachments}
onClose={() => console.log('onClose')}
onClickAttachment={attachment => {
console.log('onClickAttachment', attachment);
}}
onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment);
}}
i18n={util.i18n}
/>
</div>;
```
### A mix of attachment types
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
{
contentType: 'text/plain',
fileName: 'manifesto.txt',
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
];
<div>
<AttachmentList
attachments={attachments}
onClose={() => console.log('onClose')}
onClickAttachment={attachment => {
console.log('onClickAttachment', attachment);
}}
onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment);
}}
i18n={util.i18n}
/>
</div>;
```
### No attachments provided
Nothing is shown if attachment list is empty.
```jsx
<AttachmentList attachments={[]} i18n={util.i18n} />
```

@ -0,0 +1,106 @@
import React from 'react';
// import classNames from 'classnames';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome';
import { AttachmentType } from './types';
import { Image } from './Image';
import { StagedGenericAttachment } from './StagedGenericAttachment';
import { Localizer } from '../../types/Util';
interface Props {
attachments: Array<AttachmentType>;
i18n: Localizer;
// onError: () => void;
onClickAttachment: (attachment: AttachmentType) => void;
onCloseAttachment: (attachment: AttachmentType) => void;
onClose: () => void;
}
const IMAGE_WIDTH = 120;
const IMAGE_HEIGHT = 120;
export class AttachmentList extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length */
public render() {
const {
attachments,
i18n,
// onError,
onClickAttachment,
onCloseAttachment,
onClose,
} = this.props;
if (!attachments.length) {
return null;
}
return (
<div className="module-attachments">
{attachments.length > 1 ? (
<div className="module-attachments__header">
<div
role="button"
onClick={onClose}
className="module-attachments__close-button"
/>
</div>
) : null}
<div className="module-attachments__rail">
{(attachments || []).map((attachment, index) => {
const { contentType } = attachment;
if (
isImageTypeSupported(contentType) ||
isVideoTypeSupported(contentType)
) {
return (
<Image
key={getUrl(attachment) || attachment.fileName || index}
alt={`TODO: attachment number ${index}`}
i18n={i18n}
attachment={attachment}
softCorners={true}
playIconOverlay={isVideoAttachment(attachment)}
height={IMAGE_HEIGHT}
width={IMAGE_WIDTH}
url={getUrl(attachment)}
closeButton={true}
onClick={onClickAttachment}
onClickClose={onCloseAttachment}
/>
);
}
return (
<StagedGenericAttachment
key={getUrl(attachment) || attachment.fileName || index}
attachment={attachment}
i18n={i18n}
onClose={onCloseAttachment}
/>
);
})}
</div>
</div>
);
}
}
export function isVideoAttachment(attachment?: AttachmentType) {
return (
attachment &&
attachment.contentType &&
isVideoTypeSupported(attachment.contentType)
);
}
function getUrl(attachment: AttachmentType) {
if (attachment.screenshot) {
return attachment.screenshot.url;
}
return attachment.url;
}

@ -77,18 +77,21 @@
width="199" width="199"
attachment={{ caption: 'dogs playing' }} attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="149" height="149"
width="149" width="149"
attachment={{ caption: 'dogs playing' }} attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="99" height="99"
width="99" width="99"
attachment={{ caption: 'dogs playing' }} attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
</div> </div>
<hr /> <hr />
@ -100,6 +103,7 @@
darkOverlay darkOverlay
overlayText="+3" overlayText="+3"
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="149" height="149"
@ -108,6 +112,7 @@
darkOverlay darkOverlay
overlayText="+3" overlayText="+3"
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="99" height="99"
@ -116,6 +121,82 @@
darkOverlay darkOverlay
overlayText="+3" overlayText="+3"
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/>
</div>
</div>
```
### With top-right X and soft corners
```jsx
<div>
<div>
<Image
height="200"
width="199"
closeButton={true}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="149"
width="149"
closeButton={true}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="99"
width="99"
closeButton={true}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
</div>
<br />
<div>
<Image
height="200"
width="199"
closeButton={true}
attachment={{ caption: 'dogs playing' }}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="149"
width="149"
closeButton={true}
attachment={{ caption: 'dogs playing' }}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="99"
width="99"
closeButton={true}
attachment={{ caption: 'dogs playing' }}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/> />
</div> </div>
</div> </div>

@ -15,24 +15,29 @@ interface Props {
overlayText?: string; overlayText?: string;
bottomOverlay?: boolean; bottomOverlay?: boolean;
closeButton?: boolean;
curveBottomLeft?: boolean; curveBottomLeft?: boolean;
curveBottomRight?: boolean; curveBottomRight?: boolean;
curveTopLeft?: boolean; curveTopLeft?: boolean;
curveTopRight?: boolean; curveTopRight?: boolean;
darkOverlay?: boolean; darkOverlay?: boolean;
playIconOverlay?: boolean; playIconOverlay?: boolean;
softCorners?: boolean;
i18n: Localizer; i18n: Localizer;
onClick?: (attachment: AttachmentType) => void; onClick?: (attachment: AttachmentType) => void;
onClickClose?: (attachment: AttachmentType) => void;
onError?: () => void; onError?: () => void;
} }
export class Image extends React.Component<Props> { export class Image extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
public render() { public render() {
const { const {
alt, alt,
attachment, attachment,
bottomOverlay, bottomOverlay,
closeButton,
curveBottomLeft, curveBottomLeft,
curveBottomRight, curveBottomRight,
curveTopLeft, curveTopLeft,
@ -41,9 +46,11 @@ export class Image extends React.Component<Props> {
height, height,
i18n, i18n,
onClick, onClick,
onClickClose,
onError, onError,
overlayText, overlayText,
playIconOverlay, playIconOverlay,
softCorners,
url, url,
width, width,
} = this.props; } = this.props;
@ -52,18 +59,20 @@ export class Image extends React.Component<Props> {
return ( return (
<div <div
role={onClick ? 'button' : undefined}
onClick={() => { onClick={() => {
if (onClick) { if (onClick) {
onClick(attachment); onClick(attachment);
} }
}} }}
role="button"
className={classNames( className={classNames(
'module-image', 'module-image',
onClick ? 'module-image__with-click-handler' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null, curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null, curveBottomRight ? 'module-image--curved-bottom-right' : null,
curveTopLeft ? 'module-image--curved-top-left' : null, curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null curveTopRight ? 'module-image--curved-top-right' : null,
softCorners ? 'module-image--soft-corners' : null
)} )}
> >
<img <img
@ -88,9 +97,22 @@ export class Image extends React.Component<Props> {
curveTopRight ? 'module-image--curved-top-right' : null, curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null, curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null, curveBottomRight ? 'module-image--curved-bottom-right' : null,
softCorners ? 'module-image--soft-corners' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null darkOverlay ? 'module-image__border-overlay--dark' : null
)} )}
/> />
{closeButton ? (
<div
role="button"
onClick={(e: React.MouseEvent<{}>) => {
e.stopPropagation();
if (onClickClose) {
onClickClose(attachment);
}
}}
className="module-image__close-button"
/>
) : null}
{bottomOverlay ? ( {bottomOverlay ? (
<div <div
className={classNames( className={classNames(

@ -24,7 +24,7 @@ interface Props {
const MAX_WIDTH = 300; const MAX_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5; const MAX_HEIGHT = MAX_WIDTH * 1.5;
const MIN_WIDTH = 200; const MIN_WIDTH = 200;
const MIN_HEIGHT = 25; const MIN_HEIGHT = 50;
export class ImageGrid extends React.Component<Props> { export class ImageGrid extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length */ // tslint:disable-next-line max-func-body-length */

@ -79,52 +79,6 @@ interface State {
imageBroken: boolean; imageBroken: boolean;
} }
function isAudio(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
MIME.isAudio(attachments[0].contentType)
);
}
function canDisplayImage(attachments?: Array<AttachmentType>) {
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
return (
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
);
}
function getExtension({
fileName,
contentType,
}: {
fileName: string;
contentType: MIME.MIMEType;
}): string | null {
if (fileName && fileName.indexOf('.') >= 0) {
const lastPeriod = fileName.lastIndexOf('.');
const extension = fileName.slice(lastPeriod + 1);
if (extension.length) {
return extension;
}
}
const slash = contentType.indexOf('/');
if (slash >= 0) {
return contentType.slice(slash + 1);
}
return null;
}
const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600; const EXPIRED_DELAY = 600;
@ -847,3 +801,49 @@ export class Message extends React.Component<Props, State> {
); );
} }
} }
export function getExtension({
fileName,
contentType,
}: {
fileName: string;
contentType: MIME.MIMEType;
}): string | null {
if (fileName && fileName.indexOf('.') >= 0) {
const lastPeriod = fileName.lastIndexOf('.');
const extension = fileName.slice(lastPeriod + 1);
if (extension.length) {
return extension;
}
}
const slash = contentType.indexOf('/');
if (slash >= 0) {
return contentType.slice(slash + 1);
}
return null;
}
function isAudio(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
MIME.isAudio(attachments[0].contentType)
);
}
function canDisplayImage(attachments?: Array<AttachmentType>) {
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
return (
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
);
}

@ -0,0 +1,44 @@
Text file
```js
const attachment = {
contentType: 'text/plain',
fileName: 'manifesto.txt',
};
<StagedGenericAttachment
attachment={attachment}
i18n={util.i18n}
onClose={attachment => console.log('onClose', attachment)}
/>;
```
File with long name
```js
const attachment = {
contentType: 'text/plain',
fileName: 'this-is-my-very-important-manifesto-you-must-read-it.txt',
};
<StagedGenericAttachment
attachment={attachment}
i18n={util.i18n}
onClose={attachment => console.log('onClose', attachment)}
/>;
```
File with long extension
```js
const attachment = {
contentType: 'text/plain',
fileName: 'manifesto.reallylongtxt',
};
<StagedGenericAttachment
attachment={attachment}
i18n={util.i18n}
onClose={attachment => console.log('onClose', attachment)}
/>;
```

@ -0,0 +1,44 @@
import React from 'react';
import { getExtension } from './Message';
import { Localizer } from '../../types/Util';
import { AttachmentType } from './types';
interface Props {
attachment: AttachmentType;
onClose: (attachment: AttachmentType) => void;
i18n: Localizer;
}
export class StagedGenericAttachment extends React.Component<Props> {
public render() {
const { attachment, onClose } = this.props;
const { fileName, contentType } = attachment;
const extension = getExtension({ contentType, fileName });
return (
<div className="module-staged-generic-attachment">
<div
className="module-staged-generic-attachment__close-button"
role="button"
onClick={() => {
if (onClose) {
onClose(attachment);
}
}}
/>
<div className="module-staged-generic-attachment__icon">
{extension ? (
<div className="module-staged-generic-attachment__icon__extension">
{extension}
</div>
) : null}
</div>
<div className="module-staged-generic-attachment__filename">
{fileName}
</div>
</div>
);
}
}

@ -659,468 +659,495 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " template: $('#conversation').html(),", "line": " template: $('#conversation').html(),",
"lineNumber": 73, "lineNumber": 78,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-html(", "rule": "jQuery-html(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " template: $('#conversation').html(),", "line": " template: $('#conversation').html(),",
"lineNumber": 73, "lineNumber": 78,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-15T00:38:04.183Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Getting the value, not setting it" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.loadingScreen.$el.prependTo(this.$('.discussion-container'));", "line": " this.loadingScreen.$el.prependTo(this.$('.discussion-container'));",
"lineNumber": 143, "lineNumber": 148,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T19:09:08.182Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-prependTo(", "rule": "jQuery-prependTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.loadingScreen.$el.prependTo(this.$('.discussion-container'));", "line": " this.loadingScreen.$el.prependTo(this.$('.discussion-container'));",
"lineNumber": 143, "lineNumber": 148,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T19:07:46.079Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " el: this.$('form.send'),", "line": " el: this.$('.attachment-list'),",
"lineNumber": 147, "lineNumber": 152,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T19:07:46.079Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.conversation-header').append(this.titleView.el);", "line": " this.$('.conversation-header').append(this.titleView.el);",
"lineNumber": 205, "lineNumber": 209,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.conversation-header').append(this.titleView.el);", "line": " this.$('.conversation-header').append(this.titleView.el);",
"lineNumber": 205, "lineNumber": 209,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.discussion-container').append(this.view.el);", "line": " this.$('.discussion-container').append(this.view.el);",
"lineNumber": 211, "lineNumber": 215,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.discussion-container').append(this.view.el);", "line": " this.$('.discussion-container').append(this.view.el);",
"lineNumber": 211, "lineNumber": 215,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$messageField = this.$('.send-message');", "line": " this.$messageField = this.$('.send-message');",
"lineNumber": 214, "lineNumber": 218,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send-message').focus(this.focusBottomBar.bind(this));", "line": " this.$('.send-message').focus(this.focusBottomBar.bind(this));",
"lineNumber": 232, "lineNumber": 236,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$emojiPanelContainer = this.$('.emoji-panel-container');", "line": " this.$emojiPanelContainer = this.$('.emoji-panel-container');",
"lineNumber": 235, "lineNumber": 239,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:26:45.287Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('input.file-input').click();",
"lineNumber": 276,
"reasonCategory": "usageTrusted",
"updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " const fileField = this.$('input.file-input');",
"lineNumber": 279,
"reasonCategory": "usageTrusted",
"updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " const container = this.$('.discussion-container');", "line": " const container = this.$('.discussion-container');",
"lineNumber": 421, "lineNumber": 451,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " container.append(this.banner.el);", "line": " container.append(this.banner.el);",
"lineNumber": 422, "lineNumber": 452,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.typingBubbleView.$el.appendTo(this.$('.typing-container'));", "line": " this.typingBubbleView.$el.appendTo(this.$('.typing-container'));",
"lineNumber": 459, "lineNumber": 489,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "$() parameter is a hard-coded string" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.typingBubbleView.$el.appendTo(this.$('.typing-container'));", "line": " this.typingBubbleView.$el.appendTo(this.$('.typing-container'));",
"lineNumber": 459, "lineNumber": 489,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Both parameters are known elements from the DOM" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send-message').val().length > 0 ||", "line": " this.$('.send-message').val().length > 0 ||",
"lineNumber": 468, "lineNumber": 498,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.capture-audio').hide();", "line": " this.$('.capture-audio').hide();",
"lineNumber": 471, "lineNumber": 501,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.capture-audio').show();", "line": " this.$('.capture-audio').show();",
"lineNumber": 473, "lineNumber": 503,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " if (this.$('.send-message').val().length > 2000) {", "line": " if (this.$('.send-message').val().length > 2000) {",
"lineNumber": 477, "lineNumber": 507,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.android-length-warning').hide();", "line": " this.$('.android-length-warning').hide();",
"lineNumber": 480, "lineNumber": 510,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 518,
"reasonCategory": "usageTrusted",
"updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " view.$el.appendTo(this.$('.capture-audio'));", "line": " view.$el.appendTo(this.$('.capture-audio'));",
"lineNumber": 500, "lineNumber": 537,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " view.$el.appendTo(this.$('.capture-audio'));", "line": " view.$el.appendTo(this.$('.capture-audio'));",
"lineNumber": 500, "lineNumber": 537,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send-message').attr('disabled', true);", "line": " this.$('.send-message').attr('disabled', true);",
"lineNumber": 502, "lineNumber": 539,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').submit();", "line": " this.$('.bottom-bar form').submit();",
"lineNumber": 509, "lineNumber": 548,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send-message').removeAttr('disabled');", "line": " this.$('.send-message').removeAttr('disabled');",
"lineNumber": 512, "lineNumber": 551,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').removeClass('active');", "line": " this.$('.bottom-bar form').removeClass('active');",
"lineNumber": 518, "lineNumber": 557,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').addClass('active');", "line": " this.$('.bottom-bar form').addClass('active');",
"lineNumber": 521, "lineNumber": 560,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " const container = this.$('.discussion-container');", "line": " const container = this.$('.discussion-container');",
"lineNumber": 609, "lineNumber": 648,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " container.append(this.scrollDownButton.el);", "line": " container.append(this.scrollDownButton.el);",
"lineNumber": 610, "lineNumber": 649,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 637, "lineNumber": 676,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 670, "lineNumber": 709,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 674, "lineNumber": 713,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " const el = this.$(`#${databaseId}`);", "line": " const el = this.$(`#${databaseId}`);",
"lineNumber": 681, "lineNumber": 720,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 684, "lineNumber": 723,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));", "line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));",
"lineNumber": 861, "lineNumber": 900,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-insertBefore(", "rule": "jQuery-insertBefore(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));", "line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));",
"lineNumber": 861, "lineNumber": 900,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bar-container').show();", "line": " this.$('.bar-container').show();",
"lineNumber": 916, "lineNumber": 955,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bar-container').hide();", "line": " this.$('.bar-container').hide();",
"lineNumber": 928, "lineNumber": 967,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " const el = this.$(`#${message.id}`);", "line": " const el = this.$(`#${message.id}`);",
"lineNumber": 1025, "lineNumber": 1064,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);", "line": " this.$el.prepend(dialog.el);",
"lineNumber": 1098, "lineNumber": 1137,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 1121, "lineNumber": 1160,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-10-11T19:22:47.331Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Operating on already-existing DOM elements" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);", "line": " this.$el.prepend(dialog.el);",
"lineNumber": 1149, "lineNumber": 1188,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " view.$el.insertBefore(this.$('.panel').first());", "line": " view.$el.insertBefore(this.$('.panel').first());",
"lineNumber": 1283, "lineNumber": 1323,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-insertBefore(", "rule": "jQuery-insertBefore(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " view.$el.insertBefore(this.$('.panel').first());", "line": " view.$el.insertBefore(this.$('.panel').first());",
"lineNumber": 1283, "lineNumber": 1323,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);", "line": " this.$el.prepend(dialog.el);",
"lineNumber": 1361, "lineNumber": 1401,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send').prepend(this.quoteView.el);", "line": " this.$('.send').prepend(this.quoteView.el);",
"lineNumber": 1531, "lineNumber": 1571,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send').prepend(this.quoteView.el);", "line": " this.$('.send').prepend(this.quoteView.el);",
"lineNumber": 1531, "lineNumber": 1571,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 1555, "lineNumber": 1595,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').submit();", "line": " this.$('.bottom-bar form').submit();",
"lineNumber": 1610, "lineNumber": 1650,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " const $attachmentPreviews = this.$('.attachment-previews');", "line": " const $attachmentPreviews = this.$('.attachment-previews');",
"lineNumber": 1619, "lineNumber": 1659,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.panel').css('display') === 'none'", "line": " this.$('.panel').css('display') === 'none'",
"lineNumber": 1650, "lineNumber": 1690,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T02:21:20.921Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
@ -1196,104 +1223,59 @@
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js",
"line": " this.$input = this.$('input[type=file]');",
"lineNumber": 45,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.$('.avatar').hide();",
"lineNumber": 88,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js", "path": "js/views/file_input_view.js",
"line": " this.$('.attachment-previews').append(this.thumb.render().el);", "line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 90, "lineNumber": 216,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T03:04:48.403Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-append(", "rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js",
"line": " this.$('.attachment-previews').append(this.thumb.render().el);",
"lineNumber": 90,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js", "path": "js/views/file_input_view.js",
"line": " this.thumb.$('img')[0].onload = () => {", "line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 98, "lineNumber": 222,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T03:04:48.403Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-$(", "rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js", "path": "js/views/file_input_view.js",
"line": " this.thumb.$('img')[0].onerror = () => {", "line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 101, "lineNumber": 230,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T03:04:48.403Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-insertAfter(", "rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js", "path": "js/views/file_input_view.js",
"line": " toast.$el.insertAfter(this.$el);", "line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 108, "lineNumber": 236,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-12-15T03:04:48.403Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-insertAfter(", "rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js", "path": "js/views/file_input_view.js",
"line": " toast.$el.insertAfter(this.$el);", "line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 190, "lineNumber": 242,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-10-11T19:22:47.331Z", "updated": "2018-12-15T03:04:48.403Z",
"reasonDetail": "Operating on already-existing DOM elements" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-insertAfter(", "rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js", "path": "js/views/file_input_view.js",
"line": " toast.$el.insertAfter(this.$el);", "line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 284, "lineNumber": 248,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.$('.avatar').show();",
"lineNumber": 388,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-12-15T03:04:48.403Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
}, },
{
"rule": "jQuery-wrap(",
"path": "js/views/file_input_view.js",
"line": " .wrap('<form>')",
"lineNumber": 398,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Hard-coded value"
},
{ {
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/group_member_list_view.js", "path": "js/views/group_member_list_view.js",
@ -3493,7 +3475,7 @@
"lineNumber": 4136, "lineNumber": 4136,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "<optional>" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
@ -4083,7 +4065,7 @@
"lineNumber": 483, "lineNumber": 483,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "<optional>" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
@ -5849,7 +5831,7 @@
"lineNumber": 1699, "lineNumber": 1699,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-18T19:19:27.699Z", "updated": "2018-09-18T19:19:27.699Z",
"reasonDetail": "<optional>" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",

Loading…
Cancel
Save