Browse Source

Merge pull request #522 from threema-ch/angular17

- Upgrade Angular.js to 1.7
- Upgrade many other dependencies
- Introduce support for async/await
Danilo Bargen 7 years ago
parent
commit
e85c612700
47 changed files with 1449 additions and 1156 deletions
  1. 26 64
      LICENSE-3RD-PARTY.txt
  2. 0 0
      dist/app.js.tmp-browserify-83879006769559150136
  3. 3 4
      dist/package.sh
  4. 1 3
      gather-licenses.sh
  5. 3 3
      index.html
  6. 2 0
      karma.conf.js
  7. 379 253
      package-lock.json
  8. 24 26
      package.json
  9. 4 2
      src/controllers/status.ts
  10. 118 107
      src/directives/avatar.ts
  11. 83 88
      src/directives/avatar_area.ts
  12. 1 1
      src/directives/avatar_editor.ts
  13. 4 1
      src/directives/click_action.ts
  14. 4 3
      src/directives/compose_area.ts
  15. 17 13
      src/directives/contact_badge.ts
  16. 3 2
      src/directives/distribution_list_badge.ts
  17. 18 18
      src/directives/group_badge.ts
  18. 57 63
      src/directives/latest_message.ts
  19. 2 2
      src/directives/member_list_editor.ts
  20. 150 153
      src/directives/message.ts
  21. 6 2
      src/directives/message_icon.ts
  22. 205 197
      src/directives/message_media.ts
  23. 1 1
      src/directives/message_menu.html
  24. 14 12
      src/directives/message_meta.ts
  25. 22 26
      src/directives/message_text.ts
  26. 7 5
      src/directives/message_voip_status.ts
  27. 34 31
      src/directives/verification_level.ts
  28. 11 2
      src/filters.ts
  29. 7 0
      src/helpers.ts
  30. 36 0
      src/helpers/crypto.ts
  31. 11 2
      src/helpers/messages.ts
  32. 3 3
      src/partials/messenger.navigation.html
  33. 2 2
      src/partials/messenger.receiver/contact.html
  34. 1 1
      src/partials/messenger.receiver/me.html
  35. 65 23
      src/partials/messenger.ts
  36. 13 4
      src/partials/welcome.ts
  37. 4 2
      src/services/fingerprint.ts
  38. 4 2
      src/services/notification.ts
  39. 0 3
      src/services/peerconnection.ts
  40. 11 6
      src/services/push.ts
  41. 4 2
      src/services/webclient.ts
  42. 17 19
      src/threema.d.ts
  43. 7 5
      src/types/autolinker.d.ts
  44. 31 0
      tests/filters.js
  45. 3 0
      tests/testsuite.html
  46. 30 0
      tests/ts/crypto_helpers.ts
  47. 1 0
      tests/ts/main.ts

+ 26 - 64
LICENSE-3RD-PARTY.txt

@@ -125,7 +125,7 @@ License for angular-material
 
 The MIT License
 
-Copyright (c) 2014-2017 Google, Inc. http://angularjs.org
+Copyright (c) 2014-2018 Google, Inc. http://angularjs.org
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -148,35 +148,6 @@ THE SOFTWARE.
 
 
 
-----------
-License for angular-messages
-----------
-
-The MIT License (MIT)
-
-Copyright (c) 2016 Angular
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-
-
-
 ----------
 License for angular-qrcode
 ----------
@@ -270,7 +241,7 @@ License for angular-translate
 
 The MIT License (MIT)
 
-Copyright (c) <2014> <pascal.precht@gmail.com>
+Copyright (c) 2013-2017 The angular-translate team and Pascal Precht
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -299,7 +270,7 @@ License for angular-ui-router
 
 The MIT License
 
-Copyright (c) 2013-2015 The AngularUI Team, Karsten Sperling
+Copyright (c) 2013-2018 The AngularUI Team, Karsten Sperling
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -451,6 +422,27 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 License for browserify
 ----------
 
+This software is released under the MIT license:
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+---
+
 Some pieces from builtins/ taken from node core under this license:
 
 ----
@@ -621,41 +613,11 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
 
 
 
-----------
-License for js-sha256
-----------
-
-Copyright (c) 2014-2016 Chen, Yi-Cyuan
-
-MIT License
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-
-
-
 ----------
 License for messageformat
 ----------
 
