Media Gallery: Phase 1 (#2236)
- [x] Index each `Message` based on whether it has an attachment (visual or document), e.g. ~~`attachmentTypes: 'visual' | 'document' | 'mixed' | 'none'`~~. ~~`attachmentTypes: 'visual' | 'document' | 'none'`~~ - `hasVisualMediaAttachments: IndexedDB.IndexablePresence` (`1 | undefined`) - `hasFileAttachment: IndexedDB.IndexablePresence` (`1 | undefined`) - `hasAttachments: IndexedDB.IndexableBoolean` (`1 | 0`) - [x] Create migration to initialize index - [x] Add menu for viewing all media: **View All Media** - [x] Add IndexedDB index for: - [x] visual media attachments - [x] file attachments - [x] attachments (general) - [x] Render tabs: **Media** and **Documents** - [x] Group messages by date - [x] Add `GoogleChrome` module to explicitly whitelist file formats it can render / play back. - [x] Render list of media thumbnails - [x] Avoid loading videos into memory as they are too big. **TODO:** Could we do that for any large attachment before we have thumbnails? - [x] Show video icon for videos as we don’t have thumbnails (yet). - [x] Implement lightbox - [x] Rebuild Backbone lightbox using React - [x] Add right arrow SVG (`forward.svg` for symmetry with `back.svg`). - [x] Add next / previous buttons - [x] Port support for `Escape` key to close - [x] Port click close - [x] Show lightbox when clicking on media thumbnail - [x] Switch from `MIME.is*` to `GoogleChrome.is[Image|Video]TypeSupported` - [x] Disable access to media gallery until it’s complete. - [x] **Infrastructure:** Move `filesize` from Bower to npm/yarn. - [x] **Infrastructure:** Add support for _Prettier_ code formatting. Opt-in via pragma: ``` /** * @prettier */ ``` Run via `yarn format` command. **TODO:** Add support Git commit hook, etc. - [x] **Infrastructure:** Add basic TypeScript type definitions for Backbone `Model` and `Collection`. - [x] **Infrastructure:** Created pattern for fetching index data without adding more code to existing Backbone collections. See `Conversation.fetchVisualMediaAttachments`. - [x] **Infrastructure:** Created variable for `z-index`. **TODO:** Replace all usages of explicit `z-index` with variables over time. - [x] **Infrastructure:** Created `Signal.Backbone.Views.Lightbox` module to experiment interop with Backbone without using Backbone or jQuery itself to align with long-term plans. - [x] **Infrastructure:** Enable all strict checks by TypeScript compiler. - [x] **Infrastructure:** Add new TSLint rules (see comments in `tslint.json`). ### Phase 1 - [x] Only show images in media gallery until we have video support in lightbox (and potentially thumbnails for grid). - [x] Show up to 50 of most recent images until we have infinite scrolling. - [x] Hide ‘Save As…’ button in media gallery until we port underlying functionality from Backbone to React. - [x] Disable previous/next navigation until implemented.
This commit is contained in:
commit
acf8a1a96c
65 changed files with 1777 additions and 449 deletions
|
@ -202,7 +202,7 @@ module.exports = function(grunt) {
|
|||
tasks: ['jscs']
|
||||
},
|
||||
transpile: {
|
||||
files: ['./ts/**/*.js'],
|
||||
files: ['./ts/**/*.ts'],
|
||||
tasks: ['exec:transpile']
|
||||
}
|
||||
},
|
||||
|
|
|
@ -322,6 +322,34 @@
|
|||
"incomingError": {
|
||||
"message": "Error handling incoming message."
|
||||
},
|
||||
"media": {
|
||||
"message": "Media",
|
||||
"description": "Header of the default pane in the media gallery, showing images and videos"
|
||||
},
|
||||
"documents": {
|
||||
"message": "Documents",
|
||||
"description": "Header of the secondary pane in the media gallery, showing every non-media attachment"
|
||||
},
|
||||
"messageCaption": {
|
||||
"message": "Message caption",
|
||||
"description": "Prefix of attachment alt tags in the media gallery"
|
||||
},
|
||||
"today": {
|
||||
"message": "Today",
|
||||
"description": "Section header in the media gallery"
|
||||
},
|
||||
"yesterday": {
|
||||
"message": "Yesterday",
|
||||
"description": "Section header in the media gallery"
|
||||
},
|
||||
"thisWeek": {
|
||||
"message": "This Week",
|
||||
"description": "Section header in the media gallery"
|
||||
},
|
||||
"thisMonth": {
|
||||
"message": "This Month",
|
||||
"description": "Section header in the media gallery"
|
||||
},
|
||||
"unsupportedAttachment": {
|
||||
"message": "Unsupported attachment type. Click to save.",
|
||||
"description": "Displayed for incoming unsupported attachment"
|
||||
|
@ -522,6 +550,10 @@
|
|||
"showSafetyNumber": {
|
||||
"message": "Show safety number"
|
||||
},
|
||||
"viewAllMedia": {
|
||||
"message": "View all media",
|
||||
"description": "This is a menu item for viewing all media (images + video) in a conversation, using the imperative case, as in a command."
|
||||
},
|
||||
"verifyHelp": {
|
||||
"message": "If you wish to verify the security of your end-to-end encryption with $name$, compare the numbers above with the numbers on their device.",
|
||||
"placeholders": {
|
||||
|
@ -635,7 +667,7 @@
|
|||
},
|
||||
"deleteAllDataProgress": {
|
||||
"message": "Disconnecting and deleting all data",
|
||||
"description": "Text of the button that deletes all data"
|
||||
"description": "Message shown to user when app is disconnected and data deleted"
|
||||
},
|
||||
"notifications": {
|
||||
"message": "Notifications",
|
||||
|
|
|
@ -100,6 +100,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='lightbox-container'></div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='scroll-down-button-view'>
|
||||
<button class='text {{ cssClass }}' alt='{{ moreBelow }}'>
|
||||
|
@ -159,6 +160,8 @@
|
|||
<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> -->
|
||||
{{#group}}
|
||||
<li class='show-members'>{{ show-members }}</li>
|
||||
<!-- <li class='update-group'>Update group</li> -->
|
||||
|
@ -222,15 +225,6 @@
|
|||
<span class='time'>0:00</span>
|
||||
<button class='close'><span class='icon'></span></button>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='lightbox'>
|
||||
<div class='content'>
|
||||
<div class='controls'>
|
||||
<a class='x close' alt='Close image.' href='#'></a>
|
||||
<a class='save' alt='Save as...' href='#'></a>
|
||||
</div>
|
||||
<img class='image' src='{{ url }}' />
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='confirmation-dialog'>
|
||||
<div class="content">
|
||||
<div class='message'>{{ message }}</div>
|
||||
|
|
|
@ -16,8 +16,7 @@
|
|||
"blueimp-load-image": "~1.13.0",
|
||||
"autosize": "~4.0.0",
|
||||
"webaudiorecorder": "https://github.com/higuma/web-audio-recorder-js.git",
|
||||
"mp3lameencoder": "https://github.com/higuma/mp3-lame-encoder-js.git",
|
||||
"filesize": "https://github.com/avoidwork/filesize.js.git"
|
||||
"mp3lameencoder": "https://github.com/higuma/mp3-lame-encoder-js.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "~2.0.1",
|
||||
|
@ -83,9 +82,6 @@
|
|||
],
|
||||
"mp3lameencoder": [
|
||||
"lib/Mp3LameEncoder.js"
|
||||
],
|
||||
"filesize": [
|
||||
"lib/filesize.js"
|
||||
]
|
||||
},
|
||||
"concat": {
|
||||
|
@ -102,8 +98,7 @@
|
|||
"moment",
|
||||
"intl-tel-input",
|
||||
"backbone.typeahead",
|
||||
"autosize",
|
||||
"filesize"
|
||||
"autosize"
|
||||
],
|
||||
"libtextsecure": [
|
||||
"long",
|
||||
|
|
|
@ -1,167 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
/**
|
||||
* filesize
|
||||
*
|
||||
* @copyright 2017 Jason Mulligan <jason.mulligan@avoidwork.com>
|
||||
* @license BSD-3-Clause
|
||||
* @version 3.5.10
|
||||
*/
|
||||
(function (global) {
|
||||
var b = /^(b|B)$/,
|
||||
symbol = {
|
||||
iec: {
|
||||
bits: ["b", "Kib", "Mib", "Gib", "Tib", "Pib", "Eib", "Zib", "Yib"],
|
||||
bytes: ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
|
||||
},
|
||||
jedec: {
|
||||
bits: ["b", "Kb", "Mb", "Gb", "Tb", "Pb", "Eb", "Zb", "Yb"],
|
||||
bytes: ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||
}
|
||||
},
|
||||
fullform = {
|
||||
iec: ["", "kibi", "mebi", "gibi", "tebi", "pebi", "exbi", "zebi", "yobi"],
|
||||
jedec: ["", "kilo", "mega", "giga", "tera", "peta", "exa", "zetta", "yotta"]
|
||||
};
|
||||
|
||||
/**
|
||||
* filesize
|
||||
*
|
||||
* @method filesize
|
||||
* @param {Mixed} arg String, Int or Float to transform
|
||||
* @param {Object} descriptor [Optional] Flags
|
||||
* @return {String} Readable file size String
|
||||
*/
|
||||
function filesize(arg) {
|
||||
var descriptor = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
||||
|
||||
var result = [],
|
||||
val = 0,
|
||||
e = void 0,
|
||||
base = void 0,
|
||||
bits = void 0,
|
||||
ceil = void 0,
|
||||
full = void 0,
|
||||
fullforms = void 0,
|
||||
neg = void 0,
|
||||
num = void 0,
|
||||
output = void 0,
|
||||
round = void 0,
|
||||
unix = void 0,
|
||||
spacer = void 0,
|
||||
standard = void 0,
|
||||
symbols = void 0;
|
||||
|
||||
if (isNaN(arg)) {
|
||||
throw new Error("Invalid arguments");
|
||||
}
|
||||
|
||||
bits = descriptor.bits === true;
|
||||
unix = descriptor.unix === true;
|
||||
base = descriptor.base || 2;
|
||||
round = descriptor.round !== undefined ? descriptor.round : unix ? 1 : 2;
|
||||
spacer = descriptor.spacer !== undefined ? descriptor.spacer : unix ? "" : " ";
|
||||
symbols = descriptor.symbols || descriptor.suffixes || {};
|
||||
standard = base === 2 ? descriptor.standard || "jedec" : "jedec";
|
||||
output = descriptor.output || "string";
|
||||
full = descriptor.fullform === true;
|
||||
fullforms = descriptor.fullforms instanceof Array ? descriptor.fullforms : [];
|
||||
e = descriptor.exponent !== undefined ? descriptor.exponent : -1;
|
||||
num = Number(arg);
|
||||
neg = num < 0;
|
||||
ceil = base > 2 ? 1000 : 1024;
|
||||
|
||||
// Flipping a negative number to determine the size
|
||||
if (neg) {
|
||||
num = -num;
|
||||
}
|
||||
|
||||
// Determining the exponent
|
||||
if (e === -1 || isNaN(e)) {
|
||||
e = Math.floor(Math.log(num) / Math.log(ceil));
|
||||
|
||||
if (e < 0) {
|
||||
e = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Exceeding supported length, time to reduce & multiply
|
||||
if (e > 8) {
|
||||
e = 8;
|
||||
}
|
||||
|
||||
// Zero is now a special case because bytes divide by 1
|
||||
if (num === 0) {
|
||||
result[0] = 0;
|
||||
result[1] = unix ? "" : symbol[standard][bits ? "bits" : "bytes"][e];
|
||||
} else {
|
||||
val = num / (base === 2 ? Math.pow(2, e * 10) : Math.pow(1000, e));
|
||||
|
||||
if (bits) {
|
||||
val = val * 8;
|
||||
|
||||
if (val >= ceil && e < 8) {
|
||||
val = val / ceil;
|
||||
e++;
|
||||
}
|
||||
}
|
||||
|
||||
result[0] = Number(val.toFixed(e > 0 ? round : 0));
|
||||
result[1] = base === 10 && e === 1 ? bits ? "kb" : "kB" : symbol[standard][bits ? "bits" : "bytes"][e];
|
||||
|
||||
if (unix) {
|
||||
result[1] = standard === "jedec" ? result[1].charAt(0) : e > 0 ? result[1].replace(/B$/, "") : result[1];
|
||||
|
||||
if (b.test(result[1])) {
|
||||
result[0] = Math.floor(result[0]);
|
||||
result[1] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decorating a 'diff'
|
||||
if (neg) {
|
||||
result[0] = -result[0];
|
||||
}
|
||||
|
||||
// Applying custom symbol
|
||||
result[1] = symbols[result[1]] || result[1];
|
||||
|
||||
// Returning Array, Object, or String (default)
|
||||
if (output === "array") {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (output === "exponent") {
|
||||
return e;
|
||||
}
|
||||
|
||||
if (output === "object") {
|
||||
return { value: result[0], suffix: result[1], symbol: result[1] };
|
||||
}
|
||||
|
||||
if (full) {
|
||||
result[1] = fullforms[e] ? fullforms[e] : fullform[standard][e] + (bits ? "bit" : "byte") + (result[0] === 1 ? "" : "s");
|
||||
}
|
||||
|
||||
return result.join(spacer);
|
||||
}
|
||||
|
||||
// Partial application for functional programming
|
||||
filesize.partial = function (opt) {
|
||||
return function (arg) {
|
||||
return filesize(arg, opt);
|
||||
};
|
||||
};
|
||||
|
||||
// CommonJS, AMD, script tag
|
||||
if (typeof exports !== "undefined") {
|
||||
module.exports = filesize;
|
||||
} else if (typeof define === "function" && define.amd) {
|
||||
define(function () {
|
||||
return filesize;
|
||||
});
|
||||
} else {
|
||||
global.filesize = filesize;
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : global);
|
1
images/forward.svg
Normal file
1
images/forward.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="48" height="48" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M-1-1h582v402H-1z"/><g><path d="M16.00000183 33.17l2.83 2.83 12-12-12-12-2.83 2.83 9.17 9.17-9.17 9.17z"/></g></svg>
|
After Width: | Height: | Size: 201 B |
|
@ -1,13 +1,15 @@
|
|||
/* global _: false */
|
||||
/* global Backbone: false */
|
||||
/* global dcodeIO: false */
|
||||
/* global libphonenumber: false */
|
||||
|
||||
/* global ConversationController: false */
|
||||
/* global libsignal: false */
|
||||
/* global Signal: false */
|
||||
/* global storage: false */
|
||||
/* global textsecure: false */
|
||||
/* global Whisper: false */
|
||||
/* global Backbone: false */
|
||||
/* global _: false */
|
||||
/* global ConversationController: false */
|
||||
/* global libphonenumber: false */
|
||||
/* global wrapDeferred: false */
|
||||
/* global dcodeIO: false */
|
||||
/* global libsignal: false */
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
||||
|
@ -17,7 +19,7 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { Message, MIME } = window.Signal.Types;
|
||||
const { Message } = window.Signal.Types;
|
||||
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
|
||||
|
||||
// TODO: Factor out private and group subclasses of Conversation
|
||||
|
@ -651,7 +653,8 @@
|
|||
text: quotedMessage.get('body'),
|
||||
attachments: await Promise.all((attachments || []).map(async (attachment) => {
|
||||
const { contentType } = attachment;
|
||||
const willMakeThumbnail = MIME.isImage(contentType);
|
||||
const willMakeThumbnail =
|
||||
Signal.Util.GoogleChrome.isImageTypeSupported(contentType);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
|
@ -1111,7 +1114,9 @@
|
|||
const first = attachments[0];
|
||||
const { thumbnail, contentType } = first;
|
||||
|
||||
return thumbnail || MIME.isVideo(contentType) || MIME.isImage(contentType);
|
||||
return thumbnail ||
|
||||
Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
|
||||
},
|
||||
forceRender(message) {
|
||||
message.trigger('change', message);
|
||||
|
@ -1151,7 +1156,7 @@
|
|||
|
||||
// Maybe in the future we could try to pull the thumbnail from a video ourselves,
|
||||
// but for now we will rely on incoming thumbnails only.
|
||||
if (!MIME.isImage(first.contentType)) {
|
||||
if (!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -1191,7 +1196,7 @@
|
|||
|
||||
// Maybe in the future we could try to pull thumbnails video ourselves,
|
||||
// but for now we will rely on incoming thumbnails only.
|
||||
if (!first || !MIME.isImage(first.contentType)) {
|
||||
if (!first || !Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
1
js/modules/deferred_to_promise.d.ts
vendored
Normal file
1
js/modules/deferred_to_promise.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
export function deferredToPromise<T>(deferred: JQuery.Deferred<any, any, any>): Promise<T>;
|
19
js/modules/migrations/18/index.js
Normal file
19
js/modules/migrations/18/index.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
exports.run = (transaction) => {
|
||||
const messagesStore = transaction.objectStore('messages');
|
||||
|
||||
console.log("Create message attachment metadata index: 'hasAttachments'");
|
||||
messagesStore.createIndex(
|
||||
'hasAttachments',
|
||||
['conversationId', 'hasAttachments', 'received_at'],
|
||||
{ unique: false }
|
||||
);
|
||||
|
||||
['hasVisualMediaAttachments', 'hasFileAttachments'].forEach((name) => {
|
||||
console.log(`Create message attachment metadata index: '${name}'`);
|
||||
messagesStore.createIndex(
|
||||
name,
|
||||
['conversationId', 'received_at', name],
|
||||
{ unique: false }
|
||||
);
|
||||
});
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
const { isString, last } = require('lodash');
|
||||
|
||||
const { runMigrations } = require('./run_migrations');
|
||||
const Migration18 = require('./18');
|
||||
|
||||
|
||||
// IMPORTANT: The migrations below are run on a database that may be very large
|
||||
|
@ -133,7 +134,23 @@ const migrations = [
|
|||
const duration = Date.now() - start;
|
||||
|
||||
console.log(
|
||||
'Complete migration to database version 17.',
|
||||
'Complete migration to database version 17',
|
||||
`Duration: ${duration}ms`
|
||||
);
|
||||
next();
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 18,
|
||||
migrate(transaction, next) {
|
||||
console.log('Migration 18');
|
||||
|
||||
const start = Date.now();
|
||||
Migration18.run(transaction);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
console.log(
|
||||
'Complete migration to database version 18',
|
||||
`Duration: ${duration}ms`
|
||||
);
|
||||
next();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const { isFunction, isString } = require('lodash');
|
||||
const is = require('@sindresorhus/is');
|
||||
|
||||
const MIME = require('./mime');
|
||||
const MIME = require('../../../ts/types/MIME');
|
||||
const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util');
|
||||
const { autoOrientImage } = require('../auto_orient_image');
|
||||
const { migrateDataToFileSystem } = require('./attachment/migrate_data_to_file_system');
|
||||
|
@ -76,7 +76,7 @@ const INVALID_CHARACTERS_PATTERN = new RegExp(
|
|||
// which currently doesn’t support async testing:
|
||||
// https://github.com/leebyron/testcheck-js/issues/45
|
||||
exports._replaceUnicodeOrderOverridesSync = (attachment) => {
|
||||
if (!isString(attachment.fileName)) {
|
||||
if (!is.string(attachment.fileName)) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
|
@ -115,7 +115,7 @@ exports.hasData = attachment =>
|
|||
// Attachment ->
|
||||
// IO (Promise Attachment)
|
||||
exports.loadData = (readAttachmentData) => {
|
||||
if (!isFunction(readAttachmentData)) {
|
||||
if (!is.function(readAttachmentData)) {
|
||||
throw new TypeError("'readAttachmentData' must be a function");
|
||||
}
|
||||
|
||||
|
@ -129,7 +129,7 @@ exports.loadData = (readAttachmentData) => {
|
|||
return attachment;
|
||||
}
|
||||
|
||||
if (!isString(attachment.path)) {
|
||||
if (!is.string(attachment.path)) {
|
||||
throw new TypeError("'attachment.path' is required");
|
||||
}
|
||||
|
||||
|
@ -142,7 +142,7 @@ exports.loadData = (readAttachmentData) => {
|
|||
// Attachment ->
|
||||
// IO Unit
|
||||
exports.deleteData = (deleteAttachmentData) => {
|
||||
if (!isFunction(deleteAttachmentData)) {
|
||||
if (!is.function(deleteAttachmentData)) {
|
||||
throw new TypeError("'deleteAttachmentData' must be a function");
|
||||
}
|
||||
|
||||
|
@ -156,7 +156,7 @@ exports.deleteData = (deleteAttachmentData) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isString(attachment.path)) {
|
||||
if (!is.string(attachment.path)) {
|
||||
throw new TypeError("'attachment.path' is required");
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ const { isFunction, isString, omit } = require('lodash');
|
|||
const Attachment = require('./attachment');
|
||||
const Errors = require('./errors');
|
||||
const SchemaVersion = require('./schema_version');
|
||||
const { initializeAttachmentMetadata } =
|
||||
require('../../../ts/types/message/initializeAttachmentMetadata');
|
||||
|
||||
|
||||
const GROUP = 'group';
|
||||
|
@ -20,7 +22,11 @@ const PRIVATE = 'private';
|
|||
// - Attachments: Write attachment data to disk and store relative path to it.
|
||||
// Version 4
|
||||
// - Quotes: Write thumbnail data to disk and store relative path to it.
|
||||
|
||||
// Version 5
|
||||
// - Attachments: Track number and kind of attachments for media gallery
|
||||
// - `hasAttachments?: 1 | 0`
|
||||
// - `hasVisualMediaAttachments?: 1 | undefined` (for media gallery ‘Media’ view)
|
||||
// - `hasFileAttachments?: 1 | undefined` (for media gallery ‘Documents’ view)
|
||||
|
||||
const INITIAL_SCHEMA_VERSION = 0;
|
||||
|
||||
|
@ -29,7 +35,7 @@ const INITIAL_SCHEMA_VERSION = 0;
|
|||
// add more upgrade steps, we could design a pipeline that does this
|
||||
// incrementally, e.g. from version 0 / unknown -> 1, 1 --> 2, etc., similar to
|
||||
// how we do database migrations:
|
||||
exports.CURRENT_SCHEMA_VERSION = 4;
|
||||
exports.CURRENT_SCHEMA_VERSION = 5;
|
||||
|
||||
|
||||
// Public API
|
||||
|
@ -206,6 +212,7 @@ const toVersion4 = exports._withSchemaVersion(
|
|||
4,
|
||||
exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem)
|
||||
);
|
||||
const toVersion5 = exports._withSchemaVersion(5, initializeAttachmentMetadata);
|
||||
|
||||
// UpgradeStep
|
||||
exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
|
||||
|
@ -214,7 +221,14 @@ exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
|
|||
}
|
||||
|
||||
let message = rawMessage;
|
||||
const versions = [toVersion0, toVersion1, toVersion2, toVersion3, toVersion4];
|
||||
const versions = [
|
||||
toVersion0,
|
||||
toVersion1,
|
||||
toVersion2,
|
||||
toVersion3,
|
||||
toVersion4,
|
||||
toVersion5,
|
||||
];
|
||||
|
||||
for (let i = 0, max = versions.length; i < max; i += 1) {
|
||||
const currentVersion = versions[i];
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
exports.isJPEG = mimeType =>
|
||||
mimeType === 'image/jpeg';
|
||||
|
||||
exports.isVideo = mimeType =>
|
||||
mimeType.startsWith('video/') && mimeType !== 'video/wmv';
|
||||
|
||||
exports.isImage = mimeType =>
|
||||
mimeType.startsWith('image/') && mimeType !== 'image/tiff';
|
||||
|
||||
exports.isAudio = mimeType => mimeType.startsWith('audio/');
|
|
@ -1,9 +1,11 @@
|
|||
/* global $: false */
|
||||
/* global _: false */
|
||||
/* global Backbone: false */
|
||||
/* global filesize: false */
|
||||
/* global moment: false */
|
||||
|
||||
/* global i18n: false */
|
||||
/* global Signal: false */
|
||||
/* global textsecure: false */
|
||||
/* global Whisper: false */
|
||||
|
||||
|
@ -11,9 +13,6 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
const ESCAPE_KEY_CODE = 27;
|
||||
const { Signal } = window;
|
||||
|
||||
const FileView = Whisper.View.extend({
|
||||
tagName: 'div',
|
||||
className: 'fileView',
|
||||
|
@ -92,8 +91,8 @@
|
|||
unload() {
|
||||
this.blob = null;
|
||||
|
||||
if (this.lightBoxView) {
|
||||
this.lightBoxView.remove();
|
||||
if (this.lightboxView) {
|
||||
this.lightboxView.remove();
|
||||
}
|
||||
if (this.fileView) {
|
||||
this.fileView.remove();
|
||||
|
@ -111,14 +110,22 @@
|
|||
}
|
||||
},
|
||||
onClick() {
|
||||
if (this.isImage()) {
|
||||
this.lightBoxView = new Whisper.LightboxView({ model: this });
|
||||
this.lightBoxView.render();
|
||||
this.lightBoxView.$el.appendTo(this.el);
|
||||
this.lightBoxView.$el.trigger('show');
|
||||
} else {
|
||||
if (!this.isImage()) {
|
||||
this.saveFile();
|
||||
return;
|
||||
}
|
||||
|
||||
const props = {
|
||||
imageURL: this.objectUrl,
|
||||
onSave: () => this.saveFile(),
|
||||
// implicit: `close`
|
||||
};
|
||||
this.lightboxView = new Whisper.ReactWrapperView({
|
||||
Component: Signal.Components.Lightbox,
|
||||
props,
|
||||
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
|
||||
});
|
||||
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
|
||||
},
|
||||
isVoiceMessage() {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
|
@ -135,15 +142,16 @@
|
|||
},
|
||||
isAudio() {
|
||||
const { contentType } = this.model;
|
||||
// TODO: Implement and use `Signal.Util.GoogleChrome.isAudioTypeSupported`:
|
||||
return Signal.Types.MIME.isAudio(contentType);
|
||||
},
|
||||
isVideo() {
|
||||
const { contentType } = this.model;
|
||||
return Signal.Types.MIME.isVideo(contentType);
|
||||
return Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
|
||||
},
|
||||
isImage() {
|
||||
const { contentType } = this.model;
|
||||
return Signal.Types.MIME.isImage(contentType);
|
||||
return Signal.Util.GoogleChrome.isImageTypeSupported(contentType);
|
||||
},
|
||||
mediaType() {
|
||||
if (this.isVoiceMessage()) {
|
||||
|
@ -238,7 +246,7 @@
|
|||
model: {
|
||||
mediaType: this.mediaType(),
|
||||
fileName: this.displayName(),
|
||||
fileSize: window.filesize(this.model.size),
|
||||
fileSize: filesize(this.model.size),
|
||||
altText: i18n('clickToSave'),
|
||||
},
|
||||
});
|
||||
|
@ -252,42 +260,4 @@
|
|||
this.trigger('update');
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.LightboxView = Whisper.View.extend({
|
||||
templateName: 'lightbox',
|
||||
className: 'modal lightbox',
|
||||
initialize() {
|
||||
this.window = window;
|
||||
this.$document = $(this.window.document);
|
||||
this.listener = this.onkeyup.bind(this);
|
||||
this.$document.on('keyup', this.listener);
|
||||
},
|
||||
events: {
|
||||
'click .save': 'save',
|
||||
'click .close': 'remove',
|
||||
click: 'onclick',
|
||||
},
|
||||
save() {
|
||||
this.model.saveFile();
|
||||
},
|
||||
onclick(e) {
|
||||
const $el = this.$(e.target);
|
||||
if (!$el.hasClass('image') && !$el.closest('.controls').length) {
|
||||
e.preventDefault();
|
||||
this.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onkeyup(e) {
|
||||
if (e.keyCode === ESCAPE_KEY_CODE) {
|
||||
this.remove();
|
||||
this.$document.off('keyup', this.listener);
|
||||
}
|
||||
},
|
||||
render_attributes() {
|
||||
return { url: this.model.objectUrl };
|
||||
},
|
||||
});
|
||||
}());
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
/* global Whisper: false */
|
||||
/* global i18n: false */
|
||||
/* global $: false */
|
||||
/* global _: false */
|
||||
/* global emoji_util: false */
|
||||
/* global extension: false */
|
||||
/* global moment: false */
|
||||
/* global EmojiPanel: false */
|
||||
/* global emoji: false */
|
||||
/* global emoji_util: false */
|
||||
/* global emojiData: false */
|
||||
/* global EmojiPanel: false */
|
||||
/* global moment: false */
|
||||
|
||||
/* global extension: false */
|
||||
/* global i18n: false */
|
||||
/* global storage: false */
|
||||
/* global Whisper: false */
|
||||
/* global Signal: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function () {
|
||||
|
@ -111,6 +113,7 @@
|
|||
'disappearing-messages': i18n('disappearingMessages'),
|
||||
'android-length-warning': i18n('androidMessageLengthWarning'),
|
||||
timer_options: Whisper.ExpirationTimerOptions.models,
|
||||
'view-all-media': i18n('viewAllMedia'),
|
||||
};
|
||||
},
|
||||
initialize(options) {
|
||||
|
@ -207,6 +210,7 @@
|
|||
'click .update-group': 'newGroupUpdate',
|
||||
'click .show-identity': 'showSafetyNumber',
|
||||
'click .show-members': 'showMembers',
|
||||
'click .view-all-media': 'viewAllMedia',
|
||||
'click .conversation-menu .hamburger': 'toggleMenu',
|
||||
click: 'onClick',
|
||||
'click .bottom-bar': 'focusMessageField',
|
||||
|
@ -568,6 +572,44 @@
|
|||
el[0].scrollIntoView();
|
||||
},
|
||||
|
||||
async viewAllMedia() {
|
||||
// We have to do this manually, since our React component will not propagate click
|
||||
// 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);
|
||||
|
||||
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 view = new Whisper.ReactWrapperView({
|
||||
Component: Signal.Components.MediaGallery,
|
||||
props: mediaGalleryProps,
|
||||
onClose: () => this.resetPanel(),
|
||||
});
|
||||
|
||||
this.listenBack(view);
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
// If we're above the last seen indicator, we should scroll there instead
|
||||
// Note: if we don't end up at the bottom of the conversation, button won't go away!
|
||||
|
|
|
@ -172,7 +172,6 @@
|
|||
'click .conversation': 'focusConversation',
|
||||
'select .gutter .conversation-list-item': 'openConversation',
|
||||
'input input.search': 'filterContacts',
|
||||
'show .lightbox': 'showLightbox',
|
||||
},
|
||||
startConnectionListener() {
|
||||
this.interval = setInterval(() => {
|
||||
|
@ -259,9 +258,6 @@
|
|||
this.focusConversation();
|
||||
}
|
||||
},
|
||||
showLightbox(e) {
|
||||
this.$el.append(e.target);
|
||||
},
|
||||
closeRecording(e) {
|
||||
if (e && this.$(e.target).closest('.capture-audio').length > 0) {
|
||||
return;
|
||||
|
|
|
@ -429,7 +429,7 @@
|
|||
}
|
||||
|
||||
const first = attachments[0];
|
||||
if (Signal.Types.MIME.isImage(first.contentType)) {
|
||||
if (Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
/* global Backbone: false */
|
||||
|
||||
// Additional globals used:
|
||||
// window.React
|
||||
// window.ReactDOM
|
||||
// window.i18n
|
||||
/* global i18n: false */
|
||||
/* global React: false */
|
||||
/* global ReactDOM: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function () {
|
||||
|
@ -26,8 +24,8 @@
|
|||
},
|
||||
update(props) {
|
||||
const updatedProps = this.augmentProps(props);
|
||||
const element = window.React.createElement(this.Component, updatedProps);
|
||||
window.ReactDOM.render(element, this.el);
|
||||
const reactElement = React.createElement(this.Component, updatedProps);
|
||||
ReactDOM.render(reactElement, this.el);
|
||||
},
|
||||
augmentProps(props) {
|
||||
return Object.assign({}, props, {
|
||||
|
@ -38,11 +36,11 @@
|
|||
}
|
||||
this.remove();
|
||||
},
|
||||
i18n: window.i18n,
|
||||
i18n,
|
||||
});
|
||||
},
|
||||
remove() {
|
||||
window.ReactDOM.unmountComponentAtNode(this.el);
|
||||
ReactDOM.unmountComponentAtNode(this.el);
|
||||
Backbone.View.prototype.remove.call(this);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -43,6 +43,7 @@
|
|||
"jshint": "yarn grunt jshint",
|
||||
"lint": "yarn eslint && yarn grunt lint && yarn tslint",
|
||||
"tslint": "tslint --config tslint.json --format stylish --project .",
|
||||
"format": "prettier --require-pragma --single-quote --trailing-comma es5 --write \"ts/**/*.{ts,tsx}\"",
|
||||
"transpile": "tsc",
|
||||
"clean-transpile": "rimraf ts/**/*.js ts/*.js",
|
||||
"open-coverage": "open coverage/lcov-report/index.html",
|
||||
|
@ -67,6 +68,7 @@
|
|||
"emoji-datasource-apple": "4.0.0",
|
||||
"emoji-js": "^3.4.0",
|
||||
"emoji-panel": "https://github.com/scottnonnenberg/emoji-panel.git#v0.5.5",
|
||||
"filesize": "^3.6.1",
|
||||
"firstline": "^1.2.1",
|
||||
"form-data": "^2.3.2",
|
||||
"fs-extra": "^5.0.0",
|
||||
|
@ -95,6 +97,8 @@
|
|||
"devDependencies": {
|
||||
"@types/chai": "^4.1.2",
|
||||
"@types/classnames": "^2.2.3",
|
||||
"@types/filesize": "^3.6.0",
|
||||
"@types/jquery": "^3.3.1",
|
||||
"@types/lodash": "^4.14.106",
|
||||
"@types/mocha": "^5.0.0",
|
||||
"@types/qs": "^6.5.1",
|
||||
|
@ -130,6 +134,7 @@
|
|||
"node-sass-import-once": "^1.2.0",
|
||||
"nsp": "^3.2.1",
|
||||
"nyc": "^11.4.1",
|
||||
"prettier": "1.12.0",
|
||||
"qs": "^6.5.1",
|
||||
"react-docgen-typescript": "^1.2.6",
|
||||
"react-styleguidist": "^7.0.1",
|
||||
|
|
17
preload.js
17
preload.js
|
@ -99,6 +99,7 @@ window.dataURLToBlobSync = require('blueimp-canvas-to-blob');
|
|||
window.EmojiConvertor = require('emoji-js');
|
||||
window.emojiData = require('emoji-datasource');
|
||||
window.EmojiPanel = require('emoji-panel');
|
||||
window.filesize = require('filesize');
|
||||
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();
|
||||
window.libphonenumber.PhoneNumberFormat =
|
||||
require('google-libphonenumber').PhoneNumberFormat;
|
||||
|
@ -154,6 +155,7 @@ const { getPlaceholderMigrations } =
|
|||
const { IdleDetector } = require('./js/modules/idle_detector');
|
||||
|
||||
window.Signal = {};
|
||||
window.Signal.Backbone = require('./ts/backbone');
|
||||
window.Signal.Backup = require('./js/modules/backup');
|
||||
window.Signal.Crypto = require('./js/modules/crypto');
|
||||
window.Signal.Database = require('./js/modules/database');
|
||||
|
@ -161,9 +163,21 @@ window.Signal.Debug = require('./js/modules/debug');
|
|||
window.Signal.HTML = require('./ts/html');
|
||||
window.Signal.Logs = require('./js/modules/logs');
|
||||
|
||||
// React components
|
||||
const { Lightbox } = require('./ts/components/Lightbox');
|
||||
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');
|
||||
|
||||
window.Signal.Components = {
|
||||
Lightbox,
|
||||
MediaGallery,
|
||||
PropTypes: {
|
||||
Message: PropTypesMessage,
|
||||
},
|
||||
Quote,
|
||||
};
|
||||
|
||||
|
@ -191,8 +205,9 @@ window.Signal.Types.Conversation = require('./ts/types/Conversation');
|
|||
window.Signal.Types.Errors = require('./js/modules/types/errors');
|
||||
|
||||
window.Signal.Types.Message = Message;
|
||||
window.Signal.Types.MIME = require('./js/modules/types/mime');
|
||||
window.Signal.Types.MIME = require('./ts/types/MIME');
|
||||
window.Signal.Types.Settings = require('./js/modules/types/settings');
|
||||
window.Signal.Util = require('./ts/util');
|
||||
|
||||
window.Signal.Views = {};
|
||||
window.Signal.Views.Initialization = require('./js/modules/views/initialization');
|
||||
|
|
|
@ -7,11 +7,21 @@ const propsParser = typescriptSupport.withCustomConfig('./tsconfig.json').parse;
|
|||
|
||||
module.exports = {
|
||||
sections: [
|
||||
{
|
||||
name: 'Components',
|
||||
description: '',
|
||||
components: 'ts/components/*.tsx',
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
description: 'Everything necessary to render a conversation',
|
||||
components: 'ts/components/conversation/*.tsx',
|
||||
},
|
||||
{
|
||||
name: 'Media Gallery',
|
||||
description: 'Display media and documents in a conversation',
|
||||
components: 'ts/components/conversation/media-gallery/*.tsx',
|
||||
},
|
||||
{
|
||||
name: 'Utility',
|
||||
description: 'Utility components used across the application',
|
||||
|
|
|
@ -1,59 +1,63 @@
|
|||
.lightbox {
|
||||
&.modal {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
background-color: rgba(0,0,0,0.8);
|
||||
.lightbox-container {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: $z-index-modal;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
padding: 0 60px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
.iconButton {
|
||||
// NOTE: Cannot move these to inline styles as hover breaks due to precedence.
|
||||
// We use vanilla CSS-in-JS which outputs inline styles. The `:hover`
|
||||
// pseudo-class cannot be expressed using vanilla CSS-in-JS, so we define it
|
||||
// here. If we move the other properties to JS, they have higher precedence
|
||||
// as they are inline and the `:hover` `background` change won’t override the
|
||||
// base `background` definition. Revisit this as we adopt a more sophisticated
|
||||
// style system in the future:
|
||||
background: transparent;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $grey;
|
||||
}
|
||||
|
||||
&.save {
|
||||
&:before {
|
||||
@include color-svg('../images/save.svg', white);
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 50px;
|
||||
&.close {
|
||||
&:before {
|
||||
@include color-svg('../images/x.svg', white);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
background: transparent;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $grey;
|
||||
}
|
||||
&.previous {
|
||||
&:before {
|
||||
@include color-svg('../images/back.svg', white);
|
||||
}
|
||||
}
|
||||
|
||||
.save {
|
||||
&:before {
|
||||
@include color-svg('../images/save.svg', white);
|
||||
}
|
||||
&.next {
|
||||
&:before {
|
||||
@include color-svg('../images/forward.svg', white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ $grey_d: #454545;
|
|||
$green: #47D647;
|
||||
$red: #EF8989;
|
||||
|
||||
$z-index-modal: 100;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto-Light';
|
||||
src: url('../fonts/Roboto-Light.ttf') format('truetype');
|
||||
|
|
|
@ -265,6 +265,13 @@ describe('Backup', () => {
|
|||
return _.omit(model, ['id']);
|
||||
}
|
||||
|
||||
const getUndefinedKeys = object =>
|
||||
Object.entries(object)
|
||||
.filter(([, value]) => value === undefined)
|
||||
.map(([name]) => name);
|
||||
const omitUndefinedKeys = object =>
|
||||
_.omit(object, getUndefinedKeys(object));
|
||||
|
||||
// We want to know which paths have two slashes, since that tells us which files
|
||||
// in the attachment fan-out are files vs. directories.
|
||||
const TWO_SLASHES = /[^/]*\/[^/]*\/[^/]*/;
|
||||
|
@ -349,6 +356,9 @@ describe('Backup', () => {
|
|||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]).buffer,
|
||||
}],
|
||||
hasAttachments: 1,
|
||||
hasFileAttachments: undefined,
|
||||
hasVisualMediaAttachments: 1,
|
||||
quote: {
|
||||
text: "Isn't it cute?",
|
||||
author: CONTACT_ONE_NUMBER,
|
||||
|
@ -460,18 +470,23 @@ describe('Backup', () => {
|
|||
await window.wrapDeferred(messageCollection.fetch());
|
||||
assert.strictEqual(messageCollection.length, MESSAGE_COUNT);
|
||||
const messageFromDB = removeId(messageCollection.at(0).attributes);
|
||||
console.log({ messageFromDB, message });
|
||||
assert.deepEqual(messageFromDB, message);
|
||||
|
||||
console.log('Backup test: check that all attachments were successfully imported');
|
||||
const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(messageFromDB);
|
||||
console.log({ messageWithAttachmentsFromDB, messageWithAttachments });
|
||||
const expectedMessage = omitUndefinedKeys(message);
|
||||
console.log({ messageFromDB, expectedMessage });
|
||||
assert.deepEqual(
|
||||
_.omit(messageWithAttachmentsFromDB, ['schemaVersion']),
|
||||
messageWithAttachments
|
||||
messageFromDB,
|
||||
expectedMessage
|
||||
);
|
||||
|
||||
console.log('Backup test: check conversations');
|
||||
console.log('Backup test: Check that all attachments were successfully imported');
|
||||
const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(messageFromDB);
|
||||
const expectedMessageWithAttachments = omitUndefinedKeys(messageWithAttachments);
|
||||
console.log({ messageWithAttachmentsFromDB, expectedMessageWithAttachments });
|
||||
assert.deepEqual(
|
||||
_.omit(messageWithAttachmentsFromDB, ['schemaVersion']),
|
||||
expectedMessageWithAttachments
|
||||
);
|
||||
|
||||
console.log('Backup test: Check conversations');
|
||||
const conversationCollection = new Whisper.ConversationCollection();
|
||||
await window.wrapDeferred(conversationCollection.fetch());
|
||||
assert.strictEqual(conversationCollection.length, CONVERSATION_COUNT);
|
||||
|
|
|
@ -25,13 +25,6 @@ describe('ConversationController', function() {
|
|||
timestamp: 30,
|
||||
}));
|
||||
|
||||
console.log('WTF!');
|
||||
console.log(collection.at('0').attributes);
|
||||
console.log(collection.at('1').attributes);
|
||||
console.log(collection.at('2').attributes);
|
||||
console.log(collection.at('3').attributes);
|
||||
console.log(collection.at('4').attributes);
|
||||
|
||||
assert.strictEqual(collection.at('0').get('name'), 'First!');
|
||||
assert.strictEqual(collection.at('1').get('name'), 'Á');
|
||||
assert.strictEqual(collection.at('2').get('name'), 'B');
|
||||
|
|
|
@ -169,15 +169,6 @@
|
|||
<span class='time'>0:00</span>
|
||||
<button class='close'><span class='icon'></span></button>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='lightbox'>
|
||||
<div class='content'>
|
||||
<div class='controls'>
|
||||
<a class='x close' alt='Close image.' href='#'></a>
|
||||
<a class='save' alt='Save as...' href='#'></a>
|
||||
</div>
|
||||
<img class='image' src='{{ url }}' />
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='confirmation-dialog'>
|
||||
<div class="content">
|
||||
<div class='message'>{{ message }}</div>
|
||||
|
|
|
@ -181,6 +181,9 @@ describe('Message', () => {
|
|||
fileName: 'test\uFFFDfig.exe',
|
||||
size: 1111,
|
||||
}],
|
||||
hasAttachments: 1,
|
||||
hasVisualMediaAttachments: undefined,
|
||||
hasFileAttachments: 1,
|
||||
schemaVersion: Message.CURRENT_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const { assert } = require('chai');
|
||||
|
||||
const MIME = require('../../../js/modules/types/mime');
|
||||
const MIME = require('../../../ts/types/MIME');
|
||||
|
||||
|
||||
describe('MIME', () => {
|
||||
|
|
42
ts/backbone/Conversation.ts
Normal file
42
ts/backbone/Conversation.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import is from '@sindresorhus/is';
|
||||
|
||||
import { Collection as BackboneCollection } from '../types/backbone/Collection';
|
||||
import { deferredToPromise } from '../../js/modules/deferred_to_promise';
|
||||
import { Message } from '../types/Message';
|
||||
|
||||
export const fetchVisualMediaAttachments = async ({
|
||||
conversationId,
|
||||
WhisperMessageCollection,
|
||||
}: {
|
||||
conversationId: string;
|
||||
WhisperMessageCollection: BackboneCollection<Message>;
|
||||
}): Promise<Array<Message>> => {
|
||||
if (!is.string(conversationId)) {
|
||||
throw new TypeError("'conversationId' is required");
|
||||
}
|
||||
|
||||
if (!is.object(WhisperMessageCollection)) {
|
||||
throw new TypeError("'WhisperMessageCollection' is required");
|
||||
}
|
||||
|
||||
const collection = new WhisperMessageCollection();
|
||||
const lowerReceivedAt = 0;
|
||||
const upperReceivedAt = Number.MAX_VALUE;
|
||||
const hasVisualMediaAttachments = 1;
|
||||
await deferredToPromise(
|
||||
collection.fetch({
|
||||
index: {
|
||||
name: 'hasVisualMediaAttachments',
|
||||
lower: [conversationId, lowerReceivedAt, hasVisualMediaAttachments],
|
||||
upper: [conversationId, upperReceivedAt, hasVisualMediaAttachments],
|
||||
order: 'desc',
|
||||
},
|
||||
limit: 50,
|
||||
})
|
||||
);
|
||||
|
||||
return collection.models.map(model => model.toJSON());
|
||||
};
|
7
ts/backbone/index.ts
Normal file
7
ts/backbone/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import * as Conversation from './Conversation';
|
||||
import * as Views from './views';
|
||||
|
||||
export { Conversation, Views };
|
25
ts/backbone/views/Lightbox.ts
Normal file
25
ts/backbone/views/Lightbox.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
export const show = (element: HTMLElement): void => {
|
||||
const container: HTMLDivElement | null = document.querySelector(
|
||||
'.lightbox-container'
|
||||
);
|
||||
if (container === null) {
|
||||
throw new TypeError("'.lightbox-container' is required");
|
||||
}
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'block';
|
||||
container.appendChild(element);
|
||||
};
|
||||
|
||||
export const hide = (): void => {
|
||||
const container: HTMLDivElement | null = document.querySelector(
|
||||
'.lightbox-container'
|
||||
);
|
||||
if (container === null) {
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
};
|
6
ts/backbone/views/index.ts
Normal file
6
ts/backbone/views/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import * as Lightbox from './Lightbox';
|
||||
|
||||
export { Lightbox };
|
12
ts/components/Lightbox.md
Normal file
12
ts/components/Lightbox.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
```js
|
||||
const noop = () => {};
|
||||
|
||||
<div style={{position: 'relative', width: '100%', height: 500}}>
|
||||
<Lightbox
|
||||
imageURL="https://placekitten.com/800/600"
|
||||
onNext={noop}
|
||||
onPrevious={noop}
|
||||
onSave={noop}
|
||||
/>
|
||||
</div>
|
||||
```
|
132
ts/components/Lightbox.tsx
Normal file
132
ts/components/Lightbox.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
close: () => void;
|
||||
imageURL?: string;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
padding: 40,
|
||||
} as React.CSSProperties,
|
||||
objectContainer: {
|
||||
flexGrow: 1,
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
} as React.CSSProperties,
|
||||
image: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
} as React.CSSProperties,
|
||||
controls: {
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginLeft: 10,
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
interface IconButtonProps {
|
||||
type: 'save' | 'close' | 'previous' | 'next';
|
||||
onClick?: () => void;
|
||||
}
|
||||
const IconButton = ({ onClick, type }: IconButtonProps) => (
|
||||
<a href="#" onClick={onClick} className={classNames('iconButton', type)} />
|
||||
);
|
||||
|
||||
export class Lightbox extends React.Component<Props, {}> {
|
||||
private containerRef: HTMLDivElement | null = null;
|
||||
|
||||
public componentDidMount() {
|
||||
const useCapture = true;
|
||||
document.addEventListener('keyup', this.onKeyUp, useCapture);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
const useCapture = true;
|
||||
document.removeEventListener('keyup', this.onKeyUp, useCapture);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { imageURL } = 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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private setContainerRef = (value: HTMLDivElement) => {
|
||||
this.containerRef = value;
|
||||
};
|
||||
|
||||
private onClose = () => {
|
||||
const { close } = this.props;
|
||||
if (!close) {
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
private onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onClose();
|
||||
};
|
||||
|
||||
private onContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target !== this.containerRef) {
|
||||
return;
|
||||
}
|
||||
this.onClose();
|
||||
};
|
||||
|
||||
private onImageClick = (event: React.MouseEvent<HTMLImageElement>) => {
|
||||
event.stopPropagation();
|
||||
this.onClose();
|
||||
};
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
// @ts-ignore
|
||||
import Mime from '../../../js/modules/types/mime';
|
||||
import * as MIME from '../../../ts/types/MIME';
|
||||
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
|
||||
|
||||
|
||||
interface Props {
|
||||
|
@ -19,7 +19,7 @@ interface Props {
|
|||
}
|
||||
|
||||
interface QuotedAttachment {
|
||||
contentType: string;
|
||||
contentType: MIME.MIMEType;
|
||||
fileName: string;
|
||||
/* Not included in protobuf */
|
||||
isVoiceMessage: boolean;
|
||||
|
@ -27,7 +27,7 @@ interface QuotedAttachment {
|
|||
}
|
||||
|
||||
interface Attachment {
|
||||
contentType: string;
|
||||
contentType: MIME.MIMEType;
|
||||
/* Not included in protobuf, and is loaded asynchronously */
|
||||
objectUrl?: string;
|
||||
}
|
||||
|
@ -92,17 +92,17 @@ export class Quote extends React.Component<Props, {}> {
|
|||
const { contentType, thumbnail } = first;
|
||||
const objectUrl = getObjectUrl(thumbnail);
|
||||
|
||||
if (Mime.isVideo(contentType)) {
|
||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
return objectUrl
|
||||
? this.renderImage(objectUrl, 'play')
|
||||
: this.renderIcon('movie');
|
||||
}
|
||||
if (Mime.isImage(contentType)) {
|
||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||
return objectUrl
|
||||
? this.renderImage(objectUrl)
|
||||
: this.renderIcon('image');
|
||||
}
|
||||
if (Mime.isAudio(contentType)) {
|
||||
if (MIME.isAudio(contentType)) {
|
||||
return this.renderIcon('microphone');
|
||||
}
|
||||
|
||||
|
@ -123,16 +123,16 @@ export class Quote extends React.Component<Props, {}> {
|
|||
const first = attachments[0];
|
||||
const { contentType, fileName, isVoiceMessage } = first;
|
||||
|
||||
if (Mime.isVideo(contentType)) {
|
||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
return <div className="type-label">{i18n('video')}</div>;
|
||||
}
|
||||
if (Mime.isImage(contentType)) {
|
||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||
return <div className="type-label">{i18n('photo')}</div>;
|
||||
}
|
||||
if (Mime.isAudio(contentType) && isVoiceMessage) {
|
||||
if (MIME.isAudio(contentType) && isVoiceMessage) {
|
||||
return <div className="type-label">{i18n('voiceMessage')}</div>;
|
||||
}
|
||||
if (Mime.isAudio(contentType)) {
|
||||
if (MIME.isAudio(contentType)) {
|
||||
return <div className="type-label">{i18n('audio')}</div>;
|
||||
}
|
||||
|
||||
|
@ -196,7 +196,7 @@ export class Quote extends React.Component<Props, {}> {
|
|||
authorColor,
|
||||
'quoted-message',
|
||||
isFromMe ? 'from-me' : null,
|
||||
!onClick ? 'no-click' : null,
|
||||
!onClick ? 'no-click' : null
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
import { ItemClickEvent } from './events/ItemClickEvent';
|
||||
import { MediaGridItem } from './MediaGridItem';
|
||||
import { Message } from './propTypes/Message';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal',
|
||||
lineHeight: '28px',
|
||||
} as React.CSSProperties,
|
||||
itemContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
i18n: (value: string) => string;
|
||||
header?: string;
|
||||
type: 'media' | 'documents';
|
||||
messages: Array<Message>;
|
||||
onItemClick?: (event: ItemClickEvent) => void;
|
||||
}
|
||||
|
||||
export class AttachmentSection extends React.Component<Props, {}> {
|
||||
public render() {
|
||||
const { header } = this.props;
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.header}>{header}</h2>
|
||||
<div style={styles.itemContainer}>{this.renderItems()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderItems() {
|
||||
const { i18n, messages, type } = this.props;
|
||||
|
||||
return messages.map(message => {
|
||||
const { attachments } = message;
|
||||
const firstAttachment = attachments[0];
|
||||
|
||||
const onClick = this.createClickHandler(message);
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return (
|
||||
<MediaGridItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
case 'documents':
|
||||
return (
|
||||
<DocumentListItem
|
||||
key={message.id}
|
||||
i18n={i18n}
|
||||
fileSize={firstAttachment.size}
|
||||
fileName={firstAttachment.fileName}
|
||||
timestamp={message.received_at}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return missingCaseError(type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private createClickHandler = (message: Message) => () => {
|
||||
const { onItemClick } = this.props;
|
||||
if (!onItemClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
onItemClick({ message });
|
||||
};
|
||||
}
|
19
ts/components/conversation/media-gallery/DocumentListItem.md
Normal file
19
ts/components/conversation/media-gallery/DocumentListItem.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
DocumentListItem example:
|
||||
|
||||
```js
|
||||
<DocumentListItem
|
||||
fileName="meow.jpg"
|
||||
fileSize={1024 * 1000 * 2}
|
||||
timestamp={Date.now()}
|
||||
/>
|
||||
<DocumentListItem
|
||||
fileName="rickroll.wmv"
|
||||
fileSize={1024 * 1000 * 8}
|
||||
timestamp={Date.now() - 24 * 60 * 1000}
|
||||
/>
|
||||
<DocumentListItem
|
||||
fileName="kitten.gif"
|
||||
fileSize={1024 * 1000 * 1.2}
|
||||
timestamp={Date.now() - 14 * 24 * 60 * 1000}
|
||||
/>
|
||||
```
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import moment from 'moment';
|
||||
import formatFileSize from 'filesize';
|
||||
|
||||
interface Props {
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
i18n: (key: string, values?: Array<string>) => string;
|
||||
onClick?: () => void;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
width: '100%',
|
||||
height: 72,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#ccc',
|
||||
borderBottomStyle: 'solid',
|
||||
},
|
||||
itemContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
} as React.CSSProperties,
|
||||
itemMetadata: {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
} as React.CSSProperties,
|
||||
itemDate: {
|
||||
display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
},
|
||||
itemIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
itemFileName: {
|
||||
fontWeight: 'bold',
|
||||
} as React.CSSProperties,
|
||||
itemFileSize: {
|
||||
display: 'inline-block',
|
||||
marginTop: 8,
|
||||
fontSize: '80%',
|
||||
},
|
||||
};
|
||||
|
||||
export class DocumentListItem extends React.Component<Props, {}> {
|
||||
public renderContent() {
|
||||
const { fileName, fileSize, timestamp } = this.props;
|
||||
|
||||
return (
|
||||
<div style={styles.itemContainer} onClick={this.props.onClick}>
|
||||
<img
|
||||
src="images/file.svg"
|
||||
width="48"
|
||||
height="48"
|
||||
style={styles.itemIcon}
|
||||
/>
|
||||
<div style={styles.itemMetadata}>
|
||||
<span style={styles.itemFileName}>{fileName}</span>
|
||||
<span style={styles.itemFileSize}>
|
||||
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.itemDate}>
|
||||
{moment(timestamp).format('ddd, MMM D, Y')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div style={styles.container}>{this.renderContent()}</div>;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const LoadingIndicator = () => {
|
||||
return (
|
||||
<div className="loading-widget">
|
||||
<div className="container">
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
<span className="dot" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
61
ts/components/conversation/media-gallery/MediaGallery.md
Normal file
61
ts/components/conversation/media-gallery/MediaGallery.md
Normal file
|
@ -0,0 +1,61 @@
|
|||
```jsx
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MONTH_MS = 30 * DAY_MS;
|
||||
const YEAR_MS = 12 * MONTH_MS;
|
||||
const tokens = ['foo', 'bar', 'baz', 'qux', 'quux'];
|
||||
const fileExtensions = ['docx', 'pdf', 'txt', 'mp3', 'wmv', 'tiff'];
|
||||
const createRandomMessage = ({startTime, timeWindow} = {}) => (props) => {
|
||||
const now = Date.now();
|
||||
const fileName =
|
||||
`${_.sample(tokens)}${_.sample(tokens)}.${_.sample(fileExtensions)}`;
|
||||
return {
|
||||
id: _.random(now).toString(),
|
||||
received_at: _.random(startTime, startTime + timeWindow),
|
||||
attachments: [{
|
||||
data: null,
|
||||
fileName,
|
||||
size: _.random(1000, 1000 * 1000 * 50),
|
||||
}],
|
||||
|
||||
objectURL: `https://placekitten.com/${_.random(50, 150)}/${_.random(50, 150)}`,
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
const createRandomMessages = ({startTime, timeWindow}) =>
|
||||
_.range(_.random(5, 10)).map(createRandomMessage({startTime, timeWindow}));
|
||||
|
||||
|
||||
const startTime = Date.now();
|
||||
const messages = _.sortBy(
|
||||
[
|
||||
...createRandomMessages({
|
||||
startTime,
|
||||
timeWindow: DAY_MS,
|
||||
}),
|
||||
...createRandomMessages({
|
||||
startTime: startTime - DAY_MS,
|
||||
timeWindow: DAY_MS,
|
||||
}),
|
||||
...createRandomMessages({
|
||||
startTime: startTime - 3 * DAY_MS,
|
||||
timeWindow: 3 * DAY_MS,
|
||||
}),
|
||||
...createRandomMessages({
|
||||
startTime: startTime - 30 * DAY_MS,
|
||||
timeWindow: 15 * DAY_MS,
|
||||
}),
|
||||
...createRandomMessages({
|
||||
startTime: startTime - 365 * DAY_MS,
|
||||
timeWindow: 300 * DAY_MS,
|
||||
}),
|
||||
],
|
||||
message => -message.received_at
|
||||
);
|
||||
|
||||
<MediaGallery
|
||||
i18n={window.i18n}
|
||||
media={messages}
|
||||
documents={messages}
|
||||
/>
|
||||
```
|
148
ts/components/conversation/media-gallery/MediaGallery.tsx
Normal file
148
ts/components/conversation/media-gallery/MediaGallery.tsx
Normal file
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
import { AttachmentSection } from './AttachmentSection';
|
||||
import { groupMessagesByDate } from './groupMessagesByDate';
|
||||
import { ItemClickEvent } from './events/ItemClickEvent';
|
||||
import { Message } from './propTypes/Message';
|
||||
|
||||
type AttachmentType = 'media' | 'documents';
|
||||
|
||||
interface Props {
|
||||
documents: Array<Message>;
|
||||
i18n: (key: string, values?: Array<string>) => string;
|
||||
media: Array<Message>;
|
||||
onItemClick?: (event: ItemClickEvent) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
selectedTab: AttachmentType;
|
||||
}
|
||||
|
||||
const MONTH_FORMAT = 'MMMM YYYY';
|
||||
const COLOR_GRAY = '#f3f3f3';
|
||||
|
||||
const tabStyle = {
|
||||
width: '100%',
|
||||
backgroundColor: COLOR_GRAY,
|
||||
padding: 20,
|
||||
textAlign: 'center',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
tabContainer: {
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
},
|
||||
tab: {
|
||||
default: tabStyle,
|
||||
active: {
|
||||
...tabStyle,
|
||||
borderBottom: '2px solid #08f',
|
||||
},
|
||||
},
|
||||
attachmentsContainer: {
|
||||
padding: 20,
|
||||
},
|
||||
};
|
||||
|
||||
interface TabSelectEvent {
|
||||
type: AttachmentType;
|
||||
}
|
||||
|
||||
const Tab = ({
|
||||
isSelected,
|
||||
label,
|
||||
onSelect,
|
||||
type,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
label: string;
|
||||
onSelect?: (event: TabSelectEvent) => void;
|
||||
type: AttachmentType;
|
||||
}) => {
|
||||
const handleClick = onSelect ? () => onSelect({ type }) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={isSelected ? styles.tab.active : styles.tab.default}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class MediaGallery extends React.Component<Props, State> {
|
||||
public state: State = {
|
||||
selectedTab: 'media',
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { selectedTab } = this.state;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={styles.tabContainer}>
|
||||
<Tab
|
||||
label="Media"
|
||||
type="media"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
private handleTabSelect = (event: TabSelectEvent): void => {
|
||||
this.setState({ selectedTab: event.type });
|
||||
};
|
||||
|
||||
private renderSections() {
|
||||
const { i18n, media, documents, onItemClick } = this.props;
|
||||
const { selectedTab } = this.state;
|
||||
|
||||
const messages = selectedTab === 'media' ? media : documents;
|
||||
const type = selectedTab;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sections = groupMessagesByDate(now, messages);
|
||||
return sections.map(section => {
|
||||
const first = section.messages[0];
|
||||
const date = moment(first.received_at);
|
||||
const header =
|
||||
section.type === 'yearMonth'
|
||||
? date.format(MONTH_FORMAT)
|
||||
: i18n(section.type);
|
||||
return (
|
||||
<AttachmentSection
|
||||
key={header}
|
||||
header={header}
|
||||
i18n={i18n}
|
||||
type={type}
|
||||
messages={section.messages}
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
56
ts/components/conversation/media-gallery/MediaGridItem.tsx
Normal file
56
ts/components/conversation/media-gallery/MediaGridItem.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { Message } from './propTypes/Message';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const size = {
|
||||
width: 94,
|
||||
height: 94,
|
||||
};
|
||||
const styles = {
|
||||
container: {
|
||||
...size,
|
||||
backgroundColor: '#f3f3f3',
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
image: {
|
||||
...size,
|
||||
backgroundSize: 'cover',
|
||||
},
|
||||
};
|
||||
|
||||
export class MediaGridItem extends React.Component<Props, {}> {
|
||||
public renderContent() {
|
||||
const { message } = this.props;
|
||||
|
||||
if (!message.objectURL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...styles.container,
|
||||
...styles.image,
|
||||
backgroundImage: `url("${message.objectURL}")`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div style={styles.container} onClick={this.props.onClick}>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import { Message } from '../propTypes/Message';
|
||||
|
||||
export interface ItemClickEvent {
|
||||
message: Message;
|
||||
}
|
151
ts/components/conversation/media-gallery/groupMessagesByDate.ts
Normal file
151
ts/components/conversation/media-gallery/groupMessagesByDate.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import moment from 'moment';
|
||||
import { compact, groupBy, sortBy } from 'lodash';
|
||||
|
||||
import { Message } from './propTypes/Message';
|
||||
// import { missingCaseError } from '../../../util/missingCaseError';
|
||||
|
||||
type StaticSectionType = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth';
|
||||
type YearMonthSectionType = 'yearMonth';
|
||||
|
||||
interface GenericSection<T> {
|
||||
type: T;
|
||||
messages: Array<Message>;
|
||||
}
|
||||
type StaticSection = GenericSection<StaticSectionType>;
|
||||
type YearMonthSection = GenericSection<YearMonthSectionType> & {
|
||||
year: number;
|
||||
month: number;
|
||||
};
|
||||
export type Section = StaticSection | YearMonthSection;
|
||||
export const groupMessagesByDate = (
|
||||
timestamp: number,
|
||||
messages: Array<Message>
|
||||
): Array<Section> => {
|
||||
const referenceDateTime = moment.utc(timestamp);
|
||||
|
||||
const sortedMessages = sortBy(messages, message => -message.received_at);
|
||||
const messagesWithSection = sortedMessages.map(
|
||||
withSection(referenceDateTime)
|
||||
);
|
||||
const groupedMessages = groupBy(messagesWithSection, 'type');
|
||||
const yearMonthMessages = Object.values(
|
||||
groupBy(groupedMessages.yearMonth, 'order')
|
||||
).reverse();
|
||||
return compact([
|
||||
toSection(groupedMessages.today),
|
||||
toSection(groupedMessages.yesterday),
|
||||
toSection(groupedMessages.thisWeek),
|
||||
toSection(groupedMessages.thisMonth),
|
||||
...yearMonthMessages.map(toSection),
|
||||
]);
|
||||
};
|
||||
|
||||
const toSection = (
|
||||
messagesWithSection: Array<MessageWithSection> | undefined
|
||||
): Section | null => {
|
||||
if (!messagesWithSection || messagesWithSection.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstMessageWithSection: MessageWithSection = messagesWithSection[0];
|
||||
if (!firstMessageWithSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = messagesWithSection.map(
|
||||
messageWithSection => messageWithSection.message
|
||||
);
|
||||
switch (firstMessageWithSection.type) {
|
||||
case 'today':
|
||||
case 'yesterday':
|
||||
case 'thisWeek':
|
||||
case 'thisMonth':
|
||||
return {
|
||||
type: firstMessageWithSection.type,
|
||||
messages,
|
||||
};
|
||||
case 'yearMonth':
|
||||
return {
|
||||
type: firstMessageWithSection.type,
|
||||
year: firstMessageWithSection.year,
|
||||
month: firstMessageWithSection.month,
|
||||
messages,
|
||||
};
|
||||
default:
|
||||
// NOTE: Investigate why we get the following error:
|
||||
// error TS2345: Argument of type 'any' is not assignable to parameter
|
||||
// of type 'never'.
|
||||
// return missingCaseError(firstMessageWithSection.type);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface GenericMessageWithSection<T> {
|
||||
order: number;
|
||||
type: T;
|
||||
message: Message;
|
||||
}
|
||||
type MessageWithStaticSection = GenericMessageWithSection<StaticSectionType>;
|
||||
type MessageWithYearMonthSection = GenericMessageWithSection<
|
||||
YearMonthSectionType
|
||||
> & {
|
||||
year: number;
|
||||
month: number;
|
||||
};
|
||||
type MessageWithSection =
|
||||
| MessageWithStaticSection
|
||||
| MessageWithYearMonthSection;
|
||||
|
||||
const withSection = (referenceDateTime: moment.Moment) => (
|
||||
message: Message
|
||||
): MessageWithSection => {
|
||||
const today = moment(referenceDateTime).startOf('day');
|
||||
const yesterday = moment(referenceDateTime)
|
||||
.subtract(1, 'day')
|
||||
.startOf('day');
|
||||
const thisWeek = moment(referenceDateTime).startOf('isoWeek');
|
||||
const thisMonth = moment(referenceDateTime).startOf('month');
|
||||
|
||||
const messageReceivedDate = moment.utc(message.received_at);
|
||||
if (messageReceivedDate.isAfter(today)) {
|
||||
return {
|
||||
order: 0,
|
||||
type: 'today',
|
||||
message,
|
||||
};
|
||||
}
|
||||
if (messageReceivedDate.isAfter(yesterday)) {
|
||||
return {
|
||||
order: 1,
|
||||
type: 'yesterday',
|
||||
message,
|
||||
};
|
||||
}
|
||||
if (messageReceivedDate.isAfter(thisWeek)) {
|
||||
return {
|
||||
order: 2,
|
||||
type: 'thisWeek',
|
||||
message,
|
||||
};
|
||||
}
|
||||
if (messageReceivedDate.isAfter(thisMonth)) {
|
||||
return {
|
||||
order: 3,
|
||||
type: 'thisMonth',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
const month: number = messageReceivedDate.month();
|
||||
const year: number = messageReceivedDate.year();
|
||||
return {
|
||||
order: year * 100 + month,
|
||||
type: 'yearMonth',
|
||||
month,
|
||||
year,
|
||||
message,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import is from '@sindresorhus/is';
|
||||
import { partition, sortBy } from 'lodash';
|
||||
|
||||
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;
|
||||
attachments: Array<Attachment>;
|
||||
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>> => {
|
||||
if (!is.function_(loadMessage)) {
|
||||
throw new TypeError("'loadMessage' must be a function");
|
||||
}
|
||||
if (!is.array(messages)) {
|
||||
throw new TypeError("'messages' must be an array");
|
||||
}
|
||||
|
||||
// Messages with video are too expensive to load into memory, so we don’t:
|
||||
const [, 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,
|
||||
message => -message.received_at
|
||||
);
|
||||
|
||||
return loadedMessages.map(withObjectURL);
|
||||
};
|
||||
|
||||
const hasVideoAttachment = (message: Message): boolean =>
|
||||
message.attachments.some(
|
||||
attachment =>
|
||||
!is.undefined(attachment.contentType) &&
|
||||
MIME.isVideo(attachment.contentType)
|
||||
);
|
||||
|
||||
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') {
|
||||
throw new TypeError('`attachment.contentType` is required');
|
||||
}
|
||||
|
||||
if (MIME.isVideo(attachment.contentType)) {
|
||||
return {
|
||||
...message,
|
||||
objectURL: 'images/video.svg',
|
||||
};
|
||||
}
|
||||
|
||||
const objectURL = arrayBufferToObjectURL({
|
||||
data: attachment.data,
|
||||
type: attachment.contentType || DEFAULT_CONTENT_TYPE,
|
||||
});
|
||||
return {
|
||||
...message,
|
||||
objectURL,
|
||||
};
|
||||
};
|
|
@ -1,6 +1,3 @@
|
|||
import moment from 'moment';
|
||||
import qs from 'qs';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {
|
||||
|
@ -8,6 +5,11 @@ import {
|
|||
sample,
|
||||
} from 'lodash';
|
||||
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import qs from 'qs';
|
||||
|
||||
export { _ };
|
||||
|
||||
// Helper components used in the Style Guide, exposed at 'util' in the global scope via
|
||||
// the 'context' option in react-styleguidist.
|
||||
|
@ -20,8 +22,7 @@ export { BackboneWrapper } from '../components/utility/BackboneWrapper';
|
|||
import { Quote } from '../components/conversation/Quote';
|
||||
import * as HTML from '../html';
|
||||
|
||||
// @ts-ignore
|
||||
import MIME from '../../js/modules/types/mime';
|
||||
import * as MIME from '../../ts/types/MIME';
|
||||
|
||||
// TypeScript wants two things when you import:
|
||||
// 1) a normal typescript file
|
||||
|
@ -211,6 +212,6 @@ parent.emoji.signalReplace = (html: string): string => {
|
|||
return html.replace(
|
||||
/🔥/g,
|
||||
'<img src="node_modules/emoji-datasource-apple/img/apple/64/1f525.png"' +
|
||||
'class="emoji" data-codepoints="1f525" title=":fire:">',
|
||||
'class="emoji" data-codepoints="1f525" title=":fire:">'
|
||||
);
|
||||
};
|
||||
|
|
132
ts/test/components/media-gallery/groupMessagesByDate.ts
Normal file
132
ts/test/components/media-gallery/groupMessagesByDate.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import 'mocha';
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { shuffle } from 'lodash';
|
||||
|
||||
import {
|
||||
groupMessagesByDate,
|
||||
Section,
|
||||
} from '../../../components/conversation/media-gallery/groupMessagesByDate';
|
||||
import { Message } from '../../../components/conversation/media-gallery/propTypes/Message';
|
||||
|
||||
const toMessage = (date: Date): Message => ({
|
||||
id: date.toUTCString(),
|
||||
received_at: date.getTime(),
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
describe('groupMessagesByDate', () => {
|
||||
it('should group messages', () => {
|
||||
const referenceTime = new Date('2018-04-12T18:00Z').getTime(); // Thu
|
||||
const input: Array<Message> = shuffle([
|
||||
// Today
|
||||
toMessage(new Date('2018-04-12T12:00Z')), // Thu
|
||||
toMessage(new Date('2018-04-12T00:01Z')), // Thu
|
||||
// This week
|
||||
toMessage(new Date('2018-04-11T23:59Z')), // Wed
|
||||
toMessage(new Date('2018-04-09T00:01Z')), // Mon
|
||||
// This month
|
||||
toMessage(new Date('2018-04-08T23:59Z')), // Sun
|
||||
toMessage(new Date('2018-04-01T00:01Z')),
|
||||
// March 2018
|
||||
toMessage(new Date('2018-03-31T23:59Z')),
|
||||
toMessage(new Date('2018-03-01T14:00Z')),
|
||||
// February 2011
|
||||
toMessage(new Date('2011-02-28T23:59Z')),
|
||||
toMessage(new Date('2011-02-01T10:00Z')),
|
||||
]);
|
||||
|
||||
const expected: Array<Section> = [
|
||||
{
|
||||
type: 'today',
|
||||
messages: [
|
||||
{
|
||||
id: 'Thu, 12 Apr 2018 12:00:00 GMT',
|
||||
received_at: 1523534400000,
|
||||
attachments: [],
|
||||
},
|
||||
{
|
||||
id: 'Thu, 12 Apr 2018 00:01:00 GMT',
|
||||
received_at: 1523491260000,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'yesterday',
|
||||
messages: [
|
||||
{
|
||||
id: 'Wed, 11 Apr 2018 23:59:00 GMT',
|
||||
received_at: 1523491140000,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'thisWeek',
|
||||
messages: [
|
||||
{
|
||||
id: 'Mon, 09 Apr 2018 00:01:00 GMT',
|
||||
received_at: 1523232060000,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'thisMonth',
|
||||
messages: [
|
||||
{
|
||||
id: 'Sun, 08 Apr 2018 23:59:00 GMT',
|
||||
received_at: 1523231940000,
|
||||
attachments: [],
|
||||
},
|
||||
{
|
||||
id: 'Sun, 01 Apr 2018 00:01:00 GMT',
|
||||
received_at: 1522540860000,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'yearMonth',
|
||||
year: 2018,
|
||||
month: 2,
|
||||
messages: [
|
||||
{
|
||||
id: 'Sat, 31 Mar 2018 23:59:00 GMT',
|
||||
received_at: 1522540740000,
|
||||
attachments: [],
|
||||
},
|
||||
{
|
||||
id: 'Thu, 01 Mar 2018 14:00:00 GMT',
|
||||
received_at: 1519912800000,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'yearMonth',
|
||||
year: 2011,
|
||||
month: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 'Mon, 28 Feb 2011 23:59:00 GMT',
|
||||
received_at: 1298937540000,
|
||||
attachments: [],
|
||||
},
|
||||
{
|
||||
id: 'Tue, 01 Feb 2011 10:00:00 GMT',
|
||||
received_at: 1296554400000,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const actual = groupMessagesByDate(referenceTime, input);
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
});
|
|
@ -57,7 +57,7 @@ describe('HTML', () => {
|
|||
},
|
||||
];
|
||||
|
||||
TESTS.forEach((test) => {
|
||||
TESTS.forEach(test => {
|
||||
(test.skipped ? it.skip : it)(`should handle ${test.name}`, () => {
|
||||
const preText = test.preText || 'Hello ';
|
||||
const postText = test.postText || ' World!';
|
||||
|
|
50
ts/test/types/message/initializeAttachmentMetadata_test.ts
Normal file
50
ts/test/types/message/initializeAttachmentMetadata_test.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import 'mocha';
|
||||
import { assert } from 'chai';
|
||||
|
||||
import * as Message from '../../../../ts/types/message/initializeAttachmentMetadata';
|
||||
import { IncomingMessage } from '../../../../ts/types/Message';
|
||||
import { MIMEType } from '../../../../ts/types/MIME';
|
||||
// @ts-ignore
|
||||
import { stringToArrayBuffer } from '../../../../js/modules/string_to_array_buffer';
|
||||
|
||||
|
||||
describe('Message', () => {
|
||||
describe('initializeAttachmentMetadata', () => {
|
||||
it('should handle visual media attachments', async () => {
|
||||
const input: IncomingMessage = {
|
||||
type: 'incoming',
|
||||
conversationId: 'foo',
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
timestamp: 1523317140899,
|
||||
received_at: 1523317140899,
|
||||
sent_at: 1523317140800,
|
||||
attachments: [{
|
||||
contentType: 'image/jpeg' as MIMEType,
|
||||
data: stringToArrayBuffer('foo'),
|
||||
fileName: 'foo.jpg',
|
||||
size: 1111,
|
||||
}],
|
||||
};
|
||||
const expected: IncomingMessage = {
|
||||
type: 'incoming',
|
||||
conversationId: 'foo',
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
timestamp: 1523317140899,
|
||||
received_at: 1523317140899,
|
||||
sent_at: 1523317140800,
|
||||
attachments: [{
|
||||
contentType: 'image/jpeg' as MIMEType,
|
||||
data: stringToArrayBuffer('foo'),
|
||||
fileName: 'foo.jpg',
|
||||
size: 1111,
|
||||
}],
|
||||
hasAttachments: 1,
|
||||
hasVisualMediaAttachments: 1,
|
||||
hasFileAttachments: undefined,
|
||||
};
|
||||
|
||||
const actual = await Message.initializeAttachmentMetadata(input);
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,10 @@
|
|||
import { MIMEType } from './MIME';
|
||||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import is from '@sindresorhus/is';
|
||||
|
||||
import * as GoogleChrome from '../util/GoogleChrome';
|
||||
import { MIMEType } from './MIME';
|
||||
|
||||
export interface Attachment {
|
||||
fileName?: string;
|
||||
|
@ -17,3 +22,15 @@ export interface Attachment {
|
|||
// digest?: ArrayBuffer;
|
||||
// flags?: number;
|
||||
}
|
||||
|
||||
export const isVisualMedia = (attachment: Attachment): boolean => {
|
||||
const { contentType } = attachment;
|
||||
|
||||
if (is.undefined(contentType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isSupportedImageType = GoogleChrome.isImageTypeSupported(contentType);
|
||||
const isSupportedVideoType = GoogleChrome.isVideoTypeSupported(contentType);
|
||||
return isSupportedImageType || isSupportedVideoType;
|
||||
};
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import is from '@sindresorhus/is';
|
||||
import { Message } from './Message';
|
||||
|
||||
|
||||
interface ConversationLastMessageUpdate {
|
||||
lastMessage: string | null;
|
||||
timestamp: number | null;
|
||||
|
@ -13,10 +15,10 @@ export const createLastMessageUpdate = ({
|
|||
lastMessage,
|
||||
lastMessageNotificationText,
|
||||
}: {
|
||||
currentLastMessageText: string | null,
|
||||
currentTimestamp: number | null,
|
||||
lastMessage: Message | null,
|
||||
lastMessageNotificationText: string | null,
|
||||
currentLastMessageText: string | null;
|
||||
currentTimestamp: number | null;
|
||||
lastMessage: Message | null;
|
||||
lastMessageNotificationText: string | null;
|
||||
}): ConversationLastMessageUpdate => {
|
||||
if (lastMessage === null) {
|
||||
return {
|
||||
|
@ -30,13 +32,14 @@ export const createLastMessageUpdate = ({
|
|||
const isExpiringMessage = is.object(lastMessage.expirationTimerUpdate);
|
||||
const shouldUpdateTimestamp = !isVerifiedChangeMessage && !isExpiringMessage;
|
||||
|
||||
const newTimestamp = shouldUpdateTimestamp ?
|
||||
lastMessage.sent_at :
|
||||
currentTimestamp;
|
||||
const newTimestamp = shouldUpdateTimestamp
|
||||
? lastMessage.sent_at
|
||||
: currentTimestamp;
|
||||
|
||||
const shouldUpdateLastMessageText = !isVerifiedChangeMessage;
|
||||
const newLastMessageText = shouldUpdateLastMessageText ?
|
||||
lastMessageNotificationText : currentLastMessageText;
|
||||
const newLastMessageText = shouldUpdateLastMessageText
|
||||
? lastMessageNotificationText
|
||||
: currentLastMessageText;
|
||||
|
||||
return {
|
||||
lastMessage: newLastMessageText,
|
||||
|
|
23
ts/types/IndexedDB.ts
Normal file
23
ts/types/IndexedDB.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
|
||||
// IndexedDB doesn’t support boolean indexes so we map `true` to 1 and `false`
|
||||
// to `0`, i.e. `IndexableBoolean`.
|
||||
// N.B. Using `undefined` allows excluding an entry from an index. Useful
|
||||
// when index size is a consideration or one only needs to query for `true`,
|
||||
// i.e. `IndexablePresence`.
|
||||
export type IndexableBoolean = IndexableFalse | IndexableTrue;
|
||||
export type IndexablePresence = undefined | IndexableTrue;
|
||||
|
||||
type IndexableFalse = 0;
|
||||
type IndexableTrue = 1;
|
||||
|
||||
export const INDEXABLE_FALSE: IndexableFalse = 0;
|
||||
export const INDEXABLE_TRUE: IndexableTrue = 1;
|
||||
|
||||
export const toIndexableBoolean = (value: boolean): IndexableBoolean =>
|
||||
value ? INDEXABLE_TRUE : INDEXABLE_FALSE;
|
||||
|
||||
export const toIndexablePresence = (value: boolean): IndexablePresence =>
|
||||
value ? INDEXABLE_TRUE : undefined;
|
|
@ -1 +1,12 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
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/');
|
||||
|
|
4
ts/types/MapAsync.ts
Normal file
4
ts/types/MapAsync.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
export type MapAsync<T> = (value: T) => Promise<T>;
|
|
@ -1,45 +1,65 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import { Attachment } from './Attachment';
|
||||
import { IndexableBoolean, IndexablePresence } from './IndexedDB';
|
||||
|
||||
export type Message = UserMessage | VerifiedChangeMessage;
|
||||
export type UserMessage = IncomingMessage | OutgoingMessage;
|
||||
|
||||
export type Message
|
||||
= IncomingMessage
|
||||
| OutgoingMessage
|
||||
| VerifiedChangeMessage;
|
||||
export type IncomingMessage = Readonly<
|
||||
{
|
||||
type: 'incoming';
|
||||
// Required
|
||||
attachments: Array<Attachment>;
|
||||
id: string;
|
||||
received_at: number;
|
||||
|
||||
export type IncomingMessage = Readonly<{
|
||||
type: 'incoming';
|
||||
attachments: Array<Attachment>;
|
||||
body?: string;
|
||||
decrypted_at?: number;
|
||||
errors?: Array<any>;
|
||||
flags?: number;
|
||||
id: string;
|
||||
received_at: number;
|
||||
source?: string;
|
||||
sourceDevice?: number;
|
||||
} & SharedMessageProperties & Message4 & ExpirationTimerUpdate>;
|
||||
// Optional
|
||||
body?: string;
|
||||
decrypted_at?: number;
|
||||
errors?: Array<any>;
|
||||
flags?: number;
|
||||
source?: string;
|
||||
sourceDevice?: number;
|
||||
} & SharedMessageProperties &
|
||||
MessageSchemaVersion5 &
|
||||
ExpirationTimerUpdate
|
||||
>;
|
||||
|
||||
export type OutgoingMessage = Readonly<{
|
||||
type: 'outgoing';
|
||||
attachments: Array<Attachment>;
|
||||
body?: string;
|
||||
delivered: number;
|
||||
delivered_to: Array<string>;
|
||||
destination: string; // PhoneNumber
|
||||
expirationStartTimestamp: number;
|
||||
expires_at?: number;
|
||||
expireTimer?: number;
|
||||
id: string;
|
||||
received_at: number;
|
||||
recipients?: Array<string>; // Array<PhoneNumber>
|
||||
sent: boolean;
|
||||
sent_to: Array<string>; // Array<PhoneNumber>
|
||||
synced: boolean;
|
||||
} & SharedMessageProperties & Message4 & ExpirationTimerUpdate>;
|
||||
export type OutgoingMessage = Readonly<
|
||||
{
|
||||
type: 'outgoing';
|
||||
|
||||
export type VerifiedChangeMessage = Readonly<{
|
||||
type: 'verified-change';
|
||||
} & SharedMessageProperties & Message4 & ExpirationTimerUpdate>;
|
||||
// Required
|
||||
attachments: Array<Attachment>;
|
||||
delivered: number;
|
||||
delivered_to: Array<string>;
|
||||
destination: string; // PhoneNumber
|
||||
expirationStartTimestamp: number;
|
||||
id: string;
|
||||
received_at: number;
|
||||
sent: boolean;
|
||||
sent_to: Array<string>; // Array<PhoneNumber>
|
||||
|
||||
// Optional
|
||||
body?: string;
|
||||
expires_at?: number;
|
||||
expireTimer?: number;
|
||||
recipients?: Array<string>; // Array<PhoneNumber>
|
||||
synced: boolean;
|
||||
} & SharedMessageProperties &
|
||||
MessageSchemaVersion5 &
|
||||
ExpirationTimerUpdate
|
||||
>;
|
||||
|
||||
export type VerifiedChangeMessage = Readonly<
|
||||
{
|
||||
type: 'verified-change';
|
||||
} & SharedMessageProperties &
|
||||
MessageSchemaVersion5 &
|
||||
ExpirationTimerUpdate
|
||||
>;
|
||||
|
||||
type SharedMessageProperties = Readonly<{
|
||||
conversationId: string;
|
||||
|
@ -47,16 +67,20 @@ type SharedMessageProperties = Readonly<{
|
|||
timestamp: number;
|
||||
}>;
|
||||
|
||||
type ExpirationTimerUpdate = Readonly<{
|
||||
expirationTimerUpdate?: Readonly<{
|
||||
expireTimer: number;
|
||||
fromSync: boolean;
|
||||
source: string; // PhoneNumber
|
||||
}>,
|
||||
}>;
|
||||
type ExpirationTimerUpdate = Partial<
|
||||
Readonly<{
|
||||
expirationTimerUpdate: Readonly<{
|
||||
expireTimer: number;
|
||||
fromSync: boolean;
|
||||
source: string; // PhoneNumber
|
||||
}>;
|
||||
}>
|
||||
>;
|
||||
|
||||
type Message4 = Readonly<{
|
||||
numAttachments?: number;
|
||||
numVisualMediaAttachments?: number;
|
||||
numFileAttachments?: number;
|
||||
}>;
|
||||
type MessageSchemaVersion5 = Partial<
|
||||
Readonly<{
|
||||
hasAttachments: IndexableBoolean;
|
||||
hasVisualMediaAttachments: IndexablePresence;
|
||||
hasFileAttachments: IndexablePresence;
|
||||
}>
|
||||
>;
|
||||
|
|
11
ts/types/backbone/Collection.ts
Normal file
11
ts/types/backbone/Collection.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import { Model } from './Model';
|
||||
|
||||
export interface Collection<T> {
|
||||
models: Array<Model<T>>;
|
||||
// tslint:disable-next-line no-misused-new
|
||||
new (): Collection<T>;
|
||||
fetch(options: object): JQuery.Deferred<any, any, any>;
|
||||
}
|
7
ts/types/backbone/Model.ts
Normal file
7
ts/types/backbone/Model.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
|
||||
export interface Model<T> {
|
||||
toJSON(): T;
|
||||
}
|
33
ts/types/message/initializeAttachmentMetadata.ts
Normal file
33
ts/types/message/initializeAttachmentMetadata.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import { partition } from 'lodash';
|
||||
|
||||
import * as Attachment from '../Attachment';
|
||||
import * as IndexedDB from '../IndexedDB';
|
||||
import { Message } from '../Message';
|
||||
|
||||
export const initializeAttachmentMetadata = async (
|
||||
message: Message
|
||||
): Promise<Message> => {
|
||||
if (message.type === 'verified-change') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const hasAttachments = IndexedDB.toIndexableBoolean(
|
||||
message.attachments.length > 0
|
||||
);
|
||||
const [hasVisualMediaAttachments, hasFileAttachments] = partition(
|
||||
message.attachments,
|
||||
Attachment.isVisualMedia
|
||||
)
|
||||
.map(attachments => attachments.length > 0)
|
||||
.map(IndexedDB.toIndexablePresence);
|
||||
|
||||
return {
|
||||
...message,
|
||||
hasAttachments,
|
||||
hasVisualMediaAttachments,
|
||||
hasFileAttachments,
|
||||
};
|
||||
};
|
39
ts/util/GoogleChrome.ts
Normal file
39
ts/util/GoogleChrome.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import * as MIME from '../types/MIME';
|
||||
|
||||
interface MIMETypeSupportMap {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
// See: https://en.wikipedia.org/wiki/Comparison_of_web_browsers#Image_format_support
|
||||
const SUPPORTED_IMAGE_MIME_TYPES: MIMETypeSupportMap = {
|
||||
'image/bmp': true,
|
||||
'image/gif': true,
|
||||
'image/jpeg': true,
|
||||
'image/svg+xml': true,
|
||||
'image/webp': true,
|
||||
'image/x-xbitmap': true,
|
||||
// ICO
|
||||
'image/vnd.microsoft.icon': true,
|
||||
'image/ico': true,
|
||||
'image/icon': true,
|
||||
'image/x-icon': true,
|
||||
// PNG
|
||||
'image/apng': true,
|
||||
'image/png': true,
|
||||
};
|
||||
|
||||
export const isImageTypeSupported = (mimeType: MIME.MIMEType): boolean =>
|
||||
SUPPORTED_IMAGE_MIME_TYPES[mimeType] === true;
|
||||
|
||||
const SUPPORTED_VIDEO_MIME_TYPES: MIMETypeSupportMap = {
|
||||
'video/mp4': true,
|
||||
'video/ogg': true,
|
||||
'video/webm': true,
|
||||
};
|
||||
|
||||
// See: https://www.chromium.org/audio-video
|
||||
export const isVideoTypeSupported = (mimeType: MIME.MIMEType): boolean =>
|
||||
SUPPORTED_VIDEO_MIME_TYPES[mimeType] === true;
|
15
ts/util/arrayBufferToObjectURL.ts
Normal file
15
ts/util/arrayBufferToObjectURL.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import { MIMEType } from '../types/MIME';
|
||||
|
||||
export const arrayBufferToObjectURL = ({
|
||||
data,
|
||||
type,
|
||||
}: {
|
||||
data: ArrayBuffer;
|
||||
type: MIMEType;
|
||||
}): string => {
|
||||
const blob = new Blob([data], { type });
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
8
ts/util/index.ts
Normal file
8
ts/util/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
import * as GoogleChrome from './GoogleChrome';
|
||||
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
|
||||
import { missingCaseError } from './missingCaseError';
|
||||
|
||||
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError };
|
24
ts/util/missingCaseError.ts
Normal file
24
ts/util/missingCaseError.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* @prettier
|
||||
*/
|
||||
// `missingCaseError` is useful for compile-time checking that all `case`s in
|
||||
// a `switch` statement have been handled, e.g.
|
||||
//
|
||||
// type AttachmentType = 'media' | 'documents';
|
||||
//
|
||||
// const type: AttachmentType = selectedTab;
|
||||
// switch (type) {
|
||||
// case 'media':
|
||||
// return <MediaGridItem/>;
|
||||
// case 'documents':
|
||||
// return <DocumentListItem/>;
|
||||
// default:
|
||||
// return missingCaseError(type);
|
||||
// }
|
||||
//
|
||||
// If we extended `AttachmentType` to `'media' | 'documents' | 'links'` the code
|
||||
// above would trigger a compiler error stating that `'links'` has not been
|
||||
// handled by our `switch` / `case` statement which is useful for code
|
||||
// maintenance and system evolution.
|
||||
export const missingCaseError = (x: never): TypeError =>
|
||||
new TypeError(`Unhandled case: ${x}`);
|
|
@ -23,12 +23,6 @@
|
|||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
|
|
35
tslint.json
35
tslint.json
|
@ -6,9 +6,25 @@
|
|||
],
|
||||
"jsRules": {},
|
||||
"rules": {
|
||||
"align": [true, "arguments", "elements", "members", "parameters", "statements"],
|
||||
"array-type": [true, "generic"],
|
||||
|
||||
// Preferred by Prettier:
|
||||
"arrow-parens": [true, "ban-single-arg-parens"],
|
||||
|
||||
"import-spacing": false,
|
||||
"indent": [true, "spaces", 2],
|
||||
"interface-name": [true, "never-prefix"],
|
||||
|
||||
// Allows us to write inline `style`s. Revisit when we have a more sophisticated
|
||||
// CSS-in-JS solution:
|
||||
"jsx-no-multiline-js": false,
|
||||
|
||||
"linebreak-style": [true, "LF"],
|
||||
|
||||
// Ignore `import`s to allow Prettier formatting:
|
||||
"max-line-length": [true, {"limit": 90, "ignore-pattern": "^import"}],
|
||||
|
||||
"mocha-avoid-only": true,
|
||||
// Disabled until we can allow dynamically generated tests:
|
||||
// https://github.com/Microsoft/tslint-microsoft-contrib/issues/85#issuecomment-371749352
|
||||
|
@ -26,8 +42,25 @@
|
|||
"named-imports-order": "case-insensitive"
|
||||
}],
|
||||
|
||||
"quotemark": [true, "single", "jsx-double", "avoid-template", "avoid-escape"]
|
||||
"quotemark": [true, "single", "jsx-double", "avoid-template", "avoid-escape"],
|
||||
|
||||
// Preferred by Prettier:
|
||||
"semicolon": [true, "always", "ignore-bound-class-methods"],
|
||||
|
||||
// Preferred by Prettier:
|
||||
"trailing-comma": [
|
||||
true,
|
||||
{
|
||||
"singleline": "never",
|
||||
"multiline": {
|
||||
"objects": "always",
|
||||
"arrays": "always",
|
||||
"functions": "never",
|
||||
"typeLiterals": "always"
|
||||
},
|
||||
"esSpecCompliant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"rulesDirectory": [
|
||||
"node_modules/tslint-microsoft-contrib"
|
||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -44,6 +44,14 @@
|
|||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5"
|
||||
|
||||
"@types/filesize@^3.6.0":
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/filesize/-/filesize-3.6.0.tgz#5f1a25c7b4e3d5ee2bc63133d374d096b7008c8d"
|
||||
|
||||
"@types/jquery@^3.3.1":
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.1.tgz#55758d44d422756d6329cbf54e6d41931d7ba28f"
|
||||
|
||||
"@types/lodash@^4.14.106":
|
||||
version "4.14.106"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.106.tgz#6093e9a02aa567ddecfe9afadca89e53e5dce4dd"
|
||||
|
@ -3200,6 +3208,10 @@ filesize@3.5.11:
|
|||
version "3.5.11"
|
||||
resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.11.tgz#1919326749433bb3cf77368bd158caabcc19e9ee"
|
||||
|
||||
filesize@^3.6.1:
|
||||
version "3.6.1"
|
||||
resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317"
|
||||
|
||||
fill-range@^2.1.0:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.3.tgz#50b77dfd7e469bc7492470963699fe7a8485a723"
|
||||
|
@ -6885,6 +6897,10 @@ preserve@^0.2.0:
|
|||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
|
||||
|
||||
prettier@1.12.0:
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.12.0.tgz#d26fc5894b9230de97629b39cae225b503724ce8"
|
||||
|
||||
pretty-bytes@^1.0.2:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-1.0.4.tgz#0a22e8210609ad35542f8c8d5d2159aff0751c84"
|
||||
|
|
Loading…
Add table
Reference in a new issue