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 @@
+
+
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
+ )}
+ >
+

+ {caption ? (
+

+ ) : 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 (
+
+
+
+ );
+ }
+
+ if (attachments.length === 2) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (attachments.length === 3) {
+ return (
+
+ );
+ }
+
+ if (attachments.length === 4) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ 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 (
-
-

-
- {!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 (
-

-
- {!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] ? (