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:
Daniel Gasienica 2018-04-25 15:44:48 -04:00 committed by GitHub
commit acf8a1a96c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1777 additions and 449 deletions

View file

@ -202,7 +202,7 @@ module.exports = function(grunt) {
tasks: ['jscs']
},
transpile: {
files: ['./ts/**/*.js'],
files: ['./ts/**/*.ts'],
tasks: ['exec:transpile']
}
},

View file

@ -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",

View file

@ -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>

View file

@ -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",

View file

@ -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
View 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

View file

@ -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
View file

@ -0,0 +1 @@
export function deferredToPromise<T>(deferred: JQuery.Deferred<any, any, any>): Promise<T>;

View 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 }
);
});
};

View file

@ -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();

View file

@ -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 doesnt 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");
}

View file

@ -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];

View file

@ -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/');

View file

@ -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 };
},
});
}());

View file

@ -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!

View file

@ -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;

View file

@ -429,7 +429,7 @@
}
const first = attachments[0];
if (Signal.Types.MIME.isImage(first.contentType)) {
if (Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) {
return true;
}

View file

@ -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);
},
});

View file

@ -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",

View file

@ -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');

View file

@ -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',

View file

@ -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 wont 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);
}
}
}

View file

@ -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');

View file

@ -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);

View file

@ -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');

View file

@ -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>

View file

@ -181,6 +181,9 @@ describe('Message', () => {
fileName: 'test\uFFFDfig.exe',
size: 1111,
}],
hasAttachments: 1,
hasVisualMediaAttachments: undefined,
hasFileAttachments: 1,
schemaVersion: Message.CURRENT_SCHEMA_VERSION,
};

View file

@ -1,6 +1,6 @@
const { assert } = require('chai');
const MIME = require('../../../js/modules/types/mime');
const MIME = require('../../../ts/types/MIME');
describe('MIME', () => {

View 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
View file

@ -0,0 +1,7 @@
/**
* @prettier
*/
import * as Conversation from './Conversation';
import * as Views from './views';
export { Conversation, Views };

View 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';
};

View file

@ -0,0 +1,6 @@
/**
* @prettier
*/
import * as Lightbox from './Lightbox';
export { Lightbox };

12
ts/components/Lightbox.md Normal file
View 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
View 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();
};
}

View file

@ -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 (

View file

@ -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 });
};
}

View 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}
/>
```

View file

@ -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>;
}
}

View file

@ -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>
);
};

View 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}
/>
```

View 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}
/>
);
});
}
}

View 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>
);
}
}

View file

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

View 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,
};
};

View file

@ -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 dont:
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,
};
};

View file

@ -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:">'
);
};

View 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);
});
});

View file

@ -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!';

View 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);
});
});
});

View file

@ -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;
};

View file

@ -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
View file

@ -0,0 +1,23 @@
/**
* @prettier
*/
// IndexedDB doesnt 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;

View file

@ -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
View file

@ -0,0 +1,4 @@
/**
* @prettier
*/
export type MapAsync<T> = (value: T) => Promise<T>;

View file

@ -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;
}>
>;

View 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>;
}

View file

@ -0,0 +1,7 @@
/**
* @prettier
*/
export interface Model<T> {
toJSON(): T;
}

View 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
View 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;

View 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
View file

@ -0,0 +1,8 @@
/**
* @prettier
*/
import * as GoogleChrome from './GoogleChrome';
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
import { missingCaseError } from './missingCaseError';
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError };

View 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}`);

View file

@ -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. */

View file

@ -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"

View file

@ -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"