Support receiving multiple images in one messages

This commit is contained in:
Scott Nonnenberg 2018-11-14 10:47:19 -08:00
parent 447a217397
commit 99252702e1
33 changed files with 3121 additions and 1237 deletions

View File

@ -901,6 +901,11 @@
"description": "description":
"Used in the alt tag for the image shown in a full-screen lightbox view" "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": { "fileIconAlt": {
"message": "File icon", "message": "File icon",
"description": "description":

63
images/caption-shadow.svg Normal file
View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 52.3 (67297) - http://www.bohemiancoding.com/sketch -->
<title>caption-shadow-24</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="3" width="18" height="2"></rect>
<filter x="-19.4%" y="-125.0%" width="138.9%" height="450.0%" filterUnits="objectBoundingBox" id="filter-2">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" type="matrix" in="shadowBlurOuter2" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
</feMerge>
</filter>
<rect id="path-3" x="0" y="0" width="18" height="2"></rect>
<filter x="-19.4%" y="-125.0%" width="138.9%" height="450.0%" filterUnits="objectBoundingBox" id="filter-4">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" type="matrix" in="shadowBlurOuter2" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
</feMerge>
</filter>
<rect id="path-5" x="0" y="6" width="12" height="2"></rect>
<filter x="-29.2%" y="-125.0%" width="158.3%" height="450.0%" filterUnits="objectBoundingBox" id="filter-6">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" type="matrix" in="shadowBlurOuter2" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
</feMerge>
</filter>
</defs>
<g id="caption-shadow-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="caption-24" transform="translate(3.000000, 8.000000)">
<g id="Rectangle">
<use fill="black" fill-opacity="1" filter="url(#filter-2)" xlink:href="#path-1"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
</g>
<g id="Rectangle">
<use fill="black" fill-opacity="1" filter="url(#filter-4)" xlink:href="#path-3"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-3"></use>
</g>
<g id="Rectangle">
<use fill="black" fill-opacity="1" filter="url(#filter-6)" xlink:href="#path-5"></use>
<use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-5"></use>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -407,8 +407,8 @@
const conversation = this.getConversation(); const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate(); const isGroup = conversation && !conversation.isPrivate();
const attachments = this.get('attachments'); const attachments = this.get('attachments') || [];
const firstAttachment = attachments && attachments[0]; const firstAttachment = attachments[0];
return { return {
text: this.createNonBreakingLastSeparator(this.get('body')), text: this.createNonBreakingLastSeparator(this.get('body')),
@ -422,7 +422,9 @@
authorProfileName: contact.profileName, authorProfileName: contact.profileName,
authorPhoneNumber: contact.phoneNumber, authorPhoneNumber: contact.phoneNumber,
conversationType: isGroup ? 'group' : 'direct', conversationType: isGroup ? 'group' : 'direct',
attachment: this.getPropsForAttachment(firstAttachment), attachments: attachments.map(attachment =>
this.getPropsForAttachment(attachment)
),
quote: this.getPropsForQuote(), quote: this.getPropsForQuote(),
authorAvatarPath, authorAvatarPath,
isExpired: this.hasExpired, isExpired: this.hasExpired,
@ -432,9 +434,9 @@
onRetrySend: () => this.retrySend(), onRetrySend: () => this.retrySend(),
onShowDetail: () => this.trigger('show-message-detail', this), onShowDetail: () => this.trigger('show-message-detail', this),
onDelete: () => this.trigger('delete', this), onDelete: () => this.trigger('delete', this),
onClickAttachment: () => onClickAttachment: attachment =>
this.trigger('show-lightbox', { this.trigger('show-lightbox', {
attachment: firstAttachment, attachment,
message: this, message: this,
}), }),

View File

@ -664,7 +664,7 @@
MessageCollection: Whisper.MessageCollection, MessageCollection: Whisper.MessageCollection,
} }
); );
const documents = await Signal.Data.getMessagesWithFileAttachments( const rawDocuments = await Signal.Data.getMessagesWithFileAttachments(
conversationId, conversationId,
{ {
limit: DEFAULT_DOCUMENTS_FETCH_COUNT, limit: DEFAULT_DOCUMENTS_FETCH_COUNT,
@ -688,24 +688,39 @@
} }
} }
const media = rawMedia.map(mediaMessage => { const media = _.flatten(
const { attachments } = mediaMessage; rawMedia.map(message => {
const first = attachments && attachments[0]; const { attachments } = message;
const { thumbnail } = first; 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 { return {
...mediaMessage, contentType: attachment.contentType,
thumbnailObjectUrl: thumbnail index: 0,
? getAbsoluteAttachmentPath(thumbnail.path) attachment,
: null, message,
objectURL: getAbsoluteAttachmentPath(
mediaMessage.attachments[0].path
),
}; };
}); });
const saveAttachment = async ({ message } = {}) => { const saveAttachment = async ({ attachment, message } = {}) => {
const attachment = message.attachments[0];
const timestamp = message.received_at; const timestamp = message.received_at;
Signal.Types.Attachment.save({ Signal.Types.Attachment.save({
attachment, attachment,
@ -715,22 +730,22 @@
}); });
}; };
const onItemClick = async ({ message, type }) => { const onItemClick = async ({ message, attachment, type }) => {
switch (type) { switch (type) {
case 'documents': { case 'documents': {
saveAttachment({ message }); saveAttachment({ message, attachment });
break; break;
} }
case 'media': { case 'media': {
const selectedIndex = media.findIndex( const selectedIndex = media.findIndex(
mediaMessage => mediaMessage.id === message.id mediaMessage => mediaMessage.attachment.path === attachment.path
); );
this.lightboxGalleryView = new Whisper.ReactWrapperView({ this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper', className: 'lightbox-wrapper',
Component: Signal.Components.LightboxGallery, Component: Signal.Components.LightboxGallery,
props: { props: {
messages: media, media,
onSave: saveAttachment, onSave: saveAttachment,
selectedIndex, selectedIndex,
}, },
@ -1103,18 +1118,56 @@
return; return;
} }
const props = { const attachments = message.get('attachments') || [];
objectURL: getAbsoluteAttachmentPath(path), if (attachments.length === 1) {
contentType, const props = {
onSave: () => this.downloadAttachment({ attachment, message }), 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', className: 'lightbox-wrapper',
Component: Signal.Components.Lightbox, Component: Signal.Components.LightboxGallery,
props, props,
onClose: () => Signal.Backbone.Views.Lightbox.hide(), onClose: () => Signal.Backbone.Views.Lightbox.hide(),
}); });
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el);
}, },
showMessageDetail(message) { showMessageDetail(message) {

View File

@ -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]; const attachment = decrypted.attachments[i];
promises.push(this.handleAttachment(attachment)); promises.push(this.handleAttachment(attachment));
} }

View File

@ -269,6 +269,7 @@ message AttachmentPointer {
optional uint32 flags = 8; optional uint32 flags = 8;
optional uint32 width = 9; optional uint32 width = 9;
optional uint32 height = 10; optional uint32 height = 10;
optional string caption = 11;
} }
message GroupContext { message GroupContext {

View File

@ -200,6 +200,8 @@
background-color: $color-conversation-blue_grey; background-color: $color-conversation-blue_grey;
} }
// START
.module-message__attachment-container { .module-message__attachment-container {
// Entirely to ensure that images are centered if they aren't full width of bubble // Entirely to ensure that images are centered if they aren't full width of bubble
text-align: center; text-align: center;
@ -229,97 +231,13 @@
border-top-right-radius: 0px; 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 { .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; margin-bottom: -3px;
// redundant with attachment-container, but we get cursor flashing on move otherwise // redundant with attachment-container, but we get cursor flashing on move otherwise
cursor: pointer; 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 { .module-message__audio-attachment {
margin-top: 2px; margin-top: 2px;
} }
@ -583,7 +501,7 @@
.module-message__author-avatar { .module-message__author-avatar {
position: absolute; position: absolute;
// This accounts for the weird extra 3px we get at the bottom of messages // This accounts for the weird extra 3px we get at the bottom of messages
bottom: -3px; bottom: 0px;
right: calc(100% + 4px); right: calc(100% + 4px);
} }
@ -2101,6 +2019,150 @@
color: $color-gray-90; 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 // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

View File

@ -1,4 +1,4 @@
## Image (supported format) ## Image
```js ```js
const noop = () => {}; const noop = () => {};
@ -13,6 +13,22 @@ const noop = () => {};
</div>; </div>;
``` ```
## Image with caption
```js
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<Lightbox
objectURL="https://placekitten.com/800/600"
caption="This is the user-provided caption. We show it overlaid on the image. If it's really long, then it wraps, but it doesn't get too close to the edges of the image."
contentType="image/jpeg"
onSave={noop}
i18n={util.i18n}
/>
</div>;
```
## Image (unsupported format) ## Image (unsupported format)
```js ```js

View File

@ -28,6 +28,7 @@ interface Props {
contentType: MIME.MIMEType | undefined; contentType: MIME.MIMEType | undefined;
i18n: Localizer; i18n: Localizer;
objectURL: string; objectURL: string;
caption?: string;
onNext?: () => void; onNext?: () => void;
onPrevious?: () => void; onPrevious?: () => void;
onSave?: () => void; onSave?: () => void;
@ -57,6 +58,7 @@ const styles = {
paddingBottom: 0, paddingBottom: 0,
} as React.CSSProperties, } as React.CSSProperties,
objectContainer: { objectContainer: {
position: 'relative',
flexGrow: 1, flexGrow: 1,
display: 'inline-flex', display: 'inline-flex',
justifyContent: 'center', justifyContent: 'center',
@ -68,6 +70,18 @@ const styles = {
maxHeight: '100%', maxHeight: '100%',
objectFit: 'contain', objectFit: 'contain',
} as React.CSSProperties, } 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: { controlsOffsetPlaceholder: {
width: CONTROLS_WIDTH, width: CONTROLS_WIDTH,
marginRight: CONTROLS_SPACING, marginRight: CONTROLS_SPACING,
@ -194,6 +208,7 @@ export class Lightbox extends React.Component<Props> {
public render() { public render() {
const { const {
caption,
contentType, contentType,
objectURL, objectURL,
onNext, onNext,
@ -215,6 +230,7 @@ export class Lightbox extends React.Component<Props> {
{!is.undefined(contentType) {!is.undefined(contentType)
? this.renderObject({ objectURL, contentType, i18n }) ? this.renderObject({ objectURL, contentType, i18n })
: null} : null}
{caption ? <div style={styles.caption}>{caption}</div> : null}
</div> </div>
<div style={styles.controls}> <div style={styles.controls}>
<IconButton type="close" onClick={this.onClose} /> <IconButton type="close" onClick={this.onClose} />

View File

@ -1,44 +1,64 @@
```js ```js
const noop = () => {}; const noop = () => {};
const messages = [ const mediaItems = [
{ {
objectURL: 'https://placekitten.com/799/600', 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', objectURL: 'https://placekitten.com/900/600',
attachments: [{ contentType: 'image/jpeg' }], contentType: 'image/jpeg',
message: { id: 2 },
attachment: { contentType: 'image/jpeg' },
}, },
// Unsupported image type // Unsupported image type
{ {
objectURL: 'foo.tif', objectURL: 'foo.tif',
attachments: [{ contentType: 'image/tiff' }], contentType: 'image/tiff',
message: { id: 3 },
attachment: { contentType: 'image/tiff' },
}, },
// Video // Video
{ {
objectURL: util.mp4ObjectUrl, objectURL: util.mp4ObjectUrl,
attachments: [{ contentType: 'video/mp4' }], contentType: 'video/mp4',
message: { id: 4 },
attachment: { contentType: 'video/mp4' },
}, },
{ {
objectURL: 'https://placekitten.com/980/800', 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', 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', 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', objectURL: 'https://placekitten.com/920/620',
attachments: [{ contentType: 'image/jpeg' }], contentType: 'image/jpeg',
message: { id: 8 },
attachment: { contentType: 'image/jpeg' },
}, },
]; ];
<div style={{ position: 'relative', width: '100%', height: 500 }}> <div style={{ position: 'relative', width: '100%', height: 500 }}>
<LightboxGallery messages={messages} onSave={noop} i18n={util.i18n} /> <LightboxGallery media={mediaItems} onSave={noop} i18n={util.i18n} />
</div>; </div>;
``` ```

View File

@ -6,19 +6,26 @@ import React from 'react';
import * as MIME from '../types/MIME'; import * as MIME from '../types/MIME';
import { Lightbox } from './Lightbox'; import { Lightbox } from './Lightbox';
import { Message } from './conversation/media-gallery/types/Message'; import { Message } from './conversation/media-gallery/types/Message';
import { AttachmentType } from './conversation/types';
import { Localizer } from '../types/Util'; import { Localizer } from '../types/Util';
interface Item { export interface MediaItemType {
objectURL?: string; objectURL?: string;
contentType: MIME.MIMEType | undefined; thumbnailObjectUrl?: string;
contentType?: MIME.MIMEType;
index: number;
attachment: AttachmentType;
message: Message;
} }
interface Props { interface Props {
close: () => void; close: () => void;
i18n: Localizer; i18n: Localizer;
messages: Array<Message>; media: Array<MediaItemType>;
onSave?: ({ message }: { message: Message }) => void; onSave?: (
{ attachment, message }: { attachment: AttachmentType; message: Message }
) => void;
selectedIndex: number; selectedIndex: number;
} }
@ -26,11 +33,6 @@ interface State {
selectedIndex: number; selectedIndex: number;
} }
const messageToItem = (message: Message): Item => ({
objectURL: message.objectURL,
contentType: message.attachments[0].contentType,
});
export class LightboxGallery extends React.Component<Props, State> { export class LightboxGallery extends React.Component<Props, State> {
public static defaultProps: Partial<Props> = { public static defaultProps: Partial<Props> = {
selectedIndex: 0, selectedIndex: 0,
@ -45,20 +47,19 @@ export class LightboxGallery extends React.Component<Props, State> {
} }
public render() { public render() {
const { close, messages, onSave, i18n } = this.props; const { close, media, onSave, i18n } = this.props;
const { selectedIndex } = this.state; const { selectedIndex } = this.state;
const selectedMessage: Message = messages[selectedIndex]; const selectedMedia = media[selectedIndex];
const selectedItem = messageToItem(selectedMessage);
const firstIndex = 0; const firstIndex = 0;
const lastIndex = media.length - 1;
const onPrevious = const onPrevious =
selectedIndex > firstIndex ? this.handlePrevious : undefined; selectedIndex > firstIndex ? this.handlePrevious : undefined;
const lastIndex = messages.length - 1;
const onNext = selectedIndex < lastIndex ? this.handleNext : undefined; 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 ( return (
<Lightbox <Lightbox
@ -67,7 +68,8 @@ export class LightboxGallery extends React.Component<Props, State> {
onNext={onNext} onNext={onNext}
onSave={onSave ? this.handleSave : undefined} onSave={onSave ? this.handleSave : undefined}
objectURL={objectURL} objectURL={objectURL}
contentType={selectedItem.contentType} caption={attachment ? attachment.caption : undefined}
contentType={selectedMedia.contentType}
i18n={i18n} i18n={i18n}
/> />
); );
@ -83,19 +85,21 @@ export class LightboxGallery extends React.Component<Props, State> {
this.setState((prevState, props) => ({ this.setState((prevState, props) => ({
selectedIndex: Math.min( selectedIndex: Math.min(
prevState.selectedIndex + 1, prevState.selectedIndex + 1,
props.messages.length - 1 props.media.length - 1
), ),
})); }));
}; };
private handleSave = () => { private handleSave = () => {
const { messages, onSave } = this.props; const { media, onSave } = this.props;
if (!onSave) { if (!onSave) {
return; return;
} }
const { selectedIndex } = this.state; const { selectedIndex } = this.state;
const message = messages[selectedIndex]; const mediaItem = media[selectedIndex];
onSave({ message }); const { attachment, message } = mediaItem;
onSave({ attachment, message });
}; };
} }

View File

@ -0,0 +1,122 @@
### Various sizes
```jsx
<Image height='200' width='199' url={util.pngObjectUrl} />
<Image height='149' width='149' url={util.pngObjectUrl} />
<Image height='99' width='99' url={util.pngObjectUrl} />
```
### Various curved corners
```jsx
<Image height='149' width='149' curveTopLeft url={util.pngObjectUrl} />
<Image height='149' width='149' curveTopRight url={util.pngObjectUrl} />
<Image height='149' width='149' curveBottomLeft url={util.pngObjectUrl} />
<Image height='149' width='149' curveBottomRight url={util.pngObjectUrl} />
```
### With bottom overlay
```jsx
<Image height='149' width='149' bottomOverlay url={util.pngObjectUrl} />
<Image height='149' width='149' bottomOverlay curveBottomRight url={util.pngObjectUrl} />
<Image height='149' width='149' bottomOverlay curveBottomLeft url={util.pngObjectUrl} />
```
### With play icon
```jsx
<Image height='200' width='199' playIconOverlay url={util.pngObjectUrl} />
<Image height='149' width='149' playIconOverlay url={util.pngObjectUrl} />
<Image height='99' width='99' playIconOverlay url={util.pngObjectUrl} />
```
### With dark overlay and text
```jsx
<div>
<div>
<Image height="200" width="199" darkOverlay url={util.pngObjectUrl} />
<Image height="149" width="149" darkOverlay url={util.pngObjectUrl} />
<Image height="99" width="99" darkOverlay url={util.pngObjectUrl} />
</div>
<hr />
<div>
<Image
height="200"
width="199"
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="149"
width="149"
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="99"
width="99"
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
</div>
</div>
```
### With caption
```jsx
<div>
<div>
<Image
height="200"
width="199"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
/>
<Image
height="149"
width="149"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
/>
<Image
height="99"
width="99"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
/>
</div>
<hr />
<div>
<Image
height="200"
width="199"
attachment={{ caption: 'dogs playing' }}
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="149"
width="149"
attachment={{ caption: 'dogs playing' }}
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
<Image
height="99"
width="99"
attachment={{ caption: 'dogs playing' }}
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
/>
</div>
</div>
```

View File

@ -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<Props> {
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 (
<div
onClick={() => {
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
)}
>
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
{caption ? (
<img
className="module-image__caption-icon"
src="images/caption-shadow.svg"
alt={i18n('imageCaptionIconAlt')}
/>
) : null}
<div
className={classNames(
'module-image__border-overlay',
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null
)}
/>
{bottomOverlay ? (
<div
className={classNames(
'module-image__bottom-overlay',
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null
)}
/>
) : null}
{playIconOverlay ? (
<div className="module-image__play-overlay__circle">
<div className="module-image__play-overlay__icon" />
</div>
) : null}
{overlayText ? (
<div
className="module-image__text-container"
style={{ lineHeight: `${height}px` }}
>
{overlayText}
</div>
) : null}
</div>
);
}
}

View File

@ -0,0 +1,354 @@
### One image
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### One image, various aspect ratios
```jsx
<div>
<ImageGrid
attachments={[
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapeObjectUrl,
contentType: 'image/png',
width: 4496,
height: 3000,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapeGreenObjectUrl,
contentType: 'image/png',
width: 1000,
height: 50,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapePurpleObjectUrl,
contentType: 'image/png',
width: 200,
height: 50,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.portraitYellowObjectUrl,
contentType: 'image/png',
width: 20,
height: 200,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.landscapeRedObjectUrl,
contentType: 'image/png',
width: 300,
height: 1,
},
]}
i18n={util.i18n}
/>
<hr />
<ImageGrid
attachments={[
{
url: util.portraitTealObjectUrl,
contentType: 'image/png',
width: 50,
height: 1000,
},
]}
i18n={util.i18n}
/>
</div>
```
### 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,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### 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,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### 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,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### 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,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid
withContentAbove
withContentBelow
attachments={attachments}
i18n={util.i18n}
/>
</div>
</div>;
```
### 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,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid withContentAbove withContentBelow attachments={attachments} i18n={util.i18n} />
</div>
</div>;
```

View File

@ -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<AttachmentType>;
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<Props> {
// 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 (
<div
className={classNames(
'module-image-grid',
'module-image-grid--one-image'
)}
>
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveTopLeft={curveTopLeft}
curveTopRight={curveTopRight}
curveBottomLeft={curveBottomLeft}
curveBottomRight={curveBottomRight}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={height}
width={width}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
);
}
if (attachments.length === 2) {
return (
<div className="module-image-grid">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
attachment={attachments[0]}
bottomOverlay={bottomOverlay && curveBottom}
curveTopLeft={curveTopLeft}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveTopRight={curveTopRight}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
);
}
if (attachments.length === 3) {
return (
<div className="module-image-grid">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveTopLeft={curveTopLeft}
curveBottomLeft={curveBottomLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={200}
width={199}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<div className="module-image-grid__column">
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
curveTopRight={curveTopRight}
height={99}
width={99}
attachment={attachments[1]}
playIconOverlay={isVideoAttachment(attachments[1])}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomRight={curveBottomRight}
height={99}
width={99}
attachment={attachments[2]}
playIconOverlay={isVideoAttachment(attachments[2])}
url={getUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
</div>
);
}
if (attachments.length === 4) {
return (
<div className="module-image-grid">
<div className="module-image-grid__column">
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
curveTopLeft={curveTopLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[2])}
height={149}
width={149}
attachment={attachments[2]}
url={getUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[3])}
height={149}
width={149}
attachment={attachments[3]}
url={getUrl(attachments[3])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
</div>
</div>
);
}
return (
<div className="module-image-grid">
<div className="module-image-grid__column">
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
curveTopLeft={curveTopLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
<div className="module-image-grid__row">
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomLeft={curveBottomLeft}
playIconOverlay={isVideoAttachment(attachments[2])}
height={99}
width={99}
attachment={attachments[2]}
url={getUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
playIconOverlay={isVideoAttachment(attachments[3])}
height={99}
width={98}
attachment={attachments[3]}
url={getUrl(attachments[3])}
onClick={onClickAttachment}
onError={onError}
/>
<Image
alt={getAlt(attachments[4], i18n)}
i18n={i18n}
bottomOverlay={bottomOverlay && curveBottom}
curveBottomRight={curveBottomRight}
playIconOverlay={isVideoAttachment(attachments[4])}
height={99}
width={99}
darkOverlay={attachments.length > 5}
overlayText={
attachments.length > 5
? `+${attachments.length - 5}`
: undefined
}
attachment={attachments[4]}
url={getUrl(attachments[4])}
onClick={onClickAttachment}
onError={onError}
/>
</div>
</div>
</div>
);
}
}
function getUrl(attachment: AttachmentType) {
if (attachment.screenshot) {
return attachment.screenshot.url;
}
return attachment.url;
}
export function isImage(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
isImageTypeSupported(attachments[0].contentType)
);
}
export function hasImage(attachments?: Array<AttachmentType>) {
return attachments && attachments[0] && attachments[0].url;
}
export function isVideo(attachments?: Array<AttachmentType>) {
return attachments && isVideoAttachment(attachments[0]);
}
export function isVideoAttachment(attachment?: AttachmentType) {
return (
attachment &&
attachment.contentType &&
isVideoTypeSupported(attachment.contentType)
);
}
export function hasVideoScreenshot(attachments?: Array<AttachmentType>) {
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<AttachmentType>
): 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');
}

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +1,33 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome';
import { Avatar } from '../Avatar'; import { Avatar } from '../Avatar';
import { MessageBody } from './MessageBody'; import { MessageBody } from './MessageBody';
import { ExpireTimer, getIncrement } from './ExpireTimer'; import { ExpireTimer, getIncrement } from './ExpireTimer';
import {
getGridDimensions,
hasImage,
hasVideoScreenshot,
ImageGrid,
isImage,
isVideo,
} from './ImageGrid';
import { Timestamp } from './Timestamp'; import { Timestamp } from './Timestamp';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Quote, QuotedAttachment } from './Quote'; import { Quote, QuotedAttachmentType } from './Quote';
import { EmbeddedContact } from './EmbeddedContact'; import { EmbeddedContact } from './EmbeddedContact';
import * as MIME from '../../../ts/types/MIME';
import { AttachmentType } from './types';
import { isFileDangerous } from '../../util/isFileDangerous'; import { isFileDangerous } from '../../util/isFileDangerous';
import { Contact } from '../../types/Contact'; import { Contact } from '../../types/Contact';
import { Color, Localizer } from '../../types/Util'; import { Color, Localizer } from '../../types/Util';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import * as MIME from '../../../ts/types/MIME';
interface Trigger { interface Trigger {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void; handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => 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 { export interface Props {
disableMenu?: boolean; disableMenu?: boolean;
text?: string; text?: string;
@ -70,10 +49,10 @@ export interface Props {
authorPhoneNumber: string; authorPhoneNumber: string;
authorColor?: Color; authorColor?: Color;
conversationType: 'group' | 'direct'; conversationType: 'group' | 'direct';
attachment?: Attachment; attachments?: Array<AttachmentType>;
quote?: { quote?: {
text: string; text: string;
attachment?: QuotedAttachment; attachment?: QuotedAttachmentType;
isFromMe: boolean; isFromMe: boolean;
authorPhoneNumber: string; authorPhoneNumber: string;
authorProfileName?: string; authorProfileName?: string;
@ -86,7 +65,7 @@ export interface Props {
isExpired: boolean; isExpired: boolean;
expirationLength?: number; expirationLength?: number;
expirationTimestamp?: number; expirationTimestamp?: number;
onClickAttachment?: () => void; onClickAttachment?: (attachment: AttachmentType) => void;
onReply?: () => void; onReply?: () => void;
onRetrySend?: () => void; onRetrySend?: () => void;
onDownload?: (isDangerous: boolean) => void; onDownload?: (isDangerous: boolean) => void;
@ -100,42 +79,29 @@ interface State {
imageBroken: boolean; imageBroken: boolean;
} }
function isImage(attachment?: Attachment) { function isAudio(attachments?: Array<AttachmentType>) {
return ( return (
attachment && attachments &&
attachment.contentType && attachments[0] &&
isImageTypeSupported(attachment.contentType) attachments[0].contentType &&
MIME.isAudio(attachments[0].contentType)
); );
} }
function hasImage(attachment?: Attachment) { function canDisplayImage(attachments?: Array<AttachmentType>) {
return attachment && attachment.url; const { height, width } =
} attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
function isVideo(attachment?: Attachment) {
return ( return (
attachment && height &&
attachment.contentType && height > 0 &&
isVideoTypeSupported(attachment.contentType) 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({ function getExtension({
fileName, fileName,
contentType, contentType,
@ -159,8 +125,6 @@ function getExtension({
return null; return null;
} }
const MINIMUM_IMG_HEIGHT = 150;
const MAXIMUM_IMG_HEIGHT = 300;
const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600; const EXPIRED_DELAY = 600;
@ -255,7 +219,7 @@ export class Message extends React.Component<Props, State> {
public renderMetadata() { public renderMetadata() {
const { const {
attachment, attachments,
collapseMetadata, collapseMetadata,
direction, direction,
expirationLength, expirationLength,
@ -271,13 +235,13 @@ export class Message extends React.Component<Props, State> {
return null; return null;
} }
const canDisplayAttachment = canDisplayImage(attachment); const canDisplayAttachment = canDisplayImage(attachments);
const withImageNoCaption = Boolean( const withImageNoCaption = Boolean(
!text && !text &&
canDisplayAttachment && canDisplayAttachment &&
!imageBroken && !imageBroken &&
((isImage(attachment) && hasImage(attachment)) || ((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachment) && hasVideoScreenshot(attachment))) (isVideo(attachments) && hasVideoScreenshot(attachments)))
); );
const showError = status === 'error' && direction === 'outgoing'; const showError = status === 'error' && direction === 'outgoing';
@ -368,124 +332,59 @@ export class Message extends React.Component<Props, State> {
// tslint:disable-next-line max-func-body-length cyclomatic-complexity // tslint:disable-next-line max-func-body-length cyclomatic-complexity
public renderAttachment() { public renderAttachment() {
const { const {
i18n, attachments,
attachment,
text, text,
collapseMetadata, collapseMetadata,
conversationType, conversationType,
direction, direction,
i18n,
quote, quote,
onClickAttachment, onClickAttachment,
} = this.props; } = this.props;
const { imageBroken } = this.state; const { imageBroken } = this.state;
if (!attachment) { if (!attachments || !attachments[0]) {
return null; return null;
} }
const firstAttachment = attachments[0];
const withCaption = Boolean(text);
// For attachments which aren't full-frame // For attachments which aren't full-frame
const withContentBelow = withCaption || !collapseMetadata; const withContentBelow = Boolean(text);
const withContentAbove = const withContentAbove =
quote || (conversationType === 'group' && direction === 'incoming'); Boolean(quote) ||
const displayImage = canDisplayImage(attachment); (conversationType === 'group' && direction === 'incoming');
const displayImage = canDisplayImage(attachments);
if (isImage(attachment) && displayImage && !imageBroken && attachment.url) { if (
// Calculating height to prevent reflow when image loads
const imageHeight = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0);
return (
<div
onClick={onClickAttachment}
role="button"
className={classNames(
'module-message__attachment-container',
withCaption
? 'module-message__attachment-container--with-content-below'
: null,
withContentAbove
? 'module-message__attachment-container--with-content-above'
: null
)}
>
<img
onError={this.handleImageErrorBound}
className="module-message__img-attachment"
height={Math.min(MAXIMUM_IMG_HEIGHT, imageHeight)}
src={attachment.url}
alt={i18n('imageAttachmentAlt')}
/>
<div
className={classNames(
'module-message__img-border-overlay',
withCaption
? 'module-message__img-border-overlay--with-content-below'
: null,
withContentAbove
? 'module-message__img-border-overlay--with-content-above'
: null
)}
/>
{!withCaption && !collapseMetadata ? (
<div className="module-message__img-overlay" />
) : null}
</div>
);
} else if (
isVideo(attachment) &&
displayImage && displayImage &&
!imageBroken && !imageBroken &&
attachment.screenshot && ((isImage(attachments) && hasImage(attachments)) ||
attachment.screenshot.url (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 ( return (
<div <div
onClick={onClickAttachment}
role="button"
className={classNames( className={classNames(
'module-message__attachment-container', 'module-message__attachment-container',
withCaption
? 'module-message__attachment-container--with-content-below'
: null,
withContentAbove withContentAbove
? 'module-message__attachment-container--with-content-above' ? 'module-message__attachment-container--with-content-above'
: null,
withContentBelow
? 'module-message__attachment-container--with-content-below'
: null : null
)} )}
> >
<img <ImageGrid
attachments={attachments}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
bottomOverlay={!collapseMetadata}
i18n={i18n}
onError={this.handleImageErrorBound} onError={this.handleImageErrorBound}
className="module-message__img-attachment" onClickAttachment={onClickAttachment}
alt={i18n('videoAttachmentAlt')}
height={Math.min(MAXIMUM_IMG_HEIGHT, imageHeight)}
src={screenshot.url}
/> />
<div
className={classNames(
'module-message__img-border-overlay',
withCaption
? 'module-message__img-border-overlay--with-content-below'
: null,
withContentAbove
? 'module-message__img-border-overlay--with-content-above'
: null
)}
/>
{!withCaption && !collapseMetadata ? (
<div className="module-message__img-overlay" />
) : null}
<div className="module-message__video-overlay__circle">
<div className="module-message__video-overlay__play-icon" />
</div>
</div> </div>
); );
} else if (isAudio(attachment)) { } else if (isAudio(attachments)) {
return ( return (
<audio <audio
controls={true} controls={true}
@ -499,11 +398,11 @@ export class Message extends React.Component<Props, State> {
: null : null
)} )}
> >
<source src={attachment.url} /> <source src={firstAttachment.url} />
</audio> </audio>
); );
} else { } else {
const { fileName, fileSize, contentType } = attachment; const { fileName, fileSize, contentType } = firstAttachment;
const extension = getExtension({ contentType, fileName }); const extension = getExtension({ contentType, fileName });
const isDangerous = isFileDangerous(fileName || ''); const isDangerous = isFileDangerous(fileName || '');
@ -735,7 +634,7 @@ export class Message extends React.Component<Props, State> {
public renderMenu(isCorrectSide: boolean, triggerId: string) { public renderMenu(isCorrectSide: boolean, triggerId: string) {
const { const {
attachment, attachments,
direction, direction,
disableMenu, disableMenu,
onDownload, onDownload,
@ -746,23 +645,26 @@ export class Message extends React.Component<Props, State> {
return null; return null;
} }
const fileName = attachment ? attachment.fileName : null; const fileName =
attachments && attachments[0] ? attachments[0].fileName : null;
const isDangerous = isFileDangerous(fileName || ''); const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
const downloadButton = attachment ? ( const downloadButton =
<div !multipleAttachments && attachments && attachments[0] ? (
onClick={() => { <div
if (onDownload) { onClick={() => {
onDownload(isDangerous); if (onDownload) {
} onDownload(isDangerous);
}} }
role="button" }}
className={classNames( role="button"
'module-message__buttons__download', className={classNames(
`module-message__buttons__download--${direction}` 'module-message__buttons__download',
)} `module-message__buttons__download--${direction}`
/> )}
) : null; />
) : null;
const replyButton = ( const replyButton = (
<div <div
@ -807,7 +709,7 @@ export class Message extends React.Component<Props, State> {
public renderContextMenu(triggerId: string) { public renderContextMenu(triggerId: string) {
const { const {
attachment, attachments,
direction, direction,
status, status,
onDelete, onDelete,
@ -819,12 +721,14 @@ export class Message extends React.Component<Props, State> {
} = this.props; } = this.props;
const showRetry = status === 'error' && direction === 'outgoing'; 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 isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
return ( return (
<ContextMenu id={triggerId}> <ContextMenu id={triggerId}>
{attachment ? ( {!multipleAttachments && attachments && attachments[0] ? (
<MenuItem <MenuItem
attributes={{ attributes={{
className: 'module-message__context__download', className: 'module-message__context__download',
@ -878,13 +782,14 @@ export class Message extends React.Component<Props, State> {
public render() { public render() {
const { const {
attachments,
authorPhoneNumber, authorPhoneNumber,
authorColor, authorColor,
direction, direction,
id, id,
timestamp, timestamp,
} = this.props; } = 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. // This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique. // It needs to be unique.
@ -894,6 +799,16 @@ export class Message extends React.Component<Props, State> {
return null; 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 ( return (
<div <div
className={classNames( className={classNames(
@ -901,6 +816,9 @@ export class Message extends React.Component<Props, State> {
`module-message--${direction}`, `module-message--${direction}`,
expiring ? 'module-message--expired' : null expiring ? 'module-message--expired' : null
)} )}
style={{
width: showingImage ? width : undefined,
}}
> >
{this.renderError(direction === 'incoming')} {this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)} {this.renderMenu(direction === 'outgoing', triggerId)}

View File

@ -11,7 +11,7 @@ import { Color, Localizer } from '../../types/Util';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
interface Props { interface Props {
attachment?: QuotedAttachment; attachment?: QuotedAttachmentType;
authorPhoneNumber: string; authorPhoneNumber: string;
authorProfileName?: string; authorProfileName?: string;
authorName?: string; authorName?: string;
@ -26,7 +26,7 @@ interface Props {
referencedMessageNotFound: boolean; referencedMessageNotFound: boolean;
} }
export interface QuotedAttachment { export interface QuotedAttachmentType {
contentType: MIME.MIMEType; contentType: MIME.MIMEType;
fileName: string; fileName: string;
/** Not included in protobuf */ /** Not included in protobuf */

View File

@ -1,31 +1,33 @@
```jsx ```jsx
const messages = [ const mediaItems = [
{ {
id: '1', index: 0,
attachments: [ message: {
{ id: '1',
fileName: 'foo.json', },
contentType: 'application/json', attachment: {
size: 53313, fileName: 'foo.json',
}, contentType: 'application/json',
], size: 53313,
},
}, },
{ {
id: '2', index: 1,
attachments: [ message: {
{ id: '2',
fileName: 'bar.txt', },
contentType: 'text/plain', attachment: {
size: 10323, fileName: 'bar.txt',
}, contentType: 'text/plain',
], size: 10323,
},
}, },
]; ];
<AttachmentSection <AttachmentSection
header="Today" header="Today"
type="documents" type="documents"
messages={messages} mediaItems={mediaItems}
i18n={util.i18n} i18n={util.i18n}
/>; />;
``` ```

View File

@ -1,18 +1,17 @@
import React from 'react'; import React from 'react';
import { AttachmentType } from './types/AttachmentType';
import { DocumentListItem } from './DocumentListItem'; import { DocumentListItem } from './DocumentListItem';
import { ItemClickEvent } from './types/ItemClickEvent'; import { ItemClickEvent } from './types/ItemClickEvent';
import { MediaGridItem } from './MediaGridItem'; import { MediaGridItem } from './MediaGridItem';
import { Message } from './types/Message'; import { MediaItemType } from '../../LightboxGallery';
import { missingCaseError } from '../../../util/missingCaseError'; import { missingCaseError } from '../../../util/missingCaseError';
import { Localizer } from '../../../types/Util'; import { Localizer } from '../../../types/Util';
interface Props { interface Props {
i18n: Localizer; i18n: Localizer;
header?: string; header?: string;
type: AttachmentType; type: 'media' | 'documents';
messages: Array<Message>; mediaItems: Array<MediaItemType>;
onItemClick?: (event: ItemClickEvent) => void; onItemClick?: (event: ItemClickEvent) => void;
} }
@ -31,20 +30,19 @@ export class AttachmentSection extends React.Component<Props> {
} }
private renderItems() { private renderItems() {
const { i18n, messages, type } = this.props; const { i18n, mediaItems, type } = this.props;
return messages.map((message, index, array) => { return mediaItems.map((mediaItem, position, array) => {
const shouldShowSeparator = index < array.length - 1; const shouldShowSeparator = position < array.length - 1;
const { attachments } = message; const { message, index, attachment } = mediaItem;
const firstAttachment = attachments[0];
const onClick = this.createClickHandler(message); const onClick = this.createClickHandler(mediaItem);
switch (type) { switch (type) {
case 'media': case 'media':
return ( return (
<MediaGridItem <MediaGridItem
key={message.id} key={`${message.id}-${index}`}
message={message} mediaItem={mediaItem}
onClick={onClick} onClick={onClick}
i18n={i18n} i18n={i18n}
/> />
@ -52,9 +50,9 @@ export class AttachmentSection extends React.Component<Props> {
case 'documents': case 'documents':
return ( return (
<DocumentListItem <DocumentListItem
key={message.id} key={`${message.id}-${index}`}
fileName={firstAttachment.fileName} fileName={attachment.fileName}
fileSize={firstAttachment.size} fileSize={attachment.size}
shouldShowSeparator={shouldShowSeparator} shouldShowSeparator={shouldShowSeparator}
onClick={onClick} onClick={onClick}
timestamp={message.received_at} timestamp={message.received_at}
@ -66,12 +64,14 @@ export class AttachmentSection extends React.Component<Props> {
}); });
} }
private createClickHandler = (message: Message) => () => { private createClickHandler = (mediaItem: MediaItemType) => () => {
const { onItemClick, type } = this.props; const { onItemClick, type } = this.props;
const { message, attachment } = mediaItem;
if (!onItemClick) { if (!onItemClick) {
return; return;
} }
onItemClick({ type, message }); onItemClick({ type, message, attachment });
}; };
} }

View File

@ -26,16 +26,17 @@ const createRandomMessage = ({ startTime, timeWindow } = {}) => props => {
fileExtensions fileExtensions
)}`; )}`;
return { return {
id: _.random(now).toString(), contentType: 'image/jpeg',
received_at: _.random(startTime, startTime + timeWindow), message: {
attachments: [ id: _.random(now).toString(),
{ received_at: _.random(startTime, startTime + timeWindow),
data: null, },
fileName, attachment: {
size: _.random(1000, 1000 * 1000 * 50), data: null,
contentType: 'image/jpeg', fileName,
}, size: _.random(1000, 1000 * 1000 * 50),
], contentType: 'image/jpeg',
},
thumbnailObjectUrl: `https://placekitten.com/${_.random( thumbnailObjectUrl: `https://placekitten.com/${_.random(
50, 50,
@ -81,17 +82,18 @@ const messages = _.sortBy(
## Media gallery with one document ## Media gallery with one document
```jsx ```jsx
const messages = [ const mediaItems = [
{ {
id: '1',
thumbnailObjectUrl: 'https://placekitten.com/76/67', thumbnailObjectUrl: 'https://placekitten.com/76/67',
attachments: [ contentType: 'image/jpeg',
{ message: {
fileName: 'foo.jpg', id: '1',
contentType: 'image/jpeg', },
}, attachment: {
], fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
}, },
]; ];
<MediaGallery i18n={util.i18n} media={messages} documents={messages} />; <MediaGallery i18n={util.i18n} media={mediaItems} documents={mediaItems} />;
``` ```

View File

@ -4,29 +4,29 @@ import classNames from 'classnames';
import moment from 'moment'; import moment from 'moment';
import { AttachmentSection } from './AttachmentSection'; import { AttachmentSection } from './AttachmentSection';
import { AttachmentType } from './types/AttachmentType';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { groupMessagesByDate } from './groupMessagesByDate'; import { groupMediaItemsByDate } from './groupMediaItemsByDate';
import { ItemClickEvent } from './types/ItemClickEvent'; import { ItemClickEvent } from './types/ItemClickEvent';
import { Message } from './types/Message';
import { missingCaseError } from '../../../util/missingCaseError'; import { missingCaseError } from '../../../util/missingCaseError';
import { Localizer } from '../../../types/Util'; import { Localizer } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
interface Props { interface Props {
documents: Array<Message>; documents: Array<MediaItemType>;
i18n: Localizer; i18n: Localizer;
media: Array<Message>; media: Array<MediaItemType>;
onItemClick?: (event: ItemClickEvent) => void; onItemClick?: (event: ItemClickEvent) => void;
} }
interface State { interface State {
selectedTab: AttachmentType; selectedTab: 'media' | 'documents';
} }
const MONTH_FORMAT = 'MMMM YYYY'; const MONTH_FORMAT = 'MMMM YYYY';
interface TabSelectEvent { interface TabSelectEvent {
type: AttachmentType; type: 'media' | 'documents';
} }
const Tab = ({ const Tab = ({
@ -38,7 +38,7 @@ const Tab = ({
isSelected: boolean; isSelected: boolean;
label: string; label: string;
onSelect?: (event: TabSelectEvent) => void; onSelect?: (event: TabSelectEvent) => void;
type: AttachmentType; type: 'media' | 'documents';
}) => { }) => {
const handleClick = onSelect const handleClick = onSelect
? () => { ? () => {
@ -99,10 +99,10 @@ export class MediaGallery extends React.Component<Props, State> {
const { i18n, media, documents, onItemClick } = this.props; const { i18n, media, documents, onItemClick } = this.props;
const { selectedTab } = this.state; const { selectedTab } = this.state;
const messages = selectedTab === 'media' ? media : documents; const mediaItems = selectedTab === 'media' ? media : documents;
const type = selectedTab; const type = selectedTab;
if (!messages || messages.length === 0) { if (!mediaItems || mediaItems.length === 0) {
const label = (() => { const label = (() => {
switch (type) { switch (type) {
case 'media': case 'media':
@ -120,9 +120,10 @@ export class MediaGallery extends React.Component<Props, State> {
} }
const now = Date.now(); const now = Date.now();
const sections = groupMessagesByDate(now, messages).map(section => { const sections = groupMediaItemsByDate(now, mediaItems).map(section => {
const first = section.messages[0]; const first = section.mediaItems[0];
const date = moment(first.received_at); const { message } = first;
const date = moment(message.received_at);
const header = const header =
section.type === 'yearMonth' section.type === 'yearMonth'
? date.format(MONTH_FORMAT) ? date.format(MONTH_FORMAT)
@ -134,7 +135,7 @@ export class MediaGallery extends React.Component<Props, State> {
header={header} header={header}
i18n={i18n} i18n={i18n}
type={type} type={type}
messages={section.messages} mediaItems={section.mediaItems}
onItemClick={onItemClick} onItemClick={onItemClick}
/> />
); );

View File

@ -1,108 +1,94 @@
#### With image #### With image
```jsx ```jsx
const message = { const mediaItem = {
id: '1',
thumbnailObjectUrl: 'https://placekitten.com/76/67', thumbnailObjectUrl: 'https://placekitten.com/76/67',
attachments: [ contentType: 'image/jpeg',
{ attachment: {
fileName: 'foo.jpg', fileName: 'foo.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
}, },
],
}; };
<MediaGridItem i18n={util.i18n} message={message} />; <MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
``` ```
#### With video #### With video
```jsx ```jsx
const message = { const mediaItem = {
id: '1',
thumbnailObjectUrl: 'https://placekitten.com/76/67', thumbnailObjectUrl: 'https://placekitten.com/76/67',
attachments: [ contentType: 'video/mp4',
{ attachment: {
fileName: 'foo.jpg', fileName: 'foo.jpg',
contentType: 'video/mp4', contentType: 'video/mp4',
}, },
],
}; };
<MediaGridItem i18n={util.i18n} message={message} />; <MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
``` ```
#### Missing image #### Missing image
```jsx ```jsx
const message = { const mediaItem = {
id: '1', contentType: 'image/jpeg',
attachments: [ attachment: {
{ fileName: 'foo.jpg',
fileName: 'foo.jpg', contentType: 'image/jpeg',
contentType: 'image/jpeg', },
},
],
}; };
<MediaGridItem i18n={util.i18n} message={message} />; <MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
``` ```
#### Missing video #### Missing video
```jsx ```jsx
const message = { const mediaItem = {
id: '1', contentType: 'video/mp4',
attachments: [ attachment: {
{ fileName: 'foo.jpg',
fileName: 'foo.jpg', contentType: 'video/mp4',
contentType: 'video/mp4', },
},
],
}; };
<MediaGridItem i18n={util.i18n} message={message} />; <MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
``` ```
#### Image thumbnail failed to load #### Image thumbnail failed to load
```jsx ```jsx
const message = { const mediaItem = {
id: '1',
thumbnailObjectUrl: 'nonexistent', thumbnailObjectUrl: 'nonexistent',
attachments: [ contentType: 'image/jpeg',
{ attachment: {
fileName: 'foo.jpg', fileName: 'foo.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
}, },
],
}; };
<MediaGridItem i18n={util.i18n} message={message} />; <MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
``` ```
#### Video thumbnail failed to load #### Video thumbnail failed to load
```jsx ```jsx
const message = { const mediaItem = {
id: '1',
thumbnailObjectUrl: 'nonexistent', thumbnailObjectUrl: 'nonexistent',
attachments: [ contentType: 'video/mp4',
{ attachment: {
fileName: 'foo.jpg', fileName: 'foo.jpg',
contentType: 'video/mp4', contentType: 'video/mp4',
}, },
],
}; };
<MediaGridItem i18n={util.i18n} message={message} />; <MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
``` ```
#### Other contentType #### Other contentType
```jsx ```jsx
const message = { const mediaItem = {
id: '1', contentType: 'application/json',
attachments: [ attachment: {
{ fileName: 'foo.jpg',
fileName: 'foo.jpg', contentType: 'application/json',
contentType: 'application/json', },
},
],
}; };
<MediaGridItem i18n={util.i18n} message={message} />; <MediaGridItem i18n={util.i18n} mediaItem={mediaItem} />;
``` ```

View File

@ -5,11 +5,11 @@ import {
isImageTypeSupported, isImageTypeSupported,
isVideoTypeSupported, isVideoTypeSupported,
} from '../../../util/GoogleChrome'; } from '../../../util/GoogleChrome';
import { Message } from './types/Message';
import { Localizer } from '../../../types/Util'; import { Localizer } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
interface Props { interface Props {
message: Message; mediaItem: MediaItemType;
onClick?: () => void; onClick?: () => void;
i18n: Localizer; i18n: Localizer;
} }
@ -42,19 +42,16 @@ export class MediaGridItem extends React.Component<Props, State> {
} }
public renderContent() { public renderContent() {
const { message, i18n } = this.props; const { mediaItem, i18n } = this.props;
const { imageBroken } = this.state; const { imageBroken } = this.state;
const { attachments } = message; const { attachment, contentType } = mediaItem;
if (!attachments || !attachments.length) { if (!attachment) {
return null; return null;
} }
const first = attachments[0];
const { contentType } = first;
if (contentType && isImageTypeSupported(contentType)) { if (contentType && isImageTypeSupported(contentType)) {
if (imageBroken || !message.thumbnailObjectUrl) { if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return ( return (
<div <div
className={classNames( className={classNames(
@ -69,12 +66,12 @@ export class MediaGridItem extends React.Component<Props, State> {
<img <img
alt={i18n('lightboxImageAlt')} alt={i18n('lightboxImageAlt')}
className="module-media-grid-item__image" className="module-media-grid-item__image"
src={message.thumbnailObjectUrl} src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound} onError={this.onImageErrorBound}
/> />
); );
} else if (contentType && isVideoTypeSupported(contentType)) { } else if (contentType && isVideoTypeSupported(contentType)) {
if (imageBroken || !message.thumbnailObjectUrl) { if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return ( return (
<div <div
className={classNames( className={classNames(
@ -90,7 +87,7 @@ export class MediaGridItem extends React.Component<Props, State> {
<img <img
alt={i18n('lightboxImageAlt')} alt={i18n('lightboxImageAlt')}
className="module-media-grid-item__image" className="module-media-grid-item__image"
src={message.thumbnailObjectUrl} src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound} onError={this.onImageErrorBound}
/> />
<div className="module-media-grid-item__circle-overlay"> <div className="module-media-grid-item__circle-overlay">

View File

@ -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<T> {
type: T;
mediaItems: Array<MediaItemType>;
}
type StaticSection = GenericSection<StaticSectionType>;
type YearMonthSection = GenericSection<YearMonthSectionType> & {
year: number;
month: number;
};
export type Section = StaticSection | YearMonthSection;
export const groupMediaItemsByDate = (
timestamp: number,
mediaItems: Array<MediaItemType>
): Array<Section> => {
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<MediaItemWithSection> | 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<T> {
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,
};
};

View File

@ -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<T> {
type: T;
messages: Array<Message>;
}
type StaticSection = GenericSection<StaticSectionType>;
type YearMonthSection = GenericSection<YearMonthSectionType> & {
year: number;
month: number;
};
export type Section = StaticSection | YearMonthSection;
export const groupMessagesByDate = (
timestamp: number,
messages: Array<Message>
): Array<Section> => {
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<MessageWithSection> | 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<T> {
order: number;
type: T;
message: Message;
}
type MessageWithStaticSection = GenericMessageWithSection<StaticSectionType>;
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,
};
};

View File

@ -1 +0,0 @@
export type AttachmentType = 'media' | 'documents';

View File

@ -1,7 +1,8 @@
import { AttachmentType } from './AttachmentType'; import { AttachmentType } from '../../types';
import { Message } from './Message'; import { Message } from './Message';
export interface ItemClickEvent { export interface ItemClickEvent {
message: Message; message: Message;
type: AttachmentType; attachment: AttachmentType;
type: 'media' | 'documents';
} }

View File

@ -4,7 +4,4 @@ export type Message = {
id: string; id: string;
attachments: Array<Attachment>; attachments: Array<Attachment>;
received_at: number; received_at: number;
} & {
thumbnailObjectUrl?: string;
objectURL?: string;
}; };

View File

@ -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;
};
}

View File

@ -33,6 +33,11 @@ import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc
// 800×1200 // 800×1200
const pngObjectUrl = makeObjectUrl(png, 'image/png'); 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 // @ts-ignore
import landscapeGreen from '../../fixtures/1000x50-green.jpeg'; import landscapeGreen from '../../fixtures/1000x50-green.jpeg';
const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg'); const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg');
@ -68,6 +73,8 @@ export {
pngObjectUrl, pngObjectUrl,
txt, txt,
txtObjectUrl, txtObjectUrl,
landscape,
landscapeObjectUrl,
landscapeGreen, landscapeGreen,
landscapeGreenObjectUrl, landscapeGreenObjectUrl,
landscapePurple, landscapePurple,

View File

@ -1,87 +1,151 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { shuffle } from 'lodash'; import { shuffle } from 'lodash';
import { IMAGE_JPEG } from '../../../types/MIME';
import { import {
groupMessagesByDate, groupMediaItemsByDate,
Section, Section,
} from '../../../components/conversation/media-gallery/groupMessagesByDate'; } from '../../../components/conversation/media-gallery/groupMediaItemsByDate';
import { Message } from '../../../components/conversation/media-gallery/types/Message'; import { MediaItemType } from '../../../components/LightboxGallery';
const toMessage = (date: Date): Message => ({ const toMediaItem = (date: Date): MediaItemType => ({
id: date.toUTCString(), objectURL: date.toUTCString(),
received_at: date.getTime(), index: 0,
attachments: [], message: {
id: 'id',
received_at: date.getTime(),
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}); });
describe('groupMessagesByDate', () => { describe('groupMediaItemsByDate', () => {
it('should group messages', () => { it('should group mediaItems', () => {
const referenceTime = new Date('2018-04-12T18:00Z').getTime(); // Thu const referenceTime = new Date('2018-04-12T18:00Z').getTime(); // Thu
const input: Array<Message> = shuffle([ const input: Array<MediaItemType> = shuffle([
// Today // Today
toMessage(new Date('2018-04-12T12:00Z')), // Thu toMediaItem(new Date('2018-04-12T12:00Z')), // Thu
toMessage(new Date('2018-04-12T00:01Z')), // Thu toMediaItem(new Date('2018-04-12T00:01Z')), // Thu
// This week // This week
toMessage(new Date('2018-04-11T23:59Z')), // Wed toMediaItem(new Date('2018-04-11T23:59Z')), // Wed
toMessage(new Date('2018-04-09T00:01Z')), // Mon toMediaItem(new Date('2018-04-09T00:01Z')), // Mon
// This month // This month
toMessage(new Date('2018-04-08T23:59Z')), // Sun toMediaItem(new Date('2018-04-08T23:59Z')), // Sun
toMessage(new Date('2018-04-01T00:01Z')), toMediaItem(new Date('2018-04-01T00:01Z')),
// March 2018 // March 2018
toMessage(new Date('2018-03-31T23:59Z')), toMediaItem(new Date('2018-03-31T23:59Z')),
toMessage(new Date('2018-03-01T14:00Z')), toMediaItem(new Date('2018-03-01T14:00Z')),
// February 2011 // February 2011
toMessage(new Date('2011-02-28T23:59Z')), toMediaItem(new Date('2011-02-28T23:59Z')),
toMessage(new Date('2011-02-01T10:00Z')), toMediaItem(new Date('2011-02-01T10:00Z')),
]); ]);
const expected: Array<Section> = [ const expected: Array<Section> = [
{ {
type: 'today', type: 'today',
messages: [ mediaItems: [
{ {
id: 'Thu, 12 Apr 2018 12:00:00 GMT', objectURL: 'Thu, 12 Apr 2018 12:00:00 GMT',
received_at: 1523534400000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1523534400000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
{ {
id: 'Thu, 12 Apr 2018 00:01:00 GMT', objectURL: 'Thu, 12 Apr 2018 00:01:00 GMT',
received_at: 1523491260000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1523491260000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
], ],
}, },
{ {
type: 'yesterday', type: 'yesterday',
messages: [ mediaItems: [
{ {
id: 'Wed, 11 Apr 2018 23:59:00 GMT', objectURL: 'Wed, 11 Apr 2018 23:59:00 GMT',
received_at: 1523491140000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1523491140000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
], ],
}, },
{ {
type: 'thisWeek', type: 'thisWeek',
messages: [ mediaItems: [
{ {
id: 'Mon, 09 Apr 2018 00:01:00 GMT', objectURL: 'Mon, 09 Apr 2018 00:01:00 GMT',
received_at: 1523232060000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1523232060000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
], ],
}, },
{ {
type: 'thisMonth', type: 'thisMonth',
messages: [ mediaItems: [
{ {
id: 'Sun, 08 Apr 2018 23:59:00 GMT', objectURL: 'Sun, 08 Apr 2018 23:59:00 GMT',
received_at: 1523231940000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1523231940000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
{ {
id: 'Sun, 01 Apr 2018 00:01:00 GMT', objectURL: 'Sun, 01 Apr 2018 00:01:00 GMT',
received_at: 1522540860000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1522540860000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
], ],
}, },
@ -89,16 +153,34 @@ describe('groupMessagesByDate', () => {
type: 'yearMonth', type: 'yearMonth',
year: 2018, year: 2018,
month: 2, month: 2,
messages: [ mediaItems: [
{ {
id: 'Sat, 31 Mar 2018 23:59:00 GMT', objectURL: 'Sat, 31 Mar 2018 23:59:00 GMT',
received_at: 1522540740000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1522540740000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
{ {
id: 'Thu, 01 Mar 2018 14:00:00 GMT', objectURL: 'Thu, 01 Mar 2018 14:00:00 GMT',
received_at: 1519912800000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1519912800000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
], ],
}, },
@ -106,22 +188,40 @@ describe('groupMessagesByDate', () => {
type: 'yearMonth', type: 'yearMonth',
year: 2011, year: 2011,
month: 1, month: 1,
messages: [ mediaItems: [
{ {
id: 'Mon, 28 Feb 2011 23:59:00 GMT', objectURL: 'Mon, 28 Feb 2011 23:59:00 GMT',
received_at: 1298937540000, index: 0,
attachments: [], message: {
id: 'id',
received_at: 1298937540000,
attachments: [],
},
attachment: {
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
},
}, },
{ {
id: 'Tue, 01 Feb 2011 10:00:00 GMT', objectURL: 'Tue, 01 Feb 2011 10:00:00 GMT',
received_at: 1296554400000, index: 0,
attachments: [], 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); assert.deepEqual(actual, expected);
}); });
}); });

View File

@ -303,7 +303,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/models/messages.js", "path": "js/models/messages.js",
"line": " this.send(wrap(promise));", "line": " this.send(wrap(promise));",
"lineNumber": 791, "lineNumber": 793,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
@ -311,7 +311,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/models/messages.js", "path": "js/models/messages.js",
"line": " return wrap(", "line": " return wrap(",
"lineNumber": 1000, "lineNumber": 1002,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
@ -964,7 +964,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));", "line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));",
"lineNumber": 803, "lineNumber": 818,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -973,7 +973,7 @@
"rule": "jQuery-insertBefore(", "rule": "jQuery-insertBefore(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));", "line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));",
"lineNumber": 803, "lineNumber": 818,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -982,7 +982,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bar-container').show();", "line": " this.$('.bar-container').show();",
"lineNumber": 858, "lineNumber": 873,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -991,7 +991,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bar-container').hide();", "line": " this.$('.bar-container').hide();",
"lineNumber": 870, "lineNumber": 885,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -1000,7 +1000,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " const el = this.$(`#${message.id}`);", "line": " const el = this.$(`#${message.id}`);",
"lineNumber": 967, "lineNumber": 982,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -1009,7 +1009,7 @@
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);", "line": " this.$el.prepend(dialog.el);",
"lineNumber": 1040, "lineNumber": 1055,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -1018,7 +1018,7 @@
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 1063, "lineNumber": 1078,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-10-11T19:22:47.331Z", "updated": "2018-10-11T19:22:47.331Z",
"reasonDetail": "Operating on already-existing DOM elements" "reasonDetail": "Operating on already-existing DOM elements"
@ -1027,7 +1027,7 @@
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);", "line": " this.$el.prepend(dialog.el);",
"lineNumber": 1091, "lineNumber": 1106,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -1036,7 +1036,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " view.$el.insertBefore(this.$('.panel').first());", "line": " view.$el.insertBefore(this.$('.panel').first());",
"lineNumber": 1187, "lineNumber": 1240,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -1045,7 +1045,7 @@
"rule": "jQuery-insertBefore(", "rule": "jQuery-insertBefore(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " view.$el.insertBefore(this.$('.panel').first());", "line": " view.$el.insertBefore(this.$('.panel').first());",
"lineNumber": 1187, "lineNumber": 1240,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -1054,7 +1054,7 @@
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);", "line": " this.$el.prepend(dialog.el);",
"lineNumber": 1265, "lineNumber": 1318,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -1063,7 +1063,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send').prepend(this.quoteView.el);", "line": " this.$('.send').prepend(this.quoteView.el);",
"lineNumber": 1435, "lineNumber": 1488,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -1072,7 +1072,7 @@
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.send').prepend(this.quoteView.el);", "line": " this.$('.send').prepend(this.quoteView.el);",
"lineNumber": 1435, "lineNumber": 1488,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -1081,7 +1081,7 @@
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);", "line": " toast.$el.appendTo(this.$el);",
"lineNumber": 1458, "lineNumber": 1511,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "reasonDetail": "Interacting with already-existing DOM nodes"
@ -1090,7 +1090,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').submit();", "line": " this.$('.bottom-bar form').submit();",
"lineNumber": 1504, "lineNumber": 1557,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -1099,7 +1099,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " const $attachmentPreviews = this.$('.attachment-previews');", "line": " const $attachmentPreviews = this.$('.attachment-previews');",
"lineNumber": 1513, "lineNumber": 1566,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"
@ -1108,7 +1108,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/conversation_view.js", "path": "js/views/conversation_view.js",
"line": " this.$('.panel').css('display') === 'none'", "line": " this.$('.panel').css('display') === 'none'",
"lineNumber": 1544, "lineNumber": 1597,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "reasonDetail": "Protected from arbitrary input"