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. // - Attachments: Write attachment data to disk and store relative path to it.
// Version 4 // Version 4
// - Quotes: Write thumbnail data to disk and store relative path to it. // - 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 // - Attachments: Track number and kind of attachments for media gallery
// - `hasAttachments?: 1 | 0` // - `hasAttachments?: 1 | 0`
// - `hasVisualMediaAttachments?: 1 | undefined` (for media gallery Media view) // - `hasVisualMediaAttachments?: 1 | undefined` (for media gallery Media view)
// - `hasFileAttachments?: 1 | undefined` (for media gallery Documents 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 // Version 6
// - Contact: Write contact avatar to disk, ensure contact data is well-formed // - 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; 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 // Public API
exports.GROUP = GROUP; exports.GROUP = GROUP;
exports.PRIVATE = PRIVATE; exports.PRIVATE = PRIVATE;
@ -212,7 +213,6 @@ exports._mapQuotedAttachments = upgradeAttachment => async (
}; };
const toVersion0 = async message => exports.initializeSchemaVersion(message); const toVersion0 = async message => exports.initializeSchemaVersion(message);
const toVersion1 = exports._withSchemaVersion( const toVersion1 = exports._withSchemaVersion(
1, 1,
exports._mapAttachments(Attachment.autoOrientJPEG) exports._mapAttachments(Attachment.autoOrientJPEG)
@ -230,13 +230,28 @@ const toVersion4 = exports._withSchemaVersion(
exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem) exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem)
); );
const toVersion5 = exports._withSchemaVersion(5, initializeAttachmentMetadata); const toVersion5 = exports._withSchemaVersion(5, initializeAttachmentMetadata);
const toVersion6 = exports._withSchemaVersion( const toVersion6 = exports._withSchemaVersion(
6, 6,
exports._mapContact( exports._mapContact(
Contact.parseAndWriteAvatar(Attachment.migrateDataToFileSystem) 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 // UpgradeStep
exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => { exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
@ -245,18 +260,8 @@ exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
} }
let message = rawMessage; let message = rawMessage;
const versions = [ // eslint-disable-next-line no-restricted-syntax
toVersion0, for (const currentVersion of VERSIONS) {
toVersion1,
toVersion2,
toVersion3,
toVersion4,
toVersion5,
toVersion6,
];
for (let i = 0, max = versions.length; i < max; i += 1) {
const currentVersion = versions[i];
// We really do want this intra-loop await because this is a chained async action, // We really do want this intra-loop await because this is a chained async action,
// each step dependent on the previous // each step dependent on the previous
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop

View file

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

View file

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

View file

@ -4,51 +4,31 @@ const noop = () => {};
const messages = [ const messages = [
{ {
objectURL: 'https://placekitten.com/800/600', objectURL: 'https://placekitten.com/800/600',
attachments: [ attachments: [{ contentType: 'image/jpeg' }],
{
contentType: 'image/jpeg',
},
],
}, },
{ {
objectURL: 'https://placekitten.com/900/600', objectURL: 'https://placekitten.com/900/600',
attachments: [ attachments: [{ contentType: 'image/jpeg' }],
{
contentType: 'image/jpeg',
}, },
], {
objectURL: 'foo.tif',
attachments: [{ contentType: 'image/tiff' }],
}, },
{ {
objectURL: 'https://placekitten.com/980/800', objectURL: 'https://placekitten.com/980/800',
attachments: [ attachments: [{ contentType: 'image/jpeg' }],
{
contentType: 'image/jpeg',
},
],
}, },
{ {
objectURL: 'https://placekitten.com/656/540', objectURL: 'https://placekitten.com/656/540',
attachments: [ attachments: [{ contentType: 'image/jpeg' }],
{
contentType: 'image/jpeg',
},
],
}, },
{ {
objectURL: 'https://placekitten.com/762/400', objectURL: 'https://placekitten.com/762/400',
attachments: [ attachments: [{ contentType: 'image/jpeg' }],
{
contentType: 'image/jpeg',
},
],
}, },
{ {
objectURL: 'https://placekitten.com/920/620', objectURL: 'https://placekitten.com/920/620',
attachments: [ attachments: [{ contentType: 'image/jpeg' }],
{
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() { private renderItems() {
const { i18n, messages, type } = this.props; 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 { attachments } = message;
const firstAttachment = attachments[0]; const firstAttachment = attachments[0];
@ -66,11 +67,12 @@ export class AttachmentSection extends React.Component<Props, {}> {
return ( return (
<DocumentListItem <DocumentListItem
key={message.id} key={message.id}
i18n={i18n}
fileSize={firstAttachment.size}
fileName={firstAttachment.fileName} fileName={firstAttachment.fileName}
timestamp={message.received_at} fileSize={firstAttachment.size}
i18n={i18n}
shouldShowSeparator={shouldShowSeparator}
onClick={onClick} onClick={onClick}
timestamp={message.received_at}
/> />
); );
default: default:

View file

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

View file

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

View file

@ -1,11 +1,29 @@
### Media Empty State
```js ```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" /> <EmptyState label="You have no attachments with media" />
</div> </div>
``` ```
### Documents Empty State
```js ```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" /> <EmptyState label="You have no documents with media" />
</div> </div>
``` ```

View file

@ -74,3 +74,14 @@ const messages = _.sortBy(
<MediaGallery i18n={window.i18n} media={messages} documents={messages} />; <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 * @prettier
*/ */
export const TEXT_SECONDARY = '#bbb'; 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', () => { describe('isVoiceMessage', () => {
it('should return true for voice message attachment', () => { it('should return true for voice message attachment', () => {
const attachment: Attachment.Attachment = { const attachment: Attachment.Attachment = {

View file

@ -3,13 +3,14 @@ import { assert } from 'chai';
import * as Message from '../../../../ts/types/message/initializeAttachmentMetadata'; import * as Message from '../../../../ts/types/message/initializeAttachmentMetadata';
import { IncomingMessage } from '../../../../ts/types/Message'; import { IncomingMessage } from '../../../../ts/types/Message';
import { SignalService } from '../../../../ts/protobuf';
import * as MIME from '../../../../ts/types/MIME'; import * as MIME from '../../../../ts/types/MIME';
// @ts-ignore // @ts-ignore
import { stringToArrayBuffer } from '../../../../js/modules/string_to_array_buffer'; import { stringToArrayBuffer } from '../../../../js/modules/string_to_array_buffer';
describe('Message', () => { describe('Message', () => {
describe('initializeAttachmentMetadata', () => { describe('initializeAttachmentMetadata', () => {
it('should handle visual media attachments', async () => { it('should classify visual media attachments', async () => {
const input: IncomingMessage = { const input: IncomingMessage = {
type: 'incoming', type: 'incoming',
conversationId: 'foo', conversationId: 'foo',
@ -49,5 +50,89 @@ describe('Message', () => {
const actual = await Message.initializeAttachmentMetadata(input); const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected); 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 is from '@sindresorhus/is';
import moment from 'moment'; import moment from 'moment';
import * as GoogleChrome from '../util/GoogleChrome';
import * as MIME from './MIME'; import * as MIME from './MIME';
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL'; import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
import { saveURLAsFile } from '../util/saveURLAsFile'; import { saveURLAsFile } from '../util/saveURLAsFile';
@ -35,9 +34,29 @@ export const isVisualMedia = (attachment: Attachment): boolean => {
return false; return false;
} }
const isSupportedImageType = GoogleChrome.isImageTypeSupported(contentType); if (isVoiceMessage(attachment)) {
const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType); return false;
return isSupportedImageType || isSupportedVideoType; }
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 => { export const isVoiceMessage = (attachment: Attachment): boolean => {

View file

@ -1,10 +1,12 @@
export type MIMEType = string & { _mimeTypeBrand: any }; export type MIMEType = string & { _mimeTypeBrand: any };
export const APPLICATION_OCTET_STREAM = 'application/octet-stream' as MIMEType; 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_AAC = 'audio/aac' as MIMEType;
export const AUDIO_MP3 = 'audio/mp3' as MIMEType; export const AUDIO_MP3 = 'audio/mp3' as MIMEType;
export const IMAGE_GIF = 'image/gif' as MIMEType; export const IMAGE_GIF = 'image/gif' as MIMEType;
export const IMAGE_JPEG = 'image/jpeg' 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 VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg'; 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 Attachment from '../Attachment';
import * as IndexedDB from '../IndexedDB'; 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 ( export const initializeAttachmentMetadata = async (
message: Message message: Message
@ -14,17 +20,14 @@ export const initializeAttachmentMetadata = async (
const hasAttachments = IndexedDB.toIndexableBoolean( const hasAttachments = IndexedDB.toIndexableBoolean(
message.attachments.length > 0 message.attachments.length > 0
); );
const [hasVisualMediaAttachments, hasFileAttachments] = partition(
message.attachments, const hasFileAttachments = hasFileAttachment(message);
Attachment.isVisualMedia const hasVisualMediaAttachments = hasVisualMediaAttachment(message);
)
.map(attachments => attachments.length > 0)
.map(IndexedDB.toIndexablePresence);
return { return {
...message, ...message,
hasAttachments, hasAttachments,
hasVisualMediaAttachments,
hasFileAttachments, hasFileAttachments,
hasVisualMediaAttachments,
}; };
}; };