diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2fb4f6f6bf..8dc25e9ef8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -901,6 +901,11 @@ "description": "Used in the alt tag for the image shown in a full-screen lightbox view" }, + "imageCaptionIconAlt": { + "message": "Icon showing that this image has a caption", + "description": + "Used for the icon layered on top of an image in message bubbles" + }, "fileIconAlt": { "message": "File icon", "description": diff --git a/images/caption-shadow.svg b/images/caption-shadow.svg new file mode 100644 index 0000000000..96a795fac3 --- /dev/null +++ b/images/caption-shadow.svg @@ -0,0 +1,63 @@ + + + + caption-shadow-24 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/models/messages.js b/js/models/messages.js index dd3d081e53..2693c23a13 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -407,8 +407,8 @@ const conversation = this.getConversation(); const isGroup = conversation && !conversation.isPrivate(); - const attachments = this.get('attachments'); - const firstAttachment = attachments && attachments[0]; + const attachments = this.get('attachments') || []; + const firstAttachment = attachments[0]; return { text: this.createNonBreakingLastSeparator(this.get('body')), @@ -422,7 +422,9 @@ authorProfileName: contact.profileName, authorPhoneNumber: contact.phoneNumber, conversationType: isGroup ? 'group' : 'direct', - attachment: this.getPropsForAttachment(firstAttachment), + attachments: attachments.map(attachment => + this.getPropsForAttachment(attachment) + ), quote: this.getPropsForQuote(), authorAvatarPath, isExpired: this.hasExpired, @@ -432,9 +434,9 @@ onRetrySend: () => this.retrySend(), onShowDetail: () => this.trigger('show-message-detail', this), onDelete: () => this.trigger('delete', this), - onClickAttachment: () => + onClickAttachment: attachment => this.trigger('show-lightbox', { - attachment: firstAttachment, + attachment, message: this, }), diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 9e2d3216f0..a81417cf91 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -664,7 +664,7 @@ MessageCollection: Whisper.MessageCollection, } ); - const documents = await Signal.Data.getMessagesWithFileAttachments( + const rawDocuments = await Signal.Data.getMessagesWithFileAttachments( conversationId, { limit: DEFAULT_DOCUMENTS_FETCH_COUNT, @@ -688,24 +688,39 @@ } } - const media = rawMedia.map(mediaMessage => { - const { attachments } = mediaMessage; - const first = attachments && attachments[0]; - const { thumbnail } = first; + const media = _.flatten( + rawMedia.map(message => { + const { attachments } = message; + return (attachments || []).map((attachment, index) => { + const { thumbnail } = attachment; + return { + objectURL: getAbsoluteAttachmentPath(attachment.path), + thumbnailObjectUrl: thumbnail + ? getAbsoluteAttachmentPath(thumbnail.path) + : null, + contentType: attachment.contentType, + index, + attachment, + message, + }; + }); + }) + ); + + // Unlike visual media, only one non-image attachment is supported + const documents = rawDocuments.map(message => { + const attachments = message.attachments || []; + const attachment = attachments[0]; return { - ...mediaMessage, - thumbnailObjectUrl: thumbnail - ? getAbsoluteAttachmentPath(thumbnail.path) - : null, - objectURL: getAbsoluteAttachmentPath( - mediaMessage.attachments[0].path - ), + contentType: attachment.contentType, + index: 0, + attachment, + message, }; }); - const saveAttachment = async ({ message } = {}) => { - const attachment = message.attachments[0]; + const saveAttachment = async ({ attachment, message } = {}) => { const timestamp = message.received_at; Signal.Types.Attachment.save({ attachment, @@ -715,22 +730,22 @@ }); }; - const onItemClick = async ({ message, type }) => { + const onItemClick = async ({ message, attachment, type }) => { switch (type) { case 'documents': { - saveAttachment({ message }); + saveAttachment({ message, attachment }); break; } case 'media': { const selectedIndex = media.findIndex( - mediaMessage => mediaMessage.id === message.id + mediaMessage => mediaMessage.attachment.path === attachment.path ); this.lightboxGalleryView = new Whisper.ReactWrapperView({ className: 'lightbox-wrapper', Component: Signal.Components.LightboxGallery, props: { - messages: media, + media, onSave: saveAttachment, selectedIndex, }, @@ -1103,18 +1118,56 @@ return; } - const props = { - objectURL: getAbsoluteAttachmentPath(path), - contentType, - onSave: () => this.downloadAttachment({ attachment, message }), + const attachments = message.get('attachments') || []; + if (attachments.length === 1) { + const props = { + objectURL: getAbsoluteAttachmentPath(path), + contentType, + onSave: () => this.downloadAttachment({ attachment, message }), + }; + this.lightboxView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', + Component: Signal.Components.Lightbox, + props, + onClose: () => Signal.Backbone.Views.Lightbox.hide(), + }); + Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); + return; + } + + const selectedIndex = _.findIndex( + attachments, + item => attachment.path === item.path + ); + const media = attachments.map((item, index) => ({ + objectURL: getAbsoluteAttachmentPath(item.path), + contentType: item.contentType, + index, + message, + attachment: item, + })); + + const onSave = async (options = {}) => { + Signal.Types.Attachment.save({ + attachment: options.attachment, + document, + getAbsolutePath: getAbsoluteAttachmentPath, + timestamp: options.message.received_at, + }); }; - this.lightboxView = new Whisper.ReactWrapperView({ + + const props = { + media, + selectedIndex: selectedIndex >= 0 ? selectedIndex : 0, + onSave, + }; + this.lightboxGalleryView = new Whisper.ReactWrapperView({ className: 'lightbox-wrapper', - Component: Signal.Components.Lightbox, + Component: Signal.Components.LightboxGallery, props, onClose: () => Signal.Backbone.Views.Lightbox.hide(), }); - Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); + Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); }, showMessageDetail(message) { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index d54f486353..e8934dc5f1 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1292,7 +1292,15 @@ MessageReceiver.prototype.extend({ ); } - for (let i = 0, max = decrypted.attachments.length; i < max; i += 1) { + const attachmentCount = decrypted.attachments.length; + const ATTACHMENT_MAX = 32; + if (attachmentCount > ATTACHMENT_MAX) { + throw new Error( + `Too many attachments: ${attachmentCount} included in one message, max is ${ATTACHMENT_MAX}` + ); + } + + for (let i = 0; i < attachmentCount; i += 1) { const attachment = decrypted.attachments[i]; promises.push(this.handleAttachment(attachment)); } diff --git a/protos/SignalService.proto b/protos/SignalService.proto index d2dbafa511..865d5d6efe 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -269,6 +269,7 @@ message AttachmentPointer { optional uint32 flags = 8; optional uint32 width = 9; optional uint32 height = 10; + optional string caption = 11; } message GroupContext { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b964a338f9..2acaf5164e 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -200,6 +200,8 @@ background-color: $color-conversation-blue_grey; } +// START + .module-message__attachment-container { // Entirely to ensure that images are centered if they aren't full width of bubble text-align: center; @@ -229,97 +231,13 @@ border-top-right-radius: 0px; } -.module-message__img-border-overlay { - position: absolute; - top: 0; - bottom: 0; - z-index: 1; - left: 0; - right: 0; - border-radius: 16px; - box-shadow: inset 0px 0px 0px 1px $color-black-015; -} - -.module-message__img-border-overlay--with-content-below { - border-bottom-left-radius: 0px; - border-bottom-right-radius: 0px; -} - -.module-message__img-border-overlay--with-content-above { - border-top-left-radius: 0px; - border-top-right-radius: 0px; -} - .module-message__img-attachment { - object-fit: cover; - width: 100%; - min-width: 200px; - min-height: 150px; - max-height: 300px; - - // The padding on the bottom of the bubble produces three extra pixels of space at the - // bottom, so this doesn't match up with the padding numbers above. margin-bottom: -3px; // redundant with attachment-container, but we get cursor flashing on move otherwise cursor: pointer; } -.module-message__img-overlay { - height: 48px; - background-image: linear-gradient( - to bottom, - rgba(0, 0, 0, 0), - rgba(0, 0, 0, 0) 9%, - rgba(0, 0, 0, 0.02) 17%, - rgba(0, 0, 0, 0.05) 24%, - rgba(0, 0, 0, 0.08) 31%, - rgba(0, 0, 0, 0.12) 37%, - rgba(0, 0, 0, 0.16) 44%, - rgba(0, 0, 0, 0.2) 50%, - rgba(0, 0, 0, 0.24) 56%, - rgba(0, 0, 0, 0.28) 63%, - rgba(0, 0, 0, 0.32) 69%, - rgba(0, 0, 0, 0.35) 76%, - rgba(0, 0, 0, 0.38) 83%, - rgba(0, 0, 0, 0.4) 91%, - rgba(0, 0, 0, 0.4) - ); - position: absolute; - bottom: 0; - z-index: 2; - left: 0; - right: 0; - margin-left: -12px; - margin-right: -12px; - margin-bottom: -10px; - border-bottom-left-radius: 16px; - border-bottom-right-radius: 16px; -} - -.module-message__video-overlay__circle { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - width: 48px; - height: 48px; - background-color: $color-white; - border-radius: 24px; -} - -.module-message__video-overlay__play-icon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - height: 36px; - width: 36px; - @include color-svg('../images/play.svg', $color-signal-blue); -} - .module-message__audio-attachment { margin-top: 2px; } @@ -583,7 +501,7 @@ .module-message__author-avatar { position: absolute; // This accounts for the weird extra 3px we get at the bottom of messages - bottom: -3px; + bottom: 0px; right: calc(100% + 4px); } @@ -2101,6 +2019,150 @@ color: $color-gray-90; } +// Module: Image + +.module-image { + overflow: hidden; + background-color: $color-white; + position: relative; + display: inline-block; + margin: 1px; +} + +.module-image__caption-icon { + position: absolute; + top: 6px; + left: 6px; +} + +.module-image--curved-top-left { + border-top-left-radius: 16px; +} +.module-image--curved-top-right { + border-top-right-radius: 16px; +} +.module-image--curved-bottom-left { + border-bottom-left-radius: 16px; +} +.module-image--curved-bottom-right { + border-bottom-right-radius: 16px; +} + +.module-image__border-overlay { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + left: 0; + right: 0; + box-shadow: inset 0px 0px 0px 1px $color-black-015; +} + +.module-image__border-overlay--dark { + background-color: $color-black-02; +} + +.module-image__image { + object-fit: cover; + // redundant with attachment-container, but we get cursor flashing on move otherwise + cursor: pointer; + + margin-bottom: -3px; +} + +.module-image__bottom-overlay { + height: 48px; + background-image: linear-gradient( + to bottom, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0) 9%, + rgba(0, 0, 0, 0.02) 17%, + rgba(0, 0, 0, 0.05) 24%, + rgba(0, 0, 0, 0.08) 31%, + rgba(0, 0, 0, 0.12) 37%, + rgba(0, 0, 0, 0.16) 44%, + rgba(0, 0, 0, 0.2) 50%, + rgba(0, 0, 0, 0.24) 56%, + rgba(0, 0, 0, 0.28) 63%, + rgba(0, 0, 0, 0.32) 69%, + rgba(0, 0, 0, 0.35) 76%, + rgba(0, 0, 0, 0.38) 83%, + rgba(0, 0, 0, 0.4) 91%, + rgba(0, 0, 0, 0.4) + ); + position: absolute; + bottom: 0; + z-index: 2; + left: 0; + right: 0; +} + +.module-image__play-overlay__circle { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + width: 48px; + height: 48px; + background-color: $color-white; + border-radius: 24px; +} + +.module-image__play-overlay__icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + height: 36px; + width: 36px; + @include color-svg('../images/play.svg', $color-signal-blue); +} + +.module-image__text-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + + color: $color-white; + + font-size: 20px; + font-weight: normal; + letter-spacing: 0; + + text-align: center; +} + +// Module: Image Grid + +.module-image-grid { + display: inline-flex; + flex-direction: row; + align-items: center; + + margin: -1px; +} + +.module-image-grid--one-image { + margin-bottom: -5px; +} + +.module-image-grid__column { + display: inline-flex; + flex-direction: column; + align-items: center; +} + +.module-image-grid__row { + display: inline-flex; + flex-direction: row; + align-items: center; +} + // Third-party module: react-contextmenu .react-contextmenu { diff --git a/ts/components/Lightbox.md b/ts/components/Lightbox.md index b776e57504..a59ac4c41f 100644 --- a/ts/components/Lightbox.md +++ b/ts/components/Lightbox.md @@ -1,4 +1,4 @@ -## Image (supported format) +## Image ```js const noop = () => {}; @@ -13,6 +13,22 @@ const noop = () => {}; ; ``` +## Image with caption + +```js +const noop = () => {}; + +
+ +
; +``` + ## Image (unsupported format) ```js diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index e86815304b..88fc57a82b 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -28,6 +28,7 @@ interface Props { contentType: MIME.MIMEType | undefined; i18n: Localizer; objectURL: string; + caption?: string; onNext?: () => void; onPrevious?: () => void; onSave?: () => void; @@ -57,6 +58,7 @@ const styles = { paddingBottom: 0, } as React.CSSProperties, objectContainer: { + position: 'relative', flexGrow: 1, display: 'inline-flex', justifyContent: 'center', @@ -68,6 +70,18 @@ const styles = { maxHeight: '100%', objectFit: 'contain', } as React.CSSProperties, + caption: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + textAlign: 'center', + color: 'white', + padding: '1em', + paddingLeft: '3em', + paddingRight: '3em', + backgroundColor: 'rgba(192, 192, 192, .20)', + } as React.CSSProperties, controlsOffsetPlaceholder: { width: CONTROLS_WIDTH, marginRight: CONTROLS_SPACING, @@ -194,6 +208,7 @@ export class Lightbox extends React.Component { public render() { const { + caption, contentType, objectURL, onNext, @@ -215,6 +230,7 @@ export class Lightbox extends React.Component { {!is.undefined(contentType) ? this.renderObject({ objectURL, contentType, i18n }) : null} + {caption ?
{caption}
: null}
diff --git a/ts/components/LightboxGallery.md b/ts/components/LightboxGallery.md index 46b2ff5188..ded53f090a 100644 --- a/ts/components/LightboxGallery.md +++ b/ts/components/LightboxGallery.md @@ -1,44 +1,64 @@ ```js const noop = () => {}; -const messages = [ +const mediaItems = [ { objectURL: 'https://placekitten.com/799/600', - attachments: [{ contentType: 'image/jpeg' }], + contentType: 'image/jpeg', + message: { id: 1 }, + attachment: { + contentType: 'image/jpeg', + caption: + "This is a really long caption. Because the user had a lot to say. You know, it's very important to provide full context when sending an image. You don't want to make the wrong impression.", + }, }, { objectURL: 'https://placekitten.com/900/600', - attachments: [{ contentType: 'image/jpeg' }], + contentType: 'image/jpeg', + message: { id: 2 }, + attachment: { contentType: 'image/jpeg' }, }, // Unsupported image type { objectURL: 'foo.tif', - attachments: [{ contentType: 'image/tiff' }], + contentType: 'image/tiff', + message: { id: 3 }, + attachment: { contentType: 'image/tiff' }, }, // Video { objectURL: util.mp4ObjectUrl, - attachments: [{ contentType: 'video/mp4' }], + contentType: 'video/mp4', + message: { id: 4 }, + attachment: { contentType: 'video/mp4' }, }, { objectURL: 'https://placekitten.com/980/800', - attachments: [{ contentType: 'image/jpeg' }], + contentType: 'image/jpeg', + message: { id: 5 }, + attachment: { contentType: 'image/jpeg' }, }, { objectURL: 'https://placekitten.com/656/540', - attachments: [{ contentType: 'image/jpeg' }], + contentType: 'image/jpeg', + message: { id: 6 }, + attachment: { contentType: 'image/jpeg' }, }, { objectURL: 'https://placekitten.com/762/400', - attachments: [{ contentType: 'image/jpeg' }], + contentType: 'image/jpeg', + message: { id: 7 }, + attachment: { contentType: 'image/jpeg' }, }, { objectURL: 'https://placekitten.com/920/620', - attachments: [{ contentType: 'image/jpeg' }], + contentType: 'image/jpeg', + message: { id: 8 }, + attachment: { contentType: 'image/jpeg' }, }, ];
- +
; ``` diff --git a/ts/components/LightboxGallery.tsx b/ts/components/LightboxGallery.tsx index 7d83c34928..e3ebea472e 100644 --- a/ts/components/LightboxGallery.tsx +++ b/ts/components/LightboxGallery.tsx @@ -6,19 +6,26 @@ import React from 'react'; import * as MIME from '../types/MIME'; import { Lightbox } from './Lightbox'; import { Message } from './conversation/media-gallery/types/Message'; +import { AttachmentType } from './conversation/types'; import { Localizer } from '../types/Util'; -interface Item { +export interface MediaItemType { objectURL?: string; - contentType: MIME.MIMEType | undefined; + thumbnailObjectUrl?: string; + contentType?: MIME.MIMEType; + index: number; + attachment: AttachmentType; + message: Message; } interface Props { close: () => void; i18n: Localizer; - messages: Array; - onSave?: ({ message }: { message: Message }) => void; + media: Array; + onSave?: ( + { attachment, message }: { attachment: AttachmentType; message: Message } + ) => void; selectedIndex: number; } @@ -26,11 +33,6 @@ interface State { selectedIndex: number; } -const messageToItem = (message: Message): Item => ({ - objectURL: message.objectURL, - contentType: message.attachments[0].contentType, -}); - export class LightboxGallery extends React.Component { public static defaultProps: Partial = { selectedIndex: 0, @@ -45,20 +47,19 @@ export class LightboxGallery extends React.Component { } public render() { - const { close, messages, onSave, i18n } = this.props; + const { close, media, onSave, i18n } = this.props; const { selectedIndex } = this.state; - const selectedMessage: Message = messages[selectedIndex]; - const selectedItem = messageToItem(selectedMessage); - + const selectedMedia = media[selectedIndex]; const firstIndex = 0; + const lastIndex = media.length - 1; + const onPrevious = selectedIndex > firstIndex ? this.handlePrevious : undefined; - - const lastIndex = messages.length - 1; const onNext = selectedIndex < lastIndex ? this.handleNext : undefined; - const objectURL = selectedItem.objectURL || 'images/alert-outline.svg'; + const objectURL = selectedMedia.objectURL || 'images/alert-outline.svg'; + const { attachment } = selectedMedia; return ( { onNext={onNext} onSave={onSave ? this.handleSave : undefined} objectURL={objectURL} - contentType={selectedItem.contentType} + caption={attachment ? attachment.caption : undefined} + contentType={selectedMedia.contentType} i18n={i18n} /> ); @@ -83,19 +85,21 @@ export class LightboxGallery extends React.Component { this.setState((prevState, props) => ({ selectedIndex: Math.min( prevState.selectedIndex + 1, - props.messages.length - 1 + props.media.length - 1 ), })); }; private handleSave = () => { - const { messages, onSave } = this.props; + const { media, onSave } = this.props; if (!onSave) { return; } const { selectedIndex } = this.state; - const message = messages[selectedIndex]; - onSave({ message }); + const mediaItem = media[selectedIndex]; + const { attachment, message } = mediaItem; + + onSave({ attachment, message }); }; } diff --git a/ts/components/conversation/Image.md b/ts/components/conversation/Image.md new file mode 100644 index 0000000000..dc28d28aed --- /dev/null +++ b/ts/components/conversation/Image.md @@ -0,0 +1,122 @@ +### Various sizes + +```jsx + + + +``` + +### Various curved corners + +```jsx + + + + +``` + +### With bottom overlay + +```jsx + + + +``` + +### With play icon + +```jsx + + + +``` + +### With dark overlay and text + +```jsx +
+
+ + + +
+
+
+ + + +
+
+``` + +### With caption + +```jsx +
+
+ + + +
+
+
+ + + +
+
+``` diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx new file mode 100644 index 0000000000..9deeee2c4a --- /dev/null +++ b/ts/components/conversation/Image.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { Localizer } from '../../types/Util'; +import { AttachmentType } from './types'; + +interface Props { + alt: string; + attachment: AttachmentType; + url: string; + + height?: number; + width?: number; + + overlayText?: string; + + bottomOverlay?: boolean; + curveBottomLeft?: boolean; + curveBottomRight?: boolean; + curveTopLeft?: boolean; + curveTopRight?: boolean; + darkOverlay?: boolean; + playIconOverlay?: boolean; + + i18n: Localizer; + onClick?: (attachment: AttachmentType) => void; + onError?: () => void; +} + +export class Image extends React.Component { + public render() { + const { + alt, + attachment, + bottomOverlay, + curveBottomLeft, + curveBottomRight, + curveTopLeft, + curveTopRight, + darkOverlay, + height, + i18n, + onClick, + onError, + overlayText, + playIconOverlay, + url, + width, + } = this.props; + + const { caption } = attachment || { caption: null }; + + return ( +
{ + if (onClick) { + onClick(attachment); + } + }} + role="button" + className={classNames( + 'module-image', + curveBottomLeft ? 'module-image--curved-bottom-left' : null, + curveBottomRight ? 'module-image--curved-bottom-right' : null, + curveTopLeft ? 'module-image--curved-top-left' : null, + curveTopRight ? 'module-image--curved-top-right' : null + )} + > + {alt} + {caption ? ( + {i18n('imageCaptionIconAlt')} + ) : null} +
+ {bottomOverlay ? ( +
+ ) : null} + {playIconOverlay ? ( +
+
+
+ ) : null} + {overlayText ? ( +
+ {overlayText} +
+ ) : null} +
+ ); + } +} diff --git a/ts/components/conversation/ImageGrid.md b/ts/components/conversation/ImageGrid.md new file mode 100644 index 0000000000..adc1aeeeac --- /dev/null +++ b/ts/components/conversation/ImageGrid.md @@ -0,0 +1,354 @@ +### One image + +```jsx +const attachments = [ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, +]; + +
+
+ +
+
+
+ +
+
; +``` + +### One image, various aspect ratios + +```jsx +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+``` + +### Two images + +```jsx +const attachments = [ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, +]; + +
+
+ +
+
+
+ +
+
; +``` + +### Three images + +```jsx +const attachments = [ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, +]; + +
+
+ +
+
+
+ +
+
; +``` + +### Four images + +```jsx +const attachments = [ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, +]; + +
+
+ +
+
+
+ +
+
; +``` + +### Five images + +```jsx +const attachments = [ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, +]; + +
+
+ +
+
+
+ +
+
; +``` + +### Six images + +``` +const attachments = [ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 320, + height: 240, + }, +]; + +
+
+ +
+
+
+ +
+
; +``` diff --git a/ts/components/conversation/ImageGrid.tsx b/ts/components/conversation/ImageGrid.tsx new file mode 100644 index 0000000000..2af2511662 --- /dev/null +++ b/ts/components/conversation/ImageGrid.tsx @@ -0,0 +1,416 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { + isImageTypeSupported, + isVideoTypeSupported, +} from '../../util/GoogleChrome'; +import { AttachmentType } from './types'; +import { Image } from './Image'; +import { Localizer } from '../../types/Util'; + +interface Props { + attachments: Array; + withContentAbove: boolean; + withContentBelow: boolean; + bottomOverlay?: boolean; + + i18n: Localizer; + + onError: () => void; + onClickAttachment?: (attachment: AttachmentType) => void; +} + +const MAX_WIDTH = 300; +const MAX_HEIGHT = MAX_WIDTH * 1.5; +const MIN_WIDTH = 200; +const MIN_HEIGHT = 25; + +export class ImageGrid extends React.Component { + // tslint:disable-next-line max-func-body-length */ + public render() { + const { + attachments, + bottomOverlay, + i18n, + onError, + onClickAttachment, + withContentAbove, + withContentBelow, + } = this.props; + + const curveTopLeft = !Boolean(withContentAbove); + const curveTopRight = curveTopLeft; + + const curveBottom = !Boolean(withContentBelow); + const curveBottomLeft = curveBottom; + const curveBottomRight = curveBottom; + + if (!attachments || !attachments.length) { + return null; + } + + if (attachments.length === 1) { + const { height, width } = getImageDimensions(attachments[0]); + + return ( +
+ {getAlt(attachments[0], +
+ ); + } + + if (attachments.length === 2) { + return ( +
+ {getAlt(attachments[0], + {getAlt(attachments[1], +
+ ); + } + + if (attachments.length === 3) { + return ( +
+ {getAlt(attachments[0], +
+ {getAlt(attachments[1], + {getAlt(attachments[2], +
+
+ ); + } + + if (attachments.length === 4) { + return ( +
+
+
+ {getAlt(attachments[0], + {getAlt(attachments[1], +
+
+ {getAlt(attachments[2], + {getAlt(attachments[3], +
+
+
+ ); + } + + return ( +
+
+
+ {getAlt(attachments[0], + {getAlt(attachments[1], +
+
+ {getAlt(attachments[2], + {getAlt(attachments[3], + {getAlt(attachments[4], 5} + overlayText={ + attachments.length > 5 + ? `+${attachments.length - 5}` + : undefined + } + attachment={attachments[4]} + url={getUrl(attachments[4])} + onClick={onClickAttachment} + onError={onError} + /> +
+
+
+ ); + } +} + +function getUrl(attachment: AttachmentType) { + if (attachment.screenshot) { + return attachment.screenshot.url; + } + + return attachment.url; +} + +export function isImage(attachments?: Array) { + return ( + attachments && + attachments[0] && + attachments[0].contentType && + isImageTypeSupported(attachments[0].contentType) + ); +} + +export function hasImage(attachments?: Array) { + return attachments && attachments[0] && attachments[0].url; +} + +export function isVideo(attachments?: Array) { + return attachments && isVideoAttachment(attachments[0]); +} + +export function isVideoAttachment(attachment?: AttachmentType) { + return ( + attachment && + attachment.contentType && + isVideoTypeSupported(attachment.contentType) + ); +} + +export function hasVideoScreenshot(attachments?: Array) { + const firstAttachment = attachments ? attachments[0] : null; + + return ( + firstAttachment && + firstAttachment.screenshot && + firstAttachment.screenshot.url + ); +} + +type DimensionsType = { + height: number; + width: number; +}; + +function getImageDimensions(attachment: AttachmentType): DimensionsType { + const { height, width } = attachment; + if (!height || !width) { + return { + height: MIN_HEIGHT, + width: MIN_WIDTH, + }; + } + + const aspectRatio = height / width; + const targetWidth = Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH); + const candidateHeight = Math.round(targetWidth * aspectRatio); + + return { + width: targetWidth, + height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT), + }; +} + +export function getGridDimensions( + attachments?: Array +): null | DimensionsType { + if (!attachments || !attachments.length) { + return null; + } + + if (!isImage(attachments) && !isVideo(attachments)) { + return null; + } + + if (attachments.length === 1) { + return getImageDimensions(attachments[0]); + } + + if (attachments.length === 2) { + return { + height: 150, + width: 300, + }; + } + + if (attachments.length === 4) { + return { + height: 300, + width: 300, + }; + } + + return { + height: 200, + width: 300, + }; +} + +export function getAlt(attachment: AttachmentType, i18n: Localizer): string { + return isVideoAttachment(attachment) + ? i18n('videoAttachmentAlt') + : i18n('imageAttachmentAlt'); +} diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index 8ba26ab7a7..1445d22230 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -471,12 +471,14 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean text="I am pretty confused about Pi." timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - width: 320, - height: 240, - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} @@ -490,12 +492,14 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean text="I am pretty confused about Pi." timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - width: 320, - height: 240, - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} @@ -509,12 +513,14 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean collapseMetadata timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - width: 320, - height: 240, - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} @@ -528,12 +534,14 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean collapseMetadata timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - width: 320, - height: 240, - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} @@ -556,12 +564,14 @@ First, showing the metadata overlay on dark and light images, then a message wit i18n={util.i18n} expirationLength={60 * 1000} expirationTimestamp={Date.now() + 30 * 1000} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - width: 320, - height: 240, - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -574,12 +584,14 @@ First, showing the metadata overlay on dark and light images, then a message wit i18n={util.i18n} expirationLength={60 * 1000} expirationTimestamp={Date.now() + 30 * 1000} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - width: 320, - height: 240, - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -589,12 +601,14 @@ First, showing the metadata overlay on dark and light images, then a message wit direction="incoming" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -605,12 +619,14 @@ First, showing the metadata overlay on dark and light images, then a message wit status="sent" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -621,12 +637,14 @@ First, showing the metadata overlay on dark and light images, then a message wit collapseMetadata timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -638,12 +656,383 @@ First, showing the metadata overlay on dark and light images, then a message wit status="sent" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} + onClickAttachment={() => console.log('onClickAttachment')} + /> + + +``` + +#### Multiple images + +```jsx + +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
    +``` + +#### Multiple images with caption + +```jsx + +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} />
  • @@ -663,12 +1052,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co authorColor="pink" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -679,12 +1070,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co authorColor="red" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -695,12 +1088,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co authorColor="blue" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -711,12 +1106,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co authorColor="purple" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.pngObjectUrl, - contentType: 'image/png', - width: 800, - height: 1200, - }} + attachments={[ + { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -733,12 +1130,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -749,12 +1148,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -765,12 +1166,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -781,12 +1184,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -804,12 +1209,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -821,12 +1228,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co text="This is an odd yellow bar. Cool, huh?" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -838,12 +1247,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -852,18 +1263,62 @@ Note that the delivered indicator is always Signal Blue, not the conversation co authorColor="green" direction="outgoing" text="This is an odd yellow bar. Cool, huh?" + status="delivered" collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.portraitYellowObjectUrl, - contentType: 'image/gif', - width: 20, - height: 200, - }} + attachments={[ + { + url: util.portraitYellowObjectUrl, + contentType: 'image/gif', + width: 20, + height: 200, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> +
  • + console.log('onClickAttachment')} + expirationLength={5 * 60 * 1000} + expirationTimestamp={Date.now() + 5 * 60 * 1000} + /> +
  • +
  • + console.log('onClickAttachment')} + expirationLength={5 * 60 * 1000} + expirationTimestamp={Date.now() + 5 * 60 * 1000} + /> +
  • ``` @@ -877,12 +1332,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -893,12 +1350,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co i18n={util.i18n} timestamp={Date.now()} status="delivered" - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -909,12 +1368,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -925,12 +1386,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -948,12 +1411,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -965,12 +1430,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co i18n={util.i18n} timestamp={Date.now()} status="delivered" - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -982,12 +1449,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -999,12 +1468,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.landscapePurpleObjectUrl, - contentType: 'image/gif', - width: 200, - height: 50, - }} + attachments={[ + { + url: util.landscapePurpleObjectUrl, + contentType: 'image/gif', + width: 200, + height: 50, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1022,14 +1493,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - screenshot: { - url: util.gifObjectUrl, + attachments={[ + { + screenshot: { + url: util.gifObjectUrl, + }, + contentType: 'video/mp4', + width: 320, + height: 240, }, - contentType: 'video/mp4', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1041,14 +1514,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - screenshot: { - url: util.gifObjectUrl, + attachments={[ + { + screenshot: { + url: util.gifObjectUrl, + }, + contentType: 'video/mp4', + width: 320, + height: 240, }, - contentType: 'video/mp4', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1060,16 +1535,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - screenshot: { - url: util.pngObjectUrl, + attachments={[ + { + screenshot: { + url: util.pngObjectUrl, + width: 800, + height: 1200, + }, + contentType: 'video/mp4', width: 800, height: 1200, }, - contentType: 'video/mp4', - width: 800, - height: 1200, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1081,16 +1558,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - screenshot: { - url: util.pngObjectUrl, + attachments={[ + { + screenshot: { + url: util.pngObjectUrl, + width: 800, + height: 1200, + }, + contentType: 'video/mp4', width: 800, height: 1200, }, - contentType: 'video/mp4', - width: 800, - height: 1200, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1108,16 +1587,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - screenshot: { - url: util.pngObjectUrl, + attachments={[ + { + screenshot: { + url: util.pngObjectUrl, + width: 800, + height: 1200, + }, + contentType: 'video/mp4', width: 800, height: 1200, }, - contentType: 'video/mp4', - width: 800, - height: 1200, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1128,16 +1609,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co i18n={util.i18n} timestamp={Date.now()} status="delivered" - attachment={{ - screenshot: { - url: util.pngObjectUrl, + attachments={[ + { + screenshot: { + url: util.pngObjectUrl, + width: 800, + height: 1200, + }, + contentType: 'video/mp4', width: 800, height: 1200, }, - contentType: 'video/mp4', - width: 800, - height: 1200, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1149,16 +1632,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co i18n={util.i18n} timestamp={Date.now()} collapseMetadata - attachment={{ - screenshot: { - url: util.pngObjectUrl, + attachments={[ + { + screenshot: { + url: util.pngObjectUrl, + width: 800, + height: 1200, + }, + contentType: 'video/mp4', width: 800, height: 1200, }, - contentType: 'video/mp4', - width: 800, - height: 1200, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1170,16 +1655,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co i18n={util.i18n} timestamp={Date.now()} collapseMetadata - attachment={{ - screenshot: { - url: util.pngObjectUrl, + attachments={[ + { + screenshot: { + url: util.pngObjectUrl, + width: 800, + height: 1200, + }, + contentType: 'video/mp4', width: 800, height: 1200, }, - contentType: 'video/mp4', - width: 800, - height: 1200, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1197,14 +1684,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: null, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + attachments={[ + { + url: null, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1215,14 +1704,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: null, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + attachments={[ + { + url: null, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1234,14 +1725,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: null, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + attachments={[ + { + url: null, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1253,14 +1746,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: null, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + attachments={[ + { + url: null, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1271,16 +1766,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - screenshot: { - url: null, + attachments={[ + { + screenshot: { + url: null, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', + width: 320, + height: 240, }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1291,16 +1788,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co timestamp={Date.now()} i18n={util.i18n} status="delivered" - attachment={{ - screenshot: { - url: null, + attachments={[ + { + screenshot: { + url: null, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', + width: 320, + height: 240, }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1312,16 +1811,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - screenshot: { - url: null, + attachments={[ + { + screenshot: { + url: null, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', + width: 320, + height: 240, }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1333,16 +1834,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co timestamp={Date.now()} i18n={util.i18n} status="delivered" - attachment={{ - screenshot: { - url: null, + attachments={[ + { + screenshot: { + url: null, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', + width: 320, + height: 240, }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1360,14 +1863,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: 'nonexistent', - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + attachments={[ + { + url: 'nonexistent', + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1379,14 +1884,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: 'nonexistent', - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + attachments={[ + { + url: 'nonexistent', + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1397,16 +1904,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - screenshot: { - url: 'nonexistent', + attachments={[ + { + screenshot: { + url: 'nonexistent', + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', + width: 320, + height: 240, }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1417,16 +1926,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co timestamp={Date.now()} i18n={util.i18n} status="delivered" - attachment={{ - screenshot: { - url: 'nonexistent', + attachments={[ + { + screenshot: { + url: 'nonexistent', + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', + width: 320, + height: 240, }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - width: 320, - height: 240, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1444,14 +1955,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - width: 4097, - height: 4096, - url: util.gifObjectUrl, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - }} + attachments={[ + { + width: 4097, + height: 4096, + url: util.gifObjectUrl, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1462,14 +1975,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - width: 4096, - height: 4097, - url: util.gifObjectUrl, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - }} + attachments={[ + { + width: 4096, + height: 4097, + url: util.gifObjectUrl, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1480,16 +1995,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - height: 4096, - width: 4097, - screenshot: { - url: util.gifObjectUrl, + attachments={[ + { + height: 4096, + width: 4097, + screenshot: { + url: util.gifObjectUrl, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1500,16 +2017,18 @@ Note that the delivered indicator is always Signal Blue, not the conversation co timestamp={Date.now()} i18n={util.i18n} status="delivered" - attachment={{ - height: 4097, - width: 4096, - screenshot: { - url: util.gifObjectUrl, + attachments={[ + { + height: 4097, + width: 4096, + screenshot: { + url: util.gifObjectUrl, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1527,12 +2046,14 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1543,13 +2064,15 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - height: 240, - url: util.gifObjectUrl, - contentType: 'image/gif', - fileName: 'image.gif', - fileSize: '3.05 KB', - }} + attachments={[ + { + height: 240, + url: util.gifObjectUrl, + contentType: 'image/gif', + fileName: 'image.gif', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1560,17 +2083,19 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="delivered" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - width: 320, - screenshot: { - url: util.gifObjectUrl, + attachments={[ + { width: 320, - height: 240, + screenshot: { + url: util.gifObjectUrl, + width: 320, + height: 240, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1581,14 +2106,16 @@ Note that the delivered indicator is always Signal Blue, not the conversation co timestamp={Date.now()} i18n={util.i18n} status="delivered" - attachment={{ - screenshot: { - url: util.gifObjectUrl, + attachments={[ + { + screenshot: { + url: util.gifObjectUrl, + }, + contentType: 'video/mp4', + fileName: 'video.mp4', + fileSize: '3.05 KB', }, - contentType: 'video/mp4', - fileName: 'video.mp4', - fileSize: '3.05 KB', - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1606,10 +2133,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" timestamp={Date.now()} i18n={util.i18n} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1621,10 +2150,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co text="This is a nice song" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1636,10 +2167,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1651,10 +2184,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1671,10 +2206,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1685,10 +2222,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co status="sent" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1699,10 +2238,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1713,10 +2254,12 @@ Note that the delivered indicator is always Signal Blue, not the conversation co i18n={util.i18n} timestamp={Date.now()} collapseMetadata - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1738,12 +2281,14 @@ Voice notes are not shown any differently from audio attachments. text="My manifesto is now complete!" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1755,12 +2300,14 @@ Voice notes are not shown any differently from audio attachments. status="sent" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1772,12 +2319,14 @@ Voice notes are not shown any differently from audio attachments. collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1789,12 +2338,14 @@ Voice notes are not shown any differently from audio attachments. i18n={util.i18n} timestamp={Date.now()} collapseMetadata - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1806,13 +2357,15 @@ Voice notes are not shown any differently from audio attachments. collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: - 'reallly_long_filename_because_it_needs_all_the_information.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: + 'reallly_long_filename_because_it_needs_all_the_information.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1824,12 +2377,14 @@ Voice notes are not shown any differently from audio attachments. i18n={util.i18n} timestamp={Date.now()} collapseMetadata - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'filename_with_long_extension.the_txt_is_beautiful', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'filename_with_long_extension.the_txt_is_beautiful', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1841,12 +2396,14 @@ Voice notes are not shown any differently from audio attachments. i18n={util.i18n} timestamp={Date.now()} collapseMetadata - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'a_normal_four_letter_extension.jpeg', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'a_normal_four_letter_extension.jpeg', + fileSize: '3.05 KB', + }, + ]} /> @@ -1862,12 +2419,14 @@ Voice notes are not shown any differently from audio attachments. direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1878,12 +2437,14 @@ Voice notes are not shown any differently from audio attachments. i18n={util.i18n} timestamp={Date.now()} status="sent" - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1894,12 +2455,14 @@ Voice notes are not shown any differently from audio attachments. collapseMetadata i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1910,12 +2473,14 @@ Voice notes are not shown any differently from audio attachments. i18n={util.i18n} timestamp={Date.now()} collapseMetadata - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> @@ -1932,12 +2497,14 @@ Voice notes are not shown any differently from audio attachments. direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'blah.exe', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'blah.exe', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={isDangerous => console.log('onClickAttachment - isDangerous:', isDangerous) } @@ -1950,12 +2517,14 @@ Voice notes are not shown any differently from audio attachments. i18n={util.i18n} timestamp={Date.now()} status="sent" - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'blah.exe', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'blah.exe', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={isDangerous => console.log('onClickAttachment - isDangerous:', isDangerous) } @@ -2031,12 +2600,14 @@ Note that the author avatar goes away if `collapseMetadata` is set. direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.gifObjectUrl, - contentType: 'image/gif', - width: 320, - height: 240, - }} + attachments={[ + { + url: util.gifObjectUrl, + contentType: 'image/gif', + width: 320, + height: 240, + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} authorAvatarPath={util.gifObjectUrl} /> @@ -2049,16 +2620,18 @@ Note that the author avatar goes away if `collapseMetadata` is set. direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - screenshot: { - url: util.pngObjectUrl, + attachments={[ + { + screenshot: { + url: util.pngObjectUrl, + width: 800, + height: 1200, + }, + contentType: 'video/mp4', width: 800, height: 1200, }, - contentType: 'video/mp4', - width: 800, - height: 1200, - }} + ]} onClickAttachment={() => console.log('onClickAttachment')} authorAvatarPath={util.gifObjectUrl} /> @@ -2071,10 +2644,12 @@ Note that the author avatar goes away if `collapseMetadata` is set. direction="incoming" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.mp3ObjectUrl, - contentType: 'audio/mp3', - }} + attachments={[ + { + url: util.mp3ObjectUrl, + contentType: 'audio/mp3', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} authorAvatarPath={util.gifObjectUrl} /> @@ -2088,12 +2663,14 @@ Note that the author avatar goes away if `collapseMetadata` is set. text="My manifesto is now complete!" i18n={util.i18n} timestamp={Date.now()} - attachment={{ - url: util.txtObjectUrl, - contentType: 'text/plain', - fileName: 'my_manifesto.txt', - fileSize: '3.05 KB', - }} + attachments={[ + { + url: util.txtObjectUrl, + contentType: 'text/plain', + fileName: 'my_manifesto.txt', + fileSize: '3.05 KB', + }, + ]} onClickAttachment={() => console.log('onClickAttachment')} /> diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 2e49ca98d3..1071fa1bed 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -1,54 +1,33 @@ import React from 'react'; import classNames from 'classnames'; -import { - isImageTypeSupported, - isVideoTypeSupported, -} from '../../util/GoogleChrome'; - import { Avatar } from '../Avatar'; import { MessageBody } from './MessageBody'; import { ExpireTimer, getIncrement } from './ExpireTimer'; +import { + getGridDimensions, + hasImage, + hasVideoScreenshot, + ImageGrid, + isImage, + isVideo, +} from './ImageGrid'; import { Timestamp } from './Timestamp'; import { ContactName } from './ContactName'; -import { Quote, QuotedAttachment } from './Quote'; +import { Quote, QuotedAttachmentType } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; +import * as MIME from '../../../ts/types/MIME'; +import { AttachmentType } from './types'; import { isFileDangerous } from '../../util/isFileDangerous'; import { Contact } from '../../types/Contact'; import { Color, Localizer } from '../../types/Util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; -import * as MIME from '../../../ts/types/MIME'; - interface Trigger { handleContextClick: (event: React.MouseEvent) => void; } -interface Attachment { - contentType: MIME.MIMEType; - fileName: string; - /** Not included in protobuf, needs to be pulled from flags */ - isVoiceMessage: boolean; - /** For messages not already on disk, this will be a data url */ - url: string; - fileSize?: string; - width: number; - height: number; - screenshot?: { - height: number; - width: number; - url: string; - contentType: MIME.MIMEType; - }; - thumbnail?: { - height: number; - width: number; - url: string; - contentType: MIME.MIMEType; - }; -} - export interface Props { disableMenu?: boolean; text?: string; @@ -70,10 +49,10 @@ export interface Props { authorPhoneNumber: string; authorColor?: Color; conversationType: 'group' | 'direct'; - attachment?: Attachment; + attachments?: Array; quote?: { text: string; - attachment?: QuotedAttachment; + attachment?: QuotedAttachmentType; isFromMe: boolean; authorPhoneNumber: string; authorProfileName?: string; @@ -86,7 +65,7 @@ export interface Props { isExpired: boolean; expirationLength?: number; expirationTimestamp?: number; - onClickAttachment?: () => void; + onClickAttachment?: (attachment: AttachmentType) => void; onReply?: () => void; onRetrySend?: () => void; onDownload?: (isDangerous: boolean) => void; @@ -100,42 +79,29 @@ interface State { imageBroken: boolean; } -function isImage(attachment?: Attachment) { +function isAudio(attachments?: Array) { return ( - attachment && - attachment.contentType && - isImageTypeSupported(attachment.contentType) + attachments && + attachments[0] && + attachments[0].contentType && + MIME.isAudio(attachments[0].contentType) ); } -function hasImage(attachment?: Attachment) { - return attachment && attachment.url; -} +function canDisplayImage(attachments?: Array) { + const { height, width } = + attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 }; -function isVideo(attachment?: Attachment) { return ( - attachment && - attachment.contentType && - isVideoTypeSupported(attachment.contentType) + height && + height > 0 && + height <= 4096 && + width && + width > 0 && + width <= 4096 ); } -function hasVideoScreenshot(attachment?: Attachment) { - return attachment && attachment.screenshot && attachment.screenshot.url; -} - -function isAudio(attachment?: Attachment) { - return ( - attachment && attachment.contentType && MIME.isAudio(attachment.contentType) - ); -} - -function canDisplayImage(attachment?: Attachment) { - const { height, width } = attachment || { height: 0, width: 0 }; - - return height > 0 && height <= 4096 && width > 0 && width <= 4096; -} - function getExtension({ fileName, contentType, @@ -159,8 +125,6 @@ function getExtension({ return null; } -const MINIMUM_IMG_HEIGHT = 150; -const MAXIMUM_IMG_HEIGHT = 300; const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRED_DELAY = 600; @@ -255,7 +219,7 @@ export class Message extends React.Component { public renderMetadata() { const { - attachment, + attachments, collapseMetadata, direction, expirationLength, @@ -271,13 +235,13 @@ export class Message extends React.Component { return null; } - const canDisplayAttachment = canDisplayImage(attachment); + const canDisplayAttachment = canDisplayImage(attachments); const withImageNoCaption = Boolean( !text && canDisplayAttachment && !imageBroken && - ((isImage(attachment) && hasImage(attachment)) || - (isVideo(attachment) && hasVideoScreenshot(attachment))) + ((isImage(attachments) && hasImage(attachments)) || + (isVideo(attachments) && hasVideoScreenshot(attachments))) ); const showError = status === 'error' && direction === 'outgoing'; @@ -368,124 +332,59 @@ export class Message extends React.Component { // tslint:disable-next-line max-func-body-length cyclomatic-complexity public renderAttachment() { const { - i18n, - attachment, + attachments, text, collapseMetadata, conversationType, direction, + i18n, quote, onClickAttachment, } = this.props; const { imageBroken } = this.state; - if (!attachment) { + if (!attachments || !attachments[0]) { return null; } + const firstAttachment = attachments[0]; - const withCaption = Boolean(text); // For attachments which aren't full-frame - const withContentBelow = withCaption || !collapseMetadata; + const withContentBelow = Boolean(text); const withContentAbove = - quote || (conversationType === 'group' && direction === 'incoming'); - const displayImage = canDisplayImage(attachment); + Boolean(quote) || + (conversationType === 'group' && direction === 'incoming'); + const displayImage = canDisplayImage(attachments); - if (isImage(attachment) && displayImage && !imageBroken && attachment.url) { - // Calculating height to prevent reflow when image loads - const imageHeight = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0); - - return ( -
    - {i18n('imageAttachmentAlt')} -
    - {!withCaption && !collapseMetadata ? ( -
    - ) : null} -
    - ); - } else if ( - isVideo(attachment) && + if ( displayImage && !imageBroken && - attachment.screenshot && - attachment.screenshot.url + ((isImage(attachments) && hasImage(attachments)) || + (isVideo(attachments) && hasVideoScreenshot(attachments))) ) { - const { screenshot } = attachment; - // Calculating height to prevent reflow when image loads - const imageHeight = Math.max( - MINIMUM_IMG_HEIGHT, - attachment.screenshot.height || 0 - ); - return (
    - {i18n('videoAttachmentAlt')} -
    - {!withCaption && !collapseMetadata ? ( -
    - ) : null} -
    -
    -
    ); - } else if (isAudio(attachment)) { + } else if (isAudio(attachments)) { return ( ); } else { - const { fileName, fileSize, contentType } = attachment; + const { fileName, fileSize, contentType } = firstAttachment; const extension = getExtension({ contentType, fileName }); const isDangerous = isFileDangerous(fileName || ''); @@ -735,7 +634,7 @@ export class Message extends React.Component { public renderMenu(isCorrectSide: boolean, triggerId: string) { const { - attachment, + attachments, direction, disableMenu, onDownload, @@ -746,23 +645,26 @@ export class Message extends React.Component { return null; } - const fileName = attachment ? attachment.fileName : null; + const fileName = + attachments && attachments[0] ? attachments[0].fileName : null; const isDangerous = isFileDangerous(fileName || ''); + const multipleAttachments = attachments && attachments.length > 1; - const downloadButton = attachment ? ( -
    { - if (onDownload) { - onDownload(isDangerous); - } - }} - role="button" - className={classNames( - 'module-message__buttons__download', - `module-message__buttons__download--${direction}` - )} - /> - ) : null; + const downloadButton = + !multipleAttachments && attachments && attachments[0] ? ( +
    { + if (onDownload) { + onDownload(isDangerous); + } + }} + role="button" + className={classNames( + 'module-message__buttons__download', + `module-message__buttons__download--${direction}` + )} + /> + ) : null; const replyButton = (
    { public renderContextMenu(triggerId: string) { const { - attachment, + attachments, direction, status, onDelete, @@ -819,12 +721,14 @@ export class Message extends React.Component { } = this.props; const showRetry = status === 'error' && direction === 'outgoing'; - const fileName = attachment ? attachment.fileName : null; + const fileName = + attachments && attachments[0] ? attachments[0].fileName : null; const isDangerous = isFileDangerous(fileName || ''); + const multipleAttachments = attachments && attachments.length > 1; return ( - {attachment ? ( + {!multipleAttachments && attachments && attachments[0] ? ( { public render() { const { + attachments, authorPhoneNumber, authorColor, direction, id, timestamp, } = this.props; - const { expired, expiring } = this.state; + const { expired, expiring, imageBroken } = this.state; // This id is what connects our triple-dot click with our associated pop-up menu. // It needs to be unique. @@ -894,6 +799,16 @@ export class Message extends React.Component { return null; } + const displayImage = canDisplayImage(attachments); + + const showingImage = + displayImage && + !imageBroken && + ((isImage(attachments) && hasImage(attachments)) || + (isVideo(attachments) && hasVideoScreenshot(attachments))); + + const { width } = getGridDimensions(attachments) || { width: undefined }; + return (
    { `module-message--${direction}`, expiring ? 'module-message--expired' : null )} + style={{ + width: showingImage ? width : undefined, + }} > {this.renderError(direction === 'incoming')} {this.renderMenu(direction === 'outgoing', triggerId)} diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 6166abb7f5..ad9d05ae4e 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -11,7 +11,7 @@ import { Color, Localizer } from '../../types/Util'; import { ContactName } from './ContactName'; interface Props { - attachment?: QuotedAttachment; + attachment?: QuotedAttachmentType; authorPhoneNumber: string; authorProfileName?: string; authorName?: string; @@ -26,7 +26,7 @@ interface Props { referencedMessageNotFound: boolean; } -export interface QuotedAttachment { +export interface QuotedAttachmentType { contentType: MIME.MIMEType; fileName: string; /** Not included in protobuf */ diff --git a/ts/components/conversation/media-gallery/AttachmentSection.md b/ts/components/conversation/media-gallery/AttachmentSection.md index 4c5bae2bcc..8ff0a89302 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.md +++ b/ts/components/conversation/media-gallery/AttachmentSection.md @@ -1,31 +1,33 @@ ```jsx -const messages = [ +const mediaItems = [ { - id: '1', - attachments: [ - { - fileName: 'foo.json', - contentType: 'application/json', - size: 53313, - }, - ], + index: 0, + message: { + id: '1', + }, + attachment: { + fileName: 'foo.json', + contentType: 'application/json', + size: 53313, + }, }, { - id: '2', - attachments: [ - { - fileName: 'bar.txt', - contentType: 'text/plain', - size: 10323, - }, - ], + index: 1, + message: { + id: '2', + }, + attachment: { + fileName: 'bar.txt', + contentType: 'text/plain', + size: 10323, + }, }, ]; ; ``` diff --git a/ts/components/conversation/media-gallery/AttachmentSection.tsx b/ts/components/conversation/media-gallery/AttachmentSection.tsx index e3c279e67e..2cfd57437d 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.tsx @@ -1,18 +1,17 @@ import React from 'react'; -import { AttachmentType } from './types/AttachmentType'; import { DocumentListItem } from './DocumentListItem'; import { ItemClickEvent } from './types/ItemClickEvent'; import { MediaGridItem } from './MediaGridItem'; -import { Message } from './types/Message'; +import { MediaItemType } from '../../LightboxGallery'; import { missingCaseError } from '../../../util/missingCaseError'; import { Localizer } from '../../../types/Util'; interface Props { i18n: Localizer; header?: string; - type: AttachmentType; - messages: Array; + type: 'media' | 'documents'; + mediaItems: Array; onItemClick?: (event: ItemClickEvent) => void; } @@ -31,20 +30,19 @@ export class AttachmentSection extends React.Component { } private renderItems() { - const { i18n, messages, type } = this.props; + const { i18n, mediaItems, type } = this.props; - return messages.map((message, index, array) => { - const shouldShowSeparator = index < array.length - 1; - const { attachments } = message; - const firstAttachment = attachments[0]; + return mediaItems.map((mediaItem, position, array) => { + const shouldShowSeparator = position < array.length - 1; + const { message, index, attachment } = mediaItem; - const onClick = this.createClickHandler(message); + const onClick = this.createClickHandler(mediaItem); switch (type) { case 'media': return ( @@ -52,9 +50,9 @@ export class AttachmentSection extends React.Component { case 'documents': return ( { }); } - private createClickHandler = (message: Message) => () => { + private createClickHandler = (mediaItem: MediaItemType) => () => { const { onItemClick, type } = this.props; + const { message, attachment } = mediaItem; + if (!onItemClick) { return; } - onItemClick({ type, message }); + onItemClick({ type, message, attachment }); }; } diff --git a/ts/components/conversation/media-gallery/MediaGallery.md b/ts/components/conversation/media-gallery/MediaGallery.md index 415ce721dc..890b96bb83 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.md +++ b/ts/components/conversation/media-gallery/MediaGallery.md @@ -26,16 +26,17 @@ const createRandomMessage = ({ startTime, timeWindow } = {}) => props => { fileExtensions )}`; return { - id: _.random(now).toString(), - received_at: _.random(startTime, startTime + timeWindow), - attachments: [ - { - data: null, - fileName, - size: _.random(1000, 1000 * 1000 * 50), - contentType: 'image/jpeg', - }, - ], + contentType: 'image/jpeg', + message: { + id: _.random(now).toString(), + received_at: _.random(startTime, startTime + timeWindow), + }, + attachment: { + data: null, + fileName, + size: _.random(1000, 1000 * 1000 * 50), + contentType: 'image/jpeg', + }, thumbnailObjectUrl: `https://placekitten.com/${_.random( 50, @@ -81,17 +82,18 @@ const messages = _.sortBy( ## Media gallery with one document ```jsx -const messages = [ +const mediaItems = [ { - id: '1', thumbnailObjectUrl: 'https://placekitten.com/76/67', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'image/jpeg', - }, - ], + contentType: 'image/jpeg', + message: { + id: '1', + }, + attachment: { + fileName: 'foo.jpg', + contentType: 'image/jpeg', + }, }, ]; -; +; ``` diff --git a/ts/components/conversation/media-gallery/MediaGallery.tsx b/ts/components/conversation/media-gallery/MediaGallery.tsx index 608942ebc0..c53128d958 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.tsx @@ -4,29 +4,29 @@ import classNames from 'classnames'; import moment from 'moment'; import { AttachmentSection } from './AttachmentSection'; -import { AttachmentType } from './types/AttachmentType'; import { EmptyState } from './EmptyState'; -import { groupMessagesByDate } from './groupMessagesByDate'; +import { groupMediaItemsByDate } from './groupMediaItemsByDate'; import { ItemClickEvent } from './types/ItemClickEvent'; -import { Message } from './types/Message'; import { missingCaseError } from '../../../util/missingCaseError'; import { Localizer } from '../../../types/Util'; +import { MediaItemType } from '../../LightboxGallery'; + interface Props { - documents: Array; + documents: Array; i18n: Localizer; - media: Array; + media: Array; onItemClick?: (event: ItemClickEvent) => void; } interface State { - selectedTab: AttachmentType; + selectedTab: 'media' | 'documents'; } const MONTH_FORMAT = 'MMMM YYYY'; interface TabSelectEvent { - type: AttachmentType; + type: 'media' | 'documents'; } const Tab = ({ @@ -38,7 +38,7 @@ const Tab = ({ isSelected: boolean; label: string; onSelect?: (event: TabSelectEvent) => void; - type: AttachmentType; + type: 'media' | 'documents'; }) => { const handleClick = onSelect ? () => { @@ -99,10 +99,10 @@ export class MediaGallery extends React.Component { const { i18n, media, documents, onItemClick } = this.props; const { selectedTab } = this.state; - const messages = selectedTab === 'media' ? media : documents; + const mediaItems = selectedTab === 'media' ? media : documents; const type = selectedTab; - if (!messages || messages.length === 0) { + if (!mediaItems || mediaItems.length === 0) { const label = (() => { switch (type) { case 'media': @@ -120,9 +120,10 @@ export class MediaGallery extends React.Component { } const now = Date.now(); - const sections = groupMessagesByDate(now, messages).map(section => { - const first = section.messages[0]; - const date = moment(first.received_at); + const sections = groupMediaItemsByDate(now, mediaItems).map(section => { + const first = section.mediaItems[0]; + const { message } = first; + const date = moment(message.received_at); const header = section.type === 'yearMonth' ? date.format(MONTH_FORMAT) @@ -134,7 +135,7 @@ export class MediaGallery extends React.Component { header={header} i18n={i18n} type={type} - messages={section.messages} + mediaItems={section.mediaItems} onItemClick={onItemClick} /> ); diff --git a/ts/components/conversation/media-gallery/MediaGridItem.md b/ts/components/conversation/media-gallery/MediaGridItem.md index f32688d6ba..842d6bb0a3 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.md +++ b/ts/components/conversation/media-gallery/MediaGridItem.md @@ -1,108 +1,94 @@ #### With image ```jsx -const message = { - id: '1', +const mediaItem = { thumbnailObjectUrl: 'https://placekitten.com/76/67', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'image/jpeg', - }, - ], + contentType: 'image/jpeg', + attachment: { + fileName: 'foo.jpg', + contentType: 'image/jpeg', + }, }; -; +; ``` #### With video ```jsx -const message = { - id: '1', +const mediaItem = { thumbnailObjectUrl: 'https://placekitten.com/76/67', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'video/mp4', - }, - ], + contentType: 'video/mp4', + attachment: { + fileName: 'foo.jpg', + contentType: 'video/mp4', + }, }; -; +; ``` #### Missing image ```jsx -const message = { - id: '1', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'image/jpeg', - }, - ], +const mediaItem = { + contentType: 'image/jpeg', + attachment: { + fileName: 'foo.jpg', + contentType: 'image/jpeg', + }, }; -; +; ``` #### Missing video ```jsx -const message = { - id: '1', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'video/mp4', - }, - ], +const mediaItem = { + contentType: 'video/mp4', + attachment: { + fileName: 'foo.jpg', + contentType: 'video/mp4', + }, }; -; +; ``` #### Image thumbnail failed to load ```jsx -const message = { - id: '1', +const mediaItem = { thumbnailObjectUrl: 'nonexistent', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'image/jpeg', - }, - ], + contentType: 'image/jpeg', + attachment: { + fileName: 'foo.jpg', + contentType: 'image/jpeg', + }, }; -; +; ``` #### Video thumbnail failed to load ```jsx -const message = { - id: '1', +const mediaItem = { thumbnailObjectUrl: 'nonexistent', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'video/mp4', - }, - ], + contentType: 'video/mp4', + attachment: { + fileName: 'foo.jpg', + contentType: 'video/mp4', + }, }; -; +; ``` #### Other contentType ```jsx -const message = { - id: '1', - attachments: [ - { - fileName: 'foo.jpg', - contentType: 'application/json', - }, - ], +const mediaItem = { + contentType: 'application/json', + attachment: { + fileName: 'foo.jpg', + contentType: 'application/json', + }, }; -; +; ``` diff --git a/ts/components/conversation/media-gallery/MediaGridItem.tsx b/ts/components/conversation/media-gallery/MediaGridItem.tsx index 0051f0c2c9..f8851a349c 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.tsx @@ -5,11 +5,11 @@ import { isImageTypeSupported, isVideoTypeSupported, } from '../../../util/GoogleChrome'; -import { Message } from './types/Message'; import { Localizer } from '../../../types/Util'; +import { MediaItemType } from '../../LightboxGallery'; interface Props { - message: Message; + mediaItem: MediaItemType; onClick?: () => void; i18n: Localizer; } @@ -42,19 +42,16 @@ export class MediaGridItem extends React.Component { } public renderContent() { - const { message, i18n } = this.props; + const { mediaItem, i18n } = this.props; const { imageBroken } = this.state; - const { attachments } = message; + const { attachment, contentType } = mediaItem; - if (!attachments || !attachments.length) { + if (!attachment) { return null; } - const first = attachments[0]; - const { contentType } = first; - if (contentType && isImageTypeSupported(contentType)) { - if (imageBroken || !message.thumbnailObjectUrl) { + if (imageBroken || !mediaItem.thumbnailObjectUrl) { return (
    { {i18n('lightboxImageAlt')} ); } else if (contentType && isVideoTypeSupported(contentType)) { - if (imageBroken || !message.thumbnailObjectUrl) { + if (imageBroken || !mediaItem.thumbnailObjectUrl) { return (
    { {i18n('lightboxImageAlt')}
    diff --git a/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts b/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts new file mode 100644 index 0000000000..8378422665 --- /dev/null +++ b/ts/components/conversation/media-gallery/groupMediaItemsByDate.ts @@ -0,0 +1,159 @@ +import moment from 'moment'; +import { compact, groupBy, sortBy } from 'lodash'; + +import { MediaItemType } from '../../LightboxGallery'; + +// import { missingCaseError } from '../../../util/missingCaseError'; + +type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth'; +type YearMonthSectionType = 'yearMonth'; + +interface GenericSection { + type: T; + mediaItems: Array; +} +type StaticSection = GenericSection; +type YearMonthSection = GenericSection & { + year: number; + month: number; +}; +export type Section = StaticSection | YearMonthSection; +export const groupMediaItemsByDate = ( + timestamp: number, + mediaItems: Array +): Array
    => { + const referenceDateTime = moment.utc(timestamp); + + const sortedMediaItem = sortBy(mediaItems, mediaItem => { + const { message } = mediaItem; + + return -message.received_at; + }); + const messagesWithSection = sortedMediaItem.map( + withSection(referenceDateTime) + ); + const groupedMediaItem = groupBy(messagesWithSection, 'type'); + const yearMonthMediaItem = Object.values( + groupBy(groupedMediaItem.yearMonth, 'order') + ).reverse(); + + return compact([ + toSection(groupedMediaItem.today), + toSection(groupedMediaItem.yesterday), + toSection(groupedMediaItem.thisWeek), + toSection(groupedMediaItem.thisMonth), + ...yearMonthMediaItem.map(toSection), + ]); +}; + +const toSection = ( + messagesWithSection: Array | undefined +): Section | null => { + if (!messagesWithSection || messagesWithSection.length === 0) { + return null; + } + + const firstMediaItemWithSection: MediaItemWithSection = + messagesWithSection[0]; + if (!firstMediaItemWithSection) { + return null; + } + + const mediaItems = messagesWithSection.map( + messageWithSection => messageWithSection.mediaItem + ); + switch (firstMediaItemWithSection.type) { + case 'today': + case 'yesterday': + case 'thisWeek': + case 'thisMonth': + return { + type: firstMediaItemWithSection.type, + mediaItems, + }; + case 'yearMonth': + return { + type: firstMediaItemWithSection.type, + year: firstMediaItemWithSection.year, + month: firstMediaItemWithSection.month, + mediaItems, + }; + default: + // NOTE: Investigate why we get the following error: + // error TS2345: Argument of type 'any' is not assignable to parameter + // of type 'never'. + // return missingCaseError(firstMediaItemWithSection.type); + return null; + } +}; + +interface GenericMediaItemWithSection { + order: number; + type: T; + mediaItem: MediaItemType; +} +type MediaItemWithStaticSection = GenericMediaItemWithSection< + StaticSectionType +>; +type MediaItemWithYearMonthSection = GenericMediaItemWithSection< + YearMonthSectionType +> & { + year: number; + month: number; +}; +type MediaItemWithSection = + | MediaItemWithStaticSection + | MediaItemWithYearMonthSection; + +const withSection = (referenceDateTime: moment.Moment) => ( + mediaItem: MediaItemType +): MediaItemWithSection => { + const today = moment(referenceDateTime).startOf('day'); + const yesterday = moment(referenceDateTime) + .subtract(1, 'day') + .startOf('day'); + const thisWeek = moment(referenceDateTime).startOf('isoWeek'); + const thisMonth = moment(referenceDateTime).startOf('month'); + + const { message } = mediaItem; + const mediaItemReceivedDate = moment.utc(message.received_at); + if (mediaItemReceivedDate.isAfter(today)) { + return { + order: 0, + type: 'today', + mediaItem, + }; + } + if (mediaItemReceivedDate.isAfter(yesterday)) { + return { + order: 1, + type: 'yesterday', + mediaItem, + }; + } + if (mediaItemReceivedDate.isAfter(thisWeek)) { + return { + order: 2, + type: 'thisWeek', + mediaItem, + }; + } + if (mediaItemReceivedDate.isAfter(thisMonth)) { + return { + order: 3, + type: 'thisMonth', + mediaItem, + }; + } + + const month: number = mediaItemReceivedDate.month(); + const year: number = mediaItemReceivedDate.year(); + + return { + order: year * 100 + month, + type: 'yearMonth', + month, + year, + mediaItem, + }; +}; diff --git a/ts/components/conversation/media-gallery/groupMessagesByDate.ts b/ts/components/conversation/media-gallery/groupMessagesByDate.ts deleted file mode 100644 index 23f5c302c2..0000000000 --- a/ts/components/conversation/media-gallery/groupMessagesByDate.ts +++ /dev/null @@ -1,150 +0,0 @@ -import moment from 'moment'; -import { compact, groupBy, sortBy } from 'lodash'; - -import { Message } from './types/Message'; -// import { missingCaseError } from '../../../util/missingCaseError'; - -type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth'; -type YearMonthSectionType = 'yearMonth'; - -interface GenericSection { - type: T; - messages: Array; -} -type StaticSection = GenericSection; -type YearMonthSection = GenericSection & { - year: number; - month: number; -}; -export type Section = StaticSection | YearMonthSection; -export const groupMessagesByDate = ( - timestamp: number, - messages: Array -): Array
    => { - const referenceDateTime = moment.utc(timestamp); - - const sortedMessages = sortBy(messages, message => -message.received_at); - const messagesWithSection = sortedMessages.map( - withSection(referenceDateTime) - ); - const groupedMessages = groupBy(messagesWithSection, 'type'); - const yearMonthMessages = Object.values( - groupBy(groupedMessages.yearMonth, 'order') - ).reverse(); - - return compact([ - toSection(groupedMessages.today), - toSection(groupedMessages.yesterday), - toSection(groupedMessages.thisWeek), - toSection(groupedMessages.thisMonth), - ...yearMonthMessages.map(toSection), - ]); -}; - -const toSection = ( - messagesWithSection: Array | undefined -): Section | null => { - if (!messagesWithSection || messagesWithSection.length === 0) { - return null; - } - - const firstMessageWithSection: MessageWithSection = messagesWithSection[0]; - if (!firstMessageWithSection) { - return null; - } - - const messages = messagesWithSection.map( - messageWithSection => messageWithSection.message - ); - switch (firstMessageWithSection.type) { - case 'today': - case 'yesterday': - case 'thisWeek': - case 'thisMonth': - return { - type: firstMessageWithSection.type, - messages, - }; - case 'yearMonth': - return { - type: firstMessageWithSection.type, - year: firstMessageWithSection.year, - month: firstMessageWithSection.month, - messages, - }; - default: - // NOTE: Investigate why we get the following error: - // error TS2345: Argument of type 'any' is not assignable to parameter - // of type 'never'. - // return missingCaseError(firstMessageWithSection.type); - return null; - } -}; - -interface GenericMessageWithSection { - order: number; - type: T; - message: Message; -} -type MessageWithStaticSection = GenericMessageWithSection; -type MessageWithYearMonthSection = GenericMessageWithSection< - YearMonthSectionType -> & { - year: number; - month: number; -}; -type MessageWithSection = - | MessageWithStaticSection - | MessageWithYearMonthSection; - -const withSection = (referenceDateTime: moment.Moment) => ( - message: Message -): MessageWithSection => { - const today = moment(referenceDateTime).startOf('day'); - const yesterday = moment(referenceDateTime) - .subtract(1, 'day') - .startOf('day'); - const thisWeek = moment(referenceDateTime).startOf('isoWeek'); - const thisMonth = moment(referenceDateTime).startOf('month'); - - const messageReceivedDate = moment.utc(message.received_at); - if (messageReceivedDate.isAfter(today)) { - return { - order: 0, - type: 'today', - message, - }; - } - if (messageReceivedDate.isAfter(yesterday)) { - return { - order: 1, - type: 'yesterday', - message, - }; - } - if (messageReceivedDate.isAfter(thisWeek)) { - return { - order: 2, - type: 'thisWeek', - message, - }; - } - if (messageReceivedDate.isAfter(thisMonth)) { - return { - order: 3, - type: 'thisMonth', - message, - }; - } - - const month: number = messageReceivedDate.month(); - const year: number = messageReceivedDate.year(); - - return { - order: year * 100 + month, - type: 'yearMonth', - month, - year, - message, - }; -}; diff --git a/ts/components/conversation/media-gallery/types/AttachmentType.ts b/ts/components/conversation/media-gallery/types/AttachmentType.ts deleted file mode 100644 index 2e1db7c81a..0000000000 --- a/ts/components/conversation/media-gallery/types/AttachmentType.ts +++ /dev/null @@ -1 +0,0 @@ -export type AttachmentType = 'media' | 'documents'; diff --git a/ts/components/conversation/media-gallery/types/ItemClickEvent.ts b/ts/components/conversation/media-gallery/types/ItemClickEvent.ts index c891cda16f..7bdeae5ca3 100644 --- a/ts/components/conversation/media-gallery/types/ItemClickEvent.ts +++ b/ts/components/conversation/media-gallery/types/ItemClickEvent.ts @@ -1,7 +1,8 @@ -import { AttachmentType } from './AttachmentType'; +import { AttachmentType } from '../../types'; import { Message } from './Message'; export interface ItemClickEvent { message: Message; - type: AttachmentType; + attachment: AttachmentType; + type: 'media' | 'documents'; } diff --git a/ts/components/conversation/media-gallery/types/Message.ts b/ts/components/conversation/media-gallery/types/Message.ts index 4ecfb4d93b..dd773402a7 100644 --- a/ts/components/conversation/media-gallery/types/Message.ts +++ b/ts/components/conversation/media-gallery/types/Message.ts @@ -4,7 +4,4 @@ export type Message = { id: string; attachments: Array; received_at: number; -} & { - thumbnailObjectUrl?: string; - objectURL?: string; }; diff --git a/ts/components/conversation/types.ts b/ts/components/conversation/types.ts new file mode 100644 index 0000000000..4789fb8ecd --- /dev/null +++ b/ts/components/conversation/types.ts @@ -0,0 +1,27 @@ +import { MIMEType } from '../../../ts/types/MIME'; + +export interface AttachmentType { + caption?: string; + contentType: MIMEType; + fileName: string; + /** Not included in protobuf, needs to be pulled from flags */ + isVoiceMessage?: boolean; + /** For messages not already on disk, this will be a data url */ + url: string; + size?: number; + fileSize?: string; + width?: number; + height?: number; + screenshot?: { + height: number; + width: number; + url: string; + contentType: MIMEType; + }; + thumbnail?: { + height: number; + width: number; + url: string; + contentType: MIMEType; + }; +} diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index 96c71e02a3..bcac6ed7d8 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -33,6 +33,11 @@ import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc // 800×1200 const pngObjectUrl = makeObjectUrl(png, 'image/png'); +// @ts-ignore +import landscape from '../../fixtures/koushik-chowdavarapu-105425-unsplash.jpg'; +// 800×1200 +const landscapeObjectUrl = makeObjectUrl(landscape, 'image/png'); + // @ts-ignore import landscapeGreen from '../../fixtures/1000x50-green.jpeg'; const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg'); @@ -68,6 +73,8 @@ export { pngObjectUrl, txt, txtObjectUrl, + landscape, + landscapeObjectUrl, landscapeGreen, landscapeGreenObjectUrl, landscapePurple, diff --git a/ts/test/components/media-gallery/groupMessagesByDate_test.ts b/ts/test/components/media-gallery/groupMessagesByDate_test.ts index 57cb113d94..7e3367161f 100644 --- a/ts/test/components/media-gallery/groupMessagesByDate_test.ts +++ b/ts/test/components/media-gallery/groupMessagesByDate_test.ts @@ -1,87 +1,151 @@ import { assert } from 'chai'; import { shuffle } from 'lodash'; +import { IMAGE_JPEG } from '../../../types/MIME'; import { - groupMessagesByDate, + groupMediaItemsByDate, Section, -} from '../../../components/conversation/media-gallery/groupMessagesByDate'; -import { Message } from '../../../components/conversation/media-gallery/types/Message'; +} from '../../../components/conversation/media-gallery/groupMediaItemsByDate'; +import { MediaItemType } from '../../../components/LightboxGallery'; -const toMessage = (date: Date): Message => ({ - id: date.toUTCString(), - received_at: date.getTime(), - attachments: [], +const toMediaItem = (date: Date): MediaItemType => ({ + objectURL: date.toUTCString(), + index: 0, + message: { + id: 'id', + received_at: date.getTime(), + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }); -describe('groupMessagesByDate', () => { - it('should group messages', () => { +describe('groupMediaItemsByDate', () => { + it('should group mediaItems', () => { const referenceTime = new Date('2018-04-12T18:00Z').getTime(); // Thu - const input: Array = shuffle([ + const input: Array = shuffle([ // Today - toMessage(new Date('2018-04-12T12:00Z')), // Thu - toMessage(new Date('2018-04-12T00:01Z')), // Thu + toMediaItem(new Date('2018-04-12T12:00Z')), // Thu + toMediaItem(new Date('2018-04-12T00:01Z')), // Thu // This week - toMessage(new Date('2018-04-11T23:59Z')), // Wed - toMessage(new Date('2018-04-09T00:01Z')), // Mon + toMediaItem(new Date('2018-04-11T23:59Z')), // Wed + toMediaItem(new Date('2018-04-09T00:01Z')), // Mon // This month - toMessage(new Date('2018-04-08T23:59Z')), // Sun - toMessage(new Date('2018-04-01T00:01Z')), + toMediaItem(new Date('2018-04-08T23:59Z')), // Sun + toMediaItem(new Date('2018-04-01T00:01Z')), // March 2018 - toMessage(new Date('2018-03-31T23:59Z')), - toMessage(new Date('2018-03-01T14:00Z')), + toMediaItem(new Date('2018-03-31T23:59Z')), + toMediaItem(new Date('2018-03-01T14:00Z')), // February 2011 - toMessage(new Date('2011-02-28T23:59Z')), - toMessage(new Date('2011-02-01T10:00Z')), + toMediaItem(new Date('2011-02-28T23:59Z')), + toMediaItem(new Date('2011-02-01T10:00Z')), ]); const expected: Array
    = [ { type: 'today', - messages: [ + mediaItems: [ { - id: 'Thu, 12 Apr 2018 12:00:00 GMT', - received_at: 1523534400000, - attachments: [], + objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1523534400000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, { - id: 'Thu, 12 Apr 2018 00:01:00 GMT', - received_at: 1523491260000, - attachments: [], + objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1523491260000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, ], }, { type: 'yesterday', - messages: [ + mediaItems: [ { - id: 'Wed, 11 Apr 2018 23:59:00 GMT', - received_at: 1523491140000, - attachments: [], + objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1523491140000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, ], }, { type: 'thisWeek', - messages: [ + mediaItems: [ { - id: 'Mon, 09 Apr 2018 00:01:00 GMT', - received_at: 1523232060000, - attachments: [], + objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1523232060000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, ], }, { type: 'thisMonth', - messages: [ + mediaItems: [ { - id: 'Sun, 08 Apr 2018 23:59:00 GMT', - received_at: 1523231940000, - attachments: [], + objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1523231940000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, { - id: 'Sun, 01 Apr 2018 00:01:00 GMT', - received_at: 1522540860000, - attachments: [], + objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1522540860000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, ], }, @@ -89,16 +153,34 @@ describe('groupMessagesByDate', () => { type: 'yearMonth', year: 2018, month: 2, - messages: [ + mediaItems: [ { - id: 'Sat, 31 Mar 2018 23:59:00 GMT', - received_at: 1522540740000, - attachments: [], + objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1522540740000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, { - id: 'Thu, 01 Mar 2018 14:00:00 GMT', - received_at: 1519912800000, - attachments: [], + objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1519912800000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, ], }, @@ -106,22 +188,40 @@ describe('groupMessagesByDate', () => { type: 'yearMonth', year: 2011, month: 1, - messages: [ + mediaItems: [ { - id: 'Mon, 28 Feb 2011 23:59:00 GMT', - received_at: 1298937540000, - attachments: [], + objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1298937540000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, { - id: 'Tue, 01 Feb 2011 10:00:00 GMT', - received_at: 1296554400000, - attachments: [], + objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT', + index: 0, + message: { + id: 'id', + received_at: 1296554400000, + attachments: [], + }, + attachment: { + fileName: 'fileName', + contentType: IMAGE_JPEG, + url: 'url', + }, }, ], }, ]; - const actual = groupMessagesByDate(referenceTime, input); + const actual = groupMediaItemsByDate(referenceTime, input); assert.deepEqual(actual, expected); }); }); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 5c41b5018c..2f6cd5f00e 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -303,7 +303,7 @@ "rule": "jQuery-wrap(", "path": "js/models/messages.js", "line": " this.send(wrap(promise));", - "lineNumber": 791, + "lineNumber": 793, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -311,7 +311,7 @@ "rule": "jQuery-wrap(", "path": "js/models/messages.js", "line": " return wrap(", - "lineNumber": 1000, + "lineNumber": 1002, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -964,7 +964,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));", - "lineNumber": 803, + "lineNumber": 818, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -973,7 +973,7 @@ "rule": "jQuery-insertBefore(", "path": "js/views/conversation_view.js", "line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));", - "lineNumber": 803, + "lineNumber": 818, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -982,7 +982,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " this.$('.bar-container').show();", - "lineNumber": 858, + "lineNumber": 873, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -991,7 +991,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " this.$('.bar-container').hide();", - "lineNumber": 870, + "lineNumber": 885, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -1000,7 +1000,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " const el = this.$(`#${message.id}`);", - "lineNumber": 967, + "lineNumber": 982, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -1009,7 +1009,7 @@ "rule": "jQuery-prepend(", "path": "js/views/conversation_view.js", "line": " this.$el.prepend(dialog.el);", - "lineNumber": 1040, + "lineNumber": 1055, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -1018,7 +1018,7 @@ "rule": "jQuery-appendTo(", "path": "js/views/conversation_view.js", "line": " toast.$el.appendTo(this.$el);", - "lineNumber": 1063, + "lineNumber": 1078, "reasonCategory": "usageTrusted", "updated": "2018-10-11T19:22:47.331Z", "reasonDetail": "Operating on already-existing DOM elements" @@ -1027,7 +1027,7 @@ "rule": "jQuery-prepend(", "path": "js/views/conversation_view.js", "line": " this.$el.prepend(dialog.el);", - "lineNumber": 1091, + "lineNumber": 1106, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -1036,7 +1036,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " view.$el.insertBefore(this.$('.panel').first());", - "lineNumber": 1187, + "lineNumber": 1240, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -1045,7 +1045,7 @@ "rule": "jQuery-insertBefore(", "path": "js/views/conversation_view.js", "line": " view.$el.insertBefore(this.$('.panel').first());", - "lineNumber": 1187, + "lineNumber": 1240, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -1054,7 +1054,7 @@ "rule": "jQuery-prepend(", "path": "js/views/conversation_view.js", "line": " this.$el.prepend(dialog.el);", - "lineNumber": 1265, + "lineNumber": 1318, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -1063,7 +1063,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " this.$('.send').prepend(this.quoteView.el);", - "lineNumber": 1435, + "lineNumber": 1488, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -1072,7 +1072,7 @@ "rule": "jQuery-prepend(", "path": "js/views/conversation_view.js", "line": " this.$('.send').prepend(this.quoteView.el);", - "lineNumber": 1435, + "lineNumber": 1488, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -1081,7 +1081,7 @@ "rule": "jQuery-appendTo(", "path": "js/views/conversation_view.js", "line": " toast.$el.appendTo(this.$el);", - "lineNumber": 1458, + "lineNumber": 1511, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -1090,7 +1090,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " this.$('.bottom-bar form').submit();", - "lineNumber": 1504, + "lineNumber": 1557, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -1099,7 +1099,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " const $attachmentPreviews = this.$('.attachment-previews');", - "lineNumber": 1513, + "lineNumber": 1566, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -1108,7 +1108,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_view.js", "line": " this.$('.panel').css('display') === 'none'", - "lineNumber": 1544, + "lineNumber": 1597, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input"