-Copyright 2012-2016 Alex Sexton, Eemeli Aro, and Contributors
+Copyright 2012-2018 Alex Sexton, Eemeli Aro, and Contributors
 
 Permission is hereby granted, free of charge, to any person obtaining
 a copy of this software and associated documentation files (the
@@ -743,7 +705,7 @@ License for saltyrtc-client
 
 The MIT License (MIT)
 
-Copyright (c) 2016-2017 Threema GmbH
+Copyright (c) 2016-2018 Threema GmbH
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal

+ 0 - 0
dist/app.js.tmp-browserify-83879006769559150136


+ 3 - 4
dist/package.sh

@@ -56,14 +56,14 @@ targets=(
     msgpack-lite/dist/msgpack.min.js
     tweetnacl/nacl-fast.min.js
     file-saver/FileSaver.min.js
-    js-sha256/build/sha256.min.js
     @saltyrtc/chunked-dc/dist/chunked-dc.es5.js
     @saltyrtc/client/dist/saltyrtc-client.es5.js
     @saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js
     @saltyrtc/task-relayed-data/dist/saltyrtc-task-relayed-data.es5.js
     webrtc-adapter/out/adapter_no_edge.js
     webrtc-adapter/out/adapter.js
-    qrcode-generator/js/qrcode.js
+    qrcode-generator/qrcode.js
+    qrcode-generator/qrcode_UTF8.js
     angular-qrcode/angular-qrcode.js
     angularjs-scroll-glue/src/scrollglue.js
     angular-material/angular-material.min.js
@@ -71,12 +71,11 @@ targets=(
     croppie/croppie.min.js
     croppie/croppie.css
     autolinker/dist/Autolinker.min.js
-    angular-ui-router/release/angular-ui-router.min.js
+    @uirouter/angularjs/release/angular-ui-router.min.js
     messageformat/messageformat.min.js
     angular-translate/dist/angular-translate.min.js
     angular-translate/dist/angular-translate-loader-static-files/angular-translate-loader-static-files.min.js
     angular-translate/dist/angular-translate-interpolation-messageformat/angular-translate-interpolation-messageformat.min.js
-    angular-messages/angular-messages.min.js
     sdp/sdp.js
 )
 

+ 1 - 3
gather-licenses.sh

@@ -8,12 +8,11 @@ LICENSE_FILES=(
     'angular-aria' 'node_modules/angular-aria/LICENSE.md'
     'angular-inview' 'public/libs/angular-inview/LICENSE'
     'angular-material' 'node_modules/angular-material/LICENSE'
-    'angular-messages' 'node_modules/angular-messages/LICENSE.md'
     'angular-qrcode' '.licenses/angular-qrcode'
     'angular-route' 'node_modules/angular-route/LICENSE.md'
     'angular-sanitize' 'node_modules/angular-sanitize/LICENSE.md'
     'angular-translate' 'node_modules/angular-translate/LICENSE'
-    'angular-ui-router' 'node_modules/angular-ui-router/LICENSE'
+    'angular-ui-router' 'node_modules/@uirouter/angularjs/LICENSE'
     'angularjs-scroll-glue' '.licenses/angularjs-scroll-glue'
     'autolinker' 'node_modules/autolinker/LICENSE'
     'babel-es6-polyfill' '.licenses/babel-es6-polyfill'
@@ -25,7 +24,6 @@ LICENSE_FILES=(
     'EmojiOne JS' '.licenses/emojione-js'
     'EmojiOne Artwork' '.licenses/emojione-artwork'
     'file-saver' 'node_modules/file-saver/LICENSE.md'
-    'js-sha256' 'node_modules/js-sha256/LICENSE.txt'
     'messageformat' 'node_modules/messageformat/LICENSE'
     'msgpack-lite' 'node_modules/msgpack-lite/LICENSE'
     'node-sass' 'node_modules/node-sass/LICENSE'

+ 3 - 3
index.html

@@ -110,10 +110,11 @@
     <script src="node_modules/babel-es6-polyfill/browser-polyfill.min.js?v=[[VERSION]]"></script>
 
     <!-- Various libraries -->
-    <script src="node_modules/qrcode-generator/js/qrcode.js?v=[[VERSION]]"></script>
+    <script src="node_modules/qrcode-generator/qrcode.js?v=[[VERSION]]"></script>
+    <script src="node_modules/qrcode-generator/qrcode_UTF8.js?v=[[VERSION]]"></script>
     <script src="node_modules/angular-qrcode/angular-qrcode.js?v=[[VERSION]]"></script>
     <script src="node_modules/angular-material/angular-material.min.js?v=[[VERSION]]"></script>
-    <script src="node_modules/angular-ui-router/release/angular-ui-router.min.js?v=[[VERSION]]"></script>
+    <script src="node_modules/@uirouter/angularjs/release/angular-ui-router.min.js?v=[[VERSION]]"></script>
     <script src="libs/emojione/emojione.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/angularjs-scroll-glue/src/scrollglue.js?v=[[VERSION]]"></script>
     <script src="libs/angular-inview/angular-inview.js?v=[[VERSION]]"></script>
@@ -128,7 +129,6 @@
     <script src="node_modules/msgpack-lite/dist/msgpack.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/tweetnacl/nacl-fast.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/file-saver/FileSaver.min.js?v=[[VERSION]]"></script>
-    <script src="node_modules/js-sha256/build/sha256.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/chunked-dc/dist/chunked-dc.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/client/dist/saltyrtc-client.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js?v=[[VERSION]]"></script>

+ 2 - 0
karma.conf.js

@@ -10,6 +10,8 @@ module.exports = function(config) {
             'node_modules/angular-animate/angular-animate.min.js',
             'node_modules/angular-material/angular-material.min.js',
             'node_modules/@saltyrtc/chunked-dc/dist/chunked-dc.es5.js',
+            'node_modules/autolinker/dist/Autolinker.min.js',
+            'node_modules/regenerator-runtime/runtime.js',
             'dist/app.js',
             'dist/ts-tests.js',
             'tests/filters.js',

File diff suppressed because it is too large
+ 379 - 253
package-lock.json


+ 24 - 26
package.json

@@ -29,56 +29,54 @@
     "@saltyrtc/client": "^0.11.3",
     "@saltyrtc/task-relayed-data": "^0.2.0",
     "@saltyrtc/task-webrtc": "^0.11.0",
-    "@types/angular": "^1.6.45",
+    "@types/angular": "^1.6.48",
     "@types/angular-material": "^1.1.59",
     "@types/angular-sanitize": "^1.3.7",
-    "@types/angular-translate": "~2.15.4",
-    "@types/angular-ui-router": "^1.1.40",
+    "@types/angular-translate": "^2.16.0",
     "@types/filesaver": "~0.0.30",
-    "@types/jquery": "^3.3.2",
+    "@types/jquery": "^3.3.4",
     "@types/msgpack-lite": "^0.1.6",
     "@types/webrtc": "0.0.23",
-    "angular": "~1.5.10",
-    "angular-animate": "~1.5.10",
-    "angular-aria": "~1.5.10",
-    "angular-material": "=1.1.1",
-    "angular-messages": "^1.7.0",
-    "angular-qrcode": "~6.2.1",
-    "angular-route": "~1.5.10",
-    "angular-sanitize": "~1.5.10",
-    "angular-translate": "~2.13.1",
-    "angular-ui-router": "~0.3.2",
+    "@uirouter/angularjs": "~1.0.18",
+    "angular": "~1.7.2",
+    "angular-animate": "~1.7.2",
+    "angular-aria": "~1.7.2",
+    "angular-material": "=1.1.10",
+    "angular-messages": "^1.7.2",
+    "angular-qrcode": "~7.2",
+    "angular-route": "~1.7.2",
+    "angular-sanitize": "~1.7.2",
+    "angular-translate": "~2.18",
     "angularjs-scroll-glue": "~2.1.0",
-    "autolinker": "~0.27.0",
+    "autolinker": "~1.6.2",
     "babel-es6-polyfill": "~1.1.0",
     "babel-preset-es2015": "~6.14.0",
     "babelify": "~7.3.0",
     "browserify": "~16",
     "browserify-header": "^0.9.4",
-    "croppie": "~2.4.0",
+    "croppie": "~2.6.0",
     "file-saver": "^1.3.8",
-    "js-sha256": "~0.3.2",
-    "messageformat": "~1.0.2",
+    "messageformat": "~2",
     "msgpack-lite": "~0.1.26",
-    "node-sass": "^4.9.0",
-    "sdp": "~1.3.0",
+    "node-sass": "^4.9.2",
+    "sdp": "~2.7.0",
     "ts-events": "^3.1.5",
     "tsify": "~4.0.0",
     "tweetnacl": "^1.0.0",
-    "typescript": "~2.9",
-    "webrtc-adapter": "~3.4.3"
+    "typescript": "^2.9.2",
+    "webrtc-adapter": "^6.3.0"
   },
   "devDependencies": {
     "@types/jasmine": "^2.8.8",
-    "angular-mocks": "~1.5.10",
+    "angular-mocks": "~1.7",
     "budo": "^9",
     "concurrently": "~3.3.0",
     "jasmine": "^3.1.0",
-    "jasmine-core": "~2.5.2",
-    "karma": "^2",
+    "jasmine-core": "^3.1.0",
+    "karma": "^2.0.4",
     "karma-chrome-launcher": "^2.2.0",
     "karma-firefox-launcher": "^1.1.0",
     "karma-jasmine": "^1.1.2",
-    "tslint": "~5.9"
+    "tslint": "~5.10"
   }
 }

+ 4 - 2
src/controllers/status.ts

@@ -15,6 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 import {ControllerService} from '../services/controller';
 import {StateService} from '../services/state';
 import {WebClientService} from '../services/webclient';
@@ -45,8 +47,8 @@ export class StatusController {
 
     // Angular services
     private $timeout: ng.ITimeoutService;
-    private $state: ng.ui.IStateService;
     private $log: ng.ILogService;
+    private $state: UiStateService;
 
     // Custom services
     private stateService: StateService;
@@ -55,7 +57,7 @@ export class StatusController {
 
     public static $inject = ['$scope', '$timeout', '$log', '$state', 'StateService',
         'WebClientService', 'ControllerService'];
-    constructor($scope, $timeout: ng.ITimeoutService, $log: ng.ILogService, $state: ng.ui.IStateService,
+    constructor($scope, $timeout: ng.ITimeoutService, $log: ng.ILogService, $state: UiStateService,
                 stateService: StateService, webClientService: WebClientService,
                 controllerService: ControllerService) {
 

+ 118 - 107
src/directives/avatar.ts

@@ -40,41 +40,7 @@ export default [
             controller: [function() {
                 this.logTag = '[Directives.Avatar]';
 
-                this.highResolution = this.resolution === 'high';
-                this.isLoading = this.highResolution;
-                this.backgroundColor = this.receiver.color;
                 let loadingPromise: ng.IPromise<any> = null;
-                this.avatarClass = () => {
-                    return 'avatar-' + this.resolution + (this.isLoading ? ' is-loading' : '');
-                };
-
-                this.avatarExists = () => {
-                    if (this.receiver.avatar === undefined
-                        || this.receiver.avatar[this.resolution] === undefined
-                        || this.receiver.avatar[this.resolution] === null) {
-                        return false;
-                    }
-                    this.isLoading = false;
-                    // Reset background color
-                    this.backgroundColor = null;
-                    return true;
-                };
-
-                /**
-                 * Return path to the default avatar.
-                 */
-                this.getDefaultAvatarUri = (type: threema.ReceiverType, highResolution: boolean) => {
-                    switch (type) {
-                        case 'group':
-                            return highResolution ? 'img/ic_group_picture_big.png' : 'img/ic_group_t.png';
-                        case 'distributionList':
-                            return highResolution ? 'img/ic_distribution_list_t.png' : 'img/ic_distribution_list_t.png';
-                        case 'contact':
-                        case 'me':
-                        default:
-                            return highResolution ? 'img/ic_contact_picture_big.png' : 'img/ic_contact_picture_t.png';
-                    }
-                };
 
                 /**
                  * Convert avatar bytes to an URI.
@@ -98,86 +64,131 @@ export default [
                     return avatarUri[res];
                 };
 
-                /**
-                 * Return an avatar URI.
-                 *
-                 * This will fall back to a low resolution version or to the
-                 * default avatar if no avatar for the desired resolution could
-                 * be found.
-                 */
-                this.getAvatarUri = () => {
-                    /// If an avatar for the chosen resolution exists, convert it to an URI and return
-                    if (this.avatarExists()) {
-                        return this.avatarToUri(this.receiver.avatar[this.resolution], this.resolution);
-                    }
+                this.$onInit = function() {
 
-                    // Otherwise, if we requested a high res avatar but
-                    // there is only a low-res version, show that.
-                    if (this.highResolution
-                        && this.receiver.avatar !== undefined
-                        && this.receiver.avatar.low !== undefined
-                        && this.receiver.avatar.low !== null) {
-                        return this.avatarToUri(this.receiver.avatar.low, 'low');
-                    }
+                    this.highResolution = this.resolution === 'high';
+                    this.isLoading = this.highResolution;
+                    this.backgroundColor = this.receiver.color;
+                    this.avatarClass = () => {
+                        return 'avatar-' + this.resolution + (this.isLoading ? ' is-loading' : '');
+                    };
 
-                    // As a fallback, get the default avatar.
-                    return this.getDefaultAvatarUri(this.receiver.type, this.highResolution);
-                };
+                    this.avatarExists = () => {
+                        if (this.receiver.avatar === undefined
+                            || this.receiver.avatar[this.resolution] === undefined
+                            || this.receiver.avatar[this.resolution] === null) {
+                            return false;
+                        }
+                        this.isLoading = false;
+                        // Reset background color
+                        this.backgroundColor = null;
+                        return true;
+                    };
 
-                this.requestAvatar = (inView: boolean) => {
-                    if (this.avatarExists()) {
-                        // do not request
-                        return;
-                    }
+                    /**
+                     * Return path to the default avatar.
+                     */
+                    this.getDefaultAvatarUri = (type: threema.ReceiverType, highResolution: boolean) => {
+                        switch (type) {
+                            case 'group':
+                                return highResolution
+                                    ? 'img/ic_group_picture_big.png'
+                                    : 'img/ic_group_t.png';
+                            case 'distributionList':
+                                return highResolution
+                                    ? 'img/ic_distribution_list_t.png'
+                                    : 'img/ic_distribution_list_t.png';
+                            case 'contact':
+                            case 'me':
+                            default:
+                                return highResolution
+                                    ? 'img/ic_contact_picture_big.png'
+                                    : 'img/ic_contact_picture_t.png';
+                        }
+                    };
 
-                    if (inView) {
-                        if (loadingPromise === null) {
-                            // Do not wait on high resolution avatar
-                            const loadingTimeout = this.highResolution ? 0 : 500;
-                            loadingPromise = $timeout(() => {
-                                // show loading only on high res images!
-                                webClientService.requestAvatar({
-                                    type: this.receiver.type,
-                                    id: this.receiver.id,
-                                } as threema.Receiver, this.highResolution).then((avatar) => {
-                                    $rootScope.$apply(() => {
-                                        this.isLoading = false;
-                                    });
-                                }).catch(() => {
-                                    $rootScope.$apply(() => {
-                                        this.isLoading = false;
+                    /**
+                     * Return an avatar URI.
+                     *
+                     * This will fall back to a low resolution version or to the
+                     * default avatar if no avatar for the desired resolution could
+                     * be found.
+                     */
+                    this.getAvatarUri = () => {
+                        /// If an avatar for the chosen resolution exists, convert it to an URI and return
+                        if (this.avatarExists()) {
+                            return this.avatarToUri(this.receiver.avatar[this.resolution], this.resolution);
+                        }
+
+                        // Otherwise, if we requested a high res avatar but
+                        // there is only a low-res version, show that.
+                        if (this.highResolution
+                            && this.receiver.avatar !== undefined
+                            && this.receiver.avatar.low !== undefined
+                            && this.receiver.avatar.low !== null) {
+                            return this.avatarToUri(this.receiver.avatar.low, 'low');
+                        }
+
+                        // As a fallback, get the default avatar.
+                        return this.getDefaultAvatarUri(this.receiver.type, this.highResolution);
+                    };
+
+                    this.requestAvatar = (inView: boolean) => {
+                        if (this.avatarExists()) {
+                            // do not request
+                            return;
+                        }
+
+                        if (inView) {
+                            if (loadingPromise === null) {
+                                // Do not wait on high resolution avatar
+                                const loadingTimeout = this.highResolution ? 0 : 500;
+                                loadingPromise = $timeout(() => {
+                                    // show loading only on high res images!
+                                    webClientService.requestAvatar({
+                                        type: this.receiver.type,
+                                        id: this.receiver.id,
+                                    } as threema.Receiver, this.highResolution).then((avatar) => {
+                                        $rootScope.$apply(() => {
+                                            this.isLoading = false;
+                                        });
+                                    }).catch(() => {
+                                        $rootScope.$apply(() => {
+                                            this.isLoading = false;
+                                        });
                                     });
-                                });
-                            }, loadingTimeout);
+                                }, loadingTimeout);
+                            }
+                        } else if (loadingPromise !== null) {
+                            // Cancel pending avatar loading
+                            $timeout.cancel(loadingPromise);
+                            loadingPromise = null;
                         }
-                    } else if (loadingPromise !== null) {
-                        // Cancel pending avatar loading
-                        $timeout.cancel(loadingPromise);
-                        loadingPromise = null;
-                    }
-                };
+                    };
+
+                    const isWork = webClientService.clientInfo.isWork;
+                    this.showWorkIndicator = () => {
+                        if (!isContactReceiver(this.receiver)) { return false; }
+                        const contact: threema.ContactReceiver = this.receiver;
+                        return isWork === false
+                            && !this.highResolution
+                            && contact.identityType === threema.IdentityType.Work;
+                    };
+                    this.showHomeIndicator = () => {
+                        if (!isContactReceiver(this.receiver)) { return false; }
+                        const contact: threema.ContactReceiver = this.receiver;
+                        return isWork === true
+                            && !isGatewayContact(contact)
+                            && !isEchoContact(contact)
+                            && contact.identityType === threema.IdentityType.Regular
+                            && !this.highResolution;
+                    };
+                    this.showBlocked = () => {
+                        if (!isContactReceiver(this.receiver)) { return false; }
+                        const contact: threema.ContactReceiver = this.receiver;
+                        return !this.highResolution && contact.isBlocked;
+                    };
 
-                const isWork = webClientService.clientInfo.isWork;
-                this.showWorkIndicator = () => {
-                    if (!isContactReceiver(this.receiver)) { return false; }
-                    const contact: threema.ContactReceiver = this.receiver;
-                    return isWork === false
-                        && !this.highResolution
-                        && contact.identityType === threema.IdentityType.Work;
-                };
-                this.showHomeIndicator = () => {
-                    if (!isContactReceiver(this.receiver)) { return false; }
-                    const contact: threema.ContactReceiver = this.receiver;
-                    return isWork === true
-                        && !isGatewayContact(contact)
-                        && !isEchoContact(contact)
-                        && contact.identityType === threema.IdentityType.Regular
-                        && !this.highResolution;
-                };
-                this.showBlocked = () => {
-                    if (!isContactReceiver(this.receiver)) { return false; }
-                    const contact: threema.ContactReceiver = this.receiver;
-                    return !this.highResolution && contact.isBlocked;
                 };
             }],
             template: `

+ 83 - 88
src/directives/avatar_area.ts

@@ -17,6 +17,7 @@
 
 // tslint:disable:max-line-length
 
+import {bufferToUrl, logAdapter} from '../helpers';
 import {WebClientService} from '../services/webclient';
 
 /**
@@ -25,16 +26,10 @@ import {WebClientService} from '../services/webclient';
 export default [
     '$rootScope',
     '$log',
-    '$window',
-    '$timeout',
-    '$translate',
     '$mdDialog',
     'WebClientService',
     function($rootScope: ng.IRootScopeService,
              $log: ng.ILogService,
-             $window: ng.IWindowService,
-             $timeout: ng.ITimeoutService,
-             $translate: ng.translate.ITranslateService,
              $mdDialog: ng.material.IDialogService,
              webClientService: WebClientService) {
         return {
@@ -48,95 +43,95 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.isLoading = false;
-                this.avatar = null; // Type: String
-                this.avatarFormat = webClientService.appCapabilities.imageFormat.avatar;
-
-                this.imageChanged = function(image: ArrayBuffer, notify = true) {
-                    this.isLoading = true;
-                    if (notify === true && this.onChange !== undefined) {
-                        this.onChange(image);
-                    }
-                    this.avatar = image;
-                    this.isLoading = false;
-                };
+                const logTag = '[AvatarAreaDirective]';
 
-                if (this.loadAvatar !== undefined) {
-                    this.isLoading = true;
-                    (this.loadAvatar as Promise<ArrayBuffer>)
-                        .then((image: ArrayBuffer) => {
-                            $rootScope.$apply(() => {
-                                this.avatar = image;
-                                this.isLoading = false;
-                            });
-                        })
-                        .catch(() => {
-                            $rootScope.$apply(() => {
-                                this.isLoading = false;
-                            });
-                        });
-                }
+                this.isLoading = false;
+                this.avatar = null; // String
+                const avatarFormat = webClientService.appCapabilities.imageFormat.avatar;
 
-                this.delete = () => {
-                    this.imageChanged(null, true);
-                };
+                this.$onInit = function() {
+                    this.setAvatar = (avatarBytes: ArrayBuffer) => {
+                        this.avatar = (avatarBytes === null)
+                            ? null
+                            : bufferToUrl(avatarBytes, avatarFormat, logAdapter($log.warn, logTag));
+                    };
 
-                // show editor in a dialog
-                this.modify = (ev) => {
-                    $mdDialog.show({
-                        controllerAs: 'ctrl',
-                        controller: function() {
-                            this.avatar = null;
+                    this.imageChanged = (image: ArrayBuffer, notify = true) => {
+                        this.isLoading = true;
+                        if (notify === true && this.onChange !== undefined) {
+                            this.onChange(image);
+                        }
+                        this.setAvatar(image);
+                        this.isLoading = false;
+                    };
 
-                            this.apply = () => {
-                                $mdDialog.hide(this.avatar);
-                            };
+                    if (this.loadAvatar !== undefined) {
+                        this.isLoading = true;
+                        (this.loadAvatar as Promise<ArrayBuffer>)
+                            .then((image: ArrayBuffer) => {
+                                $rootScope.$apply(() => {
+                                    this.setAvatar(image);
+                                    this.isLoading = false;
+                                });
+                            })
+                            .catch(() => {
+                                $rootScope.$apply(() => {
+                                    this.isLoading = false;
+                                });
+                            });
+                    }
 
-                            this.cancel = () => {
-                                $mdDialog.cancel();
-                            };
+                    this.delete = () => {
+                        this.imageChanged(null, true);
+                    };
 
-                            this.changeAvatar = (image: ArrayBuffer) => {
-                                this.avatar = image;
-                            };
-                        },
-                        template: `
-                            <md-dialog translate-attr="{'aria-label': 'messenger.UPLOAD_AVATAR'}">
-                                <form ng-cloak>
-                                 <md-toolbar>
-                                  <div class="md-toolbar-tools">
-                                   <h2 translate>messenger.UPLOAD_AVATAR</h2>
-                                   </div>
-                                   </md-toolbar>
-                                    <md-dialog-content>
-                                        <div class="md-dialog-content avatar-area editor">
-                                            <avatar-editor on-change="ctrl.changeAvatar"></avatar-editor>
-                                        </div>
-                                    </md-dialog-content>
-                                    <md-dialog-actions layout="row" >
-                                          <md-button ng-click="ctrl.cancel()">
-                                           <span translate>common.CANCEL</span>
-                                            </md-button>
-                                      <md-button ng-click="ctrl.apply()">
-                                         <span translate>common.OK</span>
-                                      </md-button>
-                                    </md-dialog-actions>
-                                </form>
-                            </md-dialog>
+                    // show editor in a dialog
+                    this.modify = (ev) => {
+                        $mdDialog.show({
+                            controllerAs: 'ctrl',
+                            controller: function() {
+                                this.avatar = null;
+                                this.apply = () => $mdDialog.hide(this.avatar);
+                                this.cancel = () => $mdDialog.cancel();
+                                this.changeAvatar = (image: ArrayBuffer) => this.avatar = image;
+                            },
+                            template: `
+                                <md-dialog translate-attr="{'aria-label': 'messenger.UPLOAD_AVATAR'}">
+                                    <form ng-cloak>
+                                     <md-toolbar>
+                                      <div class="md-toolbar-tools">
+                                       <h2 translate>messenger.UPLOAD_AVATAR</h2>
+                                       </div>
+                                       </md-toolbar>
+                                        <md-dialog-content>
+                                            <div class="md-dialog-content avatar-area editor">
+                                                <avatar-editor on-change="ctrl.changeAvatar"></avatar-editor>
+                                            </div>
+                                        </md-dialog-content>
+                                        <md-dialog-actions layout="row" >
+                                              <md-button ng-click="ctrl.cancel()">
+                                               <span translate>common.CANCEL</span>
+                                                </md-button>
+                                          <md-button ng-click="ctrl.apply()">
+                                             <span translate>common.OK</span>
+                                          </md-button>
+                                        </md-dialog-actions>
+                                    </form>
+                                </md-dialog>
 
-                        `,
-                        parent: angular.element(document.body),
-                        targetEvent: ev,
-                        clickOutsideToClose: true,
-                    })
-                        .then((newImage: ArrayBuffer) => {
-                            // update image only if a image was set or if enable clear is true
-                            if (this.enableClear === true || newImage !== null) {
-                                this.imageChanged(newImage, true);
-                            }
-                        }, () => null);
+                            `,
+                            parent: angular.element(document.body),
+                            targetEvent: ev,
+                            clickOutsideToClose: true,
+                        })
+                            .then((newImage: ArrayBuffer) => {
+                                // update image only if a image was set or if enable clear is true
+                                if (this.enableClear === true || newImage !== null) {
+                                    this.imageChanged(newImage, true);
+                                }
+                            }, () => null);
+                    };
                 };
-
             }],
             template: `
                 <div class="avatar-area overview">
@@ -147,7 +142,7 @@ export default [
                                     md-diameter="96"></md-progress-circular>
 
                         </div>
-                        <img ng-src="{{ ctrl.avatar|bufferToUrl:ctrl.avatarFormat }}" ng-if="ctrl.avatar !== null" />
+                        <img ng-src="{{ ctrl.avatar }}" ng-if="ctrl.avatar !== null" />
                     </div>
                     <div class="avatar-area-navigation"  layout="row" layout-wrap layout-margin layout-align="center">
 

+ 1 - 1
src/directives/avatar_editor.ts

@@ -256,7 +256,7 @@ export default [
             template: `
                 <div class="avatar-editor">
                     <div class="avatar-editor-drag croppie-container"></div>
-                    <div class="avatar-editor-navigation"  layout="column" layout-wrap layout-margin layout-align="center center">
+                    <div class="avatar-editor-navigation" layout="column" layout-wrap layout-margin layout-align="center center">
                         <input class="file-input" type="file" style="visibility: hidden" multiple/>
                           <md-button type="submit" class="file-trigger md-raised">
                             <span translate>messenger.UPLOAD_AVATAR</span>

+ 4 - 1
src/directives/click_action.ts

@@ -14,6 +14,9 @@
  * You should have received a copy of the GNU Affero General Public License
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
+
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 import {UriService} from '../services/uri';
 import {WebClientService} from '../services/webclient';
 
@@ -22,7 +25,7 @@ export default [
     '$state',
     'UriService',
     'WebClientService',
-    function($timeout, $state: ng.ui.IStateService, uriService: UriService, webClientService: WebClientService) {
+    function($timeout, $state: UiStateService, uriService: UriService, webClientService: WebClientService) {
 
         const validateThreemaId = (id: string): boolean => {
             return id !== undefined && id !== null && /^[0-9A-Z]{8}/.test(id);

+ 4 - 3
src/directives/compose_area.ts

@@ -87,7 +87,8 @@ export default [
                     from?: number,
                     to?: number,
                     fromBytes?: number,
-                    toBytes?: number } = null;
+                    toBytes?: number,
+                } = null;
 
                 /**
                  * Stop propagation of click events and hold htmlElement of the emojipicker
@@ -293,7 +294,7 @@ export default [
                         const text = getText(false);
                         if (text === '\n') {
                             composeDiv[0].innerText = '';
-                        } else if (ev.keyCode === 190) {
+                        } else if (ev.keyCode === 190 && caretPosition !== null) {
                             // A ':' is pressed, try to parse
                             const currentWord = stringService.getWord(text, caretPosition.fromBytes, [':']);
                             if (currentWord.realLength > 2
@@ -308,7 +309,7 @@ export default [
                         }
 
                         // Update typing information (use text instead method)
-                        if (text.trim().length === 0) {
+                        if (text.trim().length === 0 || caretPosition === null) {
                             stopTyping();
                             scope.onTyping('');
                         } else {

+ 17 - 13
src/directives/contact_badge.ts

@@ -17,6 +17,8 @@
 
 // tslint:disable:max-line-length
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 import {WebClientService} from '../services/webclient';
 
 /**
@@ -25,7 +27,7 @@ import {WebClientService} from '../services/webclient';
 export default [
     'WebClientService',
     '$state',
-    function(webClientService: WebClientService, $state: ng.ui.IStateService) {
+    function(webClientService: WebClientService, $state: UiStateService) {
         return {
             restrict: 'EA',
             scope: {},
@@ -37,20 +39,22 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                if (this.contactReceiver === undefined) {
-                    this.contactReceiver = webClientService.contacts.get(this.identity);
-                } else {
-                    this.identity = this.contactReceiver.id;
-                }
-
-                this.click = () => {
-                    if (this.linked !== undefined
-                        && this.linked === true) {
-                        $state.go('messenger.home.conversation', {type: 'contact', id: this.identity, initParams: null});
+                this.$onInit = function() {
+                    if (this.contactReceiver === undefined) {
+                        this.contactReceiver = webClientService.contacts.get(this.identity);
+                    } else {
+                        this.identity = this.contactReceiver.id;
                     }
-                };
 
-                this.showActions = this.onRemove !== undefined;
+                    this.click = () => {
+                        if (this.linked !== undefined
+                            && this.linked === true) {
+                            $state.go('messenger.home.conversation', {type: 'contact', id: this.identity, initParams: null});
+                        }
+                    };
+
+                    this.showActions = this.onRemove !== undefined;
+                };
             }],
             template: `
                 <div class="contact-badge receiver-badge" ng-click="ctrl.click()">

+ 3 - 2
src/directives/distribution_list_badge.ts

@@ -15,18 +15,19 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 /**
  * Show a distribution list receiver with small avatar, name and verification level
  */
 export default [
     '$state',
-    function($state: ng.ui.IStateService) {
+    function($state: UiStateService) {
         return {
             restrict: 'EA',
             scope: {},
             bindToController: {
                 distributionListReceiver: '=eeeDistributionListReceiver',
-                contactReceiver: '=?eeeContactReceiver',
             },
             controllerAs: 'ctrl',
             controller: [function() {

+ 18 - 18
src/directives/group_badge.ts

@@ -15,13 +15,15 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 /**
  * Show a contact receiver with small avatar, name and verification level
  */
 export default [
     '$translate',
     '$state',
-    function($translate, $state: ng.ui.IStateService) {
+    function($translate, $state: UiStateService) {
         return {
             restrict: 'EA',
             scope: {},
@@ -31,23 +33,6 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.showRoleIcon = this.contactReceiver !== undefined;
-                if (this.showRoleIcon) {
-
-                    if (this.contactReceiver.id === this.groupReceiver.administrator) {
-                        this.roleIcon = 'people';
-                        $translate('messenger.GROUP_ROLE_CREATOR')
-                            .then((label) =>  {
-                                this.roleName = label;
-                            });
-                    } else {
-                        this.roleIcon = 'people_outline';
-                        $translate('messenger.GROUP_ROLE_NORMAL')
-                            .then((label) =>  {
-                                this.roleName = label;
-                            });
-                    }
-                }
                 this.click = () => {
                     $state.go('messenger.home.conversation', {
                         type: 'group',
@@ -55,6 +40,21 @@ export default [
                         initParams: null,
                     });
                 };
+
+                this.$onInit = function() {
+                    this.showRoleIcon = this.contactReceiver !== undefined;
+                    if (this.showRoleIcon) {
+                        if (this.contactReceiver.id === this.groupReceiver.administrator) {
+                            this.roleIcon = 'people';
+                            $translate('messenger.GROUP_ROLE_CREATOR')
+                                .then((label) => this.roleName = label);
+                        } else {
+                            this.roleIcon = 'people_outline';
+                            $translate('messenger.GROUP_ROLE_NORMAL')
+                                .then((label) => this.roleName = label);
+                        }
+                    }
+                };
             }],
             template: `
                 <div class="group-badge receiver-badge" ng-click="ctrl.click()">

+ 57 - 63
src/directives/latest_message.ts

@@ -15,6 +15,7 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {getSenderIdentity} from '../helpers/messages';
 import {MessageService} from '../services/message';
 import {ReceiverService} from '../services/receiver';
 import {WebClientService} from '../services/webclient';
@@ -33,80 +34,73 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                // Utilities
-                const getIdentity = function(message) {
-                    // TODO: Get rid of duplication with eeeMessage directive
-                    if (message.isOutbox) {
-                        return webClientService.me.id;
-                    }
-                    if (message.partnerId !== null) {
-                        return message.partnerId;
-                    }
-                    return null;
-                };
-                // Conversation properties
+                this.$onInit = function() {
 
-                this.isGroup = this.type as threema.ReceiverType === 'group';
-                this.isDistributionList = !this.isGroup
-                    && this.type as threema.ReceiverType === 'distributionList';
+                    // Conversation properties
+                    this.isGroup = this.type as threema.ReceiverType === 'group';
+                    this.isDistributionList = !this.isGroup
+                        && this.type as threema.ReceiverType === 'distributionList';
 
-                this.showVoipInfo = this.message
-                    && (this.message as threema.Message).type === 'voipStatus';
+                    this.showVoipInfo = this.message
+                        && (this.message as threema.Message).type === 'voipStatus';
 
-                if (this.showVoipInfo) {
-                    this.statusIcon = 'phone_locked';
-                } else if (this.isGroup) {
-                    this.statusIcon = 'group';
-                } else if (this.isDistributionList) {
-                    this.statusIcon = 'forum';
-                } else if (!this.message.isOutbox) {
-                    this.statusIcon = 'reply';
-                } else if (messageService.showStatusIcon(this.message, this.receiver)) {
-                    // Show status icon of incoming messages every time
-                    this.statusIcon = $filter('messageStateIcon')(this.message);
-                } else {
-                    // Do not show a status icon
-                    this.statusIcon = null;
-                }
+                    if (this.showVoipInfo) {
+                        this.statusIcon = 'phone_locked';
+                    } else if (this.isGroup) {
+                        this.statusIcon = 'group';
+                    } else if (this.isDistributionList) {
+                        this.statusIcon = 'forum';
+                    } else if (!this.message.isOutbox) {
+                        this.statusIcon = 'reply';
+                    } else if (messageService.showStatusIcon(this.message, this.receiver)) {
+                        // Show status icon of incoming messages every time
+                        this.statusIcon = $filter('messageStateIcon')(this.message);
+                    } else {
+                        // Do not show a status icon
+                        this.statusIcon = null;
+                    }
 
-                // Find sender of latest message in group chats
-                this.contact = null;
-                if (this.message) {
-                    this.contact = webClientService.contacts.get(getIdentity(this.message));
-                }
+                    // Find sender of latest message
+                    this.contact = null;
+                    if (this.message) {
+                        this.contact = webClientService.contacts.get(
+                            getSenderIdentity(this.message, webClientService.me.id),
+                        );
+                    }
+
+                    // Typing indicator
+                    this.isTyping = () => false;
+                    if (this.isGroup === false
+                        && this.isDistributionList === false
+                        && this.contact !== null) {
+                        this.isTyping = () => {
+                            return webClientService.isTyping(this.contact);
+                        };
+                    }
 
-                // Typing indicator
-                this.isTyping = () => false;
-                if (this.isGroup === false
-                    && this.isDistributionList === false
-                    && this.contact !== null) {
-                    this.isTyping = () => {
-                        return webClientService.isTyping(this.contact);
+                    this.isHidden = () => {
+                        return this.receiver.locked;
                     };
-                }
 
-                this.isHidden = () => {
-                    return this.receiver.locked;
-                };
+                    // Show...
+                    this.showIcon = this.message
+                        && this.message.type !== 'text'
+                        && this.message.type !== 'status';
 
-                // Show...
-                this.showIcon = this.message
-                    && this.message.type !== 'text'
-                    && this.message.type !== 'status';
+                    this.getDraft = () => {
+                        return webClientService.getDraft(this.receiver);
+                    };
 
-                this.getDraft = () => {
-                    return webClientService.getDraft(this.receiver);
-                };
+                    this.showDraft = () => {
+                        if (receiverService.isConversationActive(this.receiver)) {
+                            // Don't show draft if conversation is active
+                            return false;
+                        }
+                        const draft = this.getDraft();
+                        return draft !== undefined && draft !== null;
+                    };
 
-                this.showDraft = () => {
-                    if (receiverService.isConversationActive(this.receiver)) {
-                        // Don't show draft if conversation is active
-                        return false;
-                    }
-                    const draft = this.getDraft();
-                    return draft !== undefined && draft !== null;
                 };
-
             }],
             templateUrl: 'directives/latest_message.html',
         };

+ 2 - 2
src/directives/member_list_editor.ts

@@ -18,6 +18,8 @@
 import {hasFeature} from '../helpers';
 import {WebClientService} from '../services/webclient';
 
+const AUTOCOMPLETE_MAX_RESULTS = 20;
+
 export default [
     '$log', 'WebClientService',
     function($log: ng.ILogService, webClientService: WebClientService) {
@@ -31,8 +33,6 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                const AUTOCOMPLETE_MAX_RESULTS = 20;
-
                 // Cache all contacts with group chat support
                 this.allContacts = Array
                     .from(webClientService.contacts.values())

+ 150 - 153
src/directives/message.ts

@@ -17,6 +17,7 @@
 
 // tslint:disable:max-line-length
 
+import {getSenderIdentity} from '../helpers/messages';
 import {MessageService} from '../services/message';
 import {ReceiverService} from '../services/receiver';
 import {WebClientService} from '../services/webclient';
@@ -51,168 +52,164 @@ export default [
             controllerAs: 'ctrl',
             controller: [function() {
                 this.logTag = '[MessageDirective]';
-                // Return the contact
-                const getIdentity = function(message: threema.Message) {
-                    if (message.isOutbox) {
-                        return webClientService.me.id;
-                    }
-                    if (message.partnerId != null) {
-                        return message.partnerId;
-                    }
-                    return null;
-                };
-
-                // Defaults and variables
-                if (this.resolution == null) {
-                    this.resolution = 'low';
-                }
 
-                this.contact = webClientService.contacts.get(getIdentity(this.message));
-
-                // Show...
-                this.isStatusMessage = this.message.isStatus;
-                this.isContactMessage = !this.message.isStatus
-                    && webClientService.contacts.has(getIdentity(this.message));
-                this.isGroup = this.type as threema.ReceiverType === 'group';
-                this.isContact = this.type as threema.ReceiverType === 'contact';
-                this.isBusinessReceiver = receiverService.isBusinessContact(this.receiver);
-
-                this.showName = !this.message.isOutbox && this.isGroup;
-                // show avatar only if a name is shown
-                this.showAvatar = this.showName;
-                this.showText = this.message.type === 'text' || this.message.caption;
-                this.showMedia = this.message.type !== 'text';
-                this.showState = messageService.showStatusIcon(this.message as threema.Message, this.receiver);
-                this.showQuote = this.message.quote !== undefined;
-                this.showVoipInfo = this.message.type === 'voipStatus';
-
-                this.access = messageService.getAccess(this.message, this.receiver);
-
-                this.ack = (ack: boolean) => {
-                    webClientService.ackMessage(this.receiver, this.message, ack);
-                };
-
-                this.quote = () => {
-                    // set message as quoted
-                    webClientService.setQuote(this.receiver, this.message);
-                };
-
-                this.delete = (ev) => {
-                    const confirm = $mdDialog.confirm()
-                        .title($translate.instant('messenger.CONFIRM_DELETE_TITLE'))
-                        .textContent($translate.instant('common.ARE_YOU_SURE'))
-                        .targetEvent(ev)
-                        .ok($translate.instant('common.YES'))
-                        .cancel($translate.instant('common.CANCEL'));
-                    $mdDialog.show(confirm).then((result) => {
-                        webClientService.deleteMessage(this.receiver, this.message);
-                    }, () => { /* do nothing */});
-                };
+                this.$onInit = function() {
 
-                this.copyToClipboard = (ev: MouseEvent) => {
-                    // Get copyable text
-                    const text = messageService.getQuoteText(this.message);
-                    if (text === null) {
-                        return;
+                    // Defaults and variables
+                    if (this.resolution == null) {
+                        this.resolution = 'low';
                     }
 
-                    // In order to copy the text to the clipboard,
-                    // put it into a temporary textarea element.
-                    const textArea = document.createElement('textarea');
-                    let toastString = 'messenger.COPY_ERROR';
-                    textArea.value = text;
-                    document.body.appendChild(textArea);
-                    textArea.focus();
-                    textArea.select();
-                    try {
-                        const successful = document.execCommand('copy');
-                        if (!successful) {
-                            $log.warn(this.logTag, 'Could not copy text to clipboard');
-                        } else {
-                            toastString = 'messenger.COPIED';
+                    // Find contact
+                    this.contact = webClientService.contacts.get(
+                        getSenderIdentity(this.message, webClientService.me.id),
+                    );
+
+                    // Show...
+                    this.isStatusMessage = this.message.isStatus;
+                    this.isContactMessage = !this.message.isStatus
+                        && webClientService.contacts.has(getSenderIdentity(this.message, webClientService.me.id));
+                    this.isGroup = this.type as threema.ReceiverType === 'group';
+                    this.isContact = this.type as threema.ReceiverType === 'contact';
+                    this.isBusinessReceiver = receiverService.isBusinessContact(this.receiver);
+
+                    this.showName = !this.message.isOutbox && this.isGroup;
+                    // show avatar only if a name is shown
+                    this.showAvatar = this.showName;
+                    this.showText = this.message.type === 'text' || this.message.caption;
+                    this.showMedia = this.message.type !== 'text';
+                    this.showState = messageService.showStatusIcon(this.message as threema.Message, this.receiver);
+                    this.showQuote = this.message.quote !== undefined;
+                    this.showVoipInfo = this.message.type === 'voipStatus';
+
+                    this.access = messageService.getAccess(this.message, this.receiver);
+
+                    this.ack = (ack: boolean) => {
+                        webClientService.ackMessage(this.receiver, this.message, ack);
+                    };
+
+                    this.quote = () => {
+                        // set message as quoted
+                        webClientService.setQuote(this.receiver, this.message);
+                    };
+
+                    this.delete = (ev) => {
+                        const confirm = $mdDialog.confirm()
+                            .title($translate.instant('messenger.CONFIRM_DELETE_TITLE'))
+                            .textContent($translate.instant('common.ARE_YOU_SURE'))
+                            .targetEvent(ev)
+                            .ok($translate.instant('common.YES'))
+                            .cancel($translate.instant('common.CANCEL'));
+                        $mdDialog.show(confirm).then((result) => {
+                            webClientService.deleteMessage(this.receiver, this.message);
+                        }, () => { /* do nothing */});
+                    };
+
+                    this.copyToClipboard = (ev: MouseEvent) => {
+                        // Get copyable text
+                        const text = messageService.getQuoteText(this.message);
+                        if (text === null) {
+                            return;
                         }
-                    } catch (err) {
-                        $log.warn(this.logTag, 'Could not copy text to clipboard:', err);
-                    }
-                    document.body.removeChild(textArea);
-
-                    // Show toast
-                    const toast = $mdToast.simple()
-                        .textContent($translate.instant(toastString))
-                        .position('bottom center');
-                    $mdToast.show(toast);
-                };
 
-                this.download = (ev) => {
-                    this.downloading = true;
-                    webClientService.requestBlob(this.message.id, this.receiver)
-                        .then((blobInfo: threema.BlobInfo) => {
-                            $rootScope.$apply(() => {
+                        // In order to copy the text to the clipboard,
+                        // put it into a temporary textarea element.
+                        const textArea = document.createElement('textarea');
+                        let toastString = 'messenger.COPY_ERROR';
+                        textArea.value = text;
+                        document.body.appendChild(textArea);
+                        textArea.focus();
+                        textArea.select();
+                        try {
+                            const successful = document.execCommand('copy');
+                            if (!successful) {
+                                $log.warn(this.logTag, 'Could not copy text to clipboard');
+                            } else {
+                                toastString = 'messenger.COPIED';
+                            }
+                        } catch (err) {
+                            $log.warn(this.logTag, 'Could not copy text to clipboard:', err);
+                        }
+                        document.body.removeChild(textArea);
+
+                        // Show toast
+                        const toast = $mdToast.simple()
+                            .textContent($translate.instant(toastString))
+                            .position('bottom center');
+                        $mdToast.show(toast);
+                    };
+
+                    this.download = (ev) => {
+                        this.downloading = true;
+                        webClientService.requestBlob(this.message.id, this.receiver)
+                            .then((blobInfo: threema.BlobInfo) => {
+                                $rootScope.$apply(() => {
+                                    this.downloading = false;
+
+                                    switch (this.message.type) {
+                                        case 'image':
+                                        case 'video':
+                                        case 'file':
+                                        case 'audio':
+                                            saveAs(new Blob([blobInfo.buffer]), blobInfo.filename);
+                                            break;
+                                        default:
+                                            $log.warn(this.logTag, 'Ignored download request for message type', this.message.type);
+                                    }
+                                });
+                            })
+                            .catch((error) => {
+                                $log.error(this.logTag, 'Error downloading blob:', error);
                                 this.downloading = false;
-
-                                switch (this.message.type) {
-                                    case 'image':
-                                    case 'video':
-                                    case 'file':
-                                    case 'audio':
-                                        saveAs(new Blob([blobInfo.buffer]), blobInfo.filename);
-                                        break;
-                                    default:
-                                        $log.warn(this.logTag, 'Ignored download request for message type', this.message.type);
-                                }
                             });
-                        })
-                        .catch((error) => {
-                            $log.error(this.logTag, 'Error downloading blob:', error);
-                            this.downloading = false;
+                    };
+
+                    this.isDownloading = () => {
+                        return this.downloading;
+                    };
+
+                    this.showHistory = (ev) => {
+                        const getEvents = () => this.message.events;
+                        $mdDialog.show({
+                            controllerAs: 'ctrl',
+                            controller: function() {
+                                this.getEvents = getEvents;
+                                this.close = () => {
+                                    $mdDialog.hide();
+                                };
+                            },
+                            template: `
+                                <md-dialog class="message-history-dialog" translate-attr="{'aria-label': 'messenger.MSG_HISTORY'}">
+                                    <form ng-cloak>
+                                        <md-toolbar>
+                                            <div class="md-toolbar-tools">
+                                                <h2 translate>messenger.MSG_HISTORY</h2>
+                                            </div>
+                                        </md-toolbar>
+                                        <md-dialog-content>
+                                            <p ng-repeat="event in ctrl.getEvents()">
+                                                <span class="event-type" ng-if="event.type === 'created'" translate>messenger.MSG_HISTORY_CREATED</span>
+                                                <span class="event-type" ng-if="event.type === 'sent'" translate>messenger.MSG_HISTORY_SENT</span>
+                                                <span class="event-type" ng-if="event.type === 'delivered'" translate>messenger.MSG_HISTORY_DELIVERED</span>
+                                                <span class="event-type" ng-if="event.type === 'read'" translate>messenger.MSG_HISTORY_READ</span>
+                                                <span class="event-type" ng-if="event.type === 'acked'" translate>messenger.MSG_HISTORY_ACKED</span>
+                                                <span class="event-type" ng-if="event.type === 'modified'" translate>messenger.MSG_HISTORY_MODIFIED</span>
+                                                {{ event.date | unixToTimestring:true }}
+                                            </p>
+                                        </md-dialog-content>
+                                        <md-dialog-actions layout="row" >
+                                            <md-button ng-click="ctrl.close()">
+                                                <span translate>common.OK</span>
+                                            </md-button>
+                                        </md-dialog-actions>
+                                    </form>
+                                </md-dialog>
+                            `,
+                            parent: angular.element(document.body),
+                            targetEvent: ev,
+                            clickOutsideToClose: true,
                         });
-                };
-
-                this.isDownloading = () => {
-                    return this.downloading;
-                };
-
-                this.showHistory = (ev) => {
-                    const getEvents = () => this.message.events;
-                    $mdDialog.show({
-                        controllerAs: 'ctrl',
-                        controller: function() {
-                            this.getEvents = getEvents;
-                            this.close = () => {
-                                $mdDialog.hide();
-                            };
-                        },
-                        template: `
-                            <md-dialog class="message-history-dialog" translate-attr="{'aria-label': 'messenger.MSG_HISTORY'}">
-                                <form ng-cloak>
-                                    <md-toolbar>
-                                        <div class="md-toolbar-tools">
-                                            <h2 translate>messenger.MSG_HISTORY</h2>
-                                        </div>
-                                    </md-toolbar>
-                                    <md-dialog-content>
-                                        <p ng-repeat="event in ctrl.getEvents()">
-                                            <span class="event-type" ng-if="event.type === 'created'" translate>messenger.MSG_HISTORY_CREATED</span>
-                                            <span class="event-type" ng-if="event.type === 'sent'" translate>messenger.MSG_HISTORY_SENT</span>
-                                            <span class="event-type" ng-if="event.type === 'delivered'" translate>messenger.MSG_HISTORY_DELIVERED</span>
-                                            <span class="event-type" ng-if="event.type === 'read'" translate>messenger.MSG_HISTORY_READ</span>
-                                            <span class="event-type" ng-if="event.type === 'acked'" translate>messenger.MSG_HISTORY_ACKED</span>
-                                            <span class="event-type" ng-if="event.type === 'modified'" translate>messenger.MSG_HISTORY_MODIFIED</span>
-                                            {{ event.date | unixToTimestring:true }}
-                                        </p>
-                                    </md-dialog-content>
-                                    <md-dialog-actions layout="row" >
-                                        <md-button ng-click="ctrl.close()">
-                                            <span translate>common.OK</span>
-                                        </md-button>
-                                    </md-dialog-actions>
-                                </form>
-                            </md-dialog>
-                        `,
-                        parent: angular.element(document.body),
-                        targetEvent: ev,
-                        clickOutsideToClose: true,
-                    });
+                    };
                 };
             }],
             link: function(scope: any, element: ng.IAugmentedJQuery, attrs) {

+ 6 - 2
src/directives/message_icon.ts

@@ -47,10 +47,14 @@ export default [
                             return null;
                     }
                 };
-                this.icon = getIcon(this.message.type);
+
+                this.$onInit = function() {
+                    this.icon = getIcon(this.message.type);
+                    this.altText = this.message.type + ' icon';
+                };
             }],
             template: `
-                <img ng-if="ctrl.icon !== null" ng-src="img/{{ ctrl.icon }}" alt="{{ ctrl.message.type }} icon" />
+                <img ng-if="ctrl.icon !== null" ng-src="img/{{ ctrl.icon }}" alt="{{ ctrl.altText }}" />
             `,
         };
     },

+ 205 - 197
src/directives/message_media.ts

@@ -20,6 +20,44 @@ import {MediaboxService} from '../services/mediabox';
 import {MessageService} from '../services/message';
 import {WebClientService} from '../services/webclient';
 
+function showAudioDialog(
+    $mdDialog: ng.material.IDialogService,
+    $log: ng.ILogService,
+    blobInfo: threema.BlobInfo,
+): void {
+    $mdDialog.show({
+        controllerAs: 'ctrl',
+        controller: function() {
+            this.cancel = () => $mdDialog.cancel();
+            this.audioSrc = bufferToUrl(
+                blobInfo.buffer,
+                blobInfo.mimetype,
+                logAdapter($log.warn, '[AudioPlayerDialog]'),
+            );
+        },
+        template: `
+            <md-dialog translate-attr="{'aria-label': 'messageTypes.AUDIO_MESSAGE'}">
+                    <md-toolbar>
+                        <div class="md-toolbar-tools">
+                            <h2 translate>messageTypes.AUDIO_MESSAGE</h2>
+                            </div>
+                    </md-toolbar>
+                    <md-dialog-content layout="row" layout-align="center">
+                        <audio controls autoplay ng-src="{{ ctrl.audioSrc | unsafeResUrl }}">
+                            Your browser does not support the <code>audio</code> element.
+                        </audio>
+                    </md-dialog-content>
+                    <md-dialog-actions layout="row" >
+                      <md-button ng-click="ctrl.cancel()">
+                         <span translate>common.OK</span>
+                      </md-button>
+                    </md-dialog-actions>
+            </md-dialog>`,
+        parent: angular.element(document.body),
+        clickOutsideToClose: true,
+    });
+}
+
 export default [
     'WebClientService',
     'MediaboxService',
@@ -53,224 +91,194 @@ export default [
             controller: [function() {
                 this.logTag = '[MessageMedia]';
 
-                this.type = this.message.type;
+                this.$onInit = function() {
+                    this.type = this.message.type;
 
-                // Downloading
-                this.downloading = false;
-                this.thumbnailDownloading = false;
-                this.downloaded = false;
+                    // Downloading
+                    this.downloading = false;
+                    this.thumbnailDownloading = false;
+                    this.downloaded = false;
 
-                // Uploading
-                this.uploading = this.message.temporaryId !== undefined
-                    && this.message.temporaryId !== null;
+                    // Uploading
+                    this.uploading = this.message.temporaryId !== undefined
+                        && this.message.temporaryId !== null;
 
-                // AnimGIF detection
-                this.isAnimGif = !this.uploading
-                    && (this.message as threema.Message).type === 'file'
-                    && (this.message as threema.Message).file.type === 'image/gif';
+                    // AnimGIF detection
+                    this.isAnimGif = !this.uploading
+                        && (this.message as threema.Message).type === 'file'
+                        && (this.message as threema.Message).file.type === 'image/gif';
 
-                // Preview thumbnail
-                let thumbnailPreviewUri = null;
-                this.getThumbnailPreviewUri = () => {
-                    // Cache thumbnail preview URI
-                    if (thumbnailPreviewUri === null && hasValue(this.message) && hasValue(this.message.thumbnail)) {
-                        thumbnailPreviewUri = bufferToUrl(
-                            (this.message as threema.Message).thumbnail.preview,
-                            webClientService.appCapabilities.imageFormat.thumbnail,
-                            logAdapter($log.warn, this.logTag),
-                        );
-                    }
-                    return thumbnailPreviewUri;
-                };
+                    // Preview thumbnail
+                    let thumbnailPreviewUri = null;
+                    this.getThumbnailPreviewUri = () => {
+                        // Cache thumbnail preview URI
+                        if (thumbnailPreviewUri === null
+                                && hasValue(this.message) && hasValue(this.message.thumbnail)) {
+                            thumbnailPreviewUri = bufferToUrl(
+                                (this.message as threema.Message).thumbnail.preview,
+                                webClientService.appCapabilities.imageFormat.thumbnail,
+                                logAdapter($log.warn, this.logTag),
+                            );
+                        }
+                        return thumbnailPreviewUri;
+                    };
 
-                // Thumbnail loading
-                //
-                // Do not show thumbnail in file messages (except anim gif).
-                // If a thumbnail in file messages are available, the thumbnail
-                // will be shown in the file circle
-                this.showThumbnail = this.message.thumbnail !== undefined
-                    && ((this.message as threema.Message).type !== 'file' || this.isAnimGif);
-                this.thumbnail = null;
-                this.thumbnailFormat = webClientService.appCapabilities.imageFormat.thumbnail;
-                if (this.message.thumbnail !== undefined) {
-                    this.thumbnailStyle = {
-                        width: this.message.thumbnail.width + 'px',
-                        height: this.message.thumbnail.height + 'px' };
-                }
+                    // Thumbnail loading
+                    //
+                    // Do not show thumbnail in file messages (except anim gif).
+                    // If a thumbnail in file messages are available, the thumbnail
+                    // will be shown in the file circle
+                    this.showThumbnail = this.message.thumbnail !== undefined
+                        && ((this.message as threema.Message).type !== 'file' || this.isAnimGif);
+                    this.thumbnail = null;
+                    this.thumbnailFormat = webClientService.appCapabilities.imageFormat.thumbnail;
+                    if (this.message.thumbnail !== undefined) {
+                        this.thumbnailStyle = {
+                            width: this.message.thumbnail.width + 'px',
+                            height: this.message.thumbnail.height + 'px' };
+                    }
 
-                let loadingThumbnailTimeout = null;
+                    let loadingThumbnailTimeout = null;
 
-                this.wasInView = false;
-                this.thumbnailInView = (inView: boolean) => {
-                    if (this.message.thumbnail === undefined
-                            || this.wasInView === inView) {
-                        // do nothing
-                        return;
-                    }
-                    this.wasInView = inView;
+                    this.wasInView = false;
+                    this.thumbnailInView = (inView: boolean) => {
+                        if (this.message.thumbnail === undefined
+                                || this.wasInView === inView) {
+                            // do nothing
+                            return;
+                        }
+                        this.wasInView = inView;
 
-                    if (!inView) {
-                        $timeout.cancel(loadingThumbnailTimeout);
-                        this.thumbnailDownloading = false;
-                        this.thumbnail = null;
-                    } else {
-                        if (this.thumbnail === null) {
-                            const setThumbnail = (buf: ArrayBuffer) => {
-                                this.thumbnail = bufferToUrl(
-                                    buf,
-                                    webClientService.appCapabilities.imageFormat.thumbnail,
-                                    logAdapter($log.warn, this.logTag),
-                                );
-                            };
+                        if (!inView) {
+                            $timeout.cancel(loadingThumbnailTimeout);
+                            this.thumbnailDownloading = false;
+                            this.thumbnail = null;
+                        } else {
+                            if (this.thumbnail === null) {
+                                const setThumbnail = (buf: ArrayBuffer) => {
+                                    this.thumbnail = bufferToUrl(
+                                        buf,
+                                        webClientService.appCapabilities.imageFormat.thumbnail,
+                                        logAdapter($log.warn, this.logTag),
+                                    );
+                                };
 
-                            if (this.message.thumbnail.img !== undefined) {
-                                setThumbnail(this.message.thumbnail.img);
-                                return;
-                            } else {
-                                this.thumbnailDownloading = true;
-                                loadingThumbnailTimeout = $timeout(() => {
-                                    webClientService
-                                        .requestThumbnail(this.receiver, this.message)
-                                        .then((img) => $timeout(() => {
-                                            setThumbnail(img);
-                                            this.thumbnailDownloading = false;
-                                        }));
-                                }, 1000);
+                                if (this.message.thumbnail.img !== undefined) {
+                                    setThumbnail(this.message.thumbnail.img);
+                                    return;
+                                } else {
+                                    this.thumbnailDownloading = true;
+                                    loadingThumbnailTimeout = $timeout(() => {
+                                        webClientService
+                                            .requestThumbnail(this.receiver, this.message)
+                                            .then((img) => $timeout(() => {
+                                                setThumbnail(img);
+                                                this.thumbnailDownloading = false;
+                                            }));
+                                    }, 1000);
+                                }
                             }
                         }
+                    };
+
+                    // For locations, retrieve the coordinates
+                    this.location = null;
+                    if (this.message.location !== undefined) {
+                        this.location = this.message.location;
+                        this.downloaded = true;
                     }
-                };
 
-                // For locations, retrieve the coordinates
-                this.location = null;
-                if (this.message.location !== undefined) {
-                    this.location = this.message.location;
-                    this.downloaded = true;
-                }
+                    // Open map link in new window using mapLink-filter
+                    this.openMapLink = () => {
+                        $window.open($filter<any>('mapLink')(this.location), '_blank');
+                    };
 
-                // Open map link in new window using mapLink-filter
-                this.openMapLink = () => {
-                    $window.open($filter<any>('mapLink')(this.location), '_blank');
-                };
+                    // Play a Audio file in a dialog
+                    this.playAudio = (blobInfo: threema.BlobInfo) => showAudioDialog($mdDialog, $log, blobInfo);
 
-                // Play a Audio file in a dialog
-                this.playAudio = (blobInfo: threema.BlobInfo) => {
-                    $mdDialog.show({
-                        controllerAs: 'ctrl',
-                        controller: function() {
-                            this.blobBuffer = blobInfo.buffer;
-                            this.mimeType = blobInfo.mimetype;
-                            this.cancel = () => {
-                                $mdDialog.cancel();
-                            };
-                        },
-                        template: `
-                            <md-dialog translate-attr="{'aria-label': 'messageTypes.AUDIO_MESSAGE'}">
-                                    <md-toolbar>
-                                        <div class="md-toolbar-tools">
-                                            <h2 translate>messageTypes.AUDIO_MESSAGE</h2>
-                                            </div>
-                                    </md-toolbar>
-                                    <md-dialog-content layout="row" layout-align="center">
-                                        <audio
-                                            controls
-                                            autoplay ng-src="{{ ctrl.blobBuffer | bufferToUrl:ctrl.mimeType }}">
-                                            Your browser does not support the <code>audio</code> element.
-                                        </audio>
-                                    </md-dialog-content>
-                                    <md-dialog-actions layout="row" >
-                                      <md-button ng-click="ctrl.cancel()">
-                                         <span translate>common.OK</span>
-                                      </md-button>
-                                    </md-dialog-actions>
-                            </md-dialog>`,
-                        parent: angular.element(document.body),
-                        clickOutsideToClose: true,
-                    });
-                };
-
-                // Download function
-                this.download = () => {
-                    $log.debug(this.logTag, 'Download blob');
-                    if (this.downloading) {
-                        $log.debug(this.logTag, 'Download already in progress...');
-                        return;
-                    }
-                    const message: threema.Message = this.message;
-                    const receiver: threema.Receiver = this.receiver;
-                    this.downloading = true;
-                    webClientService.requestBlob(message.id, receiver)
-                        .then((blobInfo: threema.BlobInfo) => {
-                            $rootScope.$apply(() => {
-                                $log.debug(this.logTag, 'Blob loaded');
-                                this.downloading = false;
-                                this.downloaded = true;
+                    // Download function
+                    this.download = () => {
+                        $log.debug(this.logTag, 'Download blob');
+                        if (this.downloading) {
+                            $log.debug(this.logTag, 'Download already in progress...');
+                            return;
+                        }
+                        const message: threema.Message = this.message;
+                        const receiver: threema.Receiver = this.receiver;
+                        this.downloading = true;
+                        webClientService.requestBlob(message.id, receiver)
+                            .then((blobInfo: threema.BlobInfo) => {
+                                $rootScope.$apply(() => {
+                                    $log.debug(this.logTag, 'Blob loaded');
+                                    this.downloading = false;
+                                    this.downloaded = true;
 
-                                switch (this.message.type) {
-                                    case 'image':
-                                        const caption = message.caption || '';
-                                        mediaboxService.setMedia(
-                                            blobInfo.buffer,
-                                            blobInfo.filename,
-                                            blobInfo.mimetype,
-                                            caption,
-                                        );
-                                        break;
-                                    case 'video':
-                                        saveAs(new Blob([blobInfo.buffer]), blobInfo.filename);
-                                        break;
-                                    case 'file':
-                                        if (this.message.file.type === 'image/gif') {
-                                            // Show inline
-                                            this.blobBufferUrl = bufferToUrl(
+                                    switch (this.message.type) {
+                                        case 'image':
+                                            const caption = message.caption || '';
+                                            mediaboxService.setMedia(
                                                 blobInfo.buffer,
-                                                'image/gif',
-                                                logAdapter($log.warn, this.logTag),
+                                                blobInfo.filename,
+                                                blobInfo.mimetype,
+                                                caption,
                                             );
-                                            // Hide thumbnail
-                                            this.showThumbnail = false;
-                                        } else {
+                                            break;
+                                        case 'video':
                                             saveAs(new Blob([blobInfo.buffer]), blobInfo.filename);
-                                        }
-                                        break;
-                                    case 'audio':
-                                        // Show inline
-                                        this.playAudio(blobInfo);
-                                        break;
-                                    default:
-                                        $log.warn(this.logTag,
-                                            'Ignored download request for message type', this.message.type);
-                                }
-                            });
-                        })
-                        .catch((error) => {
-                            $rootScope.$apply(() => {
-                                this.downloading = false;
-                                let contentString;
-                                switch (error) {
-                                    case 'blobDownloadFailed':
-                                        contentString = 'error.BLOB_DOWNLOAD_FAILED';
-                                        break;
-                                    case 'blobDecryptFailed':
-                                        contentString = 'error.BLOB_DECRYPT_FAILED';
-                                        break;
-                                    default:
-                                        contentString = 'error.ERROR_OCCURRED';
-                                        break;
-                                }
-                                const confirm = $mdDialog.alert()
-                                    .title($translate.instant('common.ERROR'))
-                                    .textContent($translate.instant(contentString))
-                                    .ok($translate.instant('common.OK'));
-                                $mdDialog.show(confirm);
+                                            break;
+                                        case 'file':
+                                            if (this.message.file.type === 'image/gif') {
+                                                // Show inline
+                                                this.blobBufferUrl = bufferToUrl(
+                                                    blobInfo.buffer,
+                                                    'image/gif',
+                                                    logAdapter($log.warn, this.logTag),
+                                                );
+                                                // Hide thumbnail
+                                                this.showThumbnail = false;
+                                            } else {
+                                                saveAs(new Blob([blobInfo.buffer]), blobInfo.filename);
+                                            }
+                                            break;
+                                        case 'audio':
+                                            // Show inline
+                                            this.playAudio(blobInfo);
+                                            break;
+                                        default:
+                                            $log.warn(this.logTag,
+                                                'Ignored download request for message type', this.message.type);
+                                    }
+                                });
+                            })
+                            .catch((error) => {
+                                $rootScope.$apply(() => {
+                                    this.downloading = false;
+                                    let contentString;
+                                    switch (error) {
+                                        case 'blobDownloadFailed':
+                                            contentString = 'error.BLOB_DOWNLOAD_FAILED';
+                                            break;
+                                        case 'blobDecryptFailed':
+                                            contentString = 'error.BLOB_DECRYPT_FAILED';
+                                            break;
+                                        default:
+                                            contentString = 'error.ERROR_OCCURRED';
+                                            break;
+                                    }
+                                    const confirm = $mdDialog.alert()
+                                        .title($translate.instant('common.ERROR'))
+                                        .textContent($translate.instant(contentString))
+                                        .ok($translate.instant('common.OK'));
+                                    $mdDialog.show(confirm);
+                                });
                             });
-                        });
-                };
+                    };
 
-                this.isDownloading = () => {
-                    return this.downloading
-                        || this.thumbnailDownloading
-                        || (this.showDownloading && this.showDownloading());
+                    this.isDownloading = () => {
+                        return this.downloading
+                            || this.thumbnailDownloading
+                            || (this.showDownloading && this.showDownloading());
+                    };
                 };
             }],
             templateUrl: 'directives/message_media.html',

+ 1 - 1
src/directives/message_menu.html

@@ -1,6 +1,6 @@
 <!-- Non status messages -->
 <md-menu md-position-mode="target-right target" md-offset="0 45">
-    <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdOpenMenu($event)">
+    <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdMenu.open($event)">
         <i class="material-icons md-dark md-24">more_vert</i>
     </md-button>
     <md-menu-content width="1">

+ 14 - 12
src/directives/message_meta.ts

@@ -26,20 +26,22 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                const msg = this.message as threema.Message;
+                this.$onInit = function() {
+                    const msg = this.message as threema.Message;
 
-                this.type = msg.type;
-                this.isGif = msg.file !== undefined && msg.file.type === 'image/gif';
+                    this.type = msg.type;
+                    this.isGif = msg.file !== undefined && msg.file.type === 'image/gif';
 
-                // For audio, video or voip call, retrieve the duration
-                this.duration = null;
-                if (this.message.audio !== undefined) {
-                    this.duration = this.message.audio.duration;
-                } else if (this.message.video !== undefined) {
-                    this.duration = this.message.video.duration;
-                } else if (this.message.voip !== undefined && this.message.voip.duration) {
-                    this.duration = this.message.voip.duration;
-                }
+                    // For audio, video or voip call, retrieve the duration
+                    this.duration = null;
+                    if (msg.audio !== undefined) {
+                        this.duration = msg.audio.duration;
+                    } else if (msg.video !== undefined) {
+                        this.duration = msg.video.duration;
+                    } else if (msg.voip !== undefined && msg.voip.duration) {
+                        this.duration = msg.voip.duration;
+                    }
+                };
             }],
             template: `
                 <span ng-if="ctrl.isGif" class="message-meta-item">GIF</span>

+ 22 - 26
src/directives/message_text.ts

@@ -31,35 +31,31 @@ export default [
             controllerAs: 'ctrl',
             controller: ['WebClientService', function(webClientService: WebClientService) {
                 // Get text depending on type
-                let rawText = null;
-                const message = this.message as threema.Message;
-                switch (message.type) {
-                    case 'text':
-                        rawText = message.body;
-                        break;
-                    case 'location':
-                        rawText = message.location.description;
-                        break;
-                    case 'file':
-                        // Prefer caption for file messages, if available
-                        if (message.caption && message.caption.length > 0) {
-                            rawText = message.caption;
-                        } else {
-                            rawText = message.file.name;
-                        }
-                        break;
-                    default:
-                        rawText = message.caption;
-                        break;
-                }
-
-                // Escaping will be done in the HTML using filters
-                this.text = rawText;
-                if (this.multiLine === undefined) {
-                    this.multiLine = true;
+                function getText(message: threema.Message) {
+                    switch (message.type) {
+                        case 'text':
+                            return message.body;
+                        case 'location':
+                            return message.location.description;
+                        case 'file':
+                            // Prefer caption for file messages, if available
+                            if (message.caption && message.caption.length > 0) {
+                                return message.caption;
+                            }
+                            return message.file.name;
+                    }
+                    return message.caption;
                 }
 
                 this.enlargeSingleEmoji = webClientService.appConfig.largeSingleEmoji;
+
+                this.$onInit = function() {
+                    // Escaping will be done in the HTML using filters
+                    this.text = getText(this.message);
+                    if (this.multiLine === undefined) {
+                        this.multiLine = true;
+                    }
+                };
             }],
             template: `
                 <span click-action

+ 7 - 5
src/directives/message_voip_status.ts

@@ -27,11 +27,13 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.status = this.message.voip.status;
-                this.duration = this.message.voip.duration;
-                this.reason = this.message.voip.reason;
-                this.incoming = !this.message.isOutbox;
-                this.outgoing = this.message.isOutbox;
+                this.$onInit = function() {
+                    this.status = this.message.voip.status;
+                    this.duration = this.message.voip.duration;
+                    this.reason = this.message.voip.reason;
+                    this.incoming = !this.message.isOutbox;
+                    this.outgoing = this.message.isOutbox;
+                };
             }],
             template: `
                 <p ng-if="ctrl.status === 1">

+ 34 - 31
src/directives/verification_level.ts

@@ -26,40 +26,43 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                const contact: threema.ContactReceiver = this.contact;
+                this.$onInit = function() {
+                    const contact: threema.ContactReceiver = this.contact;
 
-                let label;
-                switch (contact.verificationLevel) {
-                    case 1:
-                        this.cls = 'level1';
-                        label = 'VERIFICATION_LEVEL1_EXPLAIN';
-                        break;
-                    case 2:
-                        this.cls = 'level2';
-                        if (contact.isWork) {
-                            label = 'VERIFICATION_LEVEL2_WORK_EXPLAIN';
-                        } else {
-                            label = 'VERIFICATION_LEVEL2_EXPLAIN';
-                        }
-                        break;
-                    case 3:
-                        this.cls = 'level3';
-                        label = 'VERIFICATION_LEVEL3_EXPLAIN';
-                        break;
-                    default:
-                        /* ignore, handled on next line */
-                }
+                    let label;
+                    switch (contact.verificationLevel) {
+                        case 1:
+                            this.cls = 'level1';
+                            label = 'VERIFICATION_LEVEL1_EXPLAIN';
+                            break;
+                        case 2:
+                            this.cls = 'level2';
+                            if (contact.isWork) {
+                                label = 'VERIFICATION_LEVEL2_WORK_EXPLAIN';
+                            } else {
+                                label = 'VERIFICATION_LEVEL2_EXPLAIN';
+                            }
+                            break;
+                        case 3:
+                            this.cls = 'level3';
+                            label = 'VERIFICATION_LEVEL3_EXPLAIN';
+                            break;
+                        default:
+                            /* ignore, handled on next line */
+                    }
 
-                if (label === undefined) {
-                    $log.error('invalid verification level', this.level);
-                    return;
-                }
+                    if (label === undefined) {
+                        $log.error('invalid verification level', this.level);
+                        return;
+                    }
 
-                if (contact.isWork) {
-                    // append work class
-                    this.cls += ' work';
-                }
-                this.description = $translate.instant('messenger.' + label);
+                    if (contact.isWork) {
+                        // append work class
+                        this.cls += ' work';
+                    }
+
+                    this.description = $translate.instant('messenger.' + label);
+                };
             }],
             template: `
                 <span class="verification-dots {{ctrl.cls}}" title="{{ctrl.description}}">

+ 11 - 2
src/filters.ts

@@ -84,8 +84,8 @@ angular.module('3ema.filters', [])
         email: true,
         // Don't link phone numbers (doesn't work reliably)
         phone: false,
-        // Don't link twitter handles
-        twitter: false,
+        // Don't link mentions
+        mention: false,
         // Don't link hashtags
         hashtag: false,
     });
@@ -266,6 +266,7 @@ angular.module('3ema.filters', [])
         }
     };
 }])
+
 .filter('mapLink', function() {
     return function(location: threema.LocationInfo) {
         return 'https://www.openstreetmap.org/?mlat='
@@ -273,6 +274,7 @@ angular.module('3ema.filters', [])
             + location.lon;
     };
 })
+
 /**
  * Convert message state to material icon class.
  */
@@ -483,4 +485,11 @@ angular.module('3ema.filters', [])
     };
 }])
 
+/**
+ * Mark data as trusted.
+ */
+.filter('unsafeResUrl', ['$sce', function($sce: ng.ISCEService) {
+    return $sce.trustAsResourceUrl;
+}])
+
 ;

+ 7 - 0
src/helpers.ts

@@ -331,3 +331,10 @@ export function logAdapter(logFunc: (...msg: string[]) => void, logTag: string):
 export function hasValue(val: any): boolean {
     return val !== null && val !== undefined;
 }
+
+/**
+ * Awaitable timeout function.
+ */
+export function sleep(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+}

+ 36 - 0
src/helpers/crypto.ts

@@ -0,0 +1,36 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+// This file contains helper functions related to crypto.
+// Try to keep all functions pure!
+
+import {u8aToHex} from '../helpers';
+
+/**
+ * Calculate the SHA256 hash of the specified bytes.
+ * Throw an Error if the SubtleCrypto API is not available.
+ */
+export async function sha256(bytes: ArrayBuffer): Promise<string> {
+    if (window.crypto === undefined) {
+        throw new Error('window.crypto API not available');
+    }
+    if (window.crypto.subtle === undefined) {
+        throw new Error('window.subtle API not available');
+    }
+    const buf: ArrayBuffer = await crypto.subtle.digest('SHA-256', bytes);
+    return u8aToHex(new Uint8Array(buf));
+}

+ 11 - 2
src/types/js-sha256.d.ts → src/helpers/messages.ts

@@ -15,5 +15,14 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-declare function sha224(val: string | Array<any> | Uint8Array | ArrayBuffer): string;
-declare function sha256(val: string | Array<any> | Uint8Array | ArrayBuffer): string;
+// This file contains helper functions related to messages.
+// Try to keep all functions pure!
+
+/**
+ * Return the sending identity of a message.
+ */
+export function getSenderIdentity(message: threema.Message, myId: string): string | null {
+    if (message.isOutbox) { return myId; }
+    if (message.partnerId != null) { return message.partnerId; }
+    return null;
+}

+ 3 - 3
src/partials/messenger.navigation.html

@@ -9,7 +9,7 @@
     <battery-status></battery-status>
 
     <md-menu md-position-mode="target-right target" md-offset="0 45">
-        <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdOpenMenu($event)">
+        <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdMenu.open($event)">
             <i class="material-icons md-light md-24">more_vert</i>
         </md-button>
         <md-menu-content width="4">
@@ -45,10 +45,10 @@
 <div id="navigation-header">
     <div class="main">
         <md-nav-bar md-no-ink md-selected-nav-item="ctrl.activeTab" nav-bar-aria-label="navigation links">
-            <md-nav-item md-nav-click="" name="conversations">
+            <md-nav-item md-nav-click="1" name="conversations">
                 <i class="material-icons md-dark md-24" translate translate-attr-title="messenger.CONVERSATIONS">speaker_notes</i>
             </md-nav-item>
-            <md-nav-item md-nav-click="" name="contacts">
+            <md-nav-item md-nav-click="1" name="contacts">
                 <i class="material-icons md-dark md-24" translate translate-attr-title="messenger.CONTACTS">person</i>
             </md-nav-item>
         </md-nav-bar>

+ 2 - 2
src/partials/messenger.receiver/contact.html

@@ -25,7 +25,7 @@
 				</dd>
 
 				<dt><span translate>messenger.KEY_FINGERPRINT</span></dt>
-				<dd>{{ ctrl.fingerPrint }}</dd>
+				<dd>{{ ctrl.fingerPrint.value || "Loading..." }}</dd>
 
 				<dt><span translate>messenger.NICKNAME</span></dt>
 				<dd ng-if="ctrl.receiver.publicNickname" ng-bind-html="ctrl.receiver.publicNickname | emojify"></dd>
@@ -101,7 +101,7 @@
 		<md-card-content>
 			<ul class="group-list">
 				<li ng-repeat="distributionListReceiver in ctrl.inDistributionLists">
-					<eee-distribution-list-badge eee-distribution-list-receiver="distributionListReceiver" eee-contact-receiver="ctrl.receiver"/>
+					<eee-distribution-list-badge eee-distribution-list-receiver="distributionListReceiver"/>
 				</li>
 			</ul>
 		</md-card-content>

+ 1 - 1
src/partials/messenger.receiver/me.html

@@ -14,7 +14,7 @@
 					</eee-verification-level></dd>
 
 				<dt><span translate>messenger.KEY_FINGERPRINT</span></dt>
-				<dd>{{ ctrl.fingerPrint }}</dd>
+				<dd>{{ ctrl.fingerPrint.value || "Loading..." }}</dd>
 
 				<dt><span translate>messenger.MY_PUBLIC_NICKNAME</span></dt>
 				<dd>{{ ctrl.controllerModel.nickname || "-" }}</dd>

+ 65 - 23
src/partials/messenger.ts

@@ -15,6 +15,14 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {
+    StateParams as UiStateParams,
+    StateProvider as UiStateProvider,
+    StateService as UiStateService,
+    Transition as UiTransition,
+    TransitionService as UiTransitionService,
+} from '@uirouter/angularjs';
+
 import {ContactControllerModel} from '../controller_model/contact';
 import {bufferToUrl, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
 import {ContactService} from '../services/contact';
@@ -184,6 +192,12 @@ class SettingsController {
 
 }
 
+interface ConversationStateParams extends UiStateParams {
+    type: threema.ReceiverType;
+    id: string;
+    initParams: null | {text: string | null};
+}
+
 class ConversationController {
     public name = 'navigation';
     private logTag: string = '[ConversationController]';
@@ -191,7 +205,7 @@ class ConversationController {
     // Angular services
     private $stateParams;
     private $timeout: ng.ITimeoutService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
     private $log: ng.ILogService;
     private $scope: ng.IScope;
     private $rootScope: ng.IRootScopeService;
@@ -254,13 +268,13 @@ class ConversationController {
     };
 
     public static $inject = [
-        '$stateParams', '$state', '$timeout', '$log', '$scope', '$rootScope',
+        '$stateParams', '$timeout', '$log', '$scope', '$rootScope',
         '$mdDialog', '$mdToast', '$translate', '$filter',
+        '$state', '$transitions',
         'WebClientService', 'StateService', 'ReceiverService', 'MimeService', 'VersionService',
         'ControllerModelService',
     ];
-    constructor($stateParams: threema.ConversationStateParams,
-                $state: ng.ui.IStateService,
+    constructor($stateParams: ConversationStateParams,
                 $timeout: ng.ITimeoutService,
                 $log: ng.ILogService,
                 $scope: ng.IScope,
@@ -269,6 +283,8 @@ class ConversationController {
                 $mdToast: ng.material.IToastService,
                 $translate: ng.translate.ITranslateService,
                 $filter: ng.IFilterService,
+                $state: UiStateService,
+                $transitions: UiTransitionService,
                 webClientService: WebClientService,
                 stateService: StateService,
                 receiverService: ReceiverService,
@@ -298,10 +314,11 @@ class ConversationController {
         this.maxTextLength = this.webClientService.getMaxTextLength();
         this.allText = this.$translate.instant('messenger.ALL');
 
-        // On every navigation event, close all dialogs.
-        // Note: Deprecated. When migrating ui-router ($state),
-        // replace with transition hooks.
-        $rootScope.$on('$stateChangeStart', () => this.$mdDialog.cancel());
+        // On every navigation event, close all dialogs using ui-router transition hooks.
+        $transitions.onStart({}, function(trans: UiTransition) {
+            const $mdDialogInner: ng.material.IDialogService = trans.injector().get('$mdDialog');
+            $mdDialogInner.cancel();
+        });
 
         // Check for version updates
         versionService.checkForUpdate();
@@ -838,14 +855,14 @@ class NavigationController {
 
     private $mdDialog;
     private $translate: ng.translate.ITranslateService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
 
     public static $inject = [
         '$log', '$state', '$mdDialog', '$translate',
         'WebClientService', 'StateService', 'ReceiverService', 'TrustedKeyStore',
     ];
 
-    constructor($log: ng.ILogService, $state: ng.ui.IStateService,
+    constructor($log: ng.ILogService, $state: UiStateService,
                 $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, stateService: StateService,
                 receiverService: ReceiverService,
@@ -1135,7 +1152,8 @@ class ReceiverDetailController {
 
     // Angular services
     private $mdDialog: any;
-    private $state: ng.ui.IStateService;
+    private $scope: ng.IScope;
+    private $state: UiStateService;
 
     // Own services
     private fingerPrintService: FingerPrintService;
@@ -1145,7 +1163,7 @@ class ReceiverDetailController {
     public receiver: threema.Receiver;
     public me: threema.MeReceiver;
     public title: string;
-    public fingerPrint?: string;
+    public fingerPrint = { value: null };  // Object, so that data binding works
     private showGroups = false;
     private showDistributionLists = false;
     private inGroups: threema.GroupReceiver[] = [];
@@ -1158,15 +1176,16 @@ class ReceiverDetailController {
     private controllerModel: threema.ControllerModel<threema.Receiver>;
 
     public static $inject = [
-        '$log', '$stateParams', '$state', '$mdDialog', '$translate',
+        '$scope', '$log', '$stateParams', '$state', '$mdDialog', '$translate',
         'WebClientService', 'FingerPrintService', 'ContactService', 'ControllerModelService',
     ];
-    constructor($log: ng.ILogService, $stateParams, $state: ng.ui.IStateService,
+    constructor($scope: ng.IScope, $log: ng.ILogService, $stateParams, $state: UiStateService,
                 $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, fingerPrintService: FingerPrintService,
                 contactService: ContactService, controllerModelService: ControllerModelService) {
 
         this.$mdDialog = $mdDialog;
+        this.$scope = $scope;
         this.$state = $state;
         this.fingerPrintService = fingerPrintService;
         this.contactService = contactService;
@@ -1189,7 +1208,10 @@ class ReceiverDetailController {
                 });
 
             this.isWorkReceiver = contactReceiver.identityType === threema.IdentityType.Work;
-            this.fingerPrint = this.fingerPrintService.generate(contactReceiver.publicKey);
+
+            this.fingerPrintService
+                .generate(contactReceiver.publicKey)
+                .then(this.setFingerPrint.bind(this));
 
             webClientService.groups.forEach((groupReceiver: threema.GroupReceiver) => {
                 // check if my identity is a member
@@ -1215,12 +1237,16 @@ class ReceiverDetailController {
         switch (this.receiver.type) {
             case 'me':
                 const meReceiver = this.receiver as threema.MeReceiver;
-                this.fingerPrint = this.fingerPrintService.generate(meReceiver.publicKey);
+                this.fingerPrintService
+                    .generate(meReceiver.publicKey)
+                    .then(this.setFingerPrint.bind(this));
                 this.controllerModel = controllerModelService.me(meReceiver, ControllerModelMode.VIEW);
                 break;
             case 'contact':
                 const contactReceiver = this.receiver as threema.ContactReceiver;
-                this.fingerPrint = this.fingerPrintService.generate(contactReceiver.publicKey);
+                this.fingerPrintService
+                    .generate(contactReceiver.publicKey)
+                    .then(this.setFingerPrint.bind(this));
                 this.controllerModel = controllerModelService
                     .contact(contactReceiver, ControllerModelMode.VIEW);
                 break;
@@ -1247,6 +1273,17 @@ class ReceiverDetailController {
 
     }
 
+    /**
+     * Set the fingerprint value and run $digest.
+     *
+     * This may only be called from outside the $digest loop
+     * (e.g. from a plain promise callback).
+     */
+    private setFingerPrint(fingerPrint: string): void {
+        this.fingerPrint.value = fingerPrint;
+        this.$scope.$digest();
+    }
+
     public chat(): void {
         this.$state.go('messenger.home.conversation', {
             type: this.receiver.type,
@@ -1309,7 +1346,7 @@ class ReceiverEditController {
     private logTag: string = '[ReceiverEditController]';
 
     public $mdDialog: any;
-    public $state: ng.ui.IStateService;
+    public $state: UiStateService;
     private $translate: ng.translate.ITranslateService;
 
     public title: string;
@@ -1324,7 +1361,7 @@ class ReceiverEditController {
         '$log', '$stateParams', '$state', '$mdDialog',
         '$timeout', '$translate', 'WebClientService', 'ControllerModelService',
     ];
-    constructor($log: ng.ILogService, $stateParams, $state: ng.ui.IStateService,
+    constructor($log: ng.ILogService, $stateParams, $state: UiStateService,
                 $mdDialog, $timeout: ng.ITimeoutService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, controllerModelService: ControllerModelService) {
 
@@ -1413,6 +1450,11 @@ class ReceiverEditController {
     }
 }
 
+interface CreateReceiverStateParams extends UiStateParams {
+    type: threema.ReceiverType;
+    initParams: null | {identity: string | null};
+}
+
 /**
  * Control creating a group or adding contact
  * fields, validate and save routines are implemented in the specific ControllerModel
@@ -1424,7 +1466,7 @@ class ReceiverCreateController {
     private loading = false;
     private $timeout: ng.ITimeoutService;
     private $log: ng.ILogService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
     private $mdToast: any;
     public identity = '';
     private $translate: any;
@@ -1435,8 +1477,8 @@ class ReceiverCreateController {
 
     public static $inject = ['$stateParams', '$mdDialog', '$mdToast', '$translate',
         '$timeout', '$state', '$log', 'ControllerModelService'];
-    constructor($stateParams: threema.CreateReceiverStateParams, $mdDialog, $mdToast, $translate,
-                $timeout: ng.ITimeoutService, $state: ng.ui.IStateService, $log: ng.ILogService,
+    constructor($stateParams: CreateReceiverStateParams, $mdDialog, $mdToast, $translate,
+                $timeout: ng.ITimeoutService, $state: UiStateService, $log: ng.ILogService,
                 controllerModelService: ControllerModelService) {
         this.$mdDialog = $mdDialog;
         this.$timeout = $timeout;
@@ -1514,7 +1556,7 @@ class ReceiverCreateController {
 
 angular.module('3ema.messenger', ['ngMaterial'])
 
-.config(['$stateProvider', function($stateProvider: ng.ui.IStateProvider) {
+.config(['$stateProvider', function($stateProvider: UiStateProvider) {
 
     $stateProvider
 

+ 13 - 4
src/partials/welcome.ts

@@ -19,6 +19,12 @@
 
 /// <reference path="../types/broadcastchannel.d.ts" />
 
+import {
+    StateParams as UiStateParams,
+    StateProvider as UiStateProvider,
+    StateService as UiStateService,
+} from '@uirouter/angularjs';
+
 import {BrowserService} from '../services/browser';
 import {ControllerService} from '../services/controller';
 import {TrustedKeyStoreService} from '../services/keystore';
@@ -45,6 +51,10 @@ class DialogController {
     }
 }
 
+interface WelcomeStateParams extends UiStateParams {
+    initParams: null | {keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array};
+}
+
 class WelcomeController {
 
     private static REDIRECT_DELAY = 500;
@@ -53,11 +63,11 @@ class WelcomeController {
 
     // Angular services
     private $scope: ng.IScope;
-    private $state: ng.ui.IStateService;
     private $timeout: ng.ITimeoutService;
     private $interval: ng.IIntervalService;
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
+    private $state: UiStateService;
 
     // Material design services
     private $mdDialog: ng.material.IDialogService;
@@ -84,7 +94,7 @@ class WelcomeController {
         'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService', 'VersionService',
         'BROWSER_MIN_VERSIONS', 'CONFIG', 'ControllerService',
     ];
-    constructor($scope: ng.IScope, $state: ng.ui.IStateService, $stateParams: threema.WelcomeStateParams,
+    constructor($scope: ng.IScope, $state: UiStateService, $stateParams: WelcomeStateParams,
                 $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
@@ -559,10 +569,9 @@ class WelcomeController {
 
 angular.module('3ema.welcome', [])
 
-.config(['$stateProvider', ($stateProvider: ng.ui.IStateProvider) => {
+.config(['$stateProvider', ($stateProvider: UiStateProvider) => {
 
     $stateProvider
-
         .state('welcome', {
             url: '/welcome',
             templateUrl: 'partials/welcome.html',

+ 4 - 2
src/services/fingerprint.ts

@@ -15,6 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {sha256} from '../helpers/crypto';
+
 export class FingerPrintService {
     private $log: ng.ILogService;
 
@@ -23,9 +25,9 @@ export class FingerPrintService {
         this.$log = $log;
     }
 
-    public generate(publicKey: ArrayBuffer): string {
+    public async generate(publicKey: ArrayBuffer): Promise<string> {
         if (publicKey !== undefined && publicKey.byteLength === 32) {
-            const sha256PublicKey = sha256(publicKey);
+            const sha256PublicKey = await sha256(publicKey);
             if (sha256PublicKey !== undefined) {
                 return sha256PublicKey.toLowerCase().substr(0, 32);
             }

+ 4 - 2
src/services/notification.ts

@@ -15,6 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 import {SettingsService} from './settings';
 
 export class NotificationService {
@@ -26,7 +28,7 @@ export class NotificationService {
 
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
 
     private settingsService: SettingsService;
     private logTag = '[NotificationService]';
@@ -48,7 +50,7 @@ export class NotificationService {
     public static $inject = ['$log', '$window', '$state', 'SettingsService'];
 
     constructor($log: ng.ILogService, $window: ng.IWindowService,
-                $state: ng.ui.IStateService, settingsService: SettingsService) {
+                $state: UiStateService, settingsService: SettingsService) {
         this.$log = $log;
         this.$window = $window;
         this.$state = $state;

+ 0 - 3
src/services/peerconnection.ts

@@ -21,11 +21,8 @@ import TaskConnectionState = threema.TaskConnectionState;
 
 /**
  * Wrapper around the WebRTC PeerConnection.
- *
- * TODO: Convert to regular service?
  */
 export class PeerConnectionHelper {
-
     private logTag: string = '[PeerConnectionHelper]';
 
     // Angular services

+ 11 - 6
src/services/push.ts

@@ -15,6 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {sha256} from '../helpers/crypto';
+
 export class PushService {
     private static ARG_TYPE = 'type';
     private static ARG_TOKEN = 'token';
@@ -72,15 +74,18 @@ export class PushService {
      * Send a push notification for the specified session (public permanent key
      * of the initiator). The promise is always resolved to a boolean.
      */
-    public sendPush(session: Uint8Array, wakeupType: threema.WakeupType): Promise<boolean> {
+    public async sendPush(session: Uint8Array, wakeupType: threema.WakeupType): Promise<boolean> {
         if (!this.isAvailable()) {
-            return Promise.resolve(false);
+            return false;
         }
 
+        // Calculate session hash
+        const sessionHash = await sha256(session.buffer);
+
         // Prepare request
         const data = {
             [PushService.ARG_TYPE]: this.pushType,
-            [PushService.ARG_SESSION]: sha256(session),
+            [PushService.ARG_SESSION]: sessionHash,
             [PushService.ARG_VERSION]: this.version,
             [PushService.ARG_WAKEUP_TYPE]: wakeupType,
         };
@@ -89,7 +94,7 @@ export class PushService {
             const parts = this.pushToken.split(';');
             if (parts.length < 3) {
                 this.$log.warn(this.logTag, 'APNS push token contains', parts.length, 'parts, at least 3 are required');
-                return Promise.resolve(false);
+                return false;
             }
             data[PushService.ARG_TOKEN] = parts[0];
             data[PushService.ARG_ENDPOINT] = parts[1];
@@ -98,7 +103,7 @@ export class PushService {
             data[PushService.ARG_TOKEN] = this.pushToken;
         } else {
             this.$log.warn(this.logTag, 'Invalid push type');
-            return Promise.resolve(false);
+            return false;
         }
 
         const request = {
@@ -127,6 +132,6 @@ export class PushService {
                     resolve(false);
                 },
             );
-        });
+        }) as Promise<boolean>;
     }
 }

+ 4 - 2
src/services/webclient.ts

@@ -19,6 +19,8 @@
 /// <reference types="@saltyrtc/task-webrtc" />
 /// <reference types="@saltyrtc/task-relayed-data" />
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 import * as msgpack from 'msgpack-lite';
 import {hasFeature, hexToU8a, msgpackVisualizer} from '../helpers';
 import {isContactReceiver, isDistributionListReceiver, isGroupReceiver, isValidReceiverType} from '../typeguards';
@@ -118,7 +120,7 @@ export class WebClientService {
     private logTag: string = '[WebClientService]';
 
     // Angular services
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
     private $log: ng.ILogService;
     private $rootScope: any;
     private $q: ng.IQService;
@@ -210,7 +212,7 @@ export class WebClientService {
     constructor($log: ng.ILogService,
                 $rootScope: any,
                 $q: ng.IQService,
-                $state: ng.ui.IStateService,
+                $state: UiStateService,
                 $window: ng.IWindowService,
                 $translate: ng.translate.ITranslateService,
                 $filter: ng.IFilterService,

+ 17 - 19
src/threema.d.ts

@@ -126,8 +126,24 @@ declare namespace threema {
         duration: number;
     }
 
+    const enum VoipStatus {
+        Missed = 1,
+        Finished = 2,
+        Rejected = 3,
+        Aborted = 4,
+    }
+
+    const enum VoipRejectReason {
+        Unknown = 0,
+        Busy = 1,
+        Timeout = 2,
+        Rejected = 3,
+    }
+
     interface VoipStatusInfo {
-        status: number;
+        status: VoipStatus;
+        duration?: number;
+        reason?: VoipRejectReason;
     }
 
     interface LocationInfo {
@@ -456,24 +472,6 @@ declare namespace threema {
         text: string;
     }
 
-    /**
-     * The $stateParams format used for the welcome controller.
-     */
-    interface WelcomeStateParams extends ng.ui.IStateParamsService {
-        initParams: null | {keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array};
-    }
-
-    interface CreateReceiverStateParams extends ng.ui.IStateParamsService {
-        type: ReceiverType;
-        initParams: null | {identity: string | null};
-    }
-
-    interface ConversationStateParams extends ng.ui.IStateParamsService {
-        type: ReceiverType;
-        id: string;
-        initParams: null | {text: string | null};
-    }
-
     interface Quote {
         identity: string;
         text: string;

+ 7 - 5
src/types/autolinker.d.ts

@@ -10,13 +10,15 @@ declare namespace __Autolinker {
     hashtag?: boolean | string;
     newWindow?: boolean;
     phone?: boolean;
-    replaceFn?: (autolinker: Autolinker, match: any) => string;
+    replaceFn?: (match: any) => string;
     stripPrefix?: boolean;
+    stripTrailingSlash?: boolean;
     truncate?: number | { length?: number; location?: string; };
-    twitter?: boolean;
-    urls?: boolean | { schemeMatches: boolean; wwwMatches: boolean; tldMatches: boolean; }
+    mention?: 'twitter' | 'instagram' | false;
+    decodePercentEncoding?: boolean;
+    urls?: boolean | { schemeMatches: boolean; wwwMatches: boolean; tldMatches: boolean; };
   }
-  
+
   interface Autolinker {
     getTagBuilder(): any;
     /**
@@ -28,7 +30,7 @@ declare namespace __Autolinker {
      */
     parse(textOrHtml: string): any[];
   }
-  
+
   interface Static {
     new(cfg?: ConfigOptions): Autolinker;
     /**

+ 31 - 0
tests/filters.js

@@ -422,4 +422,35 @@ describe('Filters', function() {
         });
     });
 
+    describe('linkify', function() {
+        let process = (text) => {
+            return $filter('linkify')(text)
+        };
+
+        it('links http urls', () => {
+            expect(process('hello https://threema.ch/!'))
+                .toEqual('hello <a href="https://threema.ch/" class="autolinked autolinked-url" target="_blank" rel="noopener noreferrer">https://threema.ch</a>!');
+        });
+
+        it('links e-mails', () => {
+            expect(process('hello info@threema.ch!'))
+                .toEqual('hello <a href="mailto:info@threema.ch" class="autolinked autolinked-email" target="_blank" rel="noopener noreferrer">info@threema.ch</a>!');
+        });
+
+        it('does not link phone numbers', () => {
+            const input = 'hello +41791234567';
+            expect(process(input)).toEqual(input);
+        });
+
+        it('does not link mentions', () => {
+            const input = 'hello @threemaapp';
+            expect(process(input)).toEqual(input);
+        });
+
+        it('does not link hashtags', () => {
+            const input = 'hello #threema';
+            expect(process(input)).toEqual(input);
+        });
+    });
+
 });

+ 3 - 0
tests/testsuite.html

@@ -7,6 +7,8 @@
 
         <link rel="stylesheet" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
 
+        <script src="../node_modules/regenerator-runtime/runtime.js"></script>
+
         <script src="../node_modules/jasmine-core/lib/jasmine-core/jasmine.js"></script>
         <script src="../node_modules/jasmine-core/lib/jasmine-core/jasmine-html.js"></script>
         <script src="../node_modules/jasmine-core/lib/jasmine-core/boot.js"></script>
@@ -17,6 +19,7 @@
         <script src="../node_modules/angular-material/angular-material.min.js"></script>
         <script src="../node_modules/angular-animate/angular-animate.min.js"></script>
         <script src="../node_modules/angular-aria/angular-aria.min.js"></script>
+        <script src="../node_modules/autolinker/dist/Autolinker.min.js"></script>
 
         <script src="../node_modules/@saltyrtc/chunked-dc/dist/chunked-dc.es5.js"></script>
 

+ 30 - 0
tests/ts/crypto_helpers.ts

@@ -0,0 +1,30 @@
+/**
+ * Copyright © 2016-2018 Threema GmbH (https://threema.ch/).
+ *
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import {sha256} from '../../src/helpers/crypto';
+
+describe('Crypto Helpers', () => {
+    it('sha256', function(done) {
+        const arr = Uint8Array.of(1, 2, 4, 8, 254, 255);
+        sha256(arr.buffer).then((hash) => {
+            expect(hash).toEqual('54caf7192551d011c9018e6e00b0f2d13f71784277d581fc5146182cb8af4181');
+            done();
+        });
+    });
+});

+ 1 - 0
tests/ts/main.ts

@@ -21,5 +21,6 @@
 /// <reference path="../../src/threema.d.ts" />
 
 import './containers';
+import './crypto_helpers';
 import './helpers';
 import './receiver_helpers';

Some files were not shown because too many files changed in this diff