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:
commit
2e6f19da8f
40 changed files with 839 additions and 243 deletions
|
@ -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
|
||||
|
|
|
@ -326,10 +326,18 @@
|
|||
"message": "Media",
|
||||
"description": "Header of the default pane in the media gallery, showing images and videos"
|
||||
},
|
||||
"mediaEmptyState": {
|
||||
"message": "You don’t 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 don’t 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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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> -->
|
||||
|
|
104
js/background.js
104
js/background.js
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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 don’t 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(),
|
||||
});
|
||||
|
||||
|
|
12
preload.js
12
preload.js
|
@ -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 =
|
||||
|
|
|
@ -113,7 +113,6 @@ module.exports = {
|
|||
{
|
||||
src: 'js/expiring_messages.js',
|
||||
},
|
||||
|
||||
{
|
||||
src: 'js/chromium.js',
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
background: transparent;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
|
|
@ -6,6 +6,10 @@ module.exports = {
|
|||
browser: true,
|
||||
},
|
||||
|
||||
globals: {
|
||||
assert: true
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
19
ts/components/LightboxGallery.md
Normal file
19
ts/components/LightboxGallery.md
Normal 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>
|
||||
```
|
97
ts/components/LightboxGallery.tsx
Normal file
97
ts/components/LightboxGallery.tsx
Normal 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 });
|
||||
};
|
||||
}
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ const styles = {
|
|||
borderBottomStyle: 'solid',
|
||||
},
|
||||
itemContainer: {
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
|
|
11
ts/components/conversation/media-gallery/EmptyState.md
Normal file
11
ts/components/conversation/media-gallery/EmptyState.md
Normal 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>
|
||||
```
|
29
ts/components/conversation/media-gallery/EmptyState.tsx
Normal file
29
ts/components/conversation/media-gallery/EmptyState.tsx
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import { Message } from '../propTypes/Message';
|
||||
|
||||
export interface ItemClickEvent {
|
||||
message: Message;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
export type AttachmentType = 'media' | 'documents';
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import { AttachmentType } from './AttachmentType';
|
||||
import { Message } from './Message';
|
||||
|
||||
export interface ItemClickEvent {
|
||||
message: Message;
|
||||
type: AttachmentType;
|
||||
}
|
|
@ -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 don’t:
|
||||
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,
|
4
ts/components/styles/Colors.ts
Normal file
4
ts/components/styles/Colors.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
export const TEXT_SECONDARY = '#bbb';
|
|
@ -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(),
|
||||
|
|
60
ts/test/types/Attachment_test.ts
Normal file
60
ts/test/types/Attachment_test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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];
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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/');
|
||||
|
|
|
@ -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
17
ts/util/saveURLAsFile.ts
Normal 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();
|
||||
};
|
Loading…
Reference in a new issue