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":
"Used in the alt tag for the image shown in a full-screen lightbox view"
},
"imageCaptionIconAlt": {
"message": "Icon showing that this image has a caption",
"description":
"Used for the icon layered on top of an image in message bubbles"
},
"fileIconAlt": {
"message": "File icon",
"description":

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

View File

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

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];
promises.push(this.handleAttachment(attachment));
}

View File

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

View File

@ -200,6 +200,8 @@
background-color: $color-conversation-blue_grey;
}
// START
.module-message__attachment-container {
// Entirely to ensure that images are centered if they aren't full width of bubble
text-align: center;
@ -229,97 +231,13 @@
border-top-right-radius: 0px;
}
.module-message__img-border-overlay {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
left: 0;
right: 0;
border-radius: 16px;
box-shadow: inset 0px 0px 0px 1px $color-black-015;
}
.module-message__img-border-overlay--with-content-below {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.module-message__img-border-overlay--with-content-above {
border-top-left-radius: 0px;
border-top-right-radius: 0px;
}
.module-message__img-attachment {
object-fit: cover;
width: 100%;
min-width: 200px;
min-height: 150px;
max-height: 300px;
// The padding on the bottom of the bubble produces three extra pixels of space at the
// bottom, so this doesn't match up with the padding numbers above.
margin-bottom: -3px;
// redundant with attachment-container, but we get cursor flashing on move otherwise
cursor: pointer;
}
.module-message__img-overlay {
height: 48px;
background-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 9%,
rgba(0, 0, 0, 0.02) 17%,
rgba(0, 0, 0, 0.05) 24%,
rgba(0, 0, 0, 0.08) 31%,
rgba(0, 0, 0, 0.12) 37%,
rgba(0, 0, 0, 0.16) 44%,
rgba(0, 0, 0, 0.2) 50%,
rgba(0, 0, 0, 0.24) 56%,
rgba(0, 0, 0, 0.28) 63%,
rgba(0, 0, 0, 0.32) 69%,
rgba(0, 0, 0, 0.35) 76%,
rgba(0, 0, 0, 0.38) 83%,
rgba(0, 0, 0, 0.4) 91%,
rgba(0, 0, 0, 0.4)
);
position: absolute;
bottom: 0;
z-index: 2;
left: 0;
right: 0;
margin-left: -12px;
margin-right: -12px;
margin-bottom: -10px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.module-message__video-overlay__circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background-color: $color-white;
border-radius: 24px;
}
.module-message__video-overlay__play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 36px;
width: 36px;
@include color-svg('../images/play.svg', $color-signal-blue);
}
.module-message__audio-attachment {
margin-top: 2px;
}
@ -583,7 +501,7 @@
.module-message__author-avatar {
position: absolute;
// This accounts for the weird extra 3px we get at the bottom of messages
bottom: -3px;
bottom: 0px;
right: calc(100% + 4px);
}
@ -2101,6 +2019,150 @@
color: $color-gray-90;
}
// Module: Image
.module-image {
overflow: hidden;
background-color: $color-white;
position: relative;
display: inline-block;
margin: 1px;
}
.module-image__caption-icon {
position: absolute;
top: 6px;
left: 6px;
}
.module-image--curved-top-left {
border-top-left-radius: 16px;
}
.module-image--curved-top-right {
border-top-right-radius: 16px;
}
.module-image--curved-bottom-left {
border-bottom-left-radius: 16px;
}
.module-image--curved-bottom-right {
border-bottom-right-radius: 16px;
}
.module-image__border-overlay {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
left: 0;
right: 0;
box-shadow: inset 0px 0px 0px 1px $color-black-015;
}
.module-image__border-overlay--dark {
background-color: $color-black-02;
}
.module-image__image {
object-fit: cover;
// redundant with attachment-container, but we get cursor flashing on move otherwise
cursor: pointer;
margin-bottom: -3px;
}
.module-image__bottom-overlay {
height: 48px;
background-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 9%,
rgba(0, 0, 0, 0.02) 17%,
rgba(0, 0, 0, 0.05) 24%,
rgba(0, 0, 0, 0.08) 31%,
rgba(0, 0, 0, 0.12) 37%,
rgba(0, 0, 0, 0.16) 44%,
rgba(0, 0, 0, 0.2) 50%,
rgba(0, 0, 0, 0.24) 56%,
rgba(0, 0, 0, 0.28) 63%,
rgba(0, 0, 0, 0.32) 69%,
rgba(0, 0, 0, 0.35) 76%,
rgba(0, 0, 0, 0.38) 83%,
rgba(0, 0, 0, 0.4) 91%,
rgba(0, 0, 0, 0.4)
);
position: absolute;
bottom: 0;
z-index: 2;
left: 0;
right: 0;
}
.module-image__play-overlay__circle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background-color: $color-white;
border-radius: 24px;
}
.module-image__play-overlay__icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 36px;
width: 36px;
@include color-svg('../images/play.svg', $color-signal-blue);
}
.module-image__text-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
color: $color-white;
font-size: 20px;
font-weight: normal;
letter-spacing: 0;
text-align: center;
}
// Module: Image Grid
.module-image-grid {
display: inline-flex;
flex-direction: row;
align-items: center;
margin: -1px;
}
.module-image-grid--one-image {
margin-bottom: -5px;
}
.module-image-grid__column {
display: inline-flex;
flex-direction: column;
align-items: center;
}
.module-image-grid__row {
display: inline-flex;
flex-direction: row;
align-items: center;
}
// Third-party module: react-contextmenu
.react-contextmenu {

View File

@ -1,4 +1,4 @@
## Image (supported format)
## Image
```js
const noop = () => {};
@ -13,6 +13,22 @@ const noop = () => {};
</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)
```js

View File

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

View File

@ -1,44 +1,64 @@
```js
const noop = () => {};
const messages = [
const mediaItems = [
{
objectURL: 'https://placekitten.com/799/600',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 1 },
attachment: {
contentType: 'image/jpeg',
caption:
"This is a really long caption. Because the user had a lot to say. You know, it's very important to provide full context when sending an image. You don't want to make the wrong impression.",
},
},
{
objectURL: 'https://placekitten.com/900/600',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 2 },
attachment: { contentType: 'image/jpeg' },
},
// Unsupported image type
{
objectURL: 'foo.tif',
attachments: [{ contentType: 'image/tiff' }],
contentType: 'image/tiff',
message: { id: 3 },
attachment: { contentType: 'image/tiff' },
},
// Video
{
objectURL: util.mp4ObjectUrl,
attachments: [{ contentType: 'video/mp4' }],
contentType: 'video/mp4',
message: { id: 4 },
attachment: { contentType: 'video/mp4' },
},
{
objectURL: 'https://placekitten.com/980/800',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 5 },
attachment: { contentType: 'image/jpeg' },
},
{
objectURL: 'https://placekitten.com/656/540',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 6 },
attachment: { contentType: 'image/jpeg' },
},
{
objectURL: 'https://placekitten.com/762/400',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 7 },
attachment: { contentType: 'image/jpeg' },
},
{
objectURL: 'https://placekitten.com/920/620',
attachments: [{ contentType: 'image/jpeg' }],
contentType: 'image/jpeg',
message: { id: 8 },
attachment: { contentType: 'image/jpeg' },
},
];
<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>;
```

View File

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

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

View File

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

View File

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

View File

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

View File

@ -26,16 +26,17 @@ const createRandomMessage = ({ startTime, timeWindow } = {}) => props => {
fileExtensions
)}`;
return {
id: _.random(now).toString(),
received_at: _.random(startTime, startTime + timeWindow),
attachments: [
{
data: null,
fileName,
size: _.random(1000, 1000 * 1000 * 50),
contentType: 'image/jpeg',
},
],
contentType: 'image/jpeg',
message: {
id: _.random(now).toString(),
received_at: _.random(startTime, startTime + timeWindow),
},
attachment: {
data: null,
fileName,
size: _.random(1000, 1000 * 1000 * 50),
contentType: 'image/jpeg',
},
thumbnailObjectUrl: `https://placekitten.com/${_.random(
50,
@ -81,17 +82,18 @@ const messages = _.sortBy(
## Media gallery with one document
```jsx
const messages = [
const mediaItems = [
{
id: '1',
thumbnailObjectUrl: 'https://placekitten.com/76/67',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
],
contentType: 'image/jpeg',
message: {
id: '1',
},
attachment: {
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
},
];
<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 { AttachmentSection } from './AttachmentSection';
import { AttachmentType } from './types/AttachmentType';
import { EmptyState } from './EmptyState';
import { groupMessagesByDate } from './groupMessagesByDate';
import { groupMediaItemsByDate } from './groupMediaItemsByDate';
import { ItemClickEvent } from './types/ItemClickEvent';
import { Message } from './types/Message';
import { missingCaseError } from '../../../util/missingCaseError';
import { Localizer } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
interface Props {
documents: Array<Message>;
documents: Array<MediaItemType>;
i18n: Localizer;
media: Array<Message>;
media: Array<MediaItemType>;
onItemClick?: (event: ItemClickEvent) => void;
}
interface State {
selectedTab: AttachmentType;
selectedTab: 'media' | 'documents';
}
const MONTH_FORMAT = 'MMMM YYYY';
interface TabSelectEvent {
type: AttachmentType;
type: 'media' | 'documents';
}
const Tab = ({
@ -38,7 +38,7 @@ const Tab = ({
isSelected: boolean;
label: string;
onSelect?: (event: TabSelectEvent) => void;
type: AttachmentType;
type: 'media' | 'documents';
}) => {
const handleClick = onSelect
? () => {
@ -99,10 +99,10 @@ export class MediaGallery extends React.Component<Props, State> {
const { i18n, media, documents, onItemClick } = this.props;
const { selectedTab } = this.state;
const messages = selectedTab === 'media' ? media : documents;
const mediaItems = selectedTab === 'media' ? media : documents;
const type = selectedTab;
if (!messages || messages.length === 0) {
if (!mediaItems || mediaItems.length === 0) {
const label = (() => {
switch (type) {
case 'media':
@ -120,9 +120,10 @@ export class MediaGallery extends React.Component<Props, State> {
}
const now = Date.now();
const sections = groupMessagesByDate(now, messages).map(section => {
const first = section.messages[0];
const date = moment(first.received_at);
const sections = groupMediaItemsByDate(now, mediaItems).map(section => {
const first = section.mediaItems[0];
const { message } = first;
const date = moment(message.received_at);
const header =
section.type === 'yearMonth'
? date.format(MONTH_FORMAT)
@ -134,7 +135,7 @@ export class MediaGallery extends React.Component<Props, State> {
header={header}
i18n={i18n}
type={type}
messages={section.messages}
mediaItems={section.mediaItems}
onItemClick={onItemClick}
/>
);

View File

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

View File

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

View File

@ -4,7 +4,4 @@ export type Message = {
id: string;
attachments: Array<Attachment>;
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
const pngObjectUrl = makeObjectUrl(png, 'image/png');
// @ts-ignore
import landscape from '../../fixtures/koushik-chowdavarapu-105425-unsplash.jpg';
// 800×1200
const landscapeObjectUrl = makeObjectUrl(landscape, 'image/png');
// @ts-ignore
import landscapeGreen from '../../fixtures/1000x50-green.jpeg';
const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg');
@ -68,6 +73,8 @@ export {
pngObjectUrl,
txt,
txtObjectUrl,
landscape,
landscapeObjectUrl,
landscapeGreen,
landscapeGreenObjectUrl,
landscapePurple,

View File

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

View File

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