Media Gallery: Fix Media + Documents Attachment Classification (#2351)

* [x] Introduce schema version 6: Fix media gallery file type classifications:
  * [x] Exclude voice messages from **Documents**.
  * [x] Include all media (images + video), regardless of whether we can
        display it or not.
* [x] Fix lightbox layout for small screens.
* [x] Add support for unsupported file formats in lightbox:
  * [x] Show image icon for unsupported images, e.g. TIFF.
  * [x] Show video icon for unsupported videos, e.g. QuickTime.
  * [x] Show file icon for other unsupported files, e.g. JSON.
* [x] Show all lightbox variants in style guide.
* [x] Don’t show separator for last document list entry
* [x] **Infrastructure:** Port `colorSVG` to CSS-in-JS for React.
This commit is contained in:
Daniel Gasienica 2018-05-08 17:19:12 -04:00 committed by GitHub
commit cb0d60c80d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 455 additions and 110 deletions

View file

@ -25,23 +25,24 @@ const PRIVATE = 'private';
// - Attachments: Write attachment data to disk and store relative path to it.
// Version 4
// - Quotes: Write thumbnail data to disk and store relative path to it.
// Version 5
// Version 5 (deprecated)
// - Attachments: Track number and kind of attachments for media gallery
// - `hasAttachments?: 1 | 0`
// - `hasVisualMediaAttachments?: 1 | undefined` (for media gallery Media view)
// - `hasFileAttachments?: 1 | undefined` (for media gallery Documents view)
// - IMPORTANT: Version 7 changes the classification of visual media and files.
// Therefore version 5 is considered deprecated. For an easier implementation,
// new files have the same classification in version 5 as in version 7.
// Version 6
// - Contact: Write contact avatar to disk, ensure contact data is well-formed
// Version 7 (supersedes attachment classification in version 5)
// - Attachments: Update classification for:
// - `hasVisualMediaAttachments`: Include all images and video regardless of
// whether Chromium can render it or not.
// - `hasFileAttachments`: Exclude voice messages.
const INITIAL_SCHEMA_VERSION = 0;
// Increment this version number every time we add a message schema upgrade
// step. This will allow us to retroactively upgrade existing messages. As we
// add more upgrade steps, we could design a pipeline that does this
// incrementally, e.g. from version 0 / unknown -> 1, 1 --> 2, etc., similar to
// how we do database migrations:
exports.CURRENT_SCHEMA_VERSION = 6;
// Public API
exports.GROUP = GROUP;
exports.PRIVATE = PRIVATE;
@ -212,7 +213,6 @@ exports._mapQuotedAttachments = upgradeAttachment => async (
};
const toVersion0 = async message => exports.initializeSchemaVersion(message);
const toVersion1 = exports._withSchemaVersion(
1,
exports._mapAttachments(Attachment.autoOrientJPEG)
@ -230,13 +230,28 @@ const toVersion4 = exports._withSchemaVersion(
exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem)
);
const toVersion5 = exports._withSchemaVersion(5, initializeAttachmentMetadata);
const toVersion6 = exports._withSchemaVersion(
6,
exports._mapContact(
Contact.parseAndWriteAvatar(Attachment.migrateDataToFileSystem)
)
);
// IMPORTANT: Weve updated our definition of `initializeAttachmentMetadata`, so
// we need to run it again on existing items that have previously been incorrectly
// classified:
const toVersion7 = exports._withSchemaVersion(7, initializeAttachmentMetadata);
const VERSIONS = [
toVersion0,
toVersion1,
toVersion2,
toVersion3,
toVersion4,
toVersion5,
toVersion6,
toVersion7,
];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
// UpgradeStep
exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
@ -245,18 +260,8 @@ exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
}
let message = rawMessage;
const versions = [
toVersion0,
toVersion1,
toVersion2,
toVersion3,
toVersion4,
toVersion5,
toVersion6,
];
for (let i = 0, max = versions.length; i < max; i += 1) {
const currentVersion = versions[i];
// eslint-disable-next-line no-restricted-syntax
for (const currentVersion of VERSIONS) {
// We really do want this intra-loop await because this is a chained async action,
// each step dependent on the previous
// eslint-disable-next-line no-await-in-loop

View file

@ -2,6 +2,7 @@ const { assert } = require('chai');
const sinon = require('sinon');
const Message = require('../../../js/modules/types/message');
const { SignalService } = require('../../../ts/protobuf');
const {
stringToArrayBuffer,
} = require('../../../js/modules/string_to_array_buffer');
@ -242,7 +243,8 @@ describe('Message', () => {
const input = {
attachments: [
{
contentType: 'application/json',
contentType: 'audio/aac',
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('Its easy if you try'),
fileName: 'test\u202Dfig.exe',
size: 1111,
@ -253,7 +255,8 @@ describe('Message', () => {
const expected = {
attachments: [
{
contentType: 'application/json',
contentType: 'audio/aac',
flags: 1,
path: 'abc/abcdefg',
fileName: 'test\uFFFDfig.exe',
size: 1111,
@ -261,7 +264,7 @@ describe('Message', () => {
],
hasAttachments: 1,
hasVisualMediaAttachments: undefined,
hasFileAttachments: 1,
hasFileAttachments: undefined,
schemaVersion: Message.CURRENT_SCHEMA_VERSION,
contact: [],
};

View file

@ -1,3 +1,5 @@
## Image (supported format)
```js
const noop = () => {};
@ -9,3 +11,51 @@ const noop = () => {};
/>
</div>;
```
## Image (unsupported format)
```js
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<Lightbox objectURL="foo.tif" contentType="image/tiff" onSave={noop} />
</div>;
```
## Video (supported format)
```js
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<Lightbox
objectURL="fixtures/pixabay-Soap-Bubble-7141.mp4"
contentType="video/mp4"
onSave={noop}
/>
</div>;
```
## Video (unsupported format)
```js
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<Lightbox objectURL="foo.mov" contentType="video/quicktime" onSave={noop} />
</div>;
```
## Unsupported file format
```js
const noop = () => {};
<div style={{ position: 'relative', width: '100%', height: 600 }}>
<Lightbox
objectURL="tsconfig.json"
contentType="application/json"
onSave={noop}
/>
</div>;
```

View file

@ -3,8 +3,10 @@ import React from 'react';
import classNames from 'classnames';
import is from '@sindresorhus/is';
import * as Colors from './styles/Colors';
import * as GoogleChrome from '../util/GoogleChrome';
import * as MIME from '../types/MIME';
import { colorSVG } from '../styles/colorSVG';
interface Props {
close: () => void;
@ -43,7 +45,7 @@ const styles = {
display: 'inline-flex',
justifyContent: 'center',
} as React.CSSProperties,
image: {
object: {
flexGrow: 1,
flexShrink: 0,
maxWidth: '100%',
@ -110,6 +112,25 @@ const IconButtonPlaceholder = () => (
<div style={styles.iconButtonPlaceholder} />
);
const Icon = ({
onClick,
url,
}: {
onClick?: (
event: React.MouseEvent<HTMLImageElement | HTMLDivElement>
) => void;
url: string;
}) => (
<div
style={{
...styles.object,
...colorSVG(url, Colors.ICON_SECONDARY),
maxWidth: 200,
}}
onClick={onClick}
/>
);
export class Lightbox extends React.Component<Props, {}> {
private containerRef: HTMLDivElement | null = null;
@ -172,29 +193,42 @@ export class Lightbox extends React.Component<Props, {}> {
objectURL: string;
contentType: MIME.MIMEType;
}) => {
const isImage = GoogleChrome.isImageTypeSupported(contentType);
if (isImage) {
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
if (isImageTypeSupported) {
return (
<img
style={styles.image}
style={styles.object}
src={objectURL}
onClick={this.onObjectClick}
/>
);
}
const isVideo = GoogleChrome.isVideoTypeSupported(contentType);
if (isVideo) {
const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
if (isVideoTypeSupported) {
return (
<video controls={true}>
<video controls={true} style={styles.object}>
<source src={objectURL} />
</video>
);
}
const isUnsupportedImageType =
!isImageTypeSupported && MIME.isImage(contentType);
const isUnsupportedVideoType =
!isVideoTypeSupported && MIME.isVideo(contentType);
if (isUnsupportedImageType || isUnsupportedVideoType) {
return (
<Icon
url={isUnsupportedVideoType ? 'images/video.svg' : 'images/image.svg'}
onClick={this.onObjectClick}
/>
);
}
// tslint:disable-next-line no-console
console.log('Lightbox: Unexpected content type', { contentType });
return null;
return <Icon onClick={this.onObjectClick} url="images/file.svg" />;
};
private setContainerRef = (value: HTMLDivElement) => {
@ -242,7 +276,9 @@ export class Lightbox extends React.Component<Props, {}> {
this.onClose();
};
private onObjectClick = (event: React.MouseEvent<HTMLImageElement>) => {
private onObjectClick = (
event: React.MouseEvent<HTMLImageElement | HTMLDivElement>
) => {
event.stopPropagation();
this.onClose();
};

View file

@ -4,51 +4,31 @@ const noop = () => {};
const messages = [
{
objectURL: 'https://placekitten.com/800/600',
attachments: [
{
contentType: 'image/jpeg',
},
],
attachments: [{ contentType: 'image/jpeg' }],
},
{
objectURL: 'https://placekitten.com/900/600',
attachments: [
{
contentType: 'image/jpeg',
},
],
attachments: [{ contentType: 'image/jpeg' }],
},
{
objectURL: 'foo.tif',
attachments: [{ contentType: 'image/tiff' }],
},
{
objectURL: 'https://placekitten.com/980/800',
attachments: [
{
contentType: 'image/jpeg',
},
],
attachments: [{ contentType: 'image/jpeg' }],
},
{
objectURL: 'https://placekitten.com/656/540',
attachments: [
{
contentType: 'image/jpeg',
},
],
attachments: [{ contentType: 'image/jpeg' }],
},
{
objectURL: 'https://placekitten.com/762/400',
attachments: [
{
contentType: 'image/jpeg',
},
],
attachments: [{ contentType: 'image/jpeg' }],
},
{
objectURL: 'https://placekitten.com/920/620',
attachments: [
{
contentType: 'image/jpeg',
},
],
attachments: [{ contentType: 'image/jpeg' }],
},
];

View file

@ -0,0 +1,24 @@
```jsx
const messages = [
{
attachments: [
{
fileName: 'foo.json',
contentType: 'application/json',
size: 53313,
},
],
},
{
attachments: [
{
fileName: 'bar.txt',
contentType: 'text/plain',
size: 10323,
},
],
},
];
<AttachmentSection header="Today" type="documents" messages={messages} />;
```

View file

@ -48,7 +48,8 @@ export class AttachmentSection extends React.Component<Props, {}> {
private renderItems() {
const { i18n, messages, type } = this.props;
return messages.map(message => {
return messages.map((message, index, array) => {
const shouldShowSeparator = index < array.length - 1;
const { attachments } = message;
const firstAttachment = attachments[0];
@ -66,11 +67,12 @@ export class AttachmentSection extends React.Component<Props, {}> {
return (
<DocumentListItem
key={message.id}
i18n={i18n}
fileSize={firstAttachment.size}
fileName={firstAttachment.fileName}
timestamp={message.received_at}
fileSize={firstAttachment.size}
i18n={i18n}
shouldShowSeparator={shouldShowSeparator}
onClick={onClick}
timestamp={message.received_at}
/>
);
default:

View file

@ -1,19 +1,20 @@
DocumentListItem example:
```js
<DocumentListItem
fileName="meow.jpg"
fileSize={1024 * 1000 * 2}
timestamp={Date.now()}
/>
<DocumentListItem
fileName="rickroll.wmv"
fileSize={1024 * 1000 * 8}
timestamp={Date.now() - 24 * 60 * 1000}
/>
<DocumentListItem
fileName="kitten.gif"
fileSize={1024 * 1000 * 1.2}
timestamp={Date.now() - 14 * 24 * 60 * 1000}
/>
```jsx
<div>
<DocumentListItem
fileName="meow.jpg"
fileSize={1024 * 1000 * 2}
timestamp={Date.now()}
/>
<DocumentListItem
fileName="rickroll.wmv"
fileSize={1024 * 1000 * 8}
timestamp={Date.now() - 24 * 60 * 1000}
/>
<DocumentListItem
fileName="kitten.gif"
fileSize={1024 * 1000 * 1.2}
timestamp={Date.now() - 14 * 24 * 60 * 1000}
shouldShowSeparator={false}
/>
</div>
```

View file

@ -4,17 +4,23 @@ import moment from 'moment';
import formatFileSize from 'filesize';
interface Props {
// Required
i18n: (key: string, values?: Array<string>) => string;
timestamp: number;
// Optional
fileName?: string | null;
fileSize?: number;
i18n: (key: string, values?: Array<string>) => string;
onClick?: () => void;
timestamp: number;
shouldShowSeparator?: boolean;
}
const styles = {
container: {
width: '100%',
height: 72,
},
containerSeparator: {
borderBottomWidth: 1,
borderBottomColor: '#ccc',
borderBottomStyle: 'solid',
@ -53,7 +59,25 @@ const styles = {
};
export class DocumentListItem extends React.Component<Props, {}> {
public renderContent() {
public static defaultProps: Partial<Props> = {
shouldShowSeparator: true,
};
public render() {
const { shouldShowSeparator } = this.props;
return (
<div
style={{
...styles.container,
...(shouldShowSeparator ? styles.containerSeparator : {}),
}}
>
{this.renderContent()}
</div>
);
}
private renderContent() {
const { fileName, fileSize, timestamp } = this.props;
return (
@ -76,8 +100,4 @@ export class DocumentListItem extends React.Component<Props, {}> {
</div>
);
}
public render() {
return <div style={styles.container}>{this.renderContent()}</div>;
}
}

View file

@ -1,11 +1,29 @@
### Media Empty State
```js
<div style={{ position: 'relative', width: '100%', height: 300 }}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
height: 300,
}}
>
<EmptyState label="You have no attachments with media" />
</div>
```
### Documents Empty State
```js
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
height: 500,
}}
>
<EmptyState label="You have no documents with media" />
</div>
```

View file

@ -74,3 +74,14 @@ const messages = _.sortBy(
<MediaGallery i18n={window.i18n} media={messages} documents={messages} />;
```
## Media gallery with one document
```jsx
const messages = [
{
attachments: [{ fileName: 'foo.jpg', contentType: 'application/json' }],
},
];
<MediaGallery i18n={window.i18n} media={messages} documents={messages} />;
```

View file

@ -2,3 +2,4 @@
* @prettier
*/
export const TEXT_SECONDARY = '#bbb';
export const ICON_SECONDARY = '#ccc';

7
ts/styles/colorSVG.ts Normal file
View file

@ -0,0 +1,7 @@
export const colorSVG = (url: string, color: string) => {
return {
WebkitMask: `url(${url}) no-repeat center`,
WebkitMaskSize: '100%',
backgroundColor: color,
};
};

View file

@ -59,6 +59,84 @@ describe('Attachment', () => {
});
});
describe('isVisualMedia', () => {
it('should return true for images', () => {
const attachment: Attachment.Attachment = {
fileName: 'meme.gif',
data: stringToArrayBuffer('gif'),
contentType: MIME.IMAGE_GIF,
};
assert.isTrue(Attachment.isVisualMedia(attachment));
});
it('should return true for videos', () => {
const attachment: Attachment.Attachment = {
fileName: 'meme.mp4',
data: stringToArrayBuffer('mp4'),
contentType: MIME.VIDEO_MP4,
};
assert.isTrue(Attachment.isVisualMedia(attachment));
});
it('should return false for voice message attachment', () => {
const attachment: Attachment.Attachment = {
fileName: 'Voice Message.aac',
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('voice message'),
contentType: MIME.AUDIO_AAC,
};
assert.isFalse(Attachment.isVisualMedia(attachment));
});
it('should return false for other attachments', () => {
const attachment: Attachment.Attachment = {
fileName: 'foo.json',
data: stringToArrayBuffer('{"foo": "bar"}'),
contentType: MIME.APPLICATION_JSON,
};
assert.isFalse(Attachment.isVisualMedia(attachment));
});
});
describe('isFile', () => {
it('should return true for JSON', () => {
const attachment: Attachment.Attachment = {
fileName: 'foo.json',
data: stringToArrayBuffer('{"foo": "bar"}'),
contentType: MIME.APPLICATION_JSON,
};
assert.isTrue(Attachment.isFile(attachment));
});
it('should return false for images', () => {
const attachment: Attachment.Attachment = {
fileName: 'meme.gif',
data: stringToArrayBuffer('gif'),
contentType: MIME.IMAGE_GIF,
};
assert.isFalse(Attachment.isFile(attachment));
});
it('should return false for videos', () => {
const attachment: Attachment.Attachment = {
fileName: 'meme.mp4',
data: stringToArrayBuffer('mp4'),
contentType: MIME.VIDEO_MP4,
};
assert.isFalse(Attachment.isFile(attachment));
});
it('should return false for voice message attachment', () => {
const attachment: Attachment.Attachment = {
fileName: 'Voice Message.aac',
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('voice message'),
contentType: MIME.AUDIO_AAC,
};
assert.isFalse(Attachment.isFile(attachment));
});
});
describe('isVoiceMessage', () => {
it('should return true for voice message attachment', () => {
const attachment: Attachment.Attachment = {

View file

@ -3,13 +3,14 @@ import { assert } from 'chai';
import * as Message from '../../../../ts/types/message/initializeAttachmentMetadata';
import { IncomingMessage } from '../../../../ts/types/Message';
import { SignalService } from '../../../../ts/protobuf';
import * as MIME from '../../../../ts/types/MIME';
// @ts-ignore
import { stringToArrayBuffer } from '../../../../js/modules/string_to_array_buffer';
describe('Message', () => {
describe('initializeAttachmentMetadata', () => {
it('should handle visual media attachments', async () => {
it('should classify visual media attachments', async () => {
const input: IncomingMessage = {
type: 'incoming',
conversationId: 'foo',
@ -49,5 +50,89 @@ describe('Message', () => {
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
it('should classify file attachments', async () => {
const input: IncomingMessage = {
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.APPLICATION_OCTET_STREAM,
data: stringToArrayBuffer('foo'),
fileName: 'foo.bin',
size: 1111,
},
],
};
const expected: IncomingMessage = {
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.APPLICATION_OCTET_STREAM,
data: stringToArrayBuffer('foo'),
fileName: 'foo.bin',
size: 1111,
},
],
hasAttachments: 1,
hasVisualMediaAttachments: undefined,
hasFileAttachments: 1,
};
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
it('should classify voice message attachments', async () => {
const input: IncomingMessage = {
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.AUDIO_AAC,
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('foo'),
fileName: 'Voice Message.aac',
size: 1111,
},
],
};
const expected: IncomingMessage = {
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.AUDIO_AAC,
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('foo'),
fileName: 'Voice Message.aac',
size: 1111,
},
],
hasAttachments: 1,
hasVisualMediaAttachments: undefined,
hasFileAttachments: undefined,
};
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
});
});

View file

@ -1,7 +1,6 @@
import is from '@sindresorhus/is';
import moment from 'moment';
import * as GoogleChrome from '../util/GoogleChrome';
import * as MIME from './MIME';
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
import { saveURLAsFile } from '../util/saveURLAsFile';
@ -35,9 +34,29 @@ export const isVisualMedia = (attachment: Attachment): boolean => {
return false;
}
const isSupportedImageType = GoogleChrome.isImageTypeSupported(contentType);
const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType);
return isSupportedImageType || isSupportedVideoType;
if (isVoiceMessage(attachment)) {
return false;
}
return MIME.isImage(contentType) || MIME.isVideo(contentType);
};
export const isFile = (attachment: Attachment): boolean => {
const { contentType } = attachment;
if (is.undefined(contentType)) {
return false;
}
if (isVisualMedia(attachment)) {
return false;
}
if (isVoiceMessage(attachment)) {
return false;
}
return true;
};
export const isVoiceMessage = (attachment: Attachment): boolean => {

View file

@ -1,10 +1,12 @@
export type MIMEType = string & { _mimeTypeBrand: any };
export const APPLICATION_OCTET_STREAM = 'application/octet-stream' as MIMEType;
export const APPLICATION_JSON = 'application/json' as MIMEType;
export const AUDIO_AAC = 'audio/aac' as MIMEType;
export const AUDIO_MP3 = 'audio/mp3' as MIMEType;
export const IMAGE_GIF = 'image/gif' as MIMEType;
export const IMAGE_JPEG = 'image/jpeg' as MIMEType;
export const VIDEO_MP4 = 'video/mp4' as MIMEType;
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';

View file

@ -1,8 +1,14 @@
import { partition } from 'lodash';
import * as Attachment from '../Attachment';
import * as IndexedDB from '../IndexedDB';
import { Message } from '../Message';
import { Message, UserMessage } from '../Message';
const hasAttachment = (
predicate: (value: Attachment.Attachment) => boolean
) => (message: UserMessage): IndexedDB.IndexablePresence =>
IndexedDB.toIndexablePresence(message.attachments.some(predicate));
const hasFileAttachment = hasAttachment(Attachment.isFile);
const hasVisualMediaAttachment = hasAttachment(Attachment.isVisualMedia);
export const initializeAttachmentMetadata = async (
message: Message
@ -14,17 +20,14 @@ export const initializeAttachmentMetadata = async (
const hasAttachments = IndexedDB.toIndexableBoolean(
message.attachments.length > 0
);
const [hasVisualMediaAttachments, hasFileAttachments] = partition(
message.attachments,
Attachment.isVisualMedia
)
.map(attachments => attachments.length > 0)
.map(IndexedDB.toIndexablePresence);
const hasFileAttachments = hasFileAttachment(message);
const hasVisualMediaAttachments = hasVisualMediaAttachment(message);
return {
...message,
hasAttachments,
hasVisualMediaAttachments,
hasFileAttachments,
hasVisualMediaAttachments,
};
};