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:
commit
cb0d60c80d
18 changed files with 455 additions and 110 deletions
|
@ -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: We’ve 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
|
||||
|
|
|
@ -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('It’s 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: [],
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
```
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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' }],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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} />;
|
||||
```
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
```
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
```
|
||||
|
|
|
@ -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} />;
|
||||
```
|
||||
|
|
|
@ -2,3 +2,4 @@
|
|||
* @prettier
|
||||
*/
|
||||
export const TEXT_SECONDARY = '#bbb';
|
||||
export const ICON_SECONDARY = '#ccc';
|
||||
|
|
7
ts/styles/colorSVG.ts
Normal file
7
ts/styles/colorSVG.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const colorSVG = (url: string, color: string) => {
|
||||
return {
|
||||
WebkitMask: `url(${url}) no-repeat center`,
|
||||
WebkitMaskSize: '100%',
|
||||
backgroundColor: color,
|
||||
};
|
||||
};
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue