Media Gallery: Phase 2 (MVP) (#2291)

- [x] Render list of document items
- [x] Add support for video in lightbox
- [x] Save attachments:
  - [x] Port the following `AttachmentView` methods to support attachment file
        saving in React:
    - [x] `getFileType`
    - [x] `suggestedName`
    - [x] `saveFile`
  - [x] Add click to save for document list entries
  - [x] Add save button for media attachment in lightbox
- [x] Run background migration based on `schemaIndex` to populate media gallery
- [x] Implement navigation in media gallery
  - [x] Previous and next buttons
  - [x] Previous and next via keyboard
- [x] Empty state
- [x] Fix layout issue in iOS theme
- [x] Don’t run attachment migration for new users
- [x] Preprocess media before rendering in React
This commit is contained in:
Daniel Gasienica 2018-04-30 15:59:13 -04:00 committed by GitHub
commit 2e6f19da8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 839 additions and 243 deletions

View file

@ -23,16 +23,17 @@ ts/**/*.js
!js/logging.js !js/logging.js
!js/models/conversations.js !js/models/conversations.js
!js/models/messages.js !js/models/messages.js
!test/backup_test.js
!js/views/attachment_view.js !js/views/attachment_view.js
!js/views/conversation_view.js
!js/views/conversation_search_view.js
!js/views/backbone_wrapper_view.js !js/views/backbone_wrapper_view.js
!js/views/conversation_search_view.js
!js/views/conversation_view.js
!js/views/debug_log_view.js !js/views/debug_log_view.js
!js/views/file_input_view.js !js/views/file_input_view.js
!js/views/inbox_view.js !js/views/inbox_view.js
!js/views/message_view.js !js/views/message_view.js
!js/views/settings_view.js !js/views/settings_view.js
!test/backup_test.js
!test/views/attachment_view_test.js
!libtextsecure/message_receiver.js !libtextsecure/message_receiver.js
!main.js !main.js
!preload.js !preload.js

View file

@ -326,10 +326,18 @@
"message": "Media", "message": "Media",
"description": "Header of the default pane in the media gallery, showing images and videos" "description": "Header of the default pane in the media gallery, showing images and videos"
}, },
"mediaEmptyState": {
"message": "You dont have any media in this conversation",
"description": "Message shown to user in the media gallery when there are no messages with media attachments (images or video)"
},
"documents": { "documents": {
"message": "Documents", "message": "Documents",
"description": "Header of the secondary pane in the media gallery, showing every non-media attachment" "description": "Header of the secondary pane in the media gallery, showing every non-media attachment"
}, },
"documentsEmptyState": {
"message": "You dont have any documents in this conversation",
"description": "Message shown to user in the media gallery when there are no messages with document attachments (anything other than images or video)"
},
"messageCaption": { "messageCaption": {
"message": "Message caption", "message": "Message caption",
"description": "Prefix of attachment alt tags in the media gallery" "description": "Prefix of attachment alt tags in the media gallery"

View file

@ -114,7 +114,7 @@ exports.createName = () => {
return buffer.toString('hex'); return buffer.toString('hex');
}; };
// getRelativePath :: String -> IO Path // getRelativePath :: String -> Path
exports.getRelativePath = (name) => { exports.getRelativePath = (name) => {
if (!isString(name)) { if (!isString(name)) {
throw new TypeError("'name' must be a string"); throw new TypeError("'name' must be a string");
@ -123,3 +123,7 @@ exports.getRelativePath = (name) => {
const prefix = name.slice(0, 2); const prefix = name.slice(0, 2);
return path.join(prefix, name); return path.join(prefix, name);
}; };
// createAbsolutePathGetter :: RoothPath -> RelativePath -> AbsolutePath
exports.createAbsolutePathGetter = rootPath => relativePath =>
path.join(rootPath, relativePath);

View file

@ -160,8 +160,7 @@
<button class='hamburger' alt='conversation menu'></button> <button class='hamburger' alt='conversation menu'></button>
<ul class='menu-list'> <ul class='menu-list'>
<li class='disappearing-messages'>{{ disappearing-messages }}</li> <li class='disappearing-messages'>{{ disappearing-messages }}</li>
<!-- TODO: Enable once media gallerys ships: --> <li class='view-all-media'>{{ view-all-media }}</li>
<!-- <li class='view-all-media'>{{ view-all-media }}</li> -->
{{#group}} {{#group}}
<li class='show-members'>{{ show-members }}</li> <li class='show-members'>{{ show-members }}</li>
<!-- <li class='update-group'>Update group</li> --> <!-- <li class='update-group'>Update group</li> -->

View file

@ -17,10 +17,7 @@
const { IdleDetector, MessageDataMigrator } = Signal.Workflow; const { IdleDetector, MessageDataMigrator } = Signal.Workflow;
const { Errors, Message } = window.Signal.Types; const { Errors, Message } = window.Signal.Types;
const { upgradeMessageSchema } = window.Signal.Migrations; const { upgradeMessageSchema } = window.Signal.Migrations;
const { const { Migrations0DatabaseWithAttachmentData } = window.Signal.Migrations;
Migrations0DatabaseWithAttachmentData,
Migrations1DatabaseWithoutAttachmentData,
} = window.Signal.Migrations;
const { Views } = window.Signal; const { Views } = window.Signal;
// Implicitly used in `indexeddb-backbonejs-adapter`: // Implicitly used in `indexeddb-backbonejs-adapter`:
@ -90,18 +87,37 @@
storage.fetch(); storage.fetch();
const idleDetector = new IdleDetector(); const idleDetector = new IdleDetector();
let isMigrationWithIndexComplete = false;
let isMigrationWithoutIndexComplete = false;
idleDetector.on('idle', async () => { idleDetector.on('idle', async () => {
const NUM_MESSAGES_PER_BATCH = 1; const NUM_MESSAGES_PER_BATCH = 1;
const database = Migrations0DatabaseWithAttachmentData.getDatabase();
const batch = await MessageDataMigrator.processNextBatchWithoutIndex({
databaseName: database.name,
minDatabaseVersion: database.version,
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
upgradeMessageSchema,
});
console.log('Upgrade message schema:', batch);
if (batch.done) { if (!isMigrationWithIndexComplete) {
const batchWithIndex = await MessageDataMigrator.processNext({
BackboneMessage: Whisper.Message,
BackboneMessageCollection: Whisper.MessageCollection,
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
upgradeMessageSchema,
});
console.log('Upgrade message schema (with index):', batchWithIndex);
isMigrationWithIndexComplete = batchWithIndex.done;
}
if (!isMigrationWithoutIndexComplete) {
const database = Migrations0DatabaseWithAttachmentData.getDatabase();
const batchWithoutIndex = await MessageDataMigrator.processNextBatchWithoutIndex({
databaseName: database.name,
minDatabaseVersion: database.version,
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
upgradeMessageSchema,
});
console.log('Upgrade message schema (without index):', batchWithoutIndex);
isMigrationWithoutIndexComplete = batchWithoutIndex.done;
}
const areAllMigrationsComplete = isMigrationWithIndexComplete &&
isMigrationWithoutIndexComplete;
if (areAllMigrationsComplete) {
idleDetector.stop(); idleDetector.stop();
} }
}); });
@ -117,7 +133,6 @@
first = false; first = false;
ConversationController.load().then(start, start); ConversationController.load().then(start, start);
idleDetector.start();
}); });
Whisper.events.on('shutdown', function() { Whisper.events.on('shutdown', function() {
@ -368,33 +383,48 @@
storage, storage,
}); });
console.log('Sync read receipt configuration status:', status); console.log('Sync read receipt configuration status:', status);
/* eslint-disable */
if (firstRun === true && deviceId != '1') { if (firstRun === true && deviceId !== '1') {
if (!storage.get('theme-setting') && textsecure.storage.get('userAgent') === 'OWI') { const hasThemeSetting = Boolean(storage.get('theme-setting'));
storage.put('theme-setting', 'ios'); if (!hasThemeSetting && textsecure.storage.get('userAgent') === 'OWI') {
onChangeTheme(); storage.put('theme-setting', 'ios');
} onChangeTheme();
var syncRequest = new textsecure.SyncRequest(textsecure.messaging, messageReceiver); }
Whisper.events.trigger('contactsync:begin'); const syncRequest = new textsecure.SyncRequest(
syncRequest.addEventListener('success', function() { textsecure.messaging,
console.log('sync successful'); messageReceiver
storage.put('synced_at', Date.now()); );
Whisper.events.trigger('contactsync'); Whisper.events.trigger('contactsync:begin');
}); syncRequest.addEventListener('success', () => {
syncRequest.addEventListener('timeout', function() { console.log('sync successful');
console.log('sync timed out'); storage.put('synced_at', Date.now());
Whisper.events.trigger('contactsync'); Whisper.events.trigger('contactsync');
}); });
syncRequest.addEventListener('timeout', () => {
console.log('sync timed out');
Whisper.events.trigger('contactsync');
});
if (Whisper.Import.isComplete()) { if (Whisper.Import.isComplete()) {
textsecure.messaging.sendRequestConfigurationSyncMessage().catch(function(e) { textsecure.messaging.sendRequestConfigurationSyncMessage().catch((e) => {
console.log(e); console.log(e);
}); });
} }
}
} }
storage.onready(async () => {
const shouldSkipAttachmentMigrationForNewUsers = firstRun === true;
if (shouldSkipAttachmentMigrationForNewUsers) {
const database = Migrations0DatabaseWithAttachmentData.getDatabase();
const connection =
await Signal.Database.open(database.name, database.version);
await Signal.Settings.markAttachmentMigrationComplete(connection);
}
idleDetector.start();
});
}
/* eslint-disable */
function onChangeTheme() { function onChangeTheme() {
var view = window.owsDesktopApp.appView; var view = window.owsDesktopApp.appView;
if (view) { if (view) {

View file

@ -561,14 +561,17 @@
} }
} }
message.set({ message.set({
schemaVersion: dataMessage.schemaVersion, attachments: dataMessage.attachments,
body: dataMessage.body, body: dataMessage.body,
conversationId: conversation.id, conversationId: conversation.id,
attachments: dataMessage.attachments,
quote: dataMessage.quote,
decrypted_at: now, decrypted_at: now,
flags: dataMessage.flags,
errors: [], errors: [],
flags: dataMessage.flags,
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
quote: dataMessage.quote,
schemaVersion: dataMessage.schemaVersion,
}); });
if (type === 'outgoing') { if (type === 'outgoing') {
const receipts = Whisper.DeliveryReceipts.forMessage(conversation, message); const receipts = Whisper.DeliveryReceipts.forMessage(conversation, message);

View file

@ -1,5 +1,6 @@
const is = require('@sindresorhus/is'); const is = require('@sindresorhus/is');
const AttachmentTS = require('../../../ts/types/Attachment');
const MIME = require('../../../ts/types/MIME'); const MIME = require('../../../ts/types/MIME');
const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util'); const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util');
const { autoOrientImage } = require('../auto_orient_image'); const { autoOrientImage } = require('../auto_orient_image');
@ -163,3 +164,5 @@ exports.deleteData = (deleteAttachmentData) => {
await deleteAttachmentData(attachment.path); await deleteAttachmentData(attachment.path);
}; };
}; };
exports.save = AttachmentTS.save;

