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/models/conversations.js
!js/models/messages.js
!test/backup_test.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/conversation_search_view.js
!js/views/conversation_view.js
!js/views/debug_log_view.js
!js/views/file_input_view.js
!js/views/inbox_view.js
!js/views/message_view.js
!js/views/settings_view.js
!test/backup_test.js
!test/views/attachment_view_test.js
!libtextsecure/message_receiver.js
!main.js
!preload.js

View file

@ -326,10 +326,18 @@
"message": "Media",
"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": {
"message": "Documents",
"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": {
"message": "Message caption",
"description": "Prefix of attachment alt tags in the media gallery"

View file

@ -114,7 +114,7 @@ exports.createName = () => {
return buffer.toString('hex');
};
// getRelativePath :: String -> IO Path
// getRelativePath :: String -> Path
exports.getRelativePath = (name) => {
if (!isString(name)) {
throw new TypeError("'name' must be a string");
@ -123,3 +123,7 @@ exports.getRelativePath = (name) => {
const prefix = name.slice(0, 2);
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>
<ul class='menu-list'>
<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}}
<li class='show-members'>{{ show-members }}</li>
<!-- <li class='update-group'>Update group</li> -->

View file

@ -17,10 +17,7 @@
const { IdleDetector, MessageDataMigrator } = Signal.Workflow;
const { Errors, Message } = window.Signal.Types;
const { upgradeMessageSchema } = window.Signal.Migrations;
const {
Migrations0DatabaseWithAttachmentData,
Migrations1DatabaseWithoutAttachmentData,
} = window.Signal.Migrations;
const { Migrations0DatabaseWithAttachmentData } = window.Signal.Migrations;
const { Views } = window.Signal;
// Implicitly used in `indexeddb-backbonejs-adapter`:
@ -90,18 +87,37 @@
storage.fetch();
const idleDetector = new IdleDetector();
let isMigrationWithIndexComplete = false;
let isMigrationWithoutIndexComplete = false;
idleDetector.on('idle', async () => {
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();
}
});
@ -117,7 +133,6 @@
first = false;
ConversationController.load().then(start, start);
idleDetector.start();
});
Whisper.events.on('shutdown', function() {
@ -368,33 +383,48 @@
storage,
});
console.log('Sync read receipt configuration status:', status);
/* eslint-disable */
if (firstRun === true && deviceId != '1') {
if (!storage.get('theme-setting') && textsecure.storage.get('userAgent') === 'OWI') {
storage.put('theme-setting', 'ios');
onChangeTheme();
}
var syncRequest = new textsecure.SyncRequest(textsecure.messaging, messageReceiver);
Whisper.events.trigger('contactsync:begin');
syncRequest.addEventListener('success', function() {
console.log('sync successful');
storage.put('synced_at', Date.now());
Whisper.events.trigger('contactsync');
});
syncRequest.addEventListener('timeout', function() {
console.log('sync timed out');
Whisper.events.trigger('contactsync');
});
if (firstRun === true && deviceId !== '1') {
const hasThemeSetting = Boolean(storage.get('theme-setting'));
if (!hasThemeSetting && textsecure.storage.get('userAgent') === 'OWI') {
storage.put('theme-setting', 'ios');
onChangeTheme();
}
const syncRequest = new textsecure.SyncRequest(
textsecure.messaging,
messageReceiver
);
Whisper.events.trigger('contactsync:begin');
syncRequest.addEventListener('success', () => {
console.log('sync successful');
storage.put('synced_at', Date.now());
Whisper.events.trigger('contactsync');
});
syncRequest.addEventListener('timeout', () => {
console.log('sync timed out');
Whisper.events.trigger('contactsync');
});
if (Whisper.Import.isComplete()) {
textsecure.messaging.sendRequestConfigurationSyncMessage().catch(function(e) {
console.log(e);
});
}
}
if (Whisper.Import.isComplete()) {
textsecure.messaging.sendRequestConfigurationSyncMessage().catch((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() {
var view = window.owsDesktopApp.appView;
if (view) {

View file

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

View file

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

View file

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

View file

@ -8,9 +8,9 @@
/* global extension: false */
/* global i18n: false */
/* global Signal: false */
/* global storage: false */
/* global Whisper: false */
/* global Signal: false */
// eslint-disable-next-line func-names
(function () {
@ -282,6 +282,9 @@
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightboxGalleryView) {
this.lightboxGalleryView.remove();
}
if (this.panels && this.panels.length) {
for (let i = 0, max = this.panels.length; i < max; i += 1) {
const panel = this.panels[i];
@ -577,33 +580,82 @@
// events up to its parent elements in the DOM.
this.closeMenu();
const media = await Signal.Backbone.Conversation.fetchVisualMediaAttachments({
conversationId: this.model.get('id'),
WhisperMessageCollection: Whisper.MessageCollection,
});
const loadMessages = Signal.Components.PropTypes.Message
.loadWithObjectURL(Signal.Migrations.loadMessage);
const mediaWithObjectURLs = await loadMessages(media);
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const DEFAULT_MEDIA_FETCH_COUNT = 50;
const DEFAULT_DOCUMENTS_FETCH_COUNT = 150;
const mediaGalleryProps = {
media: mediaWithObjectURLs,
documents: [],
onItemClick: ({ message }) => {
const lightboxProps = {
imageURL: message.objectURL,
};
this.lightboxView = new Whisper.ReactWrapperView({
Component: Signal.Components.Lightbox,
props: lightboxProps,
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
},
const conversationId = this.model.get('id');
const WhisperMessageCollection = Whisper.MessageCollection;
const rawMedia = await Signal.Backbone.Conversation.fetchVisualMediaAttachments({
conversationId,
count: DEFAULT_MEDIA_FETCH_COUNT,
WhisperMessageCollection,
});
const documents = await Signal.Backbone.Conversation.fetchFileAttachments({
conversationId,
count: DEFAULT_DOCUMENTS_FETCH_COUNT,
WhisperMessageCollection,
});
// 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({
Component: Signal.Components.MediaGallery,
props: mediaGalleryProps,
props: {
documents,
media,
onItemClick,
},
onClose: () => this.resetPanel(),
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,59 +1,44 @@
describe('AttachmentView', function() {
/* global assert: false */
describe('with arbitrary files', function() {
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");
});
/* global Whisper: false */
it('should display 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({
'use strict';
describe('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,
timestamp: epoch
}).suggestedName();
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');
timestamp: now,
}).render();
assert.equal(view.el.firstChild.tagName, 'IMG');
});
});

View file

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

View file

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

View file

@ -4,26 +4,42 @@
import React from 'react';
import classNames from 'classnames';
import is from '@sindresorhus/is';
import * as GoogleChrome from '../util/GoogleChrome';
import * as MIME from '../types/MIME';
interface Props {
close: () => void;
imageURL?: string;
objectURL: string;
contentType: MIME.MIMEType | undefined;
onNext?: () => void;
onPrevious?: () => void;
onSave: () => void;
onSave?: () => void;
}
const CONTROLS_WIDTH = 50;
const CONTROLS_SPACING = 10;
const styles = {
container: {
display: 'flex',
flexDirection: 'row',
flexDirection: 'column',
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
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,
objectContainer: {
flexGrow: 1,
@ -37,20 +53,64 @@ const styles = {
maxHeight: '100%',
objectFit: 'contain',
} as React.CSSProperties,
controlsOffsetPlaceholder: {
width: CONTROLS_WIDTH,
marginRight: CONTROLS_SPACING,
flexShrink: 0,
},
controls: {
width: CONTROLS_WIDTH,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
marginLeft: 10,
marginLeft: CONTROLS_SPACING,
} 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 {
type: 'save' | 'close' | 'previous' | 'next';
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, {}> {
@ -67,36 +127,79 @@ export class Lightbox extends React.Component<Props, {}> {
}
public render() {
const { imageURL } = this.props;
const { contentType, objectURL, onNext, onPrevious, onSave } = this.props;
return (
<div
style={styles.container}
onClick={this.onContainerClick}
ref={this.setContainerRef}
>
<div style={styles.objectContainer}>
<img
style={styles.image}
src={imageURL}
onClick={this.onImageClick}
/>
<div style={styles.mainContainer}>
<div style={styles.controlsOffsetPlaceholder} />
<div style={styles.objectContainer}>
{!is.undefined(contentType)
? 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 style={styles.controls}>
<IconButton type="close" onClick={this.onClose} />
{this.props.onSave ? (
<IconButton type="save" onClick={this.props.onSave} />
) : null}
{this.props.onPrevious ? (
<IconButton type="previous" onClick={this.props.onPrevious} />
) : null}
{this.props.onNext ? (
<IconButton type="next" onClick={this.props.onNext} />
) : null}
<div style={styles.navigationContainer}>
{onPrevious ? (
<IconButton type="previous" onClick={onPrevious} />
) : (
<IconButtonPlaceholder />
)}
{onNext ? (
<IconButton type="next" onClick={onNext} />
) : (
<IconButtonPlaceholder />
)}
</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) => {
this.containerRef = value;
};
@ -111,11 +214,28 @@ export class Lightbox extends React.Component<Props, {}> {
};
private onKeyUp = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
const { onClose } = this;
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>) => {
@ -125,7 +245,7 @@ export class Lightbox extends React.Component<Props, {}> {
this.onClose();
};
private onImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
private onObjectClick = (event: React.MouseEvent<HTMLImageElement>) => {
event.stopPropagation();
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 { AttachmentType } from './types/AttachmentType';
import { DocumentListItem } from './DocumentListItem';
import { ItemClickEvent } from './events/ItemClickEvent';
import { ItemClickEvent } from './types/ItemClickEvent';
import { MediaGridItem } from './MediaGridItem';
import { Message } from './propTypes/Message';
import { Message } from './types/Message';
import { missingCaseError } from '../../../util/missingCaseError';
const styles = {
@ -30,7 +31,7 @@ const styles = {
interface Props {
i18n: (value: string) => string;
header?: string;
type: 'media' | 'documents';
type: AttachmentType;
messages: Array<Message>;
onItemClick?: (event: ItemClickEvent) => void;
}
@ -82,11 +83,11 @@ export class AttachmentSection extends React.Component<Props, {}> {
}
private createClickHandler = (message: Message) => () => {
const { onItemClick } = this.props;
const { onItemClick, type } = this.props;
if (!onItemClick) {
return;
}
onItemClick({ message });
onItemClick({ type, message });
};
}

View file

@ -23,6 +23,7 @@ const styles = {
borderBottomStyle: 'solid',
},
itemContainer: {
cursor: 'pointer',
display: 'flex',
flexDirection: 'row',
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
const DAY_MS = 24 * 60 * 60 * 1000;
const MONTH_MS = 30 * DAY_MS;

View file

@ -6,11 +6,12 @@ import React from 'react';
import moment from 'moment';
import { AttachmentSection } from './AttachmentSection';
import { AttachmentType } from './types/AttachmentType';
import { EmptyState } from './EmptyState';
import { groupMessagesByDate } from './groupMessagesByDate';
import { ItemClickEvent } from './events/ItemClickEvent';
import { Message } from './propTypes/Message';
type AttachmentType = 'media' | 'documents';
import { ItemClickEvent } from './types/ItemClickEvent';
import { Message } from './types/Message';
import { missingCaseError } from '../../../util/missingCaseError';
interface Props {
documents: Array<Message>;
@ -34,9 +35,18 @@ const tabStyle = {
};
const styles = {
tabContainer: {
cursor: 'pointer',
container: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
width: '100%',
height: '100%',
} as React.CSSProperties,
tabContainer: {
display: 'flex',
flexGrow: 0,
flexShrink: 0,
cursor: 'pointer',
width: '100%',
},
tab: {
@ -46,9 +56,17 @@ const styles = {
borderBottom: '2px solid #08f',
},
},
attachmentsContainer: {
contentContainer: {
display: 'flex',
flexGrow: 1,
overflowY: 'auto',
padding: 20,
},
} as React.CSSProperties,
sectionContainer: {
display: 'flex',
flexGrow: 1,
flexDirection: 'column',
} as React.CSSProperties,
};
interface TabSelectEvent {
@ -87,7 +105,7 @@ export class MediaGallery extends React.Component<Props, State> {
const { selectedTab } = this.state;
return (
<div>
<div style={styles.container}>
<div style={styles.tabContainer}>
<Tab
label="Media"
@ -95,16 +113,14 @@ export class MediaGallery extends React.Component<Props, State> {
isSelected={selectedTab === 'media'}
onSelect={this.handleTabSelect}
/>
{/* Disable for MVP:
<Tab
label="Documents"
type="documents"
isSelected={selectedTab === 'documents'}
onSelect={this.handleTabSelect}
/>
*/}
</div>
<div style={styles.attachmentsContainer}>{this.renderSections()}</div>
<div style={styles.contentContainer}>{this.renderSections()}</div>
</div>
);
}
@ -121,12 +137,23 @@ export class MediaGallery extends React.Component<Props, State> {
const type = selectedTab;
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 sections = groupMessagesByDate(now, messages);
return sections.map(section => {
const sections = groupMessagesByDate(now, messages).map(section => {
const first = section.messages[0];
const date = moment(first.received_at);
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 { Message } from './propTypes/Message';
import { Message } from './types/Message';
interface Props {
message: Message;
@ -17,6 +17,7 @@ const size = {
const styles = {
container: {
...size,
cursor: 'pointer',
backgroundColor: '#f3f3f3',
marginRight: 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 { compact, groupBy, sortBy } from 'lodash';
import { Message } from './propTypes/Message';
import { Message } from './types/Message';
// import { missingCaseError } from '../../../util/missingCaseError';
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 { Attachment } from '../../../../types/Attachment';
import { MapAsync } from '../../../../types/MapAsync';
import { MIMEType } from '../../../../types/MIME';
export type Message = {
id: string;
@ -16,8 +15,6 @@ export type Message = {
received_at: number;
} & { objectURL?: string };
const DEFAULT_CONTENT_TYPE: MIMEType = 'application/octet-stream' as MIMEType;
export const loadWithObjectURL = (loadMessage: MapAsync<Message>) => async (
messages: 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:
const [, messagesWithoutVideo] = partition(messages, hasVideoAttachment);
const [messagesWithVideo, messagesWithoutVideo] = partition(
messages,
hasVideoAttachment
);
const loadedMessagesWithoutVideo: Array<Message> = await Promise.all(
messagesWithoutVideo.map(loadMessage)
);
const loadedMessages = sortBy(
// // Only show images for MVP:
// [...messagesWithVideo, ...loadedMessagesWithoutVideo],
loadedMessagesWithoutVideo,
[...messagesWithVideo, ...loadedMessagesWithoutVideo],
message => -message.received_at
);
@ -50,17 +48,17 @@ const hasVideoAttachment = (message: Message): boolean =>
MIME.isVideo(attachment.contentType)
);
const withObjectURL = (message: Message): Message => {
export const withObjectURL = (message: Message): Message => {
if (message.attachments.length === 0) {
throw new TypeError('`message.attachments` cannot be empty');
}
const attachment = message.attachments[0];
if (typeof attachment.contentType === 'undefined') {
if (is.undefined(attachment.contentType)) {
throw new TypeError('`attachment.contentType` is required');
}
if (MIME.isVideo(attachment.contentType)) {
if (is.undefined(attachment.data) && MIME.isVideo(attachment.contentType)) {
return {
...message,
objectURL: 'images/video.svg',
@ -69,7 +67,7 @@ const withObjectURL = (message: Message): Message => {
const objectURL = arrayBufferToObjectURL({
data: attachment.data,
type: attachment.contentType || DEFAULT_CONTENT_TYPE,
type: attachment.contentType,
});
return {
...message,

View file

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

View file

@ -10,7 +10,7 @@ import {
groupMessagesByDate,
Section,
} 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 => ({
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
*/
import is from '@sindresorhus/is';
import moment from 'moment';
import * as GoogleChrome from '../util/GoogleChrome';
import { saveURLAsFile } from '../util/saveURLAsFile';
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
import { MIMEType } from './MIME';
export interface Attachment {
export type Attachment = {
fileName?: string;
contentType?: MIMEType;
size?: number;
@ -21,8 +24,14 @@ export interface Attachment {
// key?: ArrayBuffer;
// digest?: ArrayBuffer;
// flags?: number;
} & Partial<AttachmentSchemaVersion3>;
interface AttachmentSchemaVersion3 {
path: string;
}
const SAVE_CONTENT_TYPE = 'application/octet-stream' as MIMEType;
export const isVisualMedia = (attachment: Attachment): boolean => {
const { contentType } = attachment;
@ -34,3 +43,62 @@ export const isVisualMedia = (attachment: Attachment): boolean => {
const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType);
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 const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
export const isImage = (value: MIMEType): boolean => value.startsWith('image/');
export const isVideo = (value: MIMEType): boolean => value.startsWith('video/');
export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/');

View file

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