Support receiving multiple images in one messages
This commit is contained in:
parent
447a217397
commit
99252702e1
33 changed files with 3121 additions and 1237 deletions
|
@ -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
63
images/caption-shadow.svg
Normal 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 |
|
@ -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,
|
||||
}),
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -269,6 +269,7 @@ message AttachmentPointer {
|
|||
optional uint32 flags = 8;
|
||||
optional uint32 width = 9;
|
||||
optional uint32 height = 10;
|
||||
optional string caption = 11;
|
||||
}
|
||||
|
||||
message GroupContext {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>;
|
||||
```
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
122
ts/components/conversation/Image.md
Normal file
122
ts/components/conversation/Image.md
Normal 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>
|
||||
```
|
119
ts/components/conversation/Image.tsx
Normal file
119
ts/components/conversation/Image.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
354
ts/components/conversation/ImageGrid.md
Normal file
354
ts/components/conversation/ImageGrid.md
Normal 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>;
|
||||
```
|
416
ts/components/conversation/ImageGrid.tsx
Normal file
416
ts/components/conversation/ImageGrid.tsx
Normal 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
|
@ -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)}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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}
|
||||
/>;
|
||||
```
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
```
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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} />;
|
||||
```
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export type AttachmentType = 'media' | 'documents';
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -4,7 +4,4 @@ export type Message = {
|
|||
id: string;
|
||||
attachments: Array<Attachment>;
|
||||
received_at: number;
|
||||
} & {
|
||||
thumbnailObjectUrl?: string;
|
||||
objectURL?: string;
|
||||
};
|
||||
|
|
27
ts/components/conversation/types.ts
Normal file
27
ts/components/conversation/types.ts
Normal 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;
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue