mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 10:19:08 +00:00
As described in #998, images are sometimes displayed with an incorrect orientation. This is because cameras often write files in the native sensor byte order and attach the `Orientation` EXIF metadata to tell end-user devices how to display the images based on the original author’s capture orientation. Electron/Chromium (and therefore Signal Desktop) currently doesn’t support applying this metadata for `<img>` tags, e.g. CSS `image-orientation: from- image`. As a workaround, this change uses the `loadImage` library with the `orientation: true` flag to auto-orient images ~~before display~~ upon receipt and before sending. **Changes** - [x] ~~Auto-orient images during display in message list view~~ - [x] Ensure image is not displayed until loaded (to prevent layout reflow) . - [x] Auto-orient images upon receipt and before storing in IndexedDB (~~or preserve original data until Chromium offers native fix?~~) - [x] Auto-orient images in compose area preview. - [x] ~~Auto-orient images in lightbox view~~ - [x] Auto-orient images before sending / storage. - [x] Add EditorConfig for sharing code styles across editors. - [x] Fix ESLint ignore file. - [x] Change `function-paren-newline` ESLint rule from `consistent` to `multiline`. - [x] Add `operator-linebreak` ESLint rule for consistency. - [x] Added `blob-util` dependency for converting between array buffers, blobs, etc. - [x] Extracted `createMessageHandler` to consolidate logic for `onMessageReceived` and `onSentMessage`. - [x] Introduce `async` / `await` to simplify async coding (restore control flow for branching, loops, and exceptions). - [x] Introduce `window.Signal` namespace for exposing ES2015+ CommonJS modules. - [x] Introduce rudimentary `Message` and `Attachment` types to begin defining a schema and versioning. This will allow us to track which changes, e.g. auto-orient JPEGs, per message / attachment as well as which fields are stored. - [x] Renamed `window.dataURLtoBlob` to `window.dataURLToBlobSync` to both fix the strange `camelCase` as well as to highlight that this operation is synchronous and therefore blocks the user thread. - [x] Normalize all JPEG MIME types to `image/jpeg`, eliminating the invalid `image/jpg`. - [x] Add `npm run test-modules` command for testing non-browser specific CommonJS modules. - **Stretch Goals** - [ ] ~~Restrict `autoOrientImage` to `Blob` to narrow API interface.~~ Do this once we use PureScript. - [ ] ~~Return non-JPEGs as no-op from `autoOrientImage`.~~ Skipping `autoOrientImage` for non-JPEGs altogether. - [ ] Retroactively auto-orient existing JPEG image attachments in the background. --- Fixes #998 --- - **Blog:** EXIF Orientation Handling Is a Ghetto: https://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/ - **Chromium Bug:** EXIF orientation is ignored: https://bugs.chromium.org/p/chromium/issues/detail?id=56845 - **Chromium Bug:** Support for the CSS image-orientation CSS property: https://bugs.chromium.org/p/chromium/issues/detail?id=158753 --- commitce5090b473Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 16 10:35:36 2018 -0500 Inline message descriptors commit329036e59cAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Thu Feb 15 17:34:40 2018 -0500 Clarify order of operations Semantically, it makes more sense to do `getFile` before `clearForm` even though it seems to work either way. commitf9d4cfb2baAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Thu Feb 15 17:18:26 2018 -0500 Simplify `operator-linebreak` configuration Enabling `before` caused more code changes and it turns out our previous configuration is already the default. commitdb588997acAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Thu Feb 15 17:15:59 2018 -0500 Remove obsolete TODO commit799c881763Author: Daniel Gasienica <daniel@gasienica.ch> Date: Thu Feb 15 17:12:18 2018 -0500 Enable ESLint `function-paren-newline` `multiline` Per discussion. commitb660b6bc8eAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Thu Feb 15 17:10:48 2018 -0500 Use `messageDescriptor.id` not `source` commit5e7309d176Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 16:29:01 2018 -0500 Remove unnecessary `eslint-env` commit393b3da55eAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 16:19:17 2018 -0500 Refactor `onSentMessage` and `onMessageReceived` Since they are so similar, we create the handlers using new `createMessageHandler` function. This allows us to ensure both synced and received messages go through schema upgrade pipeline. commitb3db0bf179Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 16:18:21 2018 -0500 Add `Message` descriptor functions commit8febf125b1Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 14:46:56 2018 -0500 Fix typo commit98d951ef77Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 12:22:39 2018 -0500 Remove `promises` reference commita0e9559ed5Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 12:22:13 2018 -0500 Fix `AttachmentView::mediaType` fall-through commit67be916a83Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 12:03:41 2018 -0500 Remove minor TODOs commit0af186e118Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 11:44:41 2018 -0500 Enable ESLint for `js/views/attachment_view.js` commit28a2dc5b8aAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 11:44:12 2018 -0500 Remove dynamic type checks commitf4ce36fcfcAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 11:27:56 2018 -0500 Rename `process` to `upgradeSchema` - `Message.process` -> `Message.upgradeSchema` - `Attachment.process` -> `Attachment.upgradeSchema` - `Attachment::processVersion` -> `Attachment::schemaVersion` Document version history. commit41b92c0a31Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 11:11:50 2018 -0500 Add `operator-linebreak` ESLint rule Based on the following discussion: https://github.com/signalapp/Signal-Desktop/pull/2040#discussion_r168029106 commit462defbe55Author: Daniel Gasienica <daniel@gasienica.ch> Date: Wed Feb 14 11:01:30 2018 -0500 Add missing `await` for `ConversationController.getOrCreateAndWait` Tested this by setting `if` condition to `true` and confirming it works. It turns rotating a profile key is more involved and might require registering a new account according to Matthew. commitc08058ee4bAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 16:32:24 2018 -0500 Convert `FileList` to `Array` commit70a6c42019Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 15:46:34 2018 -0500 🎨 Fix lint errors commit2ca7cdbc31Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 15:07:09 2018 -0500 Skip `autoOrientImage` for non-JPEG images commit58eac38301Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 14:55:35 2018 -0500 Move new-style modules to `window.Signal` namespace commit02c9328877Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 14:35:23 2018 -0500 Extract `npm run test-modules` command commit2c708eb94fAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 13:25:51 2018 -0500 Extract `Message.process` commit4a2e52f68aAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 13:25:12 2018 -0500 Fix EditorConfig commita346bab5dbAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 13:13:02 2018 -0500 Remove `vim` directives on ESLint-ed files commit7ec885c635Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 13:08:24 2018 -0500 Remove CSP whitelisting of `blob:` We no longer use `autoOrientImage` using blob URLs. Bring this back if we decide to auto-orient legacy attachments. commit879b6f58f4Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:57:05 2018 -0500 Use `Message` type to determine send function Throws on invalid message type. commit5203d945c9Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:56:48 2018 -0500 Whitelist `Whisper` global commit8ad0b066a3Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:56:32 2018 -0500 Add `Whisper.Types` namespace This avoids namespace collision for `Whisper.Message`. commit785a949fceAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:55:43 2018 -0500 Add `Message` type commit674a7357abAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:35:23 2018 -0500 Run ESLint on `Conversation::sendMessage` commitcd985aa700Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:34:38 2018 -0500 Document type signature of `FileInputView::readFile` commitd70d70e52cAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:31:16 2018 -0500 Move attachment processing closer to sending This helps ensure processing happens uniformly, regardless of which code paths are taken to send an attachment. commit532ac3e273Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:22:29 2018 -0500 Process attachment before it’s sent Picked this place since it already had various async steps, similar to `onMessageReceived` for the incoming `Attachment.process`. Could we try have this live closer to where we store it in IndexedDB, e.g. `Conversation::sendMessage`? commita4582ae2fbAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 12:21:42 2018 -0500 Refactor `getFile` and `getFiles` Lint them using ESLint. commit07e9114e65Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 11:37:31 2018 -0500 Document incoming and outgoing attachments fields Note how outgoing message attachments only have 4 fields. This presumably means the others are not used in our code and could be discarded for simplicity. commitfdc3ef289dAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 11:36:21 2018 -0500 Highlight that `dataURLToBlob` is synchronous commitb9c6bf600fAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 11:35:49 2018 -0500 Add EditorConfig configuration commite56101e229Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 11:34:23 2018 -0500 Replace custom with `blob-util` functions IMPORTANT: All of them are async so we need to use `await`, otherwise we get strange or silent errors. commitf95150f6a9Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 11:17:30 2018 -0500 Revert "Replace custom functions with `blob-util`" This reverts commit 8a81e9c01bfe80c0e1bf76737092206c06949512. commit33860d93f3Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 11:13:02 2018 -0500 Revert "Replace `blueimp-canvas-to-blob` with `blob-util`" This reverts commit 31b3e853e4afc78fe80995921aa4152d9f6e4783. commit7a0ba6fed6Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 11:12:58 2018 -0500 Replace `blueimp-canvas-to-blob` with `blob-util` commit47a5f2bfd8Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 10:55:34 2018 -0500 Replace custom functions with `blob-util` commit1cfa0efdb4Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 10:47:02 2018 -0500 Add `blob-util` dependency commit9ac26be1bdAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 10:46:44 2018 -0500 Document why we drop original image data during auto-orient commit4136d6c382Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 10:46:27 2018 -0500 Extract `DEFAULT_JPEG_QUALITY` commit4a7156327eAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 10:37:11 2018 -0500 Drop support for invalid `image/jpg` MIME type commit69fe96581fAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 09:54:30 2018 -0500 Document `window.onInvalidStateError` global commita48ba1c774Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 09:54:04 2018 -0500 Selectively run ESLint on `js/background.js` Enabling ESLint on a per function basis allows us to incrementally improve the codebase without requiring large and potentially risky refactorings. commite6d1cf826bAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 09:16:23 2018 -0500 Move async attachment processing to `onMessageReceived` We previously processed attachments in `handleDataMessage` which is mostly a synchronous function, except for the saving of the model. Moving the processing into the already async `onMessageReceived` improves code clarity. commitbe6ca2a9aaAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 09:14:49 2018 -0500 Document import of ES2015+ modules commiteaaf7c4160Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 09:14:29 2018 -0500 🎨 Fix lint error commita25b0e2e3dAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 09:13:57 2018 -0500 🎨 Organize `require`s commite0cc3d8fabAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 09:07:17 2018 -0500 Implement attachment process version Instead of keeping track of last normalization (processing) date, we now keep track of an internal processing version that will help us understand what kind of processing has already been completed for a given attachment. This will let us retroactively upgrade existing attachments. As we add more processing steps, we can build a processing pipeline that can convert any attachment processing version into a higher one, e.g. 4 -> 5 -> 6 -> 7. commitad9083d0fdAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:50:31 2018 -0500 Ignore ES2015+ files during JSCS linting commit96641205f7Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:48:07 2018 -0500 Improve ESLint ignore rules Apparently, using unqualified `/**` patterns prevents `!` include patterns. Using qualified glob patterns, e.g. `js/models/**/*.js`, lets us work around this. commit255e0ab15bAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:44:59 2018 -0500 🔤 ESLint ignored files commitebcb70258aAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:35:47 2018 -0500 Whitelist `browser` environment for ESLint commit3eaace6f3aAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:35:05 2018 -0500 Use `MIME` module commitba2cf7770eAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:32:54 2018 -0500 🎨 Fix lint errors commit65acc86e85Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:30:42 2018 -0500 Add ES2015+ files to JSHint ignored list commit8b6494ae6cAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:29:20 2018 -0500 Document potentially unexpected `autoScale` behavior commit8b4c69b200Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:26:47 2018 -0500 Test CommonJS modules separately Not sure how to test them as part of Grunt `unit-tests` task as `test/index.html` doesn’t allow for inclusion of CommonJS modules that use `require`. The tests are silently skipped. commit213400e4b2Author: Daniel Gasienica <daniel@gasienica.ch> Date: Tue Feb 13 08:24:27 2018 -0500 Add `MIME` type module commit37a726e4fbAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 20:18:05 2018 -0500 Return proper `Error` from `blobArrayToBuffer` commit164752db56Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 20:15:41 2018 -0500 🎨 Fix ESLint errors commitd498dd79a0Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 20:14:33 2018 -0500 Update `Attachment` type field definitions commit141155a153Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 20:12:50 2018 -0500 Move `blueimp-canvas-to-blob` from Bower to npm commit7ccb833e5dAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:33:50 2018 -0500 🎨 Clarify data flow commite7da41591fAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:31:21 2018 -0500 Use `blobUrl` for consistency commit523a80eefeAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:28:06 2018 -0500 Remove just-in-time image auto-orient for lightbox We can bring this back if our users would like auto-orient for old attachments. commit0739feae9cAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:27:21 2018 -0500 Remove just-in-time auto-orient of message attachments We can bring this back if our users would like auto-orient for old attachments. But better yet, we might implement this as database migration. commited43c66f92Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:26:24 2018 -0500 Auto-orient JPEG attachments upon receipt commite2eb8e36b0Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:25:26 2018 -0500 Expose `Attachment` type through `Whisper.Attachment` commit9638fbc987Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:23:39 2018 -0500 Use `contentType` from `model` commit032c0ced46Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:23:04 2018 -0500 Return `Error` object for `autoOrientImage` failures commitff04bad851Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:22:32 2018 -0500 Add `options` for `autoOrientImage` output type / quality commit87745b5586Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:18:46 2018 -0500 Add `Attachment` type Defines various functions on attachments, e.g. normalization (auto-orient JPEGs, etc.) commitde27fdc10aAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 16:16:34 2018 -0500 Add `yarn grunt` shortcut This allows us to use local `grunt-cli` for `grunt dev`. commit59974db5a5Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 10:10:11 2018 -0500 Improve readability commitb5ba96f1e6Author: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 10:08:12 2018 -0500 Use `snake_case` for module names Prevents problems across case-sensitive and case-insensitive file systems. We can work around this in the future using a lint rule such as `eslint-plugin-require-path-exists`. See discussion: https://github.com/signalapp/Signal-Desktop/pull/2040#discussion_r167365931 commit48c5d3155cAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Mon Feb 12 10:05:44 2018 -0500 🎨 Use destructuring commit4822f49f22Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 17:41:40 2018 -0500 Auto-orient images in lightbox view commit7317110809Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 17:40:14 2018 -0500 Document magic number for escape key commitc790d07389Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 17:38:35 2018 -0500 Make second `View` argument an `options` object commitfbe010bb63Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 17:27:40 2018 -0500 Allow `loadImage` to fetch `blob://` URLs commitec35710d00Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 16:57:48 2018 -0500 🎨 Shorten `autoOrientImage` import commitd07433e3cfAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 16:57:19 2018 -0500 Make `autoOrientImage` module standalone commitc285bf5e33Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 16:55:44 2018 -0500 Replace `loadImage` with `autoOrientImage` commit4431854923Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 16:53:23 2018 -0500 Add `autoOrientImage` module This module exposes `loadImage` with a `Promise` based interface and pre- populates `orientation: true` option to auto-orient input. Returns data URL as string. The module uses a named export as refactoring references of modules with `default` (`module.exports`) export references can be error-prone. See: https://basarat.gitbooks.io/typescript/docs/tips/defaultIsBad.html commitc77063afc6Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 16:44:30 2018 -0500 Auto-orient preview images See: #998 commit06dba5eb8fAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 16:43:23 2018 -0500 TODO: Use native `Canvas::toBlob` One challenge is that `Canvas::toBlob` is async whereas `dataURLtoBlob` is sync. commitb15c304a31Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 16:42:45 2018 -0500 Make `null` check strict Appeases JSHint. ESLint has a nice `smart` option for `eqeqeq` rule: https://eslint.org/docs/rules/eqeqeq#smart commitea70b92d9bAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 15:23:58 2018 -0500 Use `Canvas::toDataURL` to preserve `ImageView` logic This way, all the other code paths remain untouched in case we want to remove the auto-orient code once Chrome supports the `image-orientation` CSS property. See: - #998 - https://developer.mozilla.org/en-US/docs/Web/CSS/image-orientation commit62fd744f9fAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 14:38:04 2018 -0500 Use CSS to constrain auto-oriented images commitf4d3392687Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 14:35:02 2018 -0500 Replace `ImageView` `el` with auto-oriented `canvas` See: #998 commit1602d7f610Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 14:25:48 2018 -0500 Pass `Blob` to `View` (for `ImageView`) This allows us to do JPEG auto-orientation based on EXIF metadata. commite6a414f2b2Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 14:25:12 2018 -0500 🔪 Remove newline commit5f0d9570d7Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 11:17:02 2018 -0500 Expose `blueimp-load-image` as `window.loadImage` commit1e1c62fe2fAuthor: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 11:16:46 2018 -0500 Add `blueimp-load-image` npm dependency commitad17fa8a68Author: Daniel Gasienica <daniel@gasienica.ch> Date: Fri Feb 9 11:14:40 2018 -0500 Remove `blueimp-load-image` Bower dependency
1308 lines
44 KiB
JavaScript
1308 lines
44 KiB
JavaScript
/* eslint-disable */
|
|
|
|
/* global Signal: false */
|
|
/* global storage: false */
|
|
/* global textsecure: false */
|
|
/* global Whisper: false */
|
|
|
|
(function () {
|
|
'use strict';
|
|
window.Whisper = window.Whisper || {};
|
|
|
|
const { Attachment, Message } = window.Signal.Types;
|
|
|
|
// TODO: Factor out private and group subclasses of Conversation
|
|
|
|
var COLORS = [
|
|
'red',
|
|
'pink',
|
|
'purple',
|
|
'deep_purple',
|
|
'indigo',
|
|
'blue',
|
|
'light_blue',
|
|
'cyan',
|
|
'teal',
|
|
'green',
|
|
'light_green',
|
|
'orange',
|
|
'deep_orange',
|
|
'amber',
|
|
'blue_grey',
|
|
];
|
|
|
|
function constantTimeEqualArrayBuffers(ab1, ab2) {
|
|
if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) {
|
|
return false;
|
|
}
|
|
if (ab1.byteLength !== ab2.byteLength) {
|
|
return false;
|
|
}
|
|
var result = 0;
|
|
var ta1 = new Uint8Array(ab1);
|
|
var ta2 = new Uint8Array(ab2);
|
|
for (var i = 0; i < ab1.byteLength; ++i) {
|
|
result = result | ta1[i] ^ ta2[i];
|
|
}
|
|
return result === 0;
|
|
}
|
|
|
|
Whisper.Conversation = Backbone.Model.extend({
|
|
database: Whisper.Database,
|
|
storeName: 'conversations',
|
|
defaults: function() {
|
|
return {
|
|
unreadCount: 0,
|
|
verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT
|
|
};
|
|
},
|
|
|
|
idForLogging: function() {
|
|
if (this.isPrivate()) {
|
|
return this.id;
|
|
}
|
|
|
|
return 'group(' + this.id + ')';
|
|
},
|
|
|
|
handleMessageError: function(message, errors) {
|
|
this.trigger('messageError', message, errors);
|
|
},
|
|
|
|
initialize: function() {
|
|
this.ourNumber = textsecure.storage.user.getNumber();
|
|
this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus;
|
|
|
|
// This may be overridden by ConversationController.getOrCreate, and signify
|
|
// our first save to the database. Or first fetch from the database.
|
|
this.initialPromise = Promise.resolve();
|
|
|
|
this.contactCollection = new Backbone.Collection();
|
|
var collator = new Intl.Collator();
|
|
this.contactCollection.comparator = function(left, right) {
|
|
left = left.getTitle().toLowerCase();
|
|
right = right.getTitle().toLowerCase();
|
|
return collator.compare(left, right);
|
|
};
|
|
this.messageCollection = new Whisper.MessageCollection([], {
|
|
conversation: this
|
|
});
|
|
|
|
this.messageCollection.on('change:errors', this.handleMessageError, this);
|
|
this.messageCollection.on('send-error', this.onMessageError, this);
|
|
|
|
this.on('change:avatar', this.updateAvatarUrl);
|
|
this.on('change:profileAvatar', this.updateAvatarUrl);
|
|
this.on('change:profileKey', this.onChangeProfileKey);
|
|
this.on('destroy', this.revokeAvatarUrl);
|
|
},
|
|
|
|
isMe: function() {
|
|
return this.id === this.ourNumber;
|
|
},
|
|
|
|
onMessageError: function() {
|
|
this.updateVerified();
|
|
},
|
|
safeGetVerified: function() {
|
|
return textsecure.storage.protocol.getVerified(this.id).catch(function() {
|
|
return textsecure.storage.protocol.VerifiedStatus.DEFAULT;
|
|
});
|
|
},
|
|
updateVerified: function() {
|
|
if (this.isPrivate()) {
|
|
return Promise.all([
|
|
this.safeGetVerified(),
|
|
this.initialPromise,
|
|
]).then(function(results) {
|
|
var trust = results[0];
|
|
// we don't return here because we don't need to wait for this to finish
|
|
this.save({verified: trust});
|
|
}.bind(this));
|
|
} else {
|
|
return this.fetchContacts().then(function() {
|
|
return Promise.all(this.contactCollection.map(function(contact) {
|
|
if (!contact.isMe()) {
|
|
return contact.updateVerified();
|
|
}
|
|
}.bind(this)));
|
|
}.bind(this)).then(this.onMemberVerifiedChange.bind(this));
|
|
}
|
|
},
|
|
setVerifiedDefault: function(options) {
|
|
var DEFAULT = this.verifiedEnum.DEFAULT;
|
|
return this.queueJob(function() {
|
|
return this._setVerified(DEFAULT, options);
|
|
}.bind(this));
|
|
},
|
|
setVerified: function(options) {
|
|
var VERIFIED = this.verifiedEnum.VERIFIED;
|
|
return this.queueJob(function() {
|
|
return this._setVerified(VERIFIED, options);
|
|
}.bind(this));
|
|
},
|
|
setUnverified: function(options) {
|
|
var UNVERIFIED = this.verifiedEnum.UNVERIFIED;
|
|
return this.queueJob(function() {
|
|
return this._setVerified(UNVERIFIED, options);
|
|
}.bind(this));
|
|
},
|
|
_setVerified: function(verified, options) {
|
|
options = options || {};
|
|
_.defaults(options, {viaSyncMessage: false, viaContactSync: false, key: null});
|
|
|
|
var DEFAULT = this.verifiedEnum.DEFAULT;
|
|
var VERIFIED = this.verifiedEnum.VERIFIED;
|
|
var UNVERIFIED = this.verifiedEnum.UNVERIFIED;
|
|
|
|
if (!this.isPrivate()) {
|
|
throw new Error('You cannot verify a group conversation. ' +
|
|
'You must verify individual contacts.');
|
|
}
|
|
|
|
var beginningVerified = this.get('verified');
|
|
var promise;
|
|
if (options.viaSyncMessage) {
|
|
// handle the incoming key from the sync messages - need different
|
|
// behavior if that key doesn't match the current key
|
|
promise = textsecure.storage.protocol.processVerifiedMessage(
|
|
this.id, verified, options.key
|
|
);
|
|
} else {
|
|
promise = textsecure.storage.protocol.setVerified(
|
|
this.id, verified
|
|
);
|
|
}
|
|
|
|
var keychange;
|
|
return promise.then(function(updatedKey) {
|
|
keychange = updatedKey;
|
|
return new Promise(function(resolve) {
|
|
return this.save({verified: verified}).always(resolve);
|
|
}.bind(this));
|
|
}.bind(this)).then(function() {
|
|
// Three situations result in a verification notice in the conversation:
|
|
// 1) The message came from an explicit verification in another client (not
|
|
// a contact sync)
|
|
// 2) The verification value received by the contact sync is different
|
|
// from what we have on record (and it's not a transition to UNVERIFIED)
|
|
// 3) Our local verification status is VERIFIED and it hasn't changed,
|
|
// but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't
|
|
// want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED)
|
|
if (!options.viaContactSync
|
|
|| (beginningVerified !== verified && verified !== UNVERIFIED)
|
|
|| (keychange && verified === VERIFIED)) {
|
|
|
|
this.addVerifiedChange(this.id, verified === VERIFIED, {local: !options.viaSyncMessage});
|
|
}
|
|
if (!options.viaSyncMessage) {
|
|
return this.sendVerifySyncMessage(this.id, verified);
|
|
}
|
|
}.bind(this));
|
|
},
|
|
sendVerifySyncMessage: function(number, state) {
|
|
return textsecure.storage.protocol.loadIdentityKey(number).then(function(key) {
|
|
return textsecure.messaging.syncVerification(number, state, key);
|
|
});
|
|
},
|
|
getIdentityKeys: function() {
|
|
var lookup = {};
|
|
|
|
if (this.isPrivate()) {
|
|
return textsecure.storage.protocol.loadIdentityKey(this.id).then(function(key) {
|
|
lookup[this.id] = key;
|
|
return lookup;
|
|
}.bind(this)).catch(function(error) {
|
|
console.log(
|
|
'getIdentityKeys error for conversation',
|
|
this.idForLogging(),
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
return lookup;
|
|
}.bind(this));
|
|
} else {
|
|
return Promise.all(this.contactCollection.map(function(contact) {
|
|
return textsecure.storage.protocol.loadIdentityKey(contact.id).then(function(key) {
|
|
lookup[contact.id] = key;
|
|
}).catch(function(error) {
|
|
console.log(
|
|
'getIdentityKeys error for group member',
|
|
contact.idForLogging(),
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
});
|
|
})).then(function() {
|
|
return lookup;
|
|
});
|
|
}
|
|
},
|
|
replay: function(error, message) {
|
|
var replayable = new textsecure.ReplayableError(error);
|
|
return replayable.replay(message.attributes).catch(function(error) {
|
|
console.log(
|
|
'replay error:',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
});
|
|
},
|
|
decryptOldIncomingKeyErrors: function() {
|
|
// We want to run just once per conversation
|
|
if (this.get('decryptedOldIncomingKeyErrors')) {
|
|
return Promise.resolve();
|
|
}
|
|
console.log('decryptOldIncomingKeyErrors start for', this.idForLogging());
|
|
|
|
var messages = this.messageCollection.filter(function(message) {
|
|
var errors = message.get('errors');
|
|
if (!errors || !errors[0]) {
|
|
return false;
|
|
}
|
|
var error = _.find(errors, function(error) {
|
|
return error.name === 'IncomingIdentityKeyError';
|
|
});
|
|
|
|
return Boolean(error);
|
|
});
|
|
|
|
var markComplete = function() {
|
|
console.log('decryptOldIncomingKeyErrors complete for', this.idForLogging());
|
|
return new Promise(function(resolve) {
|
|
this.save({decryptedOldIncomingKeyErrors: true}).always(resolve);
|
|
}.bind(this));
|
|
}.bind(this);
|
|
|
|
if (!messages.length) {
|
|
return markComplete();
|
|
}
|
|
|
|
console.log('decryptOldIncomingKeyErrors found', messages.length, 'messages to process');
|
|
var safeDelete = function(message) {
|
|
return new Promise(function(resolve) {
|
|
message.destroy().always(resolve);
|
|
});
|
|
};
|
|
|
|
return this.getIdentityKeys().then(function(lookup) {
|
|
return Promise.all(_.map(messages, function(message) {
|
|
var source = message.get('source');
|
|
var error = _.find(message.get('errors'), function(error) {
|
|
return error.name === 'IncomingIdentityKeyError';
|
|
});
|
|
|
|
var key = lookup[source];
|
|
if (!key) {
|
|
return;
|
|
}
|
|
|
|
if (constantTimeEqualArrayBuffers(key, error.identityKey)) {
|
|
return this.replay(error, message).then(function() {
|
|
return safeDelete(message);
|
|
});
|
|
}
|
|
}.bind(this)));
|
|
}.bind(this)).catch(function(error) {
|
|
console.log(
|
|
'decryptOldIncomingKeyErrors error:',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
}).then(markComplete);
|
|
},
|
|
isVerified: function() {
|
|
if (this.isPrivate()) {
|
|
return this.get('verified') === this.verifiedEnum.VERIFIED;
|
|
} else {
|
|
if (!this.contactCollection.length) {
|
|
return false;
|
|
}
|
|
|
|
return this.contactCollection.every(function(contact) {
|
|
if (contact.isMe()) {
|
|
return true;
|
|
} else {
|
|
return contact.isVerified();
|
|
}
|
|
}.bind(this));
|
|
}
|
|
},
|
|
isUnverified: function() {
|
|
if (this.isPrivate()) {
|
|
var verified = this.get('verified');
|
|
return verified !== this.verifiedEnum.VERIFIED && verified !== this.verifiedEnum.DEFAULT;
|
|
} else {
|
|
if (!this.contactCollection.length) {
|
|
return true;
|
|
}
|
|
|
|
return this.contactCollection.any(function(contact) {
|
|
if (contact.isMe()) {
|
|
return false;
|
|
} else {
|
|
return contact.isUnverified();
|
|
}
|
|
}.bind(this));
|
|
}
|
|
},
|
|
getUnverified: function() {
|
|
if (this.isPrivate()) {
|
|
return this.isUnverified() ? new Backbone.Collection([this]) : new Backbone.Collection();
|
|
} else {
|
|
return new Backbone.Collection(this.contactCollection.filter(function(contact) {
|
|
if (contact.isMe()) {
|
|
return false;
|
|
} else {
|
|
return contact.isUnverified();
|
|
}
|
|
}.bind(this)));
|
|
}
|
|
},
|
|
setApproved: function() {
|
|
if (!this.isPrivate()) {
|
|
throw new Error('You cannot set a group conversation as trusted. ' +
|
|
'You must set individual contacts as trusted.');
|
|
}
|
|
|
|
return textsecure.storage.protocol.setApproval(this.id, true);
|
|
},
|
|
safeIsUntrusted: function() {
|
|
return textsecure.storage.protocol.isUntrusted(this.id).catch(function() {
|
|
return false;
|
|
});
|
|
},
|
|
isUntrusted: function() {
|
|
if (this.isPrivate()) {
|
|
return this.safeIsUntrusted();
|
|
} else {
|
|
if (!this.contactCollection.length) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
return Promise.all(this.contactCollection.map(function(contact) {
|
|
if (contact.isMe()) {
|
|
return false;
|
|
} else {
|
|
return contact.safeIsUntrusted();
|
|
}
|
|
}.bind(this))).then(function(results) {
|
|
return _.any(results, function(result) {
|
|
return result;
|
|
});
|
|
});
|
|
}
|
|
},
|
|
getUntrusted: function() {
|
|
// This is a bit ugly because isUntrusted() is async. Could do the work to cache
|
|
// it locally, but we really only need it for this call.
|
|
if (this.isPrivate()) {
|
|
return this.isUntrusted().then(function(untrusted) {
|
|
if (untrusted) {
|
|
return new Backbone.Collection([this]);
|
|
}
|
|
|
|
return new Backbone.Collection();
|
|
}.bind(this));
|
|
} else {
|
|
return Promise.all(this.contactCollection.map(function(contact) {
|
|
if (contact.isMe()) {
|
|
return [false, contact];
|
|
} else {
|
|
return Promise.all([contact.isUntrusted(), contact]);
|
|
}
|
|
}.bind(this))).then(function(results) {
|
|
results = _.filter(results, function(result) {
|
|
var untrusted = result[0];
|
|
return untrusted;
|
|
});
|
|
return new Backbone.Collection(_.map(results, function(result) {
|
|
var contact = result[1];
|
|
return contact;
|
|
}));
|
|
}.bind(this));
|
|
}
|
|
},
|
|
onMemberVerifiedChange: function() {
|
|
// If the verified state of a member changes, our aggregate state changes.
|
|
// We trigger both events to replicate the behavior of Backbone.Model.set()
|
|
this.trigger('change:verified');
|
|
this.trigger('change');
|
|
},
|
|
toggleVerified: function() {
|
|
if (this.isVerified()) {
|
|
return this.setVerifiedDefault();
|
|
} else {
|
|
return this.setVerified();
|
|
}
|
|
},
|
|
|
|
addKeyChange: function(id) {
|
|
console.log(
|
|
'adding key change advisory for',
|
|
this.idForLogging(),
|
|
id,
|
|
this.get('timestamp')
|
|
);
|
|
|
|
var timestamp = Date.now();
|
|
var message = new Whisper.Message({
|
|
conversationId : this.id,
|
|
type : 'keychange',
|
|
sent_at : this.get('timestamp'),
|
|
received_at : timestamp,
|
|
key_changed : id,
|
|
unread : 1
|
|
});
|
|
message.save().then(this.trigger.bind(this,'newmessage', message));
|
|
},
|
|
addVerifiedChange: function(id, verified, options) {
|
|
options = options || {};
|
|
_.defaults(options, {local: true});
|
|
|
|
if (this.isMe()) {
|
|
console.log('refusing to add verified change advisory for our own number');
|
|
return;
|
|
}
|
|
|
|
var lastMessage = this.get('timestamp') || Date.now();
|
|
|
|
console.log(
|
|
'adding verified change advisory for',
|
|
this.idForLogging(),
|
|
id,
|
|
lastMessage
|
|
);
|
|
|
|
var timestamp = Date.now();
|
|
var message = new Whisper.Message({
|
|
conversationId : this.id,
|
|
type : 'verified-change',
|
|
sent_at : lastMessage,
|
|
received_at : timestamp,
|
|
verifiedChanged : id,
|
|
verified : verified,
|
|
local : options.local,
|
|
unread : 1
|
|
});
|
|
message.save().then(this.trigger.bind(this,'newmessage', message));
|
|
|
|
if (this.isPrivate()) {
|
|
ConversationController.getAllGroupsInvolvingId(id).then(function(groups) {
|
|
_.forEach(groups, function(group) {
|
|
group.addVerifiedChange(id, verified, options);
|
|
});
|
|
});
|
|
}
|
|
},
|
|
|
|
onReadMessage: function(message) {
|
|
if (this.messageCollection.get(message.id)) {
|
|
this.messageCollection.get(message.id).fetch();
|
|
}
|
|
|
|
// We mark as read everything older than this message - to clean up old stuff
|
|
// still marked unread in the database. If the user generally doesn't read in
|
|
// the desktop app, so the desktop app only gets read syncs, we can very
|
|
// easily end up with messages never marked as read (our previous early read
|
|
// sync handling, read syncs never sent because app was offline)
|
|
|
|
// We queue it because we often get a whole lot of read syncs at once, and
|
|
// their markRead calls could very easily overlap given the async pull from DB.
|
|
|
|
// Lastly, we don't send read syncs for any message marked read due to a read
|
|
// sync. That's a notification explosion we don't need.
|
|
return this.queueJob(function() {
|
|
return this.markRead(message.get('received_at'), {sendReadReceipts: false});
|
|
}.bind(this));
|
|
},
|
|
|
|
getUnread: function() {
|
|
var conversationId = this.id;
|
|
var unreadMessages = new Whisper.MessageCollection();
|
|
return new Promise(function(resolve) {
|
|
return unreadMessages.fetch({
|
|
index: {
|
|
// 'unread' index
|
|
name : 'unread',
|
|
lower : [conversationId],
|
|
upper : [conversationId, Number.MAX_VALUE],
|
|
}
|
|
}).always(function() {
|
|
resolve(unreadMessages);
|
|
});
|
|
});
|
|
|
|
},
|
|
|
|
validate: function(attributes, options) {
|
|
var required = ['id', 'type'];
|
|
var missing = _.filter(required, function(attr) { return !attributes[attr]; });
|
|
if (missing.length) { return "Conversation must have " + missing; }
|
|
|
|
if (attributes.type !== 'private' && attributes.type !== 'group') {
|
|
return "Invalid conversation type: " + attributes.type;
|
|
}
|
|
|
|
var error = this.validateNumber();
|
|
if (error) { return error; }
|
|
|
|
this.updateTokens();
|
|
},
|
|
|
|
validateNumber: function() {
|
|
if (this.isPrivate()) {
|
|
var regionCode = storage.get('regionCode');
|
|
var number = libphonenumber.util.parseNumber(this.id, regionCode);
|
|
if (number.isValidNumber) {
|
|
this.set({ id: number.e164 });
|
|
} else {
|
|
return number.error || "Invalid phone number";
|
|
}
|
|
}
|
|
},
|
|
|
|
updateTokens: function() {
|
|
var tokens = [];
|
|
var name = this.get('name');
|
|
if (typeof name === 'string') {
|
|
tokens.push(name.toLowerCase());
|
|
tokens = tokens.concat(name.trim().toLowerCase().split(/[\s\-_\(\)\+]+/));
|
|
}
|
|
if (this.isPrivate()) {
|
|
var regionCode = storage.get('regionCode');
|
|
var number = libphonenumber.util.parseNumber(this.id, regionCode);
|
|
tokens.push(
|
|
number.nationalNumber,
|
|
number.countryCode + number.nationalNumber
|
|
);
|
|
}
|
|
this.set({tokens: tokens});
|
|
},
|
|
|
|
queueJob: function(callback) {
|
|
var previous = this.pending || Promise.resolve();
|
|
|
|
var taskWithTimeout = textsecure.createTaskWithTimeout(
|
|
callback,
|
|
'conversation ' + this.idForLogging()
|
|
);
|
|
|
|
var current = this.pending = previous.then(taskWithTimeout, taskWithTimeout);
|
|
|
|
current.then(function() {
|
|
if (this.pending === current) {
|
|
delete this.pending;
|
|
}
|
|
}.bind(this));
|
|
|
|
return current;
|
|
},
|
|
|
|
getRecipients: function() {
|
|
if (this.isPrivate()) {
|
|
return [ this.id ];
|
|
} else {
|
|
var me = textsecure.storage.user.getNumber();
|
|
return _.without(this.get('members'), me);
|
|
}
|
|
},
|
|
|
|
/* jshint ignore:start */
|
|
/* eslint-enable */
|
|
sendMessage(body, attachments) {
|
|
this.queueJob(async () => {
|
|
const now = Date.now();
|
|
|
|
console.log(
|
|
'Sending message to conversation',
|
|
this.idForLogging(),
|
|
'with timestamp',
|
|
now
|
|
);
|
|
|
|
const upgradedAttachments =
|
|
await Promise.all(attachments.map(Attachment.upgradeSchema));
|
|
const message = this.messageCollection.add({
|
|
body,
|
|
conversationId: this.id,
|
|
type: 'outgoing',
|
|
attachments: upgradedAttachments,
|
|
sent_at: now,
|
|
received_at: now,
|
|
expireTimer: this.get('expireTimer'),
|
|
recipients: this.getRecipients(),
|
|
});
|
|
if (this.isPrivate()) {
|
|
message.set({ destination: this.id });
|
|
}
|
|
message.save();
|
|
|
|
this.save({
|
|
active_at: now,
|
|
timestamp: now,
|
|
lastMessage: message.getNotificationText(),
|
|
});
|
|
|
|
const conversationType = this.get('type');
|
|
const sendFunc = (() => {
|
|
switch (conversationType) {
|
|
case Message.PRIVATE:
|
|
return textsecure.messaging.sendMessageToNumber;
|
|
case Message.GROUP:
|
|
return textsecure.messaging.sendMessageToGroup;
|
|
default:
|
|
throw new TypeError(`Invalid conversation type: '${conversationType}'`);
|
|
}
|
|
})();
|
|
|
|
let profileKey;
|
|
if (this.get('profileSharing')) {
|
|
profileKey = storage.get('profileKey');
|
|
}
|
|
|
|
message.send(sendFunc(
|
|
this.get('id'),
|
|
body,
|
|
upgradedAttachments,
|
|
now,
|
|
this.get('expireTimer'),
|
|
profileKey
|
|
));
|
|
});
|
|
},
|
|
/* jshint ignore:end */
|
|
/* eslint-disable */
|
|
|
|
updateLastMessage: function() {
|
|
var collection = new Whisper.MessageCollection();
|
|
return collection.fetchConversation(this.id, 1).then(function() {
|
|
var lastMessage = collection.at(0);
|
|
if (lastMessage) {
|
|
if (lastMessage.get('type') === 'verified-change') {
|
|
return;
|
|
}
|
|
this.set({
|
|
lastMessage : lastMessage.getNotificationText(),
|
|
timestamp : lastMessage.get('sent_at')
|
|
});
|
|
} else {
|
|
this.set({ lastMessage: '', timestamp: null });
|
|
}
|
|
if (this.hasChanged('lastMessage') || this.hasChanged('timestamp')) {
|
|
this.save();
|
|
}
|
|
}.bind(this));
|
|
},
|
|
|
|
updateExpirationTimer: function(expireTimer, source, received_at, options) {
|
|
options = options || {};
|
|
_.defaults(options, {fromSync: false});
|
|
|
|
if (!expireTimer) {
|
|
expireTimer = null;
|
|
}
|
|
if (this.get('expireTimer') === expireTimer
|
|
|| (!expireTimer && !this.get('expireTimer'))) {
|
|
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
'Updating expireTimer for conversation',
|
|
this.idForLogging(),
|
|
'via',
|
|
source
|
|
);
|
|
source = source || textsecure.storage.user.getNumber();
|
|
var timestamp = received_at || Date.now();
|
|
|
|
var message = this.messageCollection.add({
|
|
conversationId : this.id,
|
|
type : received_at ? 'incoming' : 'outgoing',
|
|
sent_at : timestamp,
|
|
received_at : timestamp,
|
|
flags : textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
|
expirationTimerUpdate : {
|
|
expireTimer : expireTimer,
|
|
source : source,
|
|
fromSync : options.fromSync,
|
|
}
|
|
});
|
|
if (this.isPrivate()) {
|
|
message.set({destination: this.id});
|
|
}
|
|
if (message.isOutgoing()) {
|
|
message.set({recipients: this.getRecipients() });
|
|
}
|
|
|
|
return Promise.all([
|
|
wrapDeferred(message.save()),
|
|
wrapDeferred(this.save({ expireTimer: expireTimer })),
|
|
]).then(function() {
|
|
if (message.isIncoming()) {
|
|
return message;
|
|
}
|
|
|
|
// change was made locally, send it to the number/group
|
|
var sendFunc;
|
|
if (this.get('type') == 'private') {
|
|
sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber;
|
|
}
|
|
else {
|
|
sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup;
|
|
}
|
|
var profileKey;
|
|
if (this.get('profileSharing')) {
|
|
profileKey = storage.get('profileKey');
|
|
}
|
|
var promise = sendFunc(this.get('id'),
|
|
this.get('expireTimer'),
|
|
message.get('sent_at'),
|
|
profileKey
|
|
);
|
|
|
|
return message.send(promise).then(function() {
|
|
return message;
|
|
});
|
|
}.bind(this));
|
|
},
|
|
|
|
isSearchable: function() {
|
|
return !this.get('left') || !!this.get('lastMessage');
|
|
},
|
|
|
|
endSession: function() {
|
|
if (this.isPrivate()) {
|
|
var now = Date.now();
|
|
var message = this.messageCollection.create({
|
|
conversationId : this.id,
|
|
type : 'outgoing',
|
|
sent_at : now,
|
|
received_at : now,
|
|
destination : this.id,
|
|
recipients : this.getRecipients(),
|
|
flags : textsecure.protobuf.DataMessage.Flags.END_SESSION
|
|
});
|
|
message.send(textsecure.messaging.resetSession(this.id, now));
|
|
}
|
|
|
|
},
|
|
|
|
updateGroup: function(group_update) {
|
|
if (this.isPrivate()) {
|
|
throw new Error("Called update group on private conversation");
|
|
}
|
|
if (group_update === undefined) {
|
|
group_update = this.pick(['name', 'avatar', 'members']);
|
|
}
|
|
var now = Date.now();
|
|
var message = this.messageCollection.create({
|
|
conversationId : this.id,
|
|
type : 'outgoing',
|
|
sent_at : now,
|
|
received_at : now,
|
|
group_update : group_update
|
|
});
|
|
message.send(textsecure.messaging.updateGroup(
|
|
this.id,
|
|
this.get('name'),
|
|
this.get('avatar'),
|
|
this.get('members')
|
|
));
|
|
},
|
|
|
|
leaveGroup: function() {
|
|
var now = Date.now();
|
|
if (this.get('type') === 'group') {
|
|
this.save({left: true});
|
|
var message = this.messageCollection.create({
|
|
group_update: { left: 'You' },
|
|
conversationId : this.id,
|
|
type : 'outgoing',
|
|
sent_at : now,
|
|
received_at : now
|
|
});
|
|
message.send(textsecure.messaging.leaveGroup(this.id));
|
|
}
|
|
},
|
|
|
|
markRead: function(newestUnreadDate, options) {
|
|
options = options || {};
|
|
_.defaults(options, {sendReadReceipts: true});
|
|
|
|
var conversationId = this.id;
|
|
Whisper.Notifications.remove(Whisper.Notifications.where({
|
|
conversationId: conversationId
|
|
}));
|
|
|
|
return this.getUnread().then(function(unreadMessages) {
|
|
var promises = [];
|
|
var oldUnread = unreadMessages.filter(function(message) {
|
|
return message.get('received_at') <= newestUnreadDate;
|
|
});
|
|
|
|
var read = _.map(oldUnread, function(m) {
|
|
if (this.messageCollection.get(m.id)) {
|
|
m = this.messageCollection.get(m.id);
|
|
} else {
|
|
console.log('Marked a message as read in the database, but ' +
|
|
'it was not in messageCollection.');
|
|
}
|
|
promises.push(m.markRead());
|
|
var errors = m.get('errors');
|
|
return {
|
|
sender : m.get('source'),
|
|
timestamp : m.get('sent_at'),
|
|
hasErrors : Boolean(errors && errors.length)
|
|
};
|
|
}.bind(this));
|
|
|
|
// Some messages we're marking read are local notifications with no sender
|
|
read = _.filter(read, function(m) {
|
|
return Boolean(m.sender);
|
|
});
|
|
unreadMessages = unreadMessages.filter(function(m) {
|
|
return Boolean(m.isIncoming());
|
|
});
|
|
|
|
var unreadCount = unreadMessages.length - read.length;
|
|
var promise = new Promise(function(resolve, reject) {
|
|
this.save({ unreadCount: unreadCount }).then(resolve, reject);
|
|
}.bind(this));
|
|
promises.push(promise);
|
|
|
|
// If a message has errors, we don't want to send anything out about it.
|
|
// read syncs - let's wait for a client that really understands the message
|
|
// to mark it read. we'll mark our local error read locally, though.
|
|
// read receipts - here we can run into infinite loops, where each time the
|
|
// conversation is viewed, another error message shows up for the contact
|
|
read = read.filter(function(item) {
|
|
return !item.hasErrors;
|
|
});
|
|
|
|
if (read.length && options.sendReadReceipts) {
|
|
console.log('Sending', read.length, 'read receipts');
|
|
promises.push(textsecure.messaging.syncReadMessages(read));
|
|
|
|
if (storage.get('read-receipt-setting')) {
|
|
_.each(_.groupBy(read, 'sender'), function(receipts, sender) {
|
|
var timestamps = _.map(receipts, 'timestamp');
|
|
promises.push(textsecure.messaging.sendReadReceipts(sender, timestamps));
|
|
});
|
|
}
|
|
}
|
|
|
|
return Promise.all(promises);
|
|
}.bind(this));
|
|
},
|
|
|
|
onChangeProfileKey: function() {
|
|
if (this.isPrivate()) {
|
|
this.getProfiles();
|
|
}
|
|
},
|
|
|
|
getProfiles: function() {
|
|
// request all conversation members' keys
|
|
var ids = [];
|
|
if (this.isPrivate()) {
|
|
ids = [this.id];
|
|
} else {
|
|
ids = this.get('members');
|
|
}
|
|
return Promise.all(_.map(ids, this.getProfile));
|
|
},
|
|
|
|
getProfile: function(id) {
|
|
return textsecure.messaging.getProfile(id).then(function(profile) {
|
|
var identityKey = dcodeIO.ByteBuffer.wrap(profile.identityKey, 'base64').toArrayBuffer();
|
|
|
|
return textsecure.storage.protocol.saveIdentity(
|
|
id + '.1', identityKey, false
|
|
).then(function(changed) {
|
|
if (changed) {
|
|
// save identity will close all sessions except for .1, so we
|
|
// must close that one manually.
|
|
var address = new libsignal.SignalProtocolAddress(id, 1);
|
|
console.log('closing session for', address.toString());
|
|
var sessionCipher = new libsignal.SessionCipher(textsecure.storage.protocol, address);
|
|
return sessionCipher.closeOpenSessionForDevice();
|
|
}
|
|
}).then(function() {
|
|
var c = ConversationController.get(id);
|
|
return Promise.all([
|
|
c.setProfileName(profile.name),
|
|
c.setProfileAvatar(profile.avatar)
|
|
]).then(function() {
|
|
// success
|
|
return new Promise(function(resolve, reject) {
|
|
c.save().then(resolve, reject);
|
|
});
|
|
}, function(e) {
|
|
// fail
|
|
if (e.name === 'ProfileDecryptError') {
|
|
// probably the profile key has changed.
|
|
console.log(
|
|
'decryptProfile error:',
|
|
id,
|
|
profile,
|
|
e && e.stack ? e.stack : e
|
|
);
|
|
}
|
|
});
|
|
}.bind(this));
|
|
}.bind(this)).catch(function(error) {
|
|
console.log(
|
|
'getProfile error:',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
});
|
|
},
|
|
setProfileName: function(encryptedName) {
|
|
var key = this.get('profileKey');
|
|
if (!key) { return; }
|
|
|
|
try {
|
|
// decode
|
|
var data = dcodeIO.ByteBuffer.wrap(encryptedName, 'base64').toArrayBuffer();
|
|
|
|
// decrypt
|
|
return textsecure.crypto.decryptProfileName(data, key).then(function(decrypted) {
|
|
|
|
// encode
|
|
var name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8');
|
|
|
|
// set
|
|
this.set({profileName: name});
|
|
}.bind(this));
|
|
}
|
|
catch (e) {
|
|
return Promise.reject(e);
|
|
}
|
|
},
|
|
setProfileAvatar: function(avatarPath) {
|
|
if (!avatarPath) { return; }
|
|
return textsecure.messaging.getAvatar(avatarPath).then(function(avatar) {
|
|
var key = this.get('profileKey');
|
|
if (!key) { return; }
|
|
// decrypt
|
|
return textsecure.crypto.decryptProfile(avatar, key).then(function(decrypted) {
|
|
// set
|
|
this.set({
|
|
profileAvatar: {
|
|
data: decrypted,
|
|
contentType: 'image/jpeg',
|
|
size: decrypted.byteLength
|
|
}
|
|
});
|
|
}.bind(this));
|
|
}.bind(this));
|
|
},
|
|
setProfileKey: function(key) {
|
|
return new Promise(function(resolve, reject) {
|
|
if (!constantTimeEqualArrayBuffers(this.get('profileKey'), key)) {
|
|
this.save({profileKey: key}).then(resolve, reject);
|
|
} else {
|
|
resolve();
|
|
}
|
|
}.bind(this));
|
|
},
|
|
|
|
fetchMessages: function() {
|
|
if (!this.id) {
|
|
return Promise.reject('This conversation has no id!');
|
|
}
|
|
return this.messageCollection.fetchConversation(this.id, null, this.get('unreadCount'));
|
|
},
|
|
|
|
hasMember: function(number) {
|
|
return _.contains(this.get('members'), number);
|
|
},
|
|
fetchContacts: function(options) {
|
|
if (this.isPrivate()) {
|
|
this.contactCollection.reset([this]);
|
|
return Promise.resolve();
|
|
} else {
|
|
var members = this.get('members') || [];
|
|
var promises = members.map(function(number) {
|
|
return ConversationController.getOrCreateAndWait(number, 'private');
|
|
});
|
|
|
|
return Promise.all(promises).then(function(contacts) {
|
|
_.forEach(contacts, function(contact) {
|
|
this.listenTo(contact, 'change:verified', this.onMemberVerifiedChange);
|
|
}.bind(this));
|
|
|
|
this.contactCollection.reset(contacts);
|
|
}.bind(this));
|
|
}
|
|
},
|
|
|
|
destroyMessages: function() {
|
|
this.messageCollection.fetch({
|
|
index: {
|
|
// 'conversation' index on [conversationId, received_at]
|
|
name : 'conversation',
|
|
lower : [this.id],
|
|
upper : [this.id, Number.MAX_VALUE],
|
|
}
|
|
}).then(function() {
|
|
var models = this.messageCollection.models;
|
|
this.messageCollection.reset([]);
|
|
_.each(models, function(message) {
|
|
message.destroy();
|
|
});
|
|
this.save({
|
|
lastMessage: null,
|
|
timestamp: null,
|
|
active_at: null,
|
|
});
|
|
}.bind(this));
|
|
},
|
|
|
|
getName: function() {
|
|
if (this.isPrivate()) {
|
|
return this.get('name');
|
|
} else {
|
|
return this.get('name') || 'Unknown group';
|
|
}
|
|
},
|
|
|
|
getTitle: function() {
|
|
if (this.isPrivate()) {
|
|
return this.get('name') || this.getNumber();
|
|
} else {
|
|
return this.get('name') || 'Unknown group';
|
|
}
|
|
},
|
|
|
|
getProfileName: function() {
|
|
if (this.isPrivate() && !this.get('name')) {
|
|
return this.get('profileName');
|
|
}
|
|
},
|
|
|
|
getDisplayName: function() {
|
|
if (!this.isPrivate()) {
|
|
return this.getTitle();
|
|
}
|
|
|
|
var name = this.get('name');
|
|
if (name) {
|
|
return name;
|
|
}
|
|
|
|
var profileName = this.get('profileName');
|
|
if (profileName) {
|
|
return this.getNumber() + ' ~' + profileName;
|
|
}
|
|
|
|
return this.getNumber();
|
|
},
|
|
|
|
getNumber: function() {
|
|
if (!this.isPrivate()) {
|
|
return '';
|
|
}
|
|
var number = this.id;
|
|
try {
|
|
var parsedNumber = libphonenumber.parse(number);
|
|
var regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber);
|
|
if (regionCode === storage.get('regionCode')) {
|
|
return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.NATIONAL);
|
|
} else {
|
|
return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.INTERNATIONAL);
|
|
}
|
|
} catch (e) {
|
|
return number;
|
|
}
|
|
},
|
|
|
|
isPrivate: function() {
|
|
return this.get('type') === 'private';
|
|
},
|
|
|
|
revokeAvatarUrl: function() {
|
|
if (this.avatarUrl) {
|
|
URL.revokeObjectURL(this.avatarUrl);
|
|
this.avatarUrl = null;
|
|
}
|
|
},
|
|
|
|
updateAvatarUrl: function(silent) {
|
|
this.revokeAvatarUrl();
|
|
var avatar = this.get('avatar') || this.get('profileAvatar');
|
|
if (avatar) {
|
|
this.avatarUrl = URL.createObjectURL(
|
|
new Blob([avatar.data], {type: avatar.contentType})
|
|
);
|
|
} else {
|
|
this.avatarUrl = null;
|
|
}
|
|
if (!silent) {
|
|
this.trigger('change');
|
|
}
|
|
},
|
|
getColor: function() {
|
|
var title = this.get('name');
|
|
var color = this.get('color');
|
|
if (!color) {
|
|
if (this.isPrivate()) {
|
|
if (title) {
|
|
color = COLORS[Math.abs(this.hashCode()) % 15];
|
|
} else {
|
|
color = 'grey';
|
|
}
|
|
} else {
|
|
color = 'default';
|
|
}
|
|
}
|
|
return color;
|
|
},
|
|
getAvatar: function() {
|
|
if (this.avatarUrl === undefined) {
|
|
this.updateAvatarUrl(true);
|
|
}
|
|
|
|
var title = this.get('name');
|
|
var color = this.getColor();
|
|
|
|
if (this.avatarUrl) {
|
|
return { url: this.avatarUrl, color: color };
|
|
} else if (this.isPrivate()) {
|
|
return {
|
|
color: color,
|
|
content: title ? title.trim()[0] : '#'
|
|
};
|
|
} else {
|
|
return { url: 'images/group_default.png', color: color };
|
|
}
|
|
},
|
|
|
|
getNotificationIcon: function() {
|
|
return new Promise(function(resolve) {
|
|
var avatar = this.getAvatar();
|
|
if (avatar.url) {
|
|
resolve(avatar.url);
|
|
} else {
|
|
resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl());
|
|
}
|
|
}.bind(this));
|
|
},
|
|
|
|
notify: function(message) {
|
|
if (!message.isIncoming()) {
|
|
return Promise.resolve();
|
|
}
|
|
var conversationId = this.id;
|
|
|
|
return ConversationController.getOrCreateAndWait(message.get('source'), 'private')
|
|
.then(function(sender) {
|
|
return sender.getNotificationIcon().then(function(iconUrl) {
|
|
console.log('adding notification');
|
|
Whisper.Notifications.add({
|
|
title : sender.getTitle(),
|
|
message : message.getNotificationText(),
|
|
iconUrl : iconUrl,
|
|
imageUrl : message.getImageUrl(),
|
|
conversationId : conversationId,
|
|
messageId : message.id
|
|
});
|
|
});
|
|
});
|
|
},
|
|
hashCode: function() {
|
|
if (this.hash === undefined) {
|
|
var string = this.getTitle() || '';
|
|
if (string.length === 0) {
|
|
return 0;
|
|
}
|
|
var hash = 0;
|
|
for (var i = 0; i < string.length; i++) {
|
|
hash = ((hash<<5)-hash) + string.charCodeAt(i);
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
|
|
this.hash = hash;
|
|
}
|
|
return this.hash;
|
|
}
|
|
});
|
|
|
|
Whisper.ConversationCollection = Backbone.Collection.extend({
|
|
database: Whisper.Database,
|
|
storeName: 'conversations',
|
|
model: Whisper.Conversation,
|
|
|
|
comparator: function(m) {
|
|
return -m.get('timestamp');
|
|
},
|
|
|
|
destroyAll: function () {
|
|
return Promise.all(this.models.map(function(m) {
|
|
return new Promise(function(resolve, reject) {
|
|
m.destroy().then(resolve).fail(reject);
|
|
});
|
|
}));
|
|
},
|
|
|
|
search: function(query) {
|
|
query = query.trim().toLowerCase();
|
|
if (query.length > 0) {
|
|
query = query.replace(/[-.\(\)]*/g,'').replace(/^\+(\d*)$/, '$1');
|
|
var lastCharCode = query.charCodeAt(query.length - 1);
|
|
var nextChar = String.fromCharCode(lastCharCode + 1);
|
|
var upper = query.slice(0, -1) + nextChar;
|
|
return new Promise(function(resolve) {
|
|
this.fetch({
|
|
index: {
|
|
name: 'search', // 'search' index on tokens array
|
|
lower: query,
|
|
upper: upper,
|
|
excludeUpper: true
|
|
}
|
|
}).always(resolve);
|
|
}.bind(this));
|
|
}
|
|
},
|
|
|
|
fetchAlphabetical: function() {
|
|
return new Promise(function(resolve) {
|
|
this.fetch({
|
|
index: {
|
|
name: 'search', // 'search' index on tokens array
|
|
},
|
|
limit: 100
|
|
}).always(resolve);
|
|
}.bind(this));
|
|
},
|
|
|
|
fetchGroups: function(number) {
|
|
return new Promise(function(resolve) {
|
|
this.fetch({
|
|
index: {
|
|
name: 'group',
|
|
only: number
|
|
}
|
|
}).always(resolve);
|
|
}.bind(this));
|
|
}
|
|
});
|
|
|
|
Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');
|
|
|
|
// Special collection for fetching all the groups a certain number appears in
|
|
Whisper.GroupCollection = Backbone.Collection.extend({
|
|
database: Whisper.Database,
|
|
storeName: 'conversations',
|
|
model: Whisper.Conversation,
|
|
fetchGroups: function(number) {
|
|
return new Promise(function(resolve) {
|
|
this.fetch({
|
|
index: {
|
|
name: 'group',
|
|
only: number
|
|
}
|
|
}).always(resolve);
|
|
}.bind(this));
|
|
}
|
|
});
|
|
})();
|