View file

@ -2,7 +2,6 @@
/* global _: false */ /* global _: false */
/* global Backbone: false */ /* global Backbone: false */
/* global filesize: false */ /* global filesize: false */
/* global moment: false */
/* global i18n: false */ /* global i18n: false */
/* global Signal: false */ /* global Signal: false */
@ -103,12 +102,6 @@
this.remove(); this.remove();
}, },
getFileType() {
switch (this.model.contentType) {
case 'video/quicktime': return 'mov';
default: return this.model.contentType.split('/')[1];
}
},
onClick() { onClick() {
if (!this.isImage()) { if (!this.isImage()) {
this.saveFile(); this.saveFile();
@ -116,7 +109,8 @@
} }
const props = { const props = {
imageURL: this.objectUrl, objectURL: this.objectUrl,
contentType: this.model.contentType,
onSave: () => this.saveFile(), onSave: () => this.saveFile(),
// implicit: `close` // implicit: `close`
}; };
@ -182,26 +176,13 @@
return i18n('unnamedFile'); return i18n('unnamedFile');
}, },
suggestedName() {
if (this.model.fileName) {
return this.model.fileName;
}
let suggestion = 'signal';
if (this.timestamp) {
suggestion += moment(this.timestamp).format('-YYYY-MM-DD-HHmmss');
}
const fileType = this.getFileType();
if (fileType) {
suggestion += `.${fileType}`;
}
return suggestion;
},
saveFile() { saveFile() {
const url = window.URL.createObjectURL(this.blob, { type: 'octet/stream' }); Signal.Types.Attachment.save({
const a = $('<a>').attr({ href: url, download: this.suggestedName() }); attachment: this.model,
a[0].click(); document,
window.URL.revokeObjectURL(url); getAbsolutePath: Signal.Migrations.getAbsoluteAttachmentPath,
timestamp: this.timestamp,
});
}, },
render() { render() {
if (!this.isImage()) { if (!this.isImage()) {

View file

@ -8,9 +8,9 @@
/* global extension: false */ /* global extension: false */
/* global i18n: false */ /* global i18n: false */
/* global Signal: false */
/* global storage: false */ /* global storage: false */
/* global Whisper: false */ /* global Whisper: false */
/* global Signal: false */
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
(function () { (function () {
@ -282,6 +282,9 @@
if (this.quoteView) { if (this.quoteView) {
this.quoteView.remove(); this.quoteView.remove();
} }
if (this.lightboxGalleryView) {
this.lightboxGalleryView.remove();
}
if (this.panels && this.panels.length) { if (this.panels && this.panels.length) {
for (let i = 0, max = this.panels.length; i < max; i += 1) { for (let i = 0, max = this.panels.length; i < max; i += 1) {
const panel = this.panels[i]; const panel = this.panels[i];
@ -577,33 +580,82 @@
// events up to its parent elements in the DOM. // events up to its parent elements in the DOM.
this.closeMenu(); this.closeMenu();
const media = await Signal.Backbone.Conversation.fetchVisualMediaAttachments({ // We fetch more documents than media as they dont require to be loaded
conversationId: this.model.get('id'), // into memory right away. Revisit this once we have infinite scrolling:
WhisperMessageCollection: Whisper.MessageCollection, const DEFAULT_MEDIA_FETCH_COUNT = 50;
}); const DEFAULT_DOCUMENTS_FETCH_COUNT = 150;
const loadMessages = Signal.Components.PropTypes.Message
.loadWithObjectURL(Signal.Migrations.loadMessage);
const mediaWithObjectURLs = await loadMessages(media);
const mediaGalleryProps = { const conversationId = this.model.get('id');
media: mediaWithObjectURLs, const WhisperMessageCollection = Whisper.MessageCollection;
documents: [], const rawMedia = await Signal.Backbone.Conversation.fetchVisualMediaAttachments({
onItemClick: ({ message }) => { conversationId,
const lightboxProps = { count: DEFAULT_MEDIA_FETCH_COUNT,
imageURL: message.objectURL, WhisperMessageCollection,
}; });
this.lightboxView = new Whisper.ReactWrapperView({ const documents = await Signal.Backbone.Conversation.fetchFileAttachments({
Component: Signal.Components.Lightbox, conversationId,
props: lightboxProps, count: DEFAULT_DOCUMENTS_FETCH_COUNT,
onClose: () => Signal.Backbone.Views.Lightbox.hide(), WhisperMessageCollection,
}); });
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
}, // NOTE: Could we show grid previews from disk as well?
const loadMessages = Signal.Components.Types.Message
.loadWithObjectURL(Signal.Migrations.loadMessage);
const media = await loadMessages(rawMedia);
const { getAbsoluteAttachmentPath } = Signal.Migrations;
const saveAttachment = async ({ message } = {}) => {
const attachment = message.attachments[0];
const timestamp = message.received_at;
Signal.Types.Attachment.save({
attachment,
document,
getAbsolutePath: getAbsoluteAttachmentPath,
timestamp,
});
};
const onItemClick = async ({ message, type }) => {
switch (type) {
case 'documents': {
saveAttachment({ message });
break;
}
case 'media': {
const mediaWithObjectURL = media.map(mediaMessage =>
Object.assign(
{},
mediaMessage,
{ objectURL: getAbsoluteAttachmentPath(mediaMessage.attachments[0].path) }
));
const selectedIndex = media.findIndex(mediaMessage =>
mediaMessage.id === message.id);
this.lightboxGalleryView = new Whisper.ReactWrapperView({
Component: Signal.Components.LightboxGallery,
props: {
messages: mediaWithObjectURL,
onSave: () => saveAttachment({ message }),
selectedIndex,
},
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el);
break;
}
default:
throw new TypeError(`Unknown attachment type: '${type}'`);
}
}; };
const view = new Whisper.ReactWrapperView({ const view = new Whisper.ReactWrapperView({
Component: Signal.Components.MediaGallery, Component: Signal.Components.MediaGallery,
props: mediaGalleryProps, props: {
documents,
media,
onItemClick,
},
onClose: () => this.resetPanel(), onClose: () => this.resetPanel(),
}); });

View file

@ -136,6 +136,7 @@ window.moment.locale(locale);
// ES2015+ modules // ES2015+ modules
const attachmentsPath = Attachments.getPath(app.getPath('userData')); const attachmentsPath = Attachments.getPath(app.getPath('userData'));
const getAbsoluteAttachmentPath = Attachments.createAbsolutePathGetter(attachmentsPath);
const deleteAttachmentData = Attachments.createDeleter(attachmentsPath); const deleteAttachmentData = Attachments.createDeleter(attachmentsPath);
const readAttachmentData = Attachments.createReader(attachmentsPath); const readAttachmentData = Attachments.createReader(attachmentsPath);
const writeNewAttachmentData = Attachments.createWriterForNew(attachmentsPath); const writeNewAttachmentData = Attachments.createWriterForNew(attachmentsPath);
@ -165,18 +166,20 @@ window.Signal.Logs = require('./js/modules/logs');
// React components // React components
const { Lightbox } = require('./ts/components/Lightbox'); const { Lightbox } = require('./ts/components/Lightbox');
const { LightboxGallery } = require('./ts/components/LightboxGallery');
const { MediaGallery } = const { MediaGallery } =
require('./ts/components/conversation/media-gallery/MediaGallery'); require('./ts/components/conversation/media-gallery/MediaGallery');
const { Quote } = require('./ts/components/conversation/Quote'); const { Quote } = require('./ts/components/conversation/Quote');
const PropTypesMessage = const MediaGalleryMessage =
require('./ts/components/conversation/media-gallery/propTypes/Message'); require('./ts/components/conversation/media-gallery/types/Message');
window.Signal.Components = { window.Signal.Components = {
Lightbox, Lightbox,
LightboxGallery,
MediaGallery, MediaGallery,
PropTypes: { Types: {
Message: PropTypesMessage, Message: MediaGalleryMessage,
}, },
Quote, Quote,
}; };
@ -187,6 +190,7 @@ window.Signal.Migrations.deleteAttachmentData =
window.Signal.Migrations.getPlaceholderMigrations = getPlaceholderMigrations; window.Signal.Migrations.getPlaceholderMigrations = getPlaceholderMigrations;
window.Signal.Migrations.writeMessageAttachments = window.Signal.Migrations.writeMessageAttachments =
Message.createAttachmentDataWriter(writeExistingAttachmentData); Message.createAttachmentDataWriter(writeExistingAttachmentData);
window.Signal.Migrations.getAbsoluteAttachmentPath = getAbsoluteAttachmentPath;
window.Signal.Migrations.loadAttachmentData = loadAttachmentData; window.Signal.Migrations.loadAttachmentData = loadAttachmentData;
window.Signal.Migrations.loadMessage = Message.createAttachmentLoader(loadAttachmentData); window.Signal.Migrations.loadMessage = Message.createAttachmentLoader(loadAttachmentData);
window.Signal.Migrations.Migrations0DatabaseWithAttachmentData = window.Signal.Migrations.Migrations0DatabaseWithAttachmentData =

View file

@ -113,7 +113,6 @@ module.exports = {
{ {
src: 'js/expiring_messages.js', src: 'js/expiring_messages.js',
}, },
{ {
src: 'js/chromium.js', src: 'js/chromium.js',
}, },

View file

@ -78,10 +78,13 @@
} }
} }
.panel { .panel,
.react-wrapper {
height: calc(100% - #{$header-height}); height: calc(100% - #{$header-height});
overflow-y: scroll; overflow-y: scroll;
}
.panel {
.container { .container {
padding-top: 20px; padding-top: 20px;
max-width: 750px; max-width: 750px;
@ -89,11 +92,15 @@
padding: 20px; padding: 20px;
} }
} }
.main.panel {
.main.panel,
.react-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: initial; overflow: initial;
}
.main.panel {
.discussion-container { .discussion-container {
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;

View file

@ -80,7 +80,8 @@ $ios-border-color: rgba(0,0,0,0.1);
.avatar { display: none; } .avatar { display: none; }
} }
.conversation .panel { .conversation .panel,
.conversation .react-wrapper {
position: absolute; position: absolute;
top: $header-height; top: $header-height;
bottom: 0; bottom: 0;

View file

@ -19,7 +19,6 @@
background: transparent; background: transparent;
width: 50px; width: 50px;
height: 50px; height: 50px;
margin-bottom: 10px;
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;

View file

@ -6,6 +6,10 @@ module.exports = {
browser: true, browser: true,
}, },
globals: {
assert: true
},
parserOptions: { parserOptions: {
sourceType: 'script', sourceType: 'script',
}, },

View file

@ -1,59 +1,44 @@
describe('AttachmentView', function() { /* global assert: false */
describe('with arbitrary files', function() { /* global Whisper: false */
it('should render a file view', function() {
var attachment = {
contentType: 'unused',
size: 1232
};
var view = new Whisper.AttachmentView({model: attachment}).render();
assert.match(view.el.innerHTML, /fileView/);
});
it('should display the filename if present', function() {
var attachment = {
fileName: 'foo.txt',
contentType: 'unused',
size: 1232,
};
var view = new Whisper.AttachmentView({model: attachment}).render();
assert.match(view.el.innerHTML, /foo.txt/);
});
it('should render a file size', function() {
var attachment = {
size: 1232,
contentType: 'unused'
};
var view = new Whisper.AttachmentView({model: attachment}).render();
assert.match(view.el.innerHTML, /1.2 KB/);
});
});
it('should render an image for images', function() {
var now = new Date().getTime();
var attachment = { contentType: 'image/png', data: 'grumpy cat' };
var view = new Whisper.AttachmentView({model: attachment, timestamp: now}).render();
assert.equal(view.el.firstChild.tagName, "IMG");
});
it('should display a filename', function() { 'use strict';
var epoch = new Date((new Date(0)).getTimezoneOffset() * 60 * 1000);
var attachment = { contentType: 'image/png', data: 'grumpy cat' }; describe('AttachmentView', () => {
var result = new Whisper.AttachmentView({ describe('with arbitrary files', () => {
it('should render a file view', () => {
const attachment = {
contentType: 'unused',
size: 1232,
};
const view = new Whisper.AttachmentView({ model: attachment }).render();
assert.match(view.el.innerHTML, /fileView/);
});
it('should display the filename if present', () => {
const attachment = {
fileName: 'foo.txt',
contentType: 'unused',
size: 1232,
};
const view = new Whisper.AttachmentView({ model: attachment }).render();
assert.match(view.el.innerHTML, /foo.txt/);
});
it('should render a file size', () => {
const attachment = {
size: 1232,
contentType: 'unused',
};
const view = new Whisper.AttachmentView({ model: attachment }).render();
assert.match(view.el.innerHTML, /1.2 KB/);
});
});
it('should render an image for images', () => {
const now = new Date().getTime();
const attachment = { contentType: 'image/png', data: 'grumpy cat' };
const view = new Whisper.AttachmentView({
model: attachment, model: attachment,
timestamp: epoch timestamp: now,
}).suggestedName(); }).render();
assert.equal(view.el.firstChild.tagName, 'IMG');
var expected = '1970-01-01-000000';
assert(result === 'signal-' + expected + '.png');
});
it('should auto-generate a filename', function() {
var epoch = new Date((new Date(0)).getTimezoneOffset() * 60 * 1000);
var attachment = { contentType: 'image/png', data: 'grumpy cat' };
var result = new Whisper.AttachmentView({
model: attachment,
timestamp: epoch
}).suggestedName();
var expected = '1970-01-01-000000';
assert(result === 'signal-' + expected + '.png');
}); });
}); });

View file

@ -5,14 +5,51 @@ import is from '@sindresorhus/is';
import { Collection as BackboneCollection } from '../types/backbone/Collection'; import { Collection as BackboneCollection } from '../types/backbone/Collection';
import { deferredToPromise } from '../../js/modules/deferred_to_promise'; import { deferredToPromise } from '../../js/modules/deferred_to_promise';
import { IndexableBoolean } from '../types/IndexedDB';
import { Message } from '../types/Message'; import { Message } from '../types/Message';
export const fetchVisualMediaAttachments = async ({ export const fetchVisualMediaAttachments = async ({
conversationId, conversationId,
count,
WhisperMessageCollection, WhisperMessageCollection,
}: { }: {
conversationId: string; conversationId: string;
count: number;
WhisperMessageCollection: BackboneCollection<Message>; WhisperMessageCollection: BackboneCollection<Message>;
}): Promise<Array<Message>> =>
fetchFromAttachmentsIndex({
name: 'hasVisualMediaAttachments',
conversationId,
WhisperMessageCollection,
count,
});
export const fetchFileAttachments = async ({
conversationId,
count,
WhisperMessageCollection,
}: {
conversationId: string;
count: number;
WhisperMessageCollection: BackboneCollection<Message>;
}): Promise<Array<Message>> =>
fetchFromAttachmentsIndex({
name: 'hasFileAttachments',
conversationId,
WhisperMessageCollection,
count,
});
const fetchFromAttachmentsIndex = async ({
name,
conversationId,
WhisperMessageCollection,
count,
}: {
name: 'hasVisualMediaAttachments' | 'hasFileAttachments';
conversationId: string;
WhisperMessageCollection: BackboneCollection<Message>;
count: number;
}): Promise<Array<Message>> => { }): Promise<Array<Message>> => {
if (!is.string(conversationId)) { if (!is.string(conversationId)) {
throw new TypeError("'conversationId' is required"); throw new TypeError("'conversationId' is required");
@ -25,16 +62,16 @@ export const fetchVisualMediaAttachments = async ({
const collection = new WhisperMessageCollection(); const collection = new WhisperMessageCollection();
const lowerReceivedAt = 0; const lowerReceivedAt = 0;
const upperReceivedAt = Number.MAX_VALUE; const upperReceivedAt = Number.MAX_VALUE;
const hasVisualMediaAttachments = 1; const condition: IndexableBoolean = 1;
await deferredToPromise( await deferredToPromise(
collection.fetch({ collection.fetch({
index: { index: {
name: 'hasVisualMediaAttachments', name,
lower: [conversationId, lowerReceivedAt, hasVisualMediaAttachments], lower: [conversationId, lowerReceivedAt, condition],
upper: [conversationId, upperReceivedAt, hasVisualMediaAttachments], upper: [conversationId, upperReceivedAt, condition],
order: 'desc', order: 'desc',
}, },
limit: 50, limit: count,
}) })
); );

View file

@ -3,7 +3,8 @@ const noop = () => {};
<div style={{position: 'relative', width: '100%', height: 500}}> <div style={{position: 'relative', width: '100%', height: 500}}>
<Lightbox <Lightbox
imageURL="https://placekitten.com/800/600" objectURL="https://placekitten.com/800/600"
contentType="image/jpeg"
onNext={noop} onNext={noop}
onPrevious={noop} onPrevious={noop}
onSave={noop} onSave={noop}

View file

@ -4,26 +4,42 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import is from '@sindresorhus/is';
import * as GoogleChrome from '../util/GoogleChrome';
import * as MIME from '../types/MIME';
interface Props { interface Props {
close: () => void; close: () => void;
imageURL?: string; objectURL: string;
contentType: MIME.MIMEType | undefined;
onNext?: () => void; onNext?: () => void;
onPrevious?: () => void; onPrevious?: () => void;
onSave: () => void; onSave?: () => void;
} }
const CONTROLS_WIDTH = 50;
const CONTROLS_SPACING = 10;
const styles = { const styles = {
container: { container: {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'column',
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
top: 0, top: 0,
bottom: 0, bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.9)', backgroundColor: 'rgba(0, 0, 0, 0.9)',
padding: 40, } as React.CSSProperties,
mainContainer: {
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
paddingTop: 40,
paddingLeft: 40,
paddingRight: 40,
paddingBottom: 0,
} as React.CSSProperties, } as React.CSSProperties,
objectContainer: { objectContainer: {
flexGrow: 1, flexGrow: 1,
@ -37,20 +53,64 @@ const styles = {
maxHeight: '100%', maxHeight: '100%',
objectFit: 'contain', objectFit: 'contain',
} as React.CSSProperties, } as React.CSSProperties,
controlsOffsetPlaceholder: {
width: CONTROLS_WIDTH,
marginRight: CONTROLS_SPACING,
flexShrink: 0,
},
controls: { controls: {
width: CONTROLS_WIDTH,
flexShrink: 0, flexShrink: 0,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
marginLeft: 10, marginLeft: CONTROLS_SPACING,
} as React.CSSProperties, } as React.CSSProperties,
navigationContainer: {
flexShrink: 0,
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
padding: 10,
} as React.CSSProperties,
saveButton: {
marginTop: 10,
},
iconButtonPlaceholder: {
// Dimensions match `.iconButton`:
display: 'inline-block',
width: 50,
height: 50,
},
}; };
interface IconButtonProps { interface IconButtonProps {
type: 'save' | 'close' | 'previous' | 'next';
onClick?: () => void; onClick?: () => void;
style?: React.CSSProperties;
type: 'save' | 'close' | 'previous' | 'next';
} }
const IconButton = ({ onClick, type }: IconButtonProps) => (
<a href="#" onClick={onClick} className={classNames('iconButton', type)} /> const IconButton = ({ onClick, style, type }: IconButtonProps) => {
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>): void => {
event.preventDefault();
if (!onClick) {
return;
}
onClick();
};
return (
<a
href="#"
onClick={clickHandler}
className={classNames('iconButton', type)}
style={style}
/>
);
};
const IconButtonPlaceholder = () => (
<div style={styles.iconButtonPlaceholder} />
); );
export class Lightbox extends React.Component<Props, {}> { export class Lightbox extends React.Component<Props, {}> {
@ -67,36 +127,79 @@ export class Lightbox extends React.Component<Props, {}> {
} }
public render() { public render() {
const { imageURL } = this.props; const { contentType, objectURL, onNext, onPrevious, onSave } = this.props;
return ( return (
<div <div
style={styles.container} style={styles.container}
onClick={this.onContainerClick} onClick={this.onContainerClick}
ref={this.setContainerRef} ref={this.setContainerRef}
> >
<div style={styles.objectContainer}> <div style={styles.mainContainer}>
<img <div style={styles.controlsOffsetPlaceholder} />
style={styles.image} <div style={styles.objectContainer}>
src={imageURL} {!is.undefined(contentType)
onClick={this.onImageClick} ? this.renderObject({ objectURL, contentType })
/> : null}
</div>
<div style={styles.controls}>
<IconButton type="close" onClick={this.onClose} />
{onSave ? (
<IconButton
type="save"
onClick={onSave}
style={styles.saveButton}
/>
) : null}
</div>
</div> </div>
<div style={styles.controls}> <div style={styles.navigationContainer}>
<IconButton type="close" onClick={this.onClose} /> {onPrevious ? (
{this.props.onSave ? ( <IconButton type="previous" onClick={onPrevious} />
<IconButton type="save" onClick={this.props.onSave} /> ) : (
) : null} <IconButtonPlaceholder />
{this.props.onPrevious ? ( )}
<IconButton type="previous" onClick={this.props.onPrevious} /> {onNext ? (
) : null} <IconButton type="next" onClick={onNext} />
{this.props.onNext ? ( ) : (
<IconButton type="next" onClick={this.props.onNext} /> <IconButtonPlaceholder />
) : null} )}
</div> </div>
</div> </div>
); );
} }
private renderObject = ({
objectURL,
contentType,
}: {
objectURL: string;
contentType: MIME.MIMEType;
}) => {
const isImage = GoogleChrome.isImageTypeSupported(contentType);
if (isImage) {
return (
<img
style={styles.image}
src={objectURL}
onClick={this.onObjectClick}
/>
);
}
const isVideo = GoogleChrome.isVideoTypeSupported(contentType);
if (isVideo) {
return (
<video controls={true}>
<source src={objectURL} />
</video>
);
}
// tslint:disable-next-line no-console
console.log('Lightbox: Unexpected content type', { contentType });
return null;
};
private setContainerRef = (value: HTMLDivElement) => { private setContainerRef = (value: HTMLDivElement) => {
this.containerRef = value; this.containerRef = value;
}; };
@ -111,11 +214,28 @@ export class Lightbox extends React.Component<Props, {}> {
}; };
private onKeyUp = (event: KeyboardEvent) => { private onKeyUp = (event: KeyboardEvent) => {
if (event.key !== 'Escape') { const { onClose } = this;
return; const { onNext, onPrevious } = this.props;
} switch (event.key) {
case 'Escape':
onClose();
break;
this.onClose(); case 'ArrowLeft':
if (onPrevious) {
onPrevious();
}
break;
case 'ArrowRight':
if (onNext) {
onNext();
}
break;
default:
break;
}
}; };
private onContainerClick = (event: React.MouseEvent<HTMLDivElement>) => { private onContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
@ -125,7 +245,7 @@ export class Lightbox extends React.Component<Props, {}> {
this.onClose(); this.onClose();
}; };
private onImageClick = (event: React.MouseEvent<HTMLImageElement>) => { private onObjectClick = (event: React.MouseEvent<HTMLImageElement>) => {
event.stopPropagation(); event.stopPropagation();
this.onClose(); this.onClose();
}; };

View file

@ -0,0 +1,19 @@
```js
const noop = () => {};
const items = [
{ objectURL: 'https://placekitten.com/800/600', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/900/600', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/980/800', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/656/540', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/762/400', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/920/620', contentType: 'image/jpeg' },
];
<div style={{position: 'relative', width: '100%', height: 500}}>
<LightboxGallery
items={items}
onSave={noop}
/>
</div>
```

View file

@ -0,0 +1,97 @@
/**
* @prettier
*/
import React from 'react';
import * as MIME from '../types/MIME';
import { Lightbox } from './Lightbox';
import { Message } from './conversation/media-gallery/types/Message';
interface Item {
objectURL?: string;
contentType: MIME.MIMEType | undefined;
}
interface Props {
close: () => void;
messages: Array<Message>;
onSave?: ({ message }: { message: Message }) => void;
selectedIndex: number;
}
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,
};
constructor(props: Props) {
super(props);
this.state = {
selectedIndex: this.props.selectedIndex,
};
}
public render() {
const { close, messages, onSave } = this.props;
const { selectedIndex } = this.state;
const selectedMessage: Message = messages[selectedIndex];
const selectedItem = messageToItem(selectedMessage);
const firstIndex = 0;
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';
return (
<Lightbox
close={close}
onPrevious={onPrevious}
onNext={onNext}
onSave={onSave ? this.handleSave : undefined}
objectURL={objectURL}
contentType={selectedItem.contentType}
/>
);
}
private handlePrevious = () => {
this.setState(prevState => ({
selectedIndex: Math.max(prevState.selectedIndex - 1, 0),
}));
};
private handleNext = () => {
this.setState((prevState, props) => ({
selectedIndex: Math.min(
prevState.selectedIndex + 1,
props.messages.length - 1
),
}));
};
private handleSave = () => {
const { messages, onSave } = this.props;
if (!onSave) {
return;
}
const { selectedIndex } = this.state;
const message = messages[selectedIndex];
onSave({ message });
};
}

View file

@ -3,10 +3,11 @@
*/ */
import React from 'react'; import React from 'react';
import { AttachmentType } from './types/AttachmentType';
import { DocumentListItem } from './DocumentListItem'; import { DocumentListItem } from './DocumentListItem';
import { ItemClickEvent } from './events/ItemClickEvent'; import { ItemClickEvent } from './types/ItemClickEvent';
import { MediaGridItem } from './MediaGridItem'; import { MediaGridItem } from './MediaGridItem';
import { Message } from './propTypes/Message'; import { Message } from './types/Message';
import { missingCaseError } from '../../../util/missingCaseError'; import { missingCaseError } from '../../../util/missingCaseError';
const styles = { const styles = {
@ -30,7 +31,7 @@ const styles = {
interface Props { interface Props {
i18n: (value: string) => string; i18n: (value: string) => string;
header?: string; header?: string;
type: 'media' | 'documents'; type: AttachmentType;
messages: Array<Message>; messages: Array<Message>;
onItemClick?: (event: ItemClickEvent) => void; onItemClick?: (event: ItemClickEvent) => void;
} }
@ -82,11 +83,11 @@ export class AttachmentSection extends React.Component<Props, {}> {
} }
private createClickHandler = (message: Message) => () => { private createClickHandler = (message: Message) => () => {
const { onItemClick } = this.props; const { onItemClick, type } = this.props;
if (!onItemClick) { if (!onItemClick) {
return; return;
} }
onItemClick({ message }); onItemClick({ type, message });
}; };
} }

View file

@ -23,6 +23,7 @@ const styles = {
borderBottomStyle: 'solid', borderBottomStyle: 'solid',
}, },
itemContainer: { itemContainer: {
cursor: 'pointer',
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'nowrap', flexWrap: 'nowrap',

View file

@ -0,0 +1,11 @@
```js
<div style={{ position: "relative", width: "100%", height: 300 }}>
<EmptyState label="You have no attachments with media" />
</div>
```
```js
<div style={{ position: "relative", width: "100%", height: 500 }}>
<EmptyState label="You have no documents with media" />
</div>
```

View file

@ -0,0 +1,29 @@
/**
* @prettier
*/
import React from 'react';
import * as Colors from '../../styles/Colors';
interface Props {
label: string;
}
const styles = {
container: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexGrow: 1,
fontSize: 28,
color: Colors.TEXT_SECONDARY,
} as React.CSSProperties,
};
export class EmptyState extends React.Component<Props, {}> {
public render() {
const { label } = this.props;
return <div style={styles.container}>{label}</div>;
}
}

View file

@ -1,3 +1,17 @@
### Empty states for missing media and documents
```
<div style={{width: '100%', height: 300}}>
<MediaGallery
i18n={window.i18n}
media={[]}
documents={[]}
/>
</div>
```
### Media gallery with media and documents
```jsx ```jsx
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const MONTH_MS = 30 * DAY_MS; const MONTH_MS = 30 * DAY_MS;

View file

@ -6,11 +6,12 @@ import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { AttachmentSection } from './AttachmentSection'; import { AttachmentSection } from './AttachmentSection';
import { AttachmentType } from './types/AttachmentType';
import { EmptyState } from './EmptyState';
import { groupMessagesByDate } from './groupMessagesByDate'; import { groupMessagesByDate } from './groupMessagesByDate';
import { ItemClickEvent } from './events/ItemClickEvent'; import { ItemClickEvent } from './types/ItemClickEvent';
import { Message } from './propTypes/Message'; import { Message } from './types/Message';
import { missingCaseError } from '../../../util/missingCaseError';
type AttachmentType = 'media' | 'documents';
interface Props { interface Props {
documents: Array<Message>; documents: Array<Message>;
@ -34,9 +35,18 @@ const tabStyle = {
}; };
const styles = { const styles = {
tabContainer: { container: {
cursor: 'pointer',
display: 'flex', display: 'flex',
flexDirection: 'column',
flexGrow: 1,
width: '100%',
height: '100%',
} as React.CSSProperties,
tabContainer: {
display: 'flex',
flexGrow: 0,
flexShrink: 0,
cursor: 'pointer',
width: '100%', width: '100%',
}, },
tab: { tab: {
@ -46,9 +56,17 @@ const styles = {
borderBottom: '2px solid #08f', borderBottom: '2px solid #08f',
}, },
}, },
attachmentsContainer: { contentContainer: {
display: 'flex',
flexGrow: 1,
overflowY: 'auto',
padding: 20, padding: 20,
}, } as React.CSSProperties,
sectionContainer: {
display: 'flex',
flexGrow: 1,
flexDirection: 'column',
} as React.CSSProperties,
}; };
interface TabSelectEvent { interface TabSelectEvent {
@ -87,7 +105,7 @@ export class MediaGallery extends React.Component<Props, State> {
const { selectedTab } = this.state; const { selectedTab } = this.state;
return ( return (
<div> <div style={styles.container}>
<div style={styles.tabContainer}> <div style={styles.tabContainer}>
<Tab <Tab
label="Media" label="Media"
@ -95,16 +113,14 @@ export class MediaGallery extends React.Component<Props, State> {
isSelected={selectedTab === 'media'} isSelected={selectedTab === 'media'}
onSelect={this.handleTabSelect} onSelect={this.handleTabSelect}
/> />
{/* Disable for MVP:
<Tab <Tab
label="Documents" label="Documents"
type="documents" type="documents"
isSelected={selectedTab === 'documents'} isSelected={selectedTab === 'documents'}
onSelect={this.handleTabSelect} onSelect={this.handleTabSelect}
/> />
*/}
</div> </div>
<div style={styles.attachmentsContainer}>{this.renderSections()}</div> <div style={styles.contentContainer}>{this.renderSections()}</div>
</div> </div>
); );
} }
@ -121,12 +137,23 @@ export class MediaGallery extends React.Component<Props, State> {
const type = selectedTab; const type = selectedTab;
if (!messages || messages.length === 0) { if (!messages || messages.length === 0) {
return null; const label = (() => {
switch (type) {
case 'media':
return i18n('mediaEmptyState');
case 'documents':
return i18n('documentsEmptyState');
default:
throw missingCaseError(type);
}
})();
return <EmptyState data-test="EmptyState" label={label} />;
} }
const now = Date.now(); const now = Date.now();
const sections = groupMessagesByDate(now, messages); const sections = groupMessagesByDate(now, messages).map(section => {
return sections.map(section => {
const first = section.messages[0]; const first = section.messages[0];
const date = moment(first.received_at); const date = moment(first.received_at);
const header = const header =
@ -144,5 +171,7 @@ export class MediaGallery extends React.Component<Props, State> {
/> />
); );
}); });
return <div style={styles.sectionContainer}>{sections}</div>;
} }
} }

View file

@ -3,7 +3,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Message } from './propTypes/Message'; import { Message } from './types/Message';
interface Props { interface Props {
message: Message; message: Message;
@ -17,6 +17,7 @@ const size = {
const styles = { const styles = {
container: { container: {
...size, ...size,
cursor: 'pointer',
backgroundColor: '#f3f3f3', backgroundColor: '#f3f3f3',
marginRight: 4, marginRight: 4,
marginBottom: 4, marginBottom: 4,

View file

@ -1,8 +0,0 @@
/**
* @prettier
*/
import { Message } from '../propTypes/Message';
export interface ItemClickEvent {
message: Message;
}

View file

@ -4,7 +4,7 @@
import moment from 'moment'; import moment from 'moment';
import { compact, groupBy, sortBy } from 'lodash'; import { compact, groupBy, sortBy } from 'lodash';
import { Message } from './propTypes/Message'; import { Message } from './types/Message';
// import { missingCaseError } from '../../../util/missingCaseError'; // import { missingCaseError } from '../../../util/missingCaseError';
type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth'; type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth';

View file

@ -0,0 +1,4 @@
/**
* @prettier
*/
export type AttachmentType = 'media' | 'documents';

View file

@ -0,0 +1,10 @@
/**
* @prettier
*/
import { AttachmentType } from './AttachmentType';
import { Message } from './Message';
export interface ItemClickEvent {
message: Message;
type: AttachmentType;
}

View file

@ -8,7 +8,6 @@ import * as MIME from '../../../../types/MIME';
import { arrayBufferToObjectURL } from '../../../../util/arrayBufferToObjectURL'; import { arrayBufferToObjectURL } from '../../../../util/arrayBufferToObjectURL';
import { Attachment } from '../../../../types/Attachment'; import { Attachment } from '../../../../types/Attachment';
import { MapAsync } from '../../../../types/MapAsync'; import { MapAsync } from '../../../../types/MapAsync';
import { MIMEType } from '../../../../types/MIME';
export type Message = { export type Message = {
id: string; id: string;
@ -16,8 +15,6 @@ export type Message = {
received_at: number; received_at: number;
} & { objectURL?: string }; } & { objectURL?: string };
const DEFAULT_CONTENT_TYPE: MIMEType = 'application/octet-stream' as MIMEType;
export const loadWithObjectURL = (loadMessage: MapAsync<Message>) => async ( export const loadWithObjectURL = (loadMessage: MapAsync<Message>) => async (
messages: Array<Message> messages: Array<Message>
): Promise<Array<Message>> => { ): Promise<Array<Message>> => {
@ -29,14 +26,15 @@ export const loadWithObjectURL = (loadMessage: MapAsync<Message>) => async (
} }
// Messages with video are too expensive to load into memory, so we dont: // Messages with video are too expensive to load into memory, so we dont:
const [, messagesWithoutVideo] = partition(messages, hasVideoAttachment); const [messagesWithVideo, messagesWithoutVideo] = partition(
messages,
hasVideoAttachment
);
const loadedMessagesWithoutVideo: Array<Message> = await Promise.all( const loadedMessagesWithoutVideo: Array<Message> = await Promise.all(
messagesWithoutVideo.map(loadMessage) messagesWithoutVideo.map(loadMessage)
); );
const loadedMessages = sortBy( const loadedMessages = sortBy(
// // Only show images for MVP: [...messagesWithVideo, ...loadedMessagesWithoutVideo],
// [...messagesWithVideo, ...loadedMessagesWithoutVideo],
loadedMessagesWithoutVideo,
message => -message.received_at message => -message.received_at
); );
@ -50,17 +48,17 @@ const hasVideoAttachment = (message: Message): boolean =>
MIME.isVideo(attachment.contentType) MIME.isVideo(attachment.contentType)
); );
const withObjectURL = (message: Message): Message => { export const withObjectURL = (message: Message): Message => {
if (message.attachments.length === 0) { if (message.attachments.length === 0) {
throw new TypeError('`message.attachments` cannot be empty'); throw new TypeError('`message.attachments` cannot be empty');
} }
const attachment = message.attachments[0]; const attachment = message.attachments[0];
if (typeof attachment.contentType === 'undefined') { if (is.undefined(attachment.contentType)) {
throw new TypeError('`attachment.contentType` is required'); throw new TypeError('`attachment.contentType` is required');
} }
if (MIME.isVideo(attachment.contentType)) { if (is.undefined(attachment.data) && MIME.isVideo(attachment.contentType)) {
return { return {
...message, ...message,
objectURL: 'images/video.svg', objectURL: 'images/video.svg',
@ -69,7 +67,7 @@ const withObjectURL = (message: Message): Message => {
const objectURL = arrayBufferToObjectURL({ const objectURL = arrayBufferToObjectURL({
data: attachment.data, data: attachment.data,
type: attachment.contentType || DEFAULT_CONTENT_TYPE, type: attachment.contentType,
}); });
return { return {
...message, ...message,

View file

@ -0,0 +1,4 @@
/**
* @prettier
*/
export const TEXT_SECONDARY = '#bbb';

View file

@ -10,7 +10,7 @@ import {
groupMessagesByDate, groupMessagesByDate,
Section, Section,
} from '../../../components/conversation/media-gallery/groupMessagesByDate'; } from '../../../components/conversation/media-gallery/groupMessagesByDate';
import { Message } from '../../../components/conversation/media-gallery/propTypes/Message'; import { Message } from '../../../components/conversation/media-gallery/types/Message';
const toMessage = (date: Date): Message => ({ const toMessage = (date: Date): Message => ({
id: date.toUTCString(), id: date.toUTCString(),

View file

@ -0,0 +1,60 @@
/**
* @prettier
*/
import 'mocha';
import { assert } from 'chai';
import * as Attachment from '../../types/Attachment';
import { MIMEType } from '../../types/MIME';
// @ts-ignore
import { stringToArrayBuffer } from '../../../js/modules/string_to_array_buffer';
describe('Attachment', () => {
describe('getFileExtension', () => {
it('should return file extension from content type', () => {
const input: Attachment.Attachment = {
data: stringToArrayBuffer('foo'),
contentType: 'image/gif' as MIMEType,
};
assert.strictEqual(Attachment.getFileExtension(input), 'gif');
});
it('should return file extension for QuickTime videos', () => {
const input: Attachment.Attachment = {
data: stringToArrayBuffer('foo'),
contentType: 'video/quicktime' as MIMEType,
};
assert.strictEqual(Attachment.getFileExtension(input), 'mov');
});
});
describe('getSuggestedFilename', () => {
context('for attachment with filename', () => {
it('should return existing filename if present', () => {
const attachment: Attachment.Attachment = {
fileName: 'funny-cat.mov',
data: stringToArrayBuffer('foo'),
contentType: 'video/quicktime' as MIMEType,
};
const actual = Attachment.getSuggestedFilename({ attachment });
const expected = 'funny-cat.mov';
assert.strictEqual(actual, expected);
});
});
context('for attachment without filename', () => {
it('should generate a filename based on timestamp', () => {
const attachment: Attachment.Attachment = {
data: stringToArrayBuffer('foo'),
contentType: 'video/quicktime' as MIMEType,
};
const timestamp = new Date(new Date(0).getTimezoneOffset() * 60 * 1000);
const actual = Attachment.getSuggestedFilename({
attachment,
timestamp,
});
const expected = 'signal-attachment-1970-01-01-000000.mov';
assert.strictEqual(actual, expected);
});
});
});
});

View file

@ -2,11 +2,14 @@
* @prettier * @prettier
*/ */
import is from '@sindresorhus/is'; import is from '@sindresorhus/is';
import moment from 'moment';
import * as GoogleChrome from '../util/GoogleChrome'; import * as GoogleChrome from '../util/GoogleChrome';
import { saveURLAsFile } from '../util/saveURLAsFile';
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
import { MIMEType } from './MIME'; import { MIMEType } from './MIME';
export interface Attachment { export type Attachment = {
fileName?: string; fileName?: string;
contentType?: MIMEType; contentType?: MIMEType;
size?: number; size?: number;
@ -21,8 +24,14 @@ export interface Attachment {
// key?: ArrayBuffer; // key?: ArrayBuffer;
// digest?: ArrayBuffer; // digest?: ArrayBuffer;
// flags?: number; // flags?: number;
} & Partial<AttachmentSchemaVersion3>;
interface AttachmentSchemaVersion3 {
path: string;
} }
const SAVE_CONTENT_TYPE = 'application/octet-stream' as MIMEType;
export const isVisualMedia = (attachment: Attachment): boolean => { export const isVisualMedia = (attachment: Attachment): boolean => {
const { contentType } = attachment; const { contentType } = attachment;
@ -34,3 +43,62 @@ export const isVisualMedia = (attachment: Attachment): boolean => {
const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType); const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType);
return isSupportedImageType || isSupportedVideoType; return isSupportedImageType || isSupportedVideoType;
}; };
export const save = ({
attachment,
document,
getAbsolutePath,
timestamp,
}: {
attachment: Attachment;
document: Document;
getAbsolutePath: (relativePath: string) => string;
timestamp?: number;
}): void => {
const isObjectURLRequired = is.undefined(attachment.path);
const url = !is.undefined(attachment.path)
? getAbsolutePath(attachment.path)
: arrayBufferToObjectURL({
data: attachment.data,
type: SAVE_CONTENT_TYPE,
});
const filename = getSuggestedFilename({ attachment, timestamp });
saveURLAsFile({ url, filename, document });
if (isObjectURLRequired) {
URL.revokeObjectURL(url);
}
};
export const getSuggestedFilename = ({
attachment,
timestamp,
}: {
attachment: Attachment;
timestamp?: number | Date;
}): string => {
if (attachment.fileName) {
return attachment.fileName;
}
const prefix = 'signal-attachment';
const suffix = timestamp
? moment(timestamp).format('-YYYY-MM-DD-HHmmss')
: '';
const fileType = getFileExtension(attachment);
const extension = fileType ? `.${fileType}` : '';
return `${prefix}${suffix}${extension}`;
};
export const getFileExtension = (attachment: Attachment): string | null => {
if (!attachment.contentType) {
return null;
}
switch (attachment.contentType) {
case 'video/quicktime':
return 'mov';
default:
// TODO: Use better MIME --> file extension mapping:
return attachment.contentType.split('/')[1];
}
};

View file

@ -4,9 +4,6 @@
export type MIMEType = string & { _mimeTypeBrand: any }; export type MIMEType = string & { _mimeTypeBrand: any };
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg'; export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
export const isImage = (value: MIMEType): boolean => value.startsWith('image/'); export const isImage = (value: MIMEType): boolean => value.startsWith('image/');
export const isVideo = (value: MIMEType): boolean => value.startsWith('video/'); export const isVideo = (value: MIMEType): boolean => value.startsWith('video/');
export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/'); export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/');

View file

@ -1,6 +1,8 @@
/** /**
* @prettier * @prettier
*/ */
import is from '@sindresorhus/is';
import { MIMEType } from '../types/MIME'; import { MIMEType } from '../types/MIME';
export const arrayBufferToObjectURL = ({ export const arrayBufferToObjectURL = ({
@ -10,6 +12,10 @@ export const arrayBufferToObjectURL = ({
data: ArrayBuffer; data: ArrayBuffer;
type: MIMEType; type: MIMEType;
}): string => { }): string => {
if (!is.arrayBuffer(data)) {
throw new TypeError('`data` must be an ArrayBuffer');
}
const blob = new Blob([data], { type }); const blob = new Blob([data], { type });
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
}; };

17
ts/util/saveURLAsFile.ts Normal file
View file

@ -0,0 +1,17 @@
/**
* @prettier
*/
export const saveURLAsFile = ({
filename,
url,
document,
}: {
filename: string;
url: string;
document: Document;
}): void => {
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = filename;
anchorElement.click();
};