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/logging.js
|
||||||
!js/models/conversations.js
|
!js/models/conversations.js
|
||||||
!js/models/messages.js
|
!js/models/messages.js
|
||||||
!test/backup_test.js
|
|
||||||
!js/views/attachment_view.js
|
!js/views/attachment_view.js
|
||||||
!js/views/conversation_view.js
|
|
||||||
!js/views/conversation_search_view.js
|
|
||||||
!js/views/backbone_wrapper_view.js
|
!js/views/backbone_wrapper_view.js
|
||||||
|
!js/views/conversation_search_view.js
|
||||||
|
!js/views/conversation_view.js
|
||||||
!js/views/debug_log_view.js
|
!js/views/debug_log_view.js
|
||||||
!js/views/file_input_view.js
|
!js/views/file_input_view.js
|
||||||
!js/views/inbox_view.js
|
!js/views/inbox_view.js
|
||||||
!js/views/message_view.js
|
!js/views/message_view.js
|
||||||
!js/views/settings_view.js
|
!js/views/settings_view.js
|
||||||
|
!test/backup_test.js
|
||||||
|
!test/views/attachment_view_test.js
|
||||||
!libtextsecure/message_receiver.js
|
!libtextsecure/message_receiver.js
|
||||||
!main.js
|
!main.js
|
||||||
!preload.js
|
!preload.js
|
||||||
|
|
|
@ -326,10 +326,18 @@
|
||||||
"message": "Media",
|
"message": "Media",
|
||||||
"description": "Header of the default pane in the media gallery, showing images and videos"
|
"description": "Header of the default pane in the media gallery, showing images and videos"
|
||||||
},
|
},
|
||||||
|
"mediaEmptyState": {
|
||||||
|
"message": "You 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": {
|
"documents": {
|
||||||
"message": "Documents",
|
"message": "Documents",
|
||||||
"description": "Header of the secondary pane in the media gallery, showing every non-media attachment"
|
"description": "Header of the secondary pane in the media gallery, showing every non-media attachment"
|
||||||
},
|
},
|
||||||
|
"documentsEmptyState": {
|
||||||
|
"message": "You 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": {
|
"messageCaption": {
|
||||||
"message": "Message caption",
|
"message": "Message caption",
|
||||||
"description": "Prefix of attachment alt tags in the media gallery"
|
"description": "Prefix of attachment alt tags in the media gallery"
|
||||||
|
|
|
@ -114,7 +114,7 @@ exports.createName = () => {
|
||||||
return buffer.toString('hex');
|
return buffer.toString('hex');
|
||||||
};
|
};
|
||||||
|
|
||||||
// getRelativePath :: String -> IO Path
|
// getRelativePath :: String -> Path
|
||||||
exports.getRelativePath = (name) => {
|
exports.getRelativePath = (name) => {
|
||||||
if (!isString(name)) {
|
if (!isString(name)) {
|
||||||
throw new TypeError("'name' must be a string");
|
throw new TypeError("'name' must be a string");
|
||||||
|
@ -123,3 +123,7 @@ exports.getRelativePath = (name) => {
|
||||||
const prefix = name.slice(0, 2);
|
const prefix = name.slice(0, 2);
|
||||||
return path.join(prefix, name);
|
return path.join(prefix, name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// createAbsolutePathGetter :: RoothPath -> RelativePath -> AbsolutePath
|
||||||
|
exports.createAbsolutePathGetter = rootPath => relativePath =>
|
||||||
|
path.join(rootPath, relativePath);
|
||||||
|
|
|
@ -160,8 +160,7 @@
|
||||||
<button class='hamburger' alt='conversation menu'></button>
|
<button class='hamburger' alt='conversation menu'></button>
|
||||||
<ul class='menu-list'>
|
<ul class='menu-list'>
|
||||||
<li class='disappearing-messages'>{{ disappearing-messages }}</li>
|
<li class='disappearing-messages'>{{ disappearing-messages }}</li>
|
||||||
<!-- TODO: Enable once media gallerys ships: -->
|
<li class='view-all-media'>{{ view-all-media }}</li>
|
||||||
<!-- <li class='view-all-media'>{{ view-all-media }}</li> -->
|
|
||||||
{{#group}}
|
{{#group}}
|
||||||
<li class='show-members'>{{ show-members }}</li>
|
<li class='show-members'>{{ show-members }}</li>
|
||||||
<!-- <li class='update-group'>Update group</li> -->
|
<!-- <li class='update-group'>Update group</li> -->
|
||||||
|
|
104
js/background.js
104
js/background.js
|
@ -17,10 +17,7 @@
|
||||||
const { IdleDetector, MessageDataMigrator } = Signal.Workflow;
|
const { IdleDetector, MessageDataMigrator } = Signal.Workflow;
|
||||||
const { Errors, Message } = window.Signal.Types;
|
const { Errors, Message } = window.Signal.Types;
|
||||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||||
const {
|
const { Migrations0DatabaseWithAttachmentData } = window.Signal.Migrations;
|
||||||
Migrations0DatabaseWithAttachmentData,
|
|
||||||
Migrations1DatabaseWithoutAttachmentData,
|
|
||||||
} = window.Signal.Migrations;
|
|
||||||
const { Views } = window.Signal;
|
const { Views } = window.Signal;
|
||||||
|
|
||||||
// Implicitly used in `indexeddb-backbonejs-adapter`:
|
// Implicitly used in `indexeddb-backbonejs-adapter`:
|
||||||
|
@ -90,18 +87,37 @@
|
||||||
storage.fetch();
|
storage.fetch();
|
||||||
|
|
||||||
const idleDetector = new IdleDetector();
|
const idleDetector = new IdleDetector();
|
||||||
|
let isMigrationWithIndexComplete = false;
|
||||||
|
let isMigrationWithoutIndexComplete = false;
|
||||||
idleDetector.on('idle', async () => {
|
idleDetector.on('idle', async () => {
|
||||||
const NUM_MESSAGES_PER_BATCH = 1;
|
const NUM_MESSAGES_PER_BATCH = 1;
|
||||||
const database = Migrations0DatabaseWithAttachmentData.getDatabase();
|
|
||||||
const batch = await MessageDataMigrator.processNextBatchWithoutIndex({
|
|
||||||
databaseName: database.name,
|
|
||||||
minDatabaseVersion: database.version,
|
|
||||||
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
|
|
||||||
upgradeMessageSchema,
|
|
||||||
});
|
|
||||||
console.log('Upgrade message schema:', batch);
|
|
||||||
|
|
||||||
if (batch.done) {
|
if (!isMigrationWithIndexComplete) {
|
||||||
|
const batchWithIndex = await MessageDataMigrator.processNext({
|
||||||
|
BackboneMessage: Whisper.Message,
|
||||||
|
BackboneMessageCollection: Whisper.MessageCollection,
|
||||||
|
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
|
||||||
|
upgradeMessageSchema,
|
||||||
|
});
|
||||||
|
console.log('Upgrade message schema (with index):', batchWithIndex);
|
||||||
|
isMigrationWithIndexComplete = batchWithIndex.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMigrationWithoutIndexComplete) {
|
||||||
|
const database = Migrations0DatabaseWithAttachmentData.getDatabase();
|
||||||
|
const batchWithoutIndex = await MessageDataMigrator.processNextBatchWithoutIndex({
|
||||||
|
databaseName: database.name,
|
||||||
|
minDatabaseVersion: database.version,
|
||||||
|
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
|
||||||
|
upgradeMessageSchema,
|
||||||
|
});
|
||||||
|
console.log('Upgrade message schema (without index):', batchWithoutIndex);
|
||||||
|
isMigrationWithoutIndexComplete = batchWithoutIndex.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areAllMigrationsComplete = isMigrationWithIndexComplete &&
|
||||||
|
isMigrationWithoutIndexComplete;
|
||||||
|
if (areAllMigrationsComplete) {
|
||||||
idleDetector.stop();
|
idleDetector.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -117,7 +133,6 @@
|
||||||
first = false;
|
first = false;
|
||||||
|
|
||||||
ConversationController.load().then(start, start);
|
ConversationController.load().then(start, start);
|
||||||
idleDetector.start();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Whisper.events.on('shutdown', function() {
|
Whisper.events.on('shutdown', function() {
|
||||||
|
@ -368,33 +383,48 @@
|
||||||
storage,
|
storage,
|
||||||
});
|
});
|
||||||
console.log('Sync read receipt configuration status:', status);
|
console.log('Sync read receipt configuration status:', status);
|
||||||
/* eslint-disable */
|
|
||||||
|
|
||||||
if (firstRun === true && deviceId != '1') {
|
if (firstRun === true && deviceId !== '1') {
|
||||||
if (!storage.get('theme-setting') && textsecure.storage.get('userAgent') === 'OWI') {
|
const hasThemeSetting = Boolean(storage.get('theme-setting'));
|
||||||
storage.put('theme-setting', 'ios');
|
if (!hasThemeSetting && textsecure.storage.get('userAgent') === 'OWI') {
|
||||||
onChangeTheme();
|
storage.put('theme-setting', 'ios');
|
||||||
}
|
onChangeTheme();
|
||||||
var syncRequest = new textsecure.SyncRequest(textsecure.messaging, messageReceiver);
|
}
|
||||||
Whisper.events.trigger('contactsync:begin');
|
const syncRequest = new textsecure.SyncRequest(
|
||||||
syncRequest.addEventListener('success', function() {
|
textsecure.messaging,
|
||||||
console.log('sync successful');
|
messageReceiver
|
||||||
storage.put('synced_at', Date.now());
|
);
|
||||||
Whisper.events.trigger('contactsync');
|
Whisper.events.trigger('contactsync:begin');
|
||||||
});
|
syncRequest.addEventListener('success', () => {
|
||||||
syncRequest.addEventListener('timeout', function() {
|
console.log('sync successful');
|
||||||
console.log('sync timed out');
|
storage.put('synced_at', Date.now());
|
||||||
Whisper.events.trigger('contactsync');
|
Whisper.events.trigger('contactsync');
|
||||||
});
|
});
|
||||||
|
syncRequest.addEventListener('timeout', () => {
|
||||||
|
console.log('sync timed out');
|
||||||
|
Whisper.events.trigger('contactsync');
|
||||||
|
});
|
||||||
|
|
||||||
if (Whisper.Import.isComplete()) {
|
if (Whisper.Import.isComplete()) {
|
||||||
textsecure.messaging.sendRequestConfigurationSyncMessage().catch(function(e) {
|
textsecure.messaging.sendRequestConfigurationSyncMessage().catch((e) => {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
storage.onready(async () => {
|
||||||
|
const shouldSkipAttachmentMigrationForNewUsers = firstRun === true;
|
||||||
|
if (shouldSkipAttachmentMigrationForNewUsers) {
|
||||||
|
const database = Migrations0DatabaseWithAttachmentData.getDatabase();
|
||||||
|
const connection =
|
||||||
|
await Signal.Database.open(database.name, database.version);
|
||||||
|
await Signal.Settings.markAttachmentMigrationComplete(connection);
|
||||||
|
}
|
||||||
|
idleDetector.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
function onChangeTheme() {
|
function onChangeTheme() {
|
||||||
var view = window.owsDesktopApp.appView;
|
var view = window.owsDesktopApp.appView;
|
||||||
if (view) {
|
if (view) {
|
||||||
|
|
|
@ -561,14 +561,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message.set({
|
message.set({
|
||||||
schemaVersion: dataMessage.schemaVersion,
|
attachments: dataMessage.attachments,
|
||||||
body: dataMessage.body,
|
body: dataMessage.body,
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
attachments: dataMessage.attachments,
|
|
||||||
quote: dataMessage.quote,
|
|
||||||
decrypted_at: now,
|
decrypted_at: now,
|
||||||
flags: dataMessage.flags,
|
|
||||||
errors: [],
|
errors: [],
|
||||||
|
flags: dataMessage.flags,
|
||||||
|
hasAttachments: dataMessage.hasAttachments,
|
||||||
|
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||||
|
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||||
|
quote: dataMessage.quote,
|
||||||
|
schemaVersion: dataMessage.schemaVersion,
|
||||||
});
|
});
|
||||||
if (type === 'outgoing') {
|
if (type === 'outgoing') {
|
||||||
const receipts = Whisper.DeliveryReceipts.forMessage(conversation, message);
|
const receipts = Whisper.DeliveryReceipts.forMessage(conversation, message);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const is = require('@sindresorhus/is');
|
const is = require('@sindresorhus/is');
|
||||||
|
|
||||||
|
const AttachmentTS = require('../../../ts/types/Attachment');
|
||||||
const MIME = require('../../../ts/types/MIME');
|
const MIME = require('../../../ts/types/MIME');
|
||||||
const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util');
|
const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util');
|
||||||
const { autoOrientImage } = require('../auto_orient_image');
|
const { autoOrientImage } = require('../auto_orient_image');
|
||||||
|
@ -163,3 +164,5 @@ exports.deleteData = (deleteAttachmentData) => {
|
||||||
await deleteAttachmentData(attachment.path);
|
await deleteAttachmentData(attachment.path);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.save = AttachmentTS.save;
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
/* global _: false */
|
/* global _: false */
|
||||||
/* global Backbone: false */
|
/* global Backbone: false */
|
||||||
/* global filesize: false */
|
/* global filesize: false */
|
||||||
/* global moment: false */
|
|
||||||
|
|
||||||
/* global i18n: false */
|
/* global i18n: false */
|
||||||
/* global Signal: false */
|
/* global Signal: false */
|
||||||
|
@ -103,12 +102,6 @@
|
||||||
|
|
||||||
this.remove();
|
this.remove();
|
||||||
},
|
},
|
||||||
getFileType() {
|
|
||||||
switch (this.model.contentType) {
|
|
||||||
case 'video/quicktime': return 'mov';
|
|
||||||
default: return this.model.contentType.split('/')[1];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick() {
|
onClick() {
|
||||||
if (!this.isImage()) {
|
if (!this.isImage()) {
|
||||||
this.saveFile();
|
this.saveFile();
|
||||||
|
@ -116,7 +109,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
imageURL: this.objectUrl,
|
objectURL: this.objectUrl,
|
||||||
|
contentType: this.model.contentType,
|
||||||
onSave: () => this.saveFile(),
|
onSave: () => this.saveFile(),
|
||||||
// implicit: `close`
|
// implicit: `close`
|
||||||
};
|
};
|
||||||
|
@ -182,26 +176,13 @@
|
||||||
|
|
||||||
return i18n('unnamedFile');
|
return i18n('unnamedFile');
|
||||||
},
|
},
|
||||||
suggestedName() {
|
|
||||||
if (this.model.fileName) {
|
|
||||||
return this.model.fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
let suggestion = 'signal';
|
|
||||||
if (this.timestamp) {
|
|
||||||
suggestion += moment(this.timestamp).format('-YYYY-MM-DD-HHmmss');
|
|
||||||
}
|
|
||||||
const fileType = this.getFileType();
|
|
||||||
if (fileType) {
|
|
||||||
suggestion += `.${fileType}`;
|
|
||||||
}
|
|
||||||
return suggestion;
|
|
||||||
},
|
|
||||||
saveFile() {
|
saveFile() {
|
||||||
const url = window.URL.createObjectURL(this.blob, { type: 'octet/stream' });
|
Signal.Types.Attachment.save({
|
||||||
const a = $('<a>').attr({ href: url, download: this.suggestedName() });
|
attachment: this.model,
|
||||||
a[0].click();
|
document,
|
||||||
window.URL.revokeObjectURL(url);
|
getAbsolutePath: Signal.Migrations.getAbsoluteAttachmentPath,
|
||||||
|
timestamp: this.timestamp,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
render() {
|
render() {
|
||||||
if (!this.isImage()) {
|
if (!this.isImage()) {
|
||||||
|
|
|
@ -8,9 +8,9 @@
|
||||||
|
|
||||||
/* global extension: false */
|
/* global extension: false */
|
||||||
/* global i18n: false */
|
/* global i18n: false */
|
||||||
|
/* global Signal: false */
|
||||||
/* global storage: false */
|
/* global storage: false */
|
||||||
/* global Whisper: false */
|
/* global Whisper: false */
|
||||||
/* global Signal: false */
|
|
||||||
|
|
||||||
// eslint-disable-next-line func-names
|
// eslint-disable-next-line func-names
|
||||||
(function () {
|
(function () {
|
||||||
|
@ -282,6 +282,9 @@
|
||||||
if (this.quoteView) {
|
if (this.quoteView) {
|
||||||
this.quoteView.remove();
|
this.quoteView.remove();
|
||||||
}
|
}
|
||||||
|
if (this.lightboxGalleryView) {
|
||||||
|
this.lightboxGalleryView.remove();
|
||||||
|
}
|
||||||
if (this.panels && this.panels.length) {
|
if (this.panels && this.panels.length) {
|
||||||
for (let i = 0, max = this.panels.length; i < max; i += 1) {
|
for (let i = 0, max = this.panels.length; i < max; i += 1) {
|
||||||
const panel = this.panels[i];
|
const panel = this.panels[i];
|
||||||
|
@ -577,33 +580,82 @@
|
||||||
// events up to its parent elements in the DOM.
|
// events up to its parent elements in the DOM.
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
|
|
||||||
const media = await Signal.Backbone.Conversation.fetchVisualMediaAttachments({
|
// We fetch more documents than media as they don’t require to be loaded
|
||||||
conversationId: this.model.get('id'),
|
// into memory right away. Revisit this once we have infinite scrolling:
|
||||||
WhisperMessageCollection: Whisper.MessageCollection,
|
const DEFAULT_MEDIA_FETCH_COUNT = 50;
|
||||||
});
|
const DEFAULT_DOCUMENTS_FETCH_COUNT = 150;
|
||||||
const loadMessages = Signal.Components.PropTypes.Message
|
|
||||||
.loadWithObjectURL(Signal.Migrations.loadMessage);
|
|
||||||
const mediaWithObjectURLs = await loadMessages(media);
|
|
||||||
|
|
||||||
const mediaGalleryProps = {
|
const conversationId = this.model.get('id');
|
||||||
media: mediaWithObjectURLs,
|
const WhisperMessageCollection = Whisper.MessageCollection;
|
||||||
documents: [],
|
const rawMedia = await Signal.Backbone.Conversation.fetchVisualMediaAttachments({
|
||||||
onItemClick: ({ message }) => {
|
conversationId,
|
||||||
const lightboxProps = {
|
count: DEFAULT_MEDIA_FETCH_COUNT,
|
||||||
imageURL: message.objectURL,
|
WhisperMessageCollection,
|
||||||
};
|
});
|
||||||
this.lightboxView = new Whisper.ReactWrapperView({
|
const documents = await Signal.Backbone.Conversation.fetchFileAttachments({
|
||||||
Component: Signal.Components.Lightbox,
|
conversationId,
|
||||||
props: lightboxProps,
|
count: DEFAULT_DOCUMENTS_FETCH_COUNT,
|
||||||
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
|
WhisperMessageCollection,
|
||||||
});
|
});
|
||||||
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
|
|
||||||
},
|
// NOTE: Could we show grid previews from disk as well?
|
||||||
|
const loadMessages = Signal.Components.Types.Message
|
||||||
|
.loadWithObjectURL(Signal.Migrations.loadMessage);
|
||||||
|
const media = await loadMessages(rawMedia);
|
||||||
|
|
||||||
|
const { getAbsoluteAttachmentPath } = Signal.Migrations;
|
||||||
|
const saveAttachment = async ({ message } = {}) => {
|
||||||
|
const attachment = message.attachments[0];
|
||||||
|
const timestamp = message.received_at;
|
||||||
|
Signal.Types.Attachment.save({
|
||||||
|
attachment,
|
||||||
|
document,
|
||||||
|
getAbsolutePath: getAbsoluteAttachmentPath,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onItemClick = async ({ message, type }) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'documents': {
|
||||||
|
saveAttachment({ message });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'media': {
|
||||||
|
const mediaWithObjectURL = media.map(mediaMessage =>
|
||||||
|
Object.assign(
|
||||||
|
{},
|
||||||
|
mediaMessage,
|
||||||
|
{ objectURL: getAbsoluteAttachmentPath(mediaMessage.attachments[0].path) }
|
||||||
|
));
|
||||||
|
const selectedIndex = media.findIndex(mediaMessage =>
|
||||||
|
mediaMessage.id === message.id);
|
||||||
|
this.lightboxGalleryView = new Whisper.ReactWrapperView({
|
||||||
|
Component: Signal.Components.LightboxGallery,
|
||||||
|
props: {
|
||||||
|
messages: mediaWithObjectURL,
|
||||||
|
onSave: () => saveAttachment({ message }),
|
||||||
|
selectedIndex,
|
||||||
|
},
|
||||||
|
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
|
||||||
|
});
|
||||||
|
Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new TypeError(`Unknown attachment type: '${type}'`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const view = new Whisper.ReactWrapperView({
|
const view = new Whisper.ReactWrapperView({
|
||||||
Component: Signal.Components.MediaGallery,
|
Component: Signal.Components.MediaGallery,
|
||||||
props: mediaGalleryProps,
|
props: {
|
||||||
|
documents,
|
||||||
|
media,
|
||||||
|
onItemClick,
|
||||||
|
},
|
||||||
onClose: () => this.resetPanel(),
|
onClose: () => this.resetPanel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
12
preload.js
12
preload.js
|
@ -136,6 +136,7 @@ window.moment.locale(locale);
|
||||||
|
|
||||||
// ES2015+ modules
|
// ES2015+ modules
|
||||||
const attachmentsPath = Attachments.getPath(app.getPath('userData'));
|
const attachmentsPath = Attachments.getPath(app.getPath('userData'));
|
||||||
|
const getAbsoluteAttachmentPath = Attachments.createAbsolutePathGetter(attachmentsPath);
|
||||||
const deleteAttachmentData = Attachments.createDeleter(attachmentsPath);
|
const deleteAttachmentData = Attachments.createDeleter(attachmentsPath);
|
||||||
const readAttachmentData = Attachments.createReader(attachmentsPath);
|
const readAttachmentData = Attachments.createReader(attachmentsPath);
|
||||||
const writeNewAttachmentData = Attachments.createWriterForNew(attachmentsPath);
|
const writeNewAttachmentData = Attachments.createWriterForNew(attachmentsPath);
|
||||||
|
@ -165,18 +166,20 @@ window.Signal.Logs = require('./js/modules/logs');
|
||||||
|
|
||||||
// React components
|
// React components
|
||||||
const { Lightbox } = require('./ts/components/Lightbox');
|
const { Lightbox } = require('./ts/components/Lightbox');
|
||||||
|
const { LightboxGallery } = require('./ts/components/LightboxGallery');
|
||||||
const { MediaGallery } =
|
const { MediaGallery } =
|
||||||
require('./ts/components/conversation/media-gallery/MediaGallery');
|
require('./ts/components/conversation/media-gallery/MediaGallery');
|
||||||
const { Quote } = require('./ts/components/conversation/Quote');
|
const { Quote } = require('./ts/components/conversation/Quote');
|
||||||
|
|
||||||
const PropTypesMessage =
|
const MediaGalleryMessage =
|
||||||
require('./ts/components/conversation/media-gallery/propTypes/Message');
|
require('./ts/components/conversation/media-gallery/types/Message');
|
||||||
|
|
||||||
window.Signal.Components = {
|
window.Signal.Components = {
|
||||||
Lightbox,
|
Lightbox,
|
||||||
|
LightboxGallery,
|
||||||
MediaGallery,
|
MediaGallery,
|
||||||
PropTypes: {
|
Types: {
|
||||||
Message: PropTypesMessage,
|
Message: MediaGalleryMessage,
|
||||||
},
|
},
|
||||||
Quote,
|
Quote,
|
||||||
};
|
};
|
||||||
|
@ -187,6 +190,7 @@ window.Signal.Migrations.deleteAttachmentData =
|
||||||
window.Signal.Migrations.getPlaceholderMigrations = getPlaceholderMigrations;
|
window.Signal.Migrations.getPlaceholderMigrations = getPlaceholderMigrations;
|
||||||
window.Signal.Migrations.writeMessageAttachments =
|
window.Signal.Migrations.writeMessageAttachments =
|
||||||
Message.createAttachmentDataWriter(writeExistingAttachmentData);
|
Message.createAttachmentDataWriter(writeExistingAttachmentData);
|
||||||
|
window.Signal.Migrations.getAbsoluteAttachmentPath = getAbsoluteAttachmentPath;
|
||||||
window.Signal.Migrations.loadAttachmentData = loadAttachmentData;
|
window.Signal.Migrations.loadAttachmentData = loadAttachmentData;
|
||||||
window.Signal.Migrations.loadMessage = Message.createAttachmentLoader(loadAttachmentData);
|
window.Signal.Migrations.loadMessage = Message.createAttachmentLoader(loadAttachmentData);
|
||||||
window.Signal.Migrations.Migrations0DatabaseWithAttachmentData =
|
window.Signal.Migrations.Migrations0DatabaseWithAttachmentData =
|
||||||
|
|
|
@ -113,7 +113,6 @@ module.exports = {
|
||||||
{
|
{
|
||||||
src: 'js/expiring_messages.js',
|
src: 'js/expiring_messages.js',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
src: 'js/chromium.js',
|
src: 'js/chromium.js',
|
||||||
},
|
},
|
||||||
|
|
|
@ -78,10 +78,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel,
|
||||||
|
.react-wrapper {
|
||||||
height: calc(100% - #{$header-height});
|
height: calc(100% - #{$header-height});
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
.container {
|
.container {
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
max-width: 750px;
|
max-width: 750px;
|
||||||
|
@ -89,11 +92,15 @@
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.main.panel {
|
|
||||||
|
.main.panel,
|
||||||
|
.react-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: initial;
|
overflow: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main.panel {
|
||||||
.discussion-container {
|
.discussion-container {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -80,7 +80,8 @@ $ios-border-color: rgba(0,0,0,0.1);
|
||||||
.avatar { display: none; }
|
.avatar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation .panel {
|
.conversation .panel,
|
||||||
|
.conversation .react-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: $header-height;
|
top: $header-height;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
|
@ -19,7 +19,6 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
margin-bottom: 10px;
|
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
@ -6,6 +6,10 @@ module.exports = {
|
||||||
browser: true,
|
browser: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
globals: {
|
||||||
|
assert: true
|
||||||
|
},
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'script',
|
sourceType: 'script',
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,59 +1,44 @@
|
||||||
describe('AttachmentView', function() {
|
/* global assert: false */
|
||||||
|
|
||||||
describe('with arbitrary files', function() {
|
/* global Whisper: false */
|
||||||
it('should render a file view', function() {
|
|
||||||
var attachment = {
|
|
||||||
contentType: 'unused',
|
|
||||||
size: 1232
|
|
||||||
};
|
|
||||||
var view = new Whisper.AttachmentView({model: attachment}).render();
|
|
||||||
assert.match(view.el.innerHTML, /fileView/);
|
|
||||||
});
|
|
||||||
it('should display the filename if present', function() {
|
|
||||||
var attachment = {
|
|
||||||
fileName: 'foo.txt',
|
|
||||||
contentType: 'unused',
|
|
||||||
size: 1232,
|
|
||||||
};
|
|
||||||
var view = new Whisper.AttachmentView({model: attachment}).render();
|
|
||||||
assert.match(view.el.innerHTML, /foo.txt/);
|
|
||||||
});
|
|
||||||
it('should render a file size', function() {
|
|
||||||
var attachment = {
|
|
||||||
size: 1232,
|
|
||||||
contentType: 'unused'
|
|
||||||
};
|
|
||||||
var view = new Whisper.AttachmentView({model: attachment}).render();
|
|
||||||
assert.match(view.el.innerHTML, /1.2 KB/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('should render an image for images', function() {
|
|
||||||
var now = new Date().getTime();
|
|
||||||
var attachment = { contentType: 'image/png', data: 'grumpy cat' };
|
|
||||||
var view = new Whisper.AttachmentView({model: attachment, timestamp: now}).render();
|
|
||||||
assert.equal(view.el.firstChild.tagName, "IMG");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display a filename', function() {
|
'use strict';
|
||||||
var epoch = new Date((new Date(0)).getTimezoneOffset() * 60 * 1000);
|
|
||||||
var attachment = { contentType: 'image/png', data: 'grumpy cat' };
|
describe('AttachmentView', () => {
|
||||||
var result = new Whisper.AttachmentView({
|
describe('with arbitrary files', () => {
|
||||||
|
it('should render a file view', () => {
|
||||||
|
const attachment = {
|
||||||
|
contentType: 'unused',
|
||||||
|
size: 1232,
|
||||||
|
};
|
||||||
|
const view = new Whisper.AttachmentView({ model: attachment }).render();
|
||||||
|
assert.match(view.el.innerHTML, /fileView/);
|
||||||
|
});
|
||||||
|
it('should display the filename if present', () => {
|
||||||
|
const attachment = {
|
||||||
|
fileName: 'foo.txt',
|
||||||
|
contentType: 'unused',
|
||||||
|
size: 1232,
|
||||||
|
};
|
||||||
|
const view = new Whisper.AttachmentView({ model: attachment }).render();
|
||||||
|
assert.match(view.el.innerHTML, /foo.txt/);
|
||||||
|
});
|
||||||
|
it('should render a file size', () => {
|
||||||
|
const attachment = {
|
||||||
|
size: 1232,
|
||||||
|
contentType: 'unused',
|
||||||
|
};
|
||||||
|
const view = new Whisper.AttachmentView({ model: attachment }).render();
|
||||||
|
assert.match(view.el.innerHTML, /1.2 KB/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should render an image for images', () => {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const attachment = { contentType: 'image/png', data: 'grumpy cat' };
|
||||||
|
const view = new Whisper.AttachmentView({
|
||||||
model: attachment,
|
model: attachment,
|
||||||
timestamp: epoch
|
timestamp: now,
|
||||||
}).suggestedName();
|
}).render();
|
||||||
|
assert.equal(view.el.firstChild.tagName, 'IMG');
|
||||||
var expected = '1970-01-01-000000';
|
|
||||||
assert(result === 'signal-' + expected + '.png');
|
|
||||||
});
|
|
||||||
it('should auto-generate a filename', function() {
|
|
||||||
var epoch = new Date((new Date(0)).getTimezoneOffset() * 60 * 1000);
|
|
||||||
var attachment = { contentType: 'image/png', data: 'grumpy cat' };
|
|
||||||
var result = new Whisper.AttachmentView({
|
|
||||||
model: attachment,
|
|
||||||
timestamp: epoch
|
|
||||||
}).suggestedName();
|
|
||||||
|
|
||||||
var expected = '1970-01-01-000000';
|
|
||||||
assert(result === 'signal-' + expected + '.png');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,14 +5,51 @@ import is from '@sindresorhus/is';
|
||||||
|
|
||||||
import { Collection as BackboneCollection } from '../types/backbone/Collection';
|
import { Collection as BackboneCollection } from '../types/backbone/Collection';
|
||||||
import { deferredToPromise } from '../../js/modules/deferred_to_promise';
|
import { deferredToPromise } from '../../js/modules/deferred_to_promise';
|
||||||
|
import { IndexableBoolean } from '../types/IndexedDB';
|
||||||
import { Message } from '../types/Message';
|
import { Message } from '../types/Message';
|
||||||
|
|
||||||
export const fetchVisualMediaAttachments = async ({
|
export const fetchVisualMediaAttachments = async ({
|
||||||
conversationId,
|
conversationId,
|
||||||
|
count,
|
||||||
WhisperMessageCollection,
|
WhisperMessageCollection,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
count: number;
|
||||||
WhisperMessageCollection: BackboneCollection<Message>;
|
WhisperMessageCollection: BackboneCollection<Message>;
|
||||||
|
}): Promise<Array<Message>> =>
|
||||||
|
fetchFromAttachmentsIndex({
|
||||||
|
name: 'hasVisualMediaAttachments',
|
||||||
|
conversationId,
|
||||||
|
WhisperMessageCollection,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchFileAttachments = async ({
|
||||||
|
conversationId,
|
||||||
|
count,
|
||||||
|
WhisperMessageCollection,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
count: number;
|
||||||
|
WhisperMessageCollection: BackboneCollection<Message>;
|
||||||
|
}): Promise<Array<Message>> =>
|
||||||
|
fetchFromAttachmentsIndex({
|
||||||
|
name: 'hasFileAttachments',
|
||||||
|
conversationId,
|
||||||
|
WhisperMessageCollection,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchFromAttachmentsIndex = async ({
|
||||||
|
name,
|
||||||
|
conversationId,
|
||||||
|
WhisperMessageCollection,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
name: 'hasVisualMediaAttachments' | 'hasFileAttachments';
|
||||||
|
conversationId: string;
|
||||||
|
WhisperMessageCollection: BackboneCollection<Message>;
|
||||||
|
count: number;
|
||||||
}): Promise<Array<Message>> => {
|
}): Promise<Array<Message>> => {
|
||||||
if (!is.string(conversationId)) {
|
if (!is.string(conversationId)) {
|
||||||
throw new TypeError("'conversationId' is required");
|
throw new TypeError("'conversationId' is required");
|
||||||
|
@ -25,16 +62,16 @@ export const fetchVisualMediaAttachments = async ({
|
||||||
const collection = new WhisperMessageCollection();
|
const collection = new WhisperMessageCollection();
|
||||||
const lowerReceivedAt = 0;
|
const lowerReceivedAt = 0;
|
||||||
const upperReceivedAt = Number.MAX_VALUE;
|
const upperReceivedAt = Number.MAX_VALUE;
|
||||||
const hasVisualMediaAttachments = 1;
|
const condition: IndexableBoolean = 1;
|
||||||
await deferredToPromise(
|
await deferredToPromise(
|
||||||
collection.fetch({
|
collection.fetch({
|
||||||
index: {
|
index: {
|
||||||
name: 'hasVisualMediaAttachments',
|
name,
|
||||||
lower: [conversationId, lowerReceivedAt, hasVisualMediaAttachments],
|
lower: [conversationId, lowerReceivedAt, condition],
|
||||||
upper: [conversationId, upperReceivedAt, hasVisualMediaAttachments],
|
upper: [conversationId, upperReceivedAt, condition],
|
||||||
order: 'desc',
|
order: 'desc',
|
||||||
},
|
},
|
||||||
limit: 50,
|
limit: count,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,8 @@ const noop = () => {};
|
||||||
|
|
||||||
<div style={{position: 'relative', width: '100%', height: 500}}>
|
<div style={{position: 'relative', width: '100%', height: 500}}>
|
||||||
<Lightbox
|
<Lightbox
|
||||||
imageURL="https://placekitten.com/800/600"
|
objectURL="https://placekitten.com/800/600"
|
||||||
|
contentType="image/jpeg"
|
||||||
onNext={noop}
|
onNext={noop}
|
||||||
onPrevious={noop}
|
onPrevious={noop}
|
||||||
onSave={noop}
|
onSave={noop}
|
||||||
|
|
|
@ -4,26 +4,42 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import is from '@sindresorhus/is';
|
||||||
|
|
||||||
|
import * as GoogleChrome from '../util/GoogleChrome';
|
||||||
|
import * as MIME from '../types/MIME';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
imageURL?: string;
|
objectURL: string;
|
||||||
|
contentType: MIME.MIMEType | undefined;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
onPrevious?: () => void;
|
onPrevious?: () => void;
|
||||||
onSave: () => void;
|
onSave?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONTROLS_WIDTH = 50;
|
||||||
|
const CONTROLS_SPACING = 10;
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
container: {
|
container: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'column',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
padding: 40,
|
} as React.CSSProperties,
|
||||||
|
mainContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingLeft: 40,
|
||||||
|
paddingRight: 40,
|
||||||
|
paddingBottom: 0,
|
||||||
} as React.CSSProperties,
|
} as React.CSSProperties,
|
||||||
objectContainer: {
|
objectContainer: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
@ -37,20 +53,64 @@ const styles = {
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
} as React.CSSProperties,
|
} as React.CSSProperties,
|
||||||
|
controlsOffsetPlaceholder: {
|
||||||
|
width: CONTROLS_WIDTH,
|
||||||
|
marginRight: CONTROLS_SPACING,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
controls: {
|
controls: {
|
||||||
|
width: CONTROLS_WIDTH,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
marginLeft: 10,
|
marginLeft: CONTROLS_SPACING,
|
||||||
} as React.CSSProperties,
|
} as React.CSSProperties,
|
||||||
|
navigationContainer: {
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 10,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
saveButton: {
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
iconButtonPlaceholder: {
|
||||||
|
// Dimensions match `.iconButton`:
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IconButtonProps {
|
interface IconButtonProps {
|
||||||
type: 'save' | 'close' | 'previous' | 'next';
|
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
type: 'save' | 'close' | 'previous' | 'next';
|
||||||
}
|
}
|
||||||
const IconButton = ({ onClick, type }: IconButtonProps) => (
|
|
||||||
<a href="#" onClick={onClick} className={classNames('iconButton', type)} />
|
const IconButton = ({ onClick, style, type }: IconButtonProps) => {
|
||||||
|
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!onClick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={clickHandler}
|
||||||
|
className={classNames('iconButton', type)}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconButtonPlaceholder = () => (
|
||||||
|
<div style={styles.iconButtonPlaceholder} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export class Lightbox extends React.Component<Props, {}> {
|
export class Lightbox extends React.Component<Props, {}> {
|
||||||
|
@ -67,36 +127,79 @@ export class Lightbox extends React.Component<Props, {}> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const { imageURL } = this.props;
|
const { contentType, objectURL, onNext, onPrevious, onSave } = this.props;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
onClick={this.onContainerClick}
|
onClick={this.onContainerClick}
|
||||||
ref={this.setContainerRef}
|
ref={this.setContainerRef}
|
||||||
>
|
>
|
||||||
<div style={styles.objectContainer}>
|
<div style={styles.mainContainer}>
|
||||||
<img
|
<div style={styles.controlsOffsetPlaceholder} />
|
||||||
style={styles.image}
|
<div style={styles.objectContainer}>
|
||||||
src={imageURL}
|
{!is.undefined(contentType)
|
||||||
onClick={this.onImageClick}
|
? this.renderObject({ objectURL, contentType })
|
||||||
/>
|
: null}
|
||||||
|
</div>
|
||||||
|
<div style={styles.controls}>
|
||||||
|
<IconButton type="close" onClick={this.onClose} />
|
||||||
|
{onSave ? (
|
||||||
|
<IconButton
|
||||||
|
type="save"
|
||||||
|
onClick={onSave}
|
||||||
|
style={styles.saveButton}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.controls}>
|
<div style={styles.navigationContainer}>
|
||||||
<IconButton type="close" onClick={this.onClose} />
|
{onPrevious ? (
|
||||||
{this.props.onSave ? (
|
<IconButton type="previous" onClick={onPrevious} />
|
||||||
<IconButton type="save" onClick={this.props.onSave} />
|
) : (
|
||||||
) : null}
|
<IconButtonPlaceholder />
|
||||||
{this.props.onPrevious ? (
|
)}
|
||||||
<IconButton type="previous" onClick={this.props.onPrevious} />
|
{onNext ? (
|
||||||
) : null}
|
<IconButton type="next" onClick={onNext} />
|
||||||
{this.props.onNext ? (
|
) : (
|
||||||
<IconButton type="next" onClick={this.props.onNext} />
|
<IconButtonPlaceholder />
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderObject = ({
|
||||||
|
objectURL,
|
||||||
|
contentType,
|
||||||
|
}: {
|
||||||
|
objectURL: string;
|
||||||
|
contentType: MIME.MIMEType;
|
||||||
|
}) => {
|
||||||
|
const isImage = GoogleChrome.isImageTypeSupported(contentType);
|
||||||
|
if (isImage) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
style={styles.image}
|
||||||
|
src={objectURL}
|
||||||
|
onClick={this.onObjectClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideo = GoogleChrome.isVideoTypeSupported(contentType);
|
||||||
|
if (isVideo) {
|
||||||
|
return (
|
||||||
|
<video controls={true}>
|
||||||
|
<source src={objectURL} />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// tslint:disable-next-line no-console
|
||||||
|
console.log('Lightbox: Unexpected content type', { contentType });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
private setContainerRef = (value: HTMLDivElement) => {
|
private setContainerRef = (value: HTMLDivElement) => {
|
||||||
this.containerRef = value;
|
this.containerRef = value;
|
||||||
};
|
};
|
||||||
|
@ -111,11 +214,28 @@ export class Lightbox extends React.Component<Props, {}> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private onKeyUp = (event: KeyboardEvent) => {
|
private onKeyUp = (event: KeyboardEvent) => {
|
||||||
if (event.key !== 'Escape') {
|
const { onClose } = this;
|
||||||
return;
|
const { onNext, onPrevious } = this.props;
|
||||||
}
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
|
||||||
this.onClose();
|
case 'ArrowLeft':
|
||||||
|
if (onPrevious) {
|
||||||
|
onPrevious();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (onNext) {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
private onContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
@ -125,7 +245,7 @@ export class Lightbox extends React.Component<Props, {}> {
|
||||||
this.onClose();
|
this.onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
|
private onObjectClick = (event: React.MouseEvent<HTMLImageElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.onClose();
|
this.onClose();
|
||||||
};
|
};
|
||||||
|
|
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 React from 'react';
|
||||||
|
|
||||||
|
import { AttachmentType } from './types/AttachmentType';
|
||||||
import { DocumentListItem } from './DocumentListItem';
|
import { DocumentListItem } from './DocumentListItem';
|
||||||
import { ItemClickEvent } from './events/ItemClickEvent';
|
import { ItemClickEvent } from './types/ItemClickEvent';
|
||||||
import { MediaGridItem } from './MediaGridItem';
|
import { MediaGridItem } from './MediaGridItem';
|
||||||
import { Message } from './propTypes/Message';
|
import { Message } from './types/Message';
|
||||||
import { missingCaseError } from '../../../util/missingCaseError';
|
import { missingCaseError } from '../../../util/missingCaseError';
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
|
@ -30,7 +31,7 @@ const styles = {
|
||||||
interface Props {
|
interface Props {
|
||||||
i18n: (value: string) => string;
|
i18n: (value: string) => string;
|
||||||
header?: string;
|
header?: string;
|
||||||
type: 'media' | 'documents';
|
type: AttachmentType;
|
||||||
messages: Array<Message>;
|
messages: Array<Message>;
|
||||||
onItemClick?: (event: ItemClickEvent) => void;
|
onItemClick?: (event: ItemClickEvent) => void;
|
||||||
}
|
}
|
||||||
|
@ -82,11 +83,11 @@ export class AttachmentSection extends React.Component<Props, {}> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private createClickHandler = (message: Message) => () => {
|
private createClickHandler = (message: Message) => () => {
|
||||||
const { onItemClick } = this.props;
|
const { onItemClick, type } = this.props;
|
||||||
if (!onItemClick) {
|
if (!onItemClick) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onItemClick({ message });
|
onItemClick({ type, message });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ const styles = {
|
||||||
borderBottomStyle: 'solid',
|
borderBottomStyle: 'solid',
|
||||||
},
|
},
|
||||||
itemContainer: {
|
itemContainer: {
|
||||||
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'nowrap',
|
flexWrap: 'nowrap',
|
||||||
|
|
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
|
```jsx
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const MONTH_MS = 30 * DAY_MS;
|
const MONTH_MS = 30 * DAY_MS;
|
||||||
|
|
|
@ -6,11 +6,12 @@ import React from 'react';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
import { AttachmentSection } from './AttachmentSection';
|
import { AttachmentSection } from './AttachmentSection';
|
||||||
|
import { AttachmentType } from './types/AttachmentType';
|
||||||
|
import { EmptyState } from './EmptyState';
|
||||||
import { groupMessagesByDate } from './groupMessagesByDate';
|
import { groupMessagesByDate } from './groupMessagesByDate';
|
||||||
import { ItemClickEvent } from './events/ItemClickEvent';
|
import { ItemClickEvent } from './types/ItemClickEvent';
|
||||||
import { Message } from './propTypes/Message';
|
import { Message } from './types/Message';
|
||||||
|
import { missingCaseError } from '../../../util/missingCaseError';
|
||||||
type AttachmentType = 'media' | 'documents';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
documents: Array<Message>;
|
documents: Array<Message>;
|
||||||
|
@ -34,9 +35,18 @@ const tabStyle = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
tabContainer: {
|
container: {
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
tabContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
tab: {
|
tab: {
|
||||||
|
@ -46,9 +56,17 @@ const styles = {
|
||||||
borderBottom: '2px solid #08f',
|
borderBottom: '2px solid #08f',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
attachmentsContainer: {
|
contentContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
padding: 20,
|
padding: 20,
|
||||||
},
|
} as React.CSSProperties,
|
||||||
|
sectionContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
} as React.CSSProperties,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TabSelectEvent {
|
interface TabSelectEvent {
|
||||||
|
@ -87,7 +105,7 @@ export class MediaGallery extends React.Component<Props, State> {
|
||||||
const { selectedTab } = this.state;
|
const { selectedTab } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={styles.container}>
|
||||||
<div style={styles.tabContainer}>
|
<div style={styles.tabContainer}>
|
||||||
<Tab
|
<Tab
|
||||||
label="Media"
|
label="Media"
|
||||||
|
@ -95,16 +113,14 @@ export class MediaGallery extends React.Component<Props, State> {
|
||||||
isSelected={selectedTab === 'media'}
|
isSelected={selectedTab === 'media'}
|
||||||
onSelect={this.handleTabSelect}
|
onSelect={this.handleTabSelect}
|
||||||
/>
|
/>
|
||||||
{/* Disable for MVP:
|
|
||||||
<Tab
|
<Tab
|
||||||
label="Documents"
|
label="Documents"
|
||||||
type="documents"
|
type="documents"
|
||||||
isSelected={selectedTab === 'documents'}
|
isSelected={selectedTab === 'documents'}
|
||||||
onSelect={this.handleTabSelect}
|
onSelect={this.handleTabSelect}
|
||||||
/>
|
/>
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.attachmentsContainer}>{this.renderSections()}</div>
|
<div style={styles.contentContainer}>{this.renderSections()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -121,12 +137,23 @@ export class MediaGallery extends React.Component<Props, State> {
|
||||||
const type = selectedTab;
|
const type = selectedTab;
|
||||||
|
|
||||||
if (!messages || messages.length === 0) {
|
if (!messages || messages.length === 0) {
|
||||||
return null;
|
const label = (() => {
|
||||||
|
switch (type) {
|
||||||
|
case 'media':
|
||||||
|
return i18n('mediaEmptyState');
|
||||||
|
|
||||||
|
case 'documents':
|
||||||
|
return i18n('documentsEmptyState');
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw missingCaseError(type);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return <EmptyState data-test="EmptyState" label={label} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sections = groupMessagesByDate(now, messages);
|
const sections = groupMessagesByDate(now, messages).map(section => {
|
||||||
return sections.map(section => {
|
|
||||||
const first = section.messages[0];
|
const first = section.messages[0];
|
||||||
const date = moment(first.received_at);
|
const date = moment(first.received_at);
|
||||||
const header =
|
const header =
|
||||||
|
@ -144,5 +171,7 @@ export class MediaGallery extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return <div style={styles.sectionContainer}>{sections}</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Message } from './propTypes/Message';
|
import { Message } from './types/Message';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
@ -17,6 +17,7 @@ const size = {
|
||||||
const styles = {
|
const styles = {
|
||||||
container: {
|
container: {
|
||||||
...size,
|
...size,
|
||||||
|
cursor: 'pointer',
|
||||||
backgroundColor: '#f3f3f3',
|
backgroundColor: '#f3f3f3',
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
/**
|
|
||||||
* @prettier
|
|
||||||
*/
|
|
||||||
import { Message } from '../propTypes/Message';
|
|
||||||
|
|
||||||
export interface ItemClickEvent {
|
|
||||||
message: Message;
|
|
||||||
}
|
|
|
@ -4,7 +4,7 @@
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { compact, groupBy, sortBy } from 'lodash';
|
import { compact, groupBy, sortBy } from 'lodash';
|
||||||
|
|
||||||
import { Message } from './propTypes/Message';
|
import { Message } from './types/Message';
|
||||||
// import { missingCaseError } from '../../../util/missingCaseError';
|
// import { missingCaseError } from '../../../util/missingCaseError';
|
||||||
|
|
||||||
type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth';
|
type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth';
|
||||||
|
|
|
@ -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 { arrayBufferToObjectURL } from '../../../../util/arrayBufferToObjectURL';
|
||||||
import { Attachment } from '../../../../types/Attachment';
|
import { Attachment } from '../../../../types/Attachment';
|
||||||
import { MapAsync } from '../../../../types/MapAsync';
|
import { MapAsync } from '../../../../types/MapAsync';
|
||||||
import { MIMEType } from '../../../../types/MIME';
|
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -16,8 +15,6 @@ export type Message = {
|
||||||
received_at: number;
|
received_at: number;
|
||||||
} & { objectURL?: string };
|
} & { objectURL?: string };
|
||||||
|
|
||||||
const DEFAULT_CONTENT_TYPE: MIMEType = 'application/octet-stream' as MIMEType;
|
|
||||||
|
|
||||||
export const loadWithObjectURL = (loadMessage: MapAsync<Message>) => async (
|
export const loadWithObjectURL = (loadMessage: MapAsync<Message>) => async (
|
||||||
messages: Array<Message>
|
messages: Array<Message>
|
||||||
): Promise<Array<Message>> => {
|
): Promise<Array<Message>> => {
|
||||||
|
@ -29,14 +26,15 @@ export const loadWithObjectURL = (loadMessage: MapAsync<Message>) => async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages with video are too expensive to load into memory, so we don’t:
|
// 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(
|
const loadedMessagesWithoutVideo: Array<Message> = await Promise.all(
|
||||||
messagesWithoutVideo.map(loadMessage)
|
messagesWithoutVideo.map(loadMessage)
|
||||||
);
|
);
|
||||||
const loadedMessages = sortBy(
|
const loadedMessages = sortBy(
|
||||||
// // Only show images for MVP:
|
[...messagesWithVideo, ...loadedMessagesWithoutVideo],
|
||||||
// [...messagesWithVideo, ...loadedMessagesWithoutVideo],
|
|
||||||
loadedMessagesWithoutVideo,
|
|
||||||
message => -message.received_at
|
message => -message.received_at
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -50,17 +48,17 @@ const hasVideoAttachment = (message: Message): boolean =>
|
||||||
MIME.isVideo(attachment.contentType)
|
MIME.isVideo(attachment.contentType)
|
||||||
);
|
);
|
||||||
|
|
||||||
const withObjectURL = (message: Message): Message => {
|
export const withObjectURL = (message: Message): Message => {
|
||||||
if (message.attachments.length === 0) {
|
if (message.attachments.length === 0) {
|
||||||
throw new TypeError('`message.attachments` cannot be empty');
|
throw new TypeError('`message.attachments` cannot be empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = message.attachments[0];
|
const attachment = message.attachments[0];
|
||||||
if (typeof attachment.contentType === 'undefined') {
|
if (is.undefined(attachment.contentType)) {
|
||||||
throw new TypeError('`attachment.contentType` is required');
|
throw new TypeError('`attachment.contentType` is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MIME.isVideo(attachment.contentType)) {
|
if (is.undefined(attachment.data) && MIME.isVideo(attachment.contentType)) {
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
objectURL: 'images/video.svg',
|
objectURL: 'images/video.svg',
|
||||||
|
@ -69,7 +67,7 @@ const withObjectURL = (message: Message): Message => {
|
||||||
|
|
||||||
const objectURL = arrayBufferToObjectURL({
|
const objectURL = arrayBufferToObjectURL({
|
||||||
data: attachment.data,
|
data: attachment.data,
|
||||||
type: attachment.contentType || DEFAULT_CONTENT_TYPE,
|
type: attachment.contentType,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
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,
|
groupMessagesByDate,
|
||||||
Section,
|
Section,
|
||||||
} from '../../../components/conversation/media-gallery/groupMessagesByDate';
|
} from '../../../components/conversation/media-gallery/groupMessagesByDate';
|
||||||
import { Message } from '../../../components/conversation/media-gallery/propTypes/Message';
|
import { Message } from '../../../components/conversation/media-gallery/types/Message';
|
||||||
|
|
||||||
const toMessage = (date: Date): Message => ({
|
const toMessage = (date: Date): Message => ({
|
||||||
id: date.toUTCString(),
|
id: date.toUTCString(),
|
||||||
|
|
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
|
* @prettier
|
||||||
*/
|
*/
|
||||||
import is from '@sindresorhus/is';
|
import is from '@sindresorhus/is';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
import * as GoogleChrome from '../util/GoogleChrome';
|
import * as GoogleChrome from '../util/GoogleChrome';
|
||||||
|
import { saveURLAsFile } from '../util/saveURLAsFile';
|
||||||
|
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
|
||||||
import { MIMEType } from './MIME';
|
import { MIMEType } from './MIME';
|
||||||
|
|
||||||
export interface Attachment {
|
export type Attachment = {
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
contentType?: MIMEType;
|
contentType?: MIMEType;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
@ -21,8 +24,14 @@ export interface Attachment {
|
||||||
// key?: ArrayBuffer;
|
// key?: ArrayBuffer;
|
||||||
// digest?: ArrayBuffer;
|
// digest?: ArrayBuffer;
|
||||||
// flags?: number;
|
// flags?: number;
|
||||||
|
} & Partial<AttachmentSchemaVersion3>;
|
||||||
|
|
||||||
|
interface AttachmentSchemaVersion3 {
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAVE_CONTENT_TYPE = 'application/octet-stream' as MIMEType;
|
||||||
|
|
||||||
export const isVisualMedia = (attachment: Attachment): boolean => {
|
export const isVisualMedia = (attachment: Attachment): boolean => {
|
||||||
const { contentType } = attachment;
|
const { contentType } = attachment;
|
||||||
|
|
||||||
|
@ -34,3 +43,62 @@ export const isVisualMedia = (attachment: Attachment): boolean => {
|
||||||
const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType);
|
const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType);
|
||||||
return isSupportedImageType || isSupportedVideoType;
|
return isSupportedImageType || isSupportedVideoType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const save = ({
|
||||||
|
attachment,
|
||||||
|
document,
|
||||||
|
getAbsolutePath,
|
||||||
|
timestamp,
|
||||||
|
}: {
|
||||||
|
attachment: Attachment;
|
||||||
|
document: Document;
|
||||||
|
getAbsolutePath: (relativePath: string) => string;
|
||||||
|
timestamp?: number;
|
||||||
|
}): void => {
|
||||||
|
const isObjectURLRequired = is.undefined(attachment.path);
|
||||||
|
const url = !is.undefined(attachment.path)
|
||||||
|
? getAbsolutePath(attachment.path)
|
||||||
|
: arrayBufferToObjectURL({
|
||||||
|
data: attachment.data,
|
||||||
|
type: SAVE_CONTENT_TYPE,
|
||||||
|
});
|
||||||
|
const filename = getSuggestedFilename({ attachment, timestamp });
|
||||||
|
saveURLAsFile({ url, filename, document });
|
||||||
|
if (isObjectURLRequired) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSuggestedFilename = ({
|
||||||
|
attachment,
|
||||||
|
timestamp,
|
||||||
|
}: {
|
||||||
|
attachment: Attachment;
|
||||||
|
timestamp?: number | Date;
|
||||||
|
}): string => {
|
||||||
|
if (attachment.fileName) {
|
||||||
|
return attachment.fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = 'signal-attachment';
|
||||||
|
const suffix = timestamp
|
||||||
|
? moment(timestamp).format('-YYYY-MM-DD-HHmmss')
|
||||||
|
: '';
|
||||||
|
const fileType = getFileExtension(attachment);
|
||||||
|
const extension = fileType ? `.${fileType}` : '';
|
||||||
|
return `${prefix}${suffix}${extension}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileExtension = (attachment: Attachment): string | null => {
|
||||||
|
if (!attachment.contentType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (attachment.contentType) {
|
||||||
|
case 'video/quicktime':
|
||||||
|
return 'mov';
|
||||||
|
default:
|
||||||
|
// TODO: Use better MIME --> file extension mapping:
|
||||||
|
return attachment.contentType.split('/')[1];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -4,9 +4,6 @@
|
||||||
export type MIMEType = string & { _mimeTypeBrand: any };
|
export type MIMEType = string & { _mimeTypeBrand: any };
|
||||||
|
|
||||||
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
|
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
|
||||||
|
|
||||||
export const isImage = (value: MIMEType): boolean => value.startsWith('image/');
|
export const isImage = (value: MIMEType): boolean => value.startsWith('image/');
|
||||||
|
|
||||||
export const isVideo = (value: MIMEType): boolean => value.startsWith('video/');
|
export const isVideo = (value: MIMEType): boolean => value.startsWith('video/');
|
||||||
|
|
||||||
export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/');
|
export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/');
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* @prettier
|
* @prettier
|
||||||
*/
|
*/
|
||||||
|
import is from '@sindresorhus/is';
|
||||||
|
|
||||||
import { MIMEType } from '../types/MIME';
|
import { MIMEType } from '../types/MIME';
|
||||||
|
|
||||||
export const arrayBufferToObjectURL = ({
|
export const arrayBufferToObjectURL = ({
|
||||||
|
@ -10,6 +12,10 @@ export const arrayBufferToObjectURL = ({
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
type: MIMEType;
|
type: MIMEType;
|
||||||
}): string => {
|
}): string => {
|
||||||
|
if (!is.arrayBuffer(data)) {
|
||||||
|
throw new TypeError('`data` must be an ArrayBuffer');
|
||||||
|
}
|
||||||
|
|
||||||
const blob = new Blob([data], { type });
|
const blob = new Blob([data], { type });
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
};
|
};
|
||||||
|
|
17
ts/util/saveURLAsFile.ts
Normal file
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…
Add table
Add a link
Reference in a new issue