Browse Source

Merge branch 'beta'

Danilo Bargen 6 years ago
parent
commit
6ba92f8aa3
69 changed files with 2716 additions and 894 deletions
  1. 117 1
      CHANGELOG.md
  2. 3 0
      RELEASING.md
  3. 2 2
      dist/package.sh
  4. 2 8
      index.html
  5. 45 26
      package-lock.json
  6. 5 5
      package.json
  7. 15 6
      public/i18n/de.json
  8. 14 5
      public/i18n/en.json
  9. BIN
      public/img/bg.jpg
  10. 10 0
      public/img/ic_pin.svg
  11. 10 0
      public/img/ic_unpin.svg
  12. 7 1
      public/libs/angular-inview/angular-inview.js
  13. 70 0
      public/libs/future.js
  14. 36 0
      src/app.ts
  15. 3 13
      src/components.ts
  16. 51 0
      src/components/toggle_button.ts
  17. 10 7
      src/config.ts
  18. 1 1
      src/controller_model/avatar.ts
  19. 3 1
      src/controller_model/contact.ts
  20. 6 2
      src/controller_model/distributionList.ts
  21. 9 3
      src/controller_model/group.ts
  22. 1 1
      src/controller_model/me.ts
  23. 0 2
      src/controllers.ts
  24. 91 47
      src/controllers/status.ts
  25. 83 15
      src/directives/avatar.ts
  26. 2 6
      src/directives/avatar_editor.ts
  27. 6 1
      src/directives/click_action.ts
  28. 10 15
      src/directives/compose_area.ts
  29. 37 9
      src/directives/contact_badge.ts
  30. 1 5
      src/directives/drag_file.ts
  31. 13 13
      src/directives/latest_message.html
  32. 39 45
      src/directives/latest_message.ts
  33. 17 6
      src/directives/mediabox.ts
  34. 2 2
      src/directives/message.html
  35. 31 6
      src/directives/message.ts
  36. 19 1
      src/directives/message_icon.ts
  37. 25 5
      src/directives/message_media.ts
  38. 73 26
      src/directives/message_text.ts
  39. 9 12
      src/filters.ts
  40. 31 1
      src/helpers.ts
  41. 2 2
      src/helpers/browser_info.ts
  42. 232 0
      src/markup_parser.ts
  43. 1 1
      src/partials/dialog.version.html
  44. 17 2
      src/partials/messenger.conversation.html
  45. 3 4
      src/partials/messenger.navigation.html
  46. 129 58
      src/partials/messenger.ts
  47. 29 14
      src/partials/welcome.html
  48. 73 45
      src/partials/welcome.ts
  49. 115 0
      src/protocol/cache.ts
  50. 73 0
      src/protocol/sequence_number.ts
  51. 1 0
      src/sass/sections/_conversation.scss
  52. 1 1
      src/sass/sections/_navigation.scss
  53. 2 0
      src/services.ts
  54. 0 58
      src/services/browser.ts
  55. 0 62
      src/services/execute.ts
  56. 17 12
      src/services/message.ts
  57. 14 54
      src/services/peerconnection.ts
  58. 1 3
      src/services/push.ts
  59. 8 7
      src/services/state.ts
  60. 96 0
      src/services/timeout.ts
  61. 596 146
      src/services/webclient.ts
  62. 24 32
      src/threema.d.ts
  63. 35 11
      src/threema/container.ts
  64. 46 0
      src/types/future.d.ts
  65. 0 82
      tests/filters.js
  66. 8 1
      tests/service/message.js
  67. 12 0
      tests/ts/containers.ts
  68. 1 0
      tests/ts/main.ts
  69. 271 0
      tests/ts/markup_parser.ts

+ 117 - 1
CHANGELOG.md

@@ -4,6 +4,87 @@ This changelog lists the most important changes for each released version. For
 the full log, please refer to the git commit history.
 
 
+### [v2.1.0-rc.1][v2.1.0-rc.1] (2018-10-23)
+
+Changes:
+
+* [change] Use window focus instead of page visibility to determine
+  whether messages should be marked as read ([#644][i644])
+* [bug] Hide image preview when redirecting ([#640][i640])
+* [bug] Fix updating of avatars in contact autocomplete box ([#643][i643])
+* [bug] Accessibility fixes ([#639][i639])
+
+Contributors:
+
+- [@MarcoZehe][@MarcoZehe]
+
+
+### [v2.1.0-beta.7][v2.1.0-beta.7] (2018-10-18)
+
+Changes:
+
+* [change] Accessibility improvements ([#618][i618] / [#622][i622] / [#636][i636])
+* [change] Allow more time for loading initial data ([#624][i624])
+* [change] Hide WebRTC troubleshooting when using iOS ([#625][i625])
+* [bug] Copy to clipboard: Workaround for Safari on iOS ([#626][i626])
+* [bug] Fix bug with in markup parser ([#630][i630])
+* [bug] Clear isTyping flag when receiving message ([#637][i637])
+* [bug] Fix updating of message caption ([#638][i638])
+
+Contributors:
+
+- [@MarcoZehe][@MarcoZehe]
+
+
+### [v2.1.0-beta.6][v2.1.0-beta.6] (2018-10-11)
+
+Changes:
+
+* [feature] Implement pinning of conversations ([#361][i361])
+* [change] Make default avatar colors less aggressive
+* [change] New stack based markup parser ([#453][i453] / [#458][i458] / [#590][i590])
+* [bug] Fix marking of read messages with duplicate sort key ([#606][i606])
+* [bug] Compose area: Fix newlines in Safari ([#613][i613])
+* [bug] Fix largeSingleEmoji setting ([#610][i610])
+
+
+### [v2.1.0-beta.5][v2.1.0-beta.5] (2018-10-04)
+
+Changes:
+
+* [bug] Workaround for conversation loading bug in Safari ([#602][i602])
+
+
+### [v2.1.0-beta.4][v2.1.0-beta.4] (2018-09-27)
+
+Changes:
+
+* [bug] Connectivity improvements for iOS ([#597][i597])
+
+
+### [v2.1.0-beta.3][v2.1.0-beta.3] (2018-09-18)
+
+Changes:
+
+* [bug] Don't linkify latest message excerpt in conversation list ([#544][i544])
+
+
+### [v2.1.0-beta.2][v2.1.0-beta.2] (2018-09-13)
+
+Changes:
+
+* [bug] Accessibility fixes and improvements ([#562][i562])
+* [bug] Session resumption bugfixes ([#586][i586])
+* [bug] Remove compatibility footer and beta notes
+
+
+### [v2.1.0-beta.1][v2.1.0-beta.1] (2018-09-10)
+
+Changes:
+
+* [feature] Implement ack protocol for session resumption ([#551][i551])
+
+
 ### [v2.0.3][v2.0.3] (2018-08-23)
 
 Changes:
@@ -491,6 +572,7 @@ First public release.
 [i357]: https://github.com/threema-ch/threema-web/issues/357
 [i358]: https://github.com/threema-ch/threema-web/issues/358
 [i359]: https://github.com/threema-ch/threema-web/issues/359
+[i361]: https://github.com/threema-ch/threema-web/issues/361
 [i362]: https://github.com/threema-ch/threema-web/issues/362
 [i363]: https://github.com/threema-ch/threema-web/issues/363
 [i364]: https://github.com/threema-ch/threema-web/issues/364
@@ -522,6 +604,8 @@ First public release.
 [i439]: https://github.com/threema-ch/threema-web/issues/439
 [i441]: https://github.com/threema-ch/threema-web/issues/441
 [i445]: https://github.com/threema-ch/threema-web/issues/445
+[i453]: https://github.com/threema-ch/threema-web/issues/453
+[i458]: https://github.com/threema-ch/threema-web/issues/458
 [i472]: https://github.com/threema-ch/threema-web/issues/472
 [i480]: https://github.com/threema-ch/threema-web/issues/480
 [i503]: https://github.com/threema-ch/threema-web/issues/503
@@ -529,15 +613,46 @@ First public release.
 [i519]: https://github.com/threema-ch/threema-web/issues/519
 [i522]: https://github.com/threema-ch/threema-web/issues/522
 [i528]: https://github.com/threema-ch/threema-web/issues/528
+[i544]: https://github.com/threema-ch/threema-web/issues/544
 [i545]: https://github.com/threema-ch/threema-web/issues/545
 [i547]: https://github.com/threema-ch/threema-web/issues/547
 [i550]: https://github.com/threema-ch/threema-web/issues/550
+[i551]: https://github.com/threema-ch/threema-web/issues/551
 [i558]: https://github.com/threema-ch/threema-web/issues/558
+[i562]: https://github.com/threema-ch/threema-web/issues/562
 [i563]: https://github.com/threema-ch/threema-web/issues/563
 [i567]: https://github.com/threema-ch/threema-web/issues/567
 [i569]: https://github.com/threema-ch/threema-web/issues/569
 [i572]: https://github.com/threema-ch/threema-web/issues/572
-
+[i586]: https://github.com/threema-ch/threema-web/issues/586
+[i590]: https://github.com/threema-ch/threema-web/pull/590
+[i597]: https://github.com/threema-ch/threema-web/pull/597
+[i602]: https://github.com/threema-ch/threema-web/pull/602
+[i606]: https://github.com/threema-ch/threema-web/pull/606
+[i610]: https://github.com/threema-ch/threema-web/pull/610
+[i613]: https://github.com/threema-ch/threema-web/pull/613
+[i618]: https://github.com/threema-ch/threema-web/pull/618
+[i622]: https://github.com/threema-ch/threema-web/pull/622
+[i624]: https://github.com/threema-ch/threema-web/pull/624
+[i625]: https://github.com/threema-ch/threema-web/pull/625
+[i626]: https://github.com/threema-ch/threema-web/pull/626
+[i630]: https://github.com/threema-ch/threema-web/pull/630
+[i636]: https://github.com/threema-ch/threema-web/pull/636
+[i637]: https://github.com/threema-ch/threema-web/pull/637
+[i638]: https://github.com/threema-ch/threema-web/pull/638
+[i639]: https://github.com/threema-ch/threema-web/pull/639
+[i640]: https://github.com/threema-ch/threema-web/pull/640
+[i643]: https://github.com/threema-ch/threema-web/pull/643
+[i644]: https://github.com/threema-ch/threema-web/pull/644
+
+[v2.1.0-beta.1]: https://github.com/threema-ch/threema-web/compare/v2.0.3...v2.1.0-beta.1
+[v2.1.0-beta.2]: https://github.com/threema-ch/threema-web/compare/v2.1.0-beta.1...v2.1.0-beta.2
+[v2.1.0-beta.3]: https://github.com/threema-ch/threema-web/compare/v2.1.0-beta.2...v2.1.0-beta.3
+[v2.1.0-beta.4]: https://github.com/threema-ch/threema-web/compare/v2.1.0-beta.3...v2.1.0-beta.4
+[v2.1.0-beta.5]: https://github.com/threema-ch/threema-web/compare/v2.1.0-beta.4...v2.1.0-beta.5
+[v2.1.0-beta.6]: https://github.com/threema-ch/threema-web/compare/v2.1.0-beta.5...v2.1.0-beta.6
+[v2.1.0-beta.7]: https://github.com/threema-ch/threema-web/compare/v2.1.0-beta.6...v2.1.0-beta.7
+[v2.1.0-rc.1]: https://github.com/threema-ch/threema-web/compare/v2.1.0-beta.7...v2.1.0-rc.1
 [v2.0.3]: https://github.com/threema-ch/threema-web/compare/v2.0.2...v2.0.3
 [v2.0.2]: https://github.com/threema-ch/threema-web/compare/v2.0.1...v2.0.2
 [v2.0.1]: https://github.com/threema-ch/threema-web/compare/v2.0.0...v2.0.1
@@ -581,3 +696,4 @@ First public release.
 [@heckenmann]: https://github.com/heckenmann
 [@iasdeoupxe]: https://github.com/iasdeoupxe
 [@SirTyson]: https://github.com/SirTyson
+[@MarcoZehe]: https://github.com/MarcoZehe

+ 3 - 0
RELEASING.md

@@ -1,5 +1,8 @@
 # Releasing
 
+Major release with backwards incompatible changes? Check for `TODO` comments
+with deprecations. Remove them if possible.
+
 Set variables:
 
     $ export VERSION=X.Y.Z

+ 2 - 2
dist/package.sh

@@ -33,7 +33,7 @@ VERSION=$(grep "\"version\"" package.json  | sed 's/[[:blank:]]*\"version\": \"\
 DIR="release/threema-web-$VERSION"
 
 echo "+ Create release directory..."
-mkdir -p $DIR/{dist,partials,directives,node_modules,partials/messenger.receiver,troubleshoot}
+mkdir -p $DIR/{dist,partials,directives,components,node_modules,partials/messenger.receiver,troubleshoot}
 
 echo "+ Copy code..."
 cp -R index.html $DIR/
@@ -43,6 +43,7 @@ cp -R troubleshoot/* $DIR/troubleshoot/
 cp -R src/partials/*.html $DIR/partials/
 cp -R src/partials/messenger.receiver/*.html $DIR/partials/messenger.receiver/
 cp -R src/directives/*.html $DIR/directives/
+cp -R src/components/*.html $DIR/components/ 2>/dev/null || :
 
 echo "+ Copy dependencies..."
 targets=(
@@ -55,7 +56,6 @@ targets=(
     babel-es6-polyfill/browser-polyfill.min.js
     msgpack-lite/dist/msgpack.min.js
     tweetnacl/nacl-fast.min.js
-    file-saver/FileSaver.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

+ 2 - 8
index.html

@@ -57,7 +57,7 @@
 </head>
 
 <body ng-controller="StatusController as ctrl" class="{{ ctrl.statusClass }}" ng-class="{expanded: ctrl.expandStatusBar}">
-    <img src="img/bg.jpg?v=1" alt="Background image: Blurred photo of a mountain" id="background-image" draggable="false">
+    <img src="img/bg.jpg?v=1" aria-label="Background image: Blurred photo of a mountain" id="background-image" draggable="false">
 
     <noscript>
         <img id="logo-noscript" src="img/logo.svg?v=[[VERSION]]"/>
@@ -83,12 +83,6 @@
             </div>
             <div id="main-content" ui-view></div>
         </div>
-        <div class="android-ios-only" ng-controller="AndroidIosOnlyController as ctrl" ng-show="ctrl.show">
-            <div>
-                <i class="material-icons md-24">android</i>
-                <span translate>welcome.ANDROID_IOS_ONLY</span>
-            </div>
-        </div>
         <footer ng-controller="FooterController as ctrl">
             <ul>
                 <li><a ng-click="ctrl.showVersionInfo('[[VERSION]]')" ng-keypress="ctrl.showVersionInfo('[[VERSION]]', $event)" tabindex="0">Version [[VERSION]] {{ ctrl.config.VERSION_MOUNTAIN }}</a></li>
@@ -118,6 +112,7 @@
     <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>
+    <script src="libs/future.js?v=[[VERSION]]"></script>
 
     <!-- Translation -->
     <script src="node_modules/messageformat/messageformat.min.js?v=[[VERSION]]"></script>
@@ -128,7 +123,6 @@
     <!-- Other -->
     <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/@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>

+ 45 - 26
package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "threema-web",
-  "version": "2.0.3",
+  "version": "2.1.0-rc.1",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -10,9 +10,9 @@
       "integrity": "sha512-im7GXKhUsNKTbppZOA0Jqx0Yku+3FILe/CENMlX5PT1tP95Dfu1VadIaBgNevxstnCadrYPTtxVeXc2MwxO3jw=="
     },
     "@saltyrtc/client": {
-      "version": "0.12.4",
-      "resolved": "https://registry.npmjs.org/@saltyrtc/client/-/client-0.12.4.tgz",
-      "integrity": "sha512-Le1VgSQtDWaxLWWksKSrptuIsBv7oe2zS9p3V4xU532H3DKr1cwrrvlsCaZiefB4WEx7pvwK3VYeSW9DZ12kdQ=="
+      "version": "0.13.1",
+      "resolved": "https://registry.npmjs.org/@saltyrtc/client/-/client-0.13.1.tgz",
+      "integrity": "sha512-XlUU1HFcv8jm5pLlpezuR6xFVZgvpEBflcSq6MvOcKDdOhGuF0ZgLfD90v/Ofp5cxzxQqW9LHsCrxXGj1LWY+A=="
     },
     "@saltyrtc/task-relayed-data": {
       "version": "0.3.1",
@@ -20,9 +20,9 @@
       "integrity": "sha512-TgucXvVHKKS40nMk+xoLdo4rqDP4seby0iE19gUj+oYytuwf58rc00DBRguwf7KLlf1IUVoJIXEt4TAvUe97lA=="
     },
     "@saltyrtc/task-webrtc": {
-      "version": "0.12.1",
-      "resolved": "https://registry.npmjs.org/@saltyrtc/task-webrtc/-/task-webrtc-0.12.1.tgz",
-      "integrity": "sha512-Uc4wHGpx2Y+5ZjyYpXK2Rgq+wUL3UFr/5PRoqbIjNLz/yB515v1pRbbgVOViY2tOcSw8JIP+g6xfm8KAS87vEw==",
+      "version": "0.13.0",
+      "resolved": "https://registry.npmjs.org/@saltyrtc/task-webrtc/-/task-webrtc-0.13.0.tgz",
+      "integrity": "sha512-FAnsCjPt3/ksap741V9BT5AE6uvqfltgavk9QHTGlMrY/Qp5VMfCDdOJc2/tf3CVglprfe8XN6maZwErHUsr5A==",
       "requires": {
         "@saltyrtc/chunked-dc": "^1.1.1"
       }
@@ -56,10 +56,10 @@
         "@types/angular": "*"
       }
     },
-    "@types/filesaver": {
-      "version": "0.0.30",
-      "resolved": "https://registry.npmjs.org/@types/filesaver/-/filesaver-0.0.30.tgz",
-      "integrity": "sha1-cUiW9WpI9ki/DFAnPHcaNfNYACc="
+    "@types/file-saver": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-1.3.1.tgz",
+      "integrity": "sha512-A+lNc0nnhtX3iTLEYd/DisKTZdNKTf1bN0aSfQD/fG8bQ6SfUe5u8Fm2ab8qQHaMY5GVZumAXLnYptwX+mmQgg=="
     },
     "@types/jasmine": {
       "version": "2.8.8",
@@ -3224,9 +3224,9 @@
       "optional": true
     },
     "file-saver": {
-      "version": "1.3.8",
-      "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz",
-      "integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg=="
+      "version": "2.0.0-rc.3",
+      "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.0-rc.3.tgz",
+      "integrity": "sha512-LZ89x9kYxsAbJFoeLFiD5dRQnGoppXn3NLmPuULYyiKeAcEHQ8TTUwzGAEWT1VjoNGCapP3z+OG2qrwPoas80Q=="
     },
     "file-uri-to-path": {
       "version": "1.0.0",
@@ -3445,7 +3445,8 @@
         "ansi-regex": {
           "version": "2.1.1",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "aproba": {
           "version": "1.2.0",
@@ -3466,12 +3467,14 @@
         "balanced-match": {
           "version": "1.0.0",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "brace-expansion": {
           "version": "1.1.11",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "balanced-match": "^1.0.0",
             "concat-map": "0.0.1"
@@ -3486,17 +3489,20 @@
         "code-point-at": {
           "version": "1.1.0",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "concat-map": {
           "version": "0.0.1",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "console-control-strings": {
           "version": "1.1.0",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "core-util-is": {
           "version": "1.0.2",
@@ -3613,7 +3619,8 @@
         "inherits": {
           "version": "2.0.3",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "ini": {
           "version": "1.3.5",
@@ -3625,6 +3632,7 @@
           "version": "1.0.0",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "number-is-nan": "^1.0.0"
           }
@@ -3639,6 +3647,7 @@
           "version": "3.0.4",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "brace-expansion": "^1.1.7"
           }
@@ -3646,12 +3655,14 @@
         "minimist": {
           "version": "0.0.8",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "minipass": {
           "version": "2.2.4",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "safe-buffer": "^5.1.1",
             "yallist": "^3.0.0"
@@ -3670,6 +3681,7 @@
           "version": "0.5.1",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "minimist": "0.0.8"
           }
@@ -3750,7 +3762,8 @@
         "number-is-nan": {
           "version": "1.0.1",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "object-assign": {
           "version": "4.1.1",
@@ -3762,6 +3775,7 @@
           "version": "1.4.0",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "wrappy": "1"
           }
@@ -3847,7 +3861,8 @@
         "safe-buffer": {
           "version": "5.1.1",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "safer-buffer": {
           "version": "2.1.2",
@@ -3883,6 +3898,7 @@
           "version": "1.0.2",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "code-point-at": "^1.0.0",
             "is-fullwidth-code-point": "^1.0.0",
@@ -3902,6 +3918,7 @@
           "version": "3.0.1",
           "bundled": true,
           "dev": true,
+          "optional": true,
           "requires": {
             "ansi-regex": "^2.0.0"
           }
@@ -3945,12 +3962,14 @@
         "wrappy": {
           "version": "1.0.2",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         "yallist": {
           "version": "3.0.2",
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         }
       }
     },
@@ -4013,7 +4032,7 @@
         },
         "chalk": {
           "version": "0.5.1",
-          "resolved": "http://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz",
           "integrity": "sha1-Zjs6ZItotV0EaQ1JFnqoN4WPIXQ=",
           "dev": true,
           "requires": {

+ 5 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "threema-web",
-  "version": "2.0.3",
+  "version": "2.1.0-rc.1",
   "description": "Threema Webclient",
   "scripts": {
     "build": "npm run build:js && npm run build:css",
@@ -26,14 +26,14 @@
   "private": true,
   "homepage": "https://threema.ch/",
   "dependencies": {
-    "@saltyrtc/client": "^0.12.4",
+    "@saltyrtc/client": "^0.13.1",
     "@saltyrtc/task-relayed-data": "^0.3.1",
-    "@saltyrtc/task-webrtc": "^0.12.1",
+    "@saltyrtc/task-webrtc": "^0.13.0",
     "@types/angular": "^1.6.50",
     "@types/angular-material": "^1.1.59",
     "@types/angular-sanitize": "^1.3.7",
     "@types/angular-translate": "^2.16.0",
-    "@types/filesaver": "~0.0.30",
+    "@types/file-saver": "^1.3.0",
     "@types/jquery": "^3.3.6",
     "@types/msgpack-lite": "^0.1.6",
     "@types/webrtc": "0.0.23",
@@ -55,7 +55,7 @@
     "browserify": "~16",
     "browserify-header": "^0.9.4",
     "croppie": "~2.6.0",
-    "file-saver": "^1.3.8",
+    "file-saver": "2.0.0-rc.3",
     "messageformat": "^2.0.4",
     "msgpack-lite": "~0.1.26",
     "node-sass": "^4.9.3",

+ 15 - 6
public/i18n/de.json

@@ -24,7 +24,6 @@
         "BROWSER_NOT_SUPPORTED_ANDROID": "Dieser Browser wird von Android-Geräten nicht unterst\u00fctzt",
         "BROWSER_NOT_SUPPORTED_DETAILS": "Bitte verwenden Sie die aktuelle Version von <a href='https:\/\/www.google.com\/chrome\/browser\/desktop\/' target='_blank' rel='noopener noreferrer'>Google Chrome<\/a>, <a href='https:\/\/www.mozilla.org\/' target='_blank' rel='noopener noreferrer'>Mozilla Firefox<\/a>, <a href='https:\/\/www.opera.com\/' target='_blank' rel='noopener noreferrer'>Opera</a> oder <a href='https:\/\/www.apple.com\/safari\/' target='_blank' rel='noopener noreferrer'>Safari</a> (nur mit iOS), um den Webclient ohne Einschr\u00e4nkungen zu nutzen.",
         "SAFARI": "Safari ist nur kompatibel mit Threema Web für iOS. Android-Nutzer verwenden bitte einen anderen Browser.",
-        "ANDROID_IOS_ONLY": "Kompatibel mit Threema für Android.",
         "CONNECTING": "Verbindung wird aufgebaut",
         "WAITING_FOR_PUSH": "Threema-App wird<br>aufgeweckt …",
         "CONNECTING_TO_APP": "Verbindung zu App<br>wird aufgebaut …",
@@ -42,7 +41,7 @@
         "ALREADY_CONNECTED_DETAILS": "Sie sind bereits in einem anderen Tab oder Fenster mit Threema Web verbunden!",
         "VERSION": "Version",
         "BACKGROUND_IMAGE": "Hintergrundbild",
-        "NOTIFICATION_IOS_BETA": "iOS Beta-User? Bitte benutzen Sie <a href=\"https://web-beta.threema.ch/\">web-beta.threema.ch</a>."
+        "NOTIFICATION_IOS_BETA": "Um Threema Web mit iOS zu nutzen, benötigen Sie die neueste Beta-Version (Build 2354), ältere Versionen werden nicht mehr unterstützt."
     },
     "connecting": {
         "CONNECTION_PROBLEMS": "Verbindungsprobleme",
@@ -52,7 +51,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services nicht installiert. Bitte starten Sie die Sitzung manuell.",
         "CONNECTING_TO_SERVER": "Verbinden mit Server\u2026",
         "CONNECTING_TO_APP": "Verbindung zu App wird aufgebaut\u2026",
-        "CONNECTION_CLOSED": "Server-Verbindung wurde geschlossen."
+        "CONNECTION_CLOSED": "Server-Verbindung wurde geschlossen.",
+        "RECONNECT_FAILED": "Verbindung zur App fehlgeschlagen."
     },
     "troubleshooting": {
         "SLOW_CONNECT": "Verbindungsaufbau scheint länger zu dauern<br>als normal …",
@@ -101,6 +101,7 @@
         "CONVERSATIONS": "Chats",
         "CONTACTS": "Kontakte",
         "NO_CONVERSATIONS_FOUND": "Keine Chats gefunden.",
+        "LOADING_CONVERSATIONS": "Chats werden geladen …",
         "ABOUT": "\u00dcber",
         "SETTINGS": "Einstellungen",
         "HELP": "Hilfe",
@@ -183,7 +184,13 @@
         "MUTED_MENTION_ONLY": "Nur bei Erwähnung benachrichtigen",
         "MUTED_SILENT": "Stumme Benachrichtigungen",
         "ALL": "Alle",
-        "LOADING_MESSAGES": "Nachrichten werden geladen…"
+        "LOADING_MESSAGES": "Nachrichten werden geladen…",
+        "PINNED_CONVERSATION": "Unterhaltung ist angepinnt. Klicken, um sie zu entpinnen.",
+        "UNPINNED_CONVERSATION": "Unterhaltung ist nicht angepinnt. Klicken, um sie anzupinnen.",
+        "PINNED_CONVERSATION_OK": "Unterhaltung angepinnt",
+        "PINNED_CONVERSATION_ERROR": "Unterhaltung konnte nicht angepinnt werden",
+        "UNPINNED_CONVERSATION_OK": "Unterhaltung entpinnt",
+        "UNPINNED_CONVERSATION_ERROR": "Unterhaltung konnte nicht entpinnt werden"
     },
     "messageStates": {
         "WE_ACK": "Sie haben ein Daumen-Hoch gesendet",
@@ -216,6 +223,7 @@
             "badRequest": "Ungültige Anfrage (Protokollfehler?)",
             "timeout": "Timeout",
             "internalError": "Ein interner Fehler ist aufgetreten",
+            "invalidAvatar": "Ungültiger Avatar",
             "invalidIdentity": "Ungültige Threema-ID",
             "invalidContact": "Ungültiger Kontakt",
             "invalidGroup": "Ungültige Gruppe",
@@ -323,10 +331,11 @@
         }
     },
     "connection": {
-        "SESSION_CLOSED_TITLE": "Sitzung Geschlossen",
+        "SESSION_CLOSED_TITLE": "Sitzung geschlossen",
         "SESSION_STOPPED": "Die Sitzung wurde auf Ihrem Gerät gestoppt.",
         "SESSION_DELETED": "Die Sitzung wurde auf Ihrem Gerät gelöscht.",
         "WEBCLIENT_DISABLED": "Threema Web wurde auf Ihrem Gerät deaktiviert.",
-        "SESSION_REPLACED": "Die Sitzung wurde beendet, weil Sie eine andere Sitzung gestartet haben."
+        "SESSION_REPLACED": "Die Sitzung wurde beendet, weil Sie eine andere Sitzung gestartet haben.",
+        "SESSION_ERROR": "Die Sitzung wurde aufgrund eines Protokollfehlers beendet."
     }
 }

+ 14 - 5
public/i18n/en.json

@@ -24,7 +24,6 @@
         "BROWSER_NOT_SUPPORTED_ANDROID": "This browser is not supported on Android",
         "BROWSER_NOT_SUPPORTED_DETAILS": "Please use the latest version of <a href='https:\/\/www.google.com\/chrome\/browser\/desktop\/' target='_blank' rel='noopener noreferrer'>Google Chrome<\/a>, <a href='https:\/\/www.mozilla.org\/' target='_blank' rel='noopener noreferrer'>Mozilla Firefox<\/a>, <a href='https:\/\/www.opera.com\/' target='_blank' rel='noopener noreferrer'>Opera</a> or <a href='https:\/\/www.apple.com\/safari\/' target='_blank' rel='noopener noreferrer'>Safari</a> (iOS only), otherwise the web client might not work properly.",
         "SAFARI": "Safari is only compatible with Threema Web for iOS.<br>If you are using Android, please use another browser.",
-        "ANDROID_IOS_ONLY": "Compatible with Threema for Android.",
         "CONNECTING": "Connecting",
         "WAITING_FOR_PUSH": "Waiting for<br>app wakeup …",
         "CONNECTING_TO_APP": "Connection to app is<br>being established …",
@@ -42,7 +41,7 @@
         "ALREADY_CONNECTED_DETAILS": "You are already connected to Threema Web in another tab or window!",
         "VERSION": "Version",
         "BACKGROUND_IMAGE": "Background Image",
-        "NOTIFICATION_IOS_BETA": "Using the iOS beta? Please go to <a href=\"https://web-beta.threema.ch/\">web-beta.threema.ch</a>."
+        "NOTIFICATION_IOS_BETA": "To use Threema Web on iOS, you need the newest build (2354), older builds will not work anymore."
     },
     "connecting": {
         "CONNECTION_PROBLEMS": "Connection problems",
@@ -52,7 +51,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services not installed. Please start the session manually.",
         "CONNECTING_TO_SERVER": "Connecting to server\u2026",
         "CONNECTING_TO_APP": "Connection to app is being established\u2026",
-        "CONNECTION_CLOSED": "Connection to server has been closed."
+        "CONNECTION_CLOSED": "Connection to server has been closed.",
+        "RECONNECT_FAILED": "Connecting to app failed."
     },
     "troubleshooting": {
         "SLOW_CONNECT": "Connecting seems to take longer than usual …",
@@ -101,6 +101,7 @@
         "CONVERSATIONS": "Conversations",
         "CONTACTS": "Contacts",
         "NO_CONVERSATIONS_FOUND": "No conversations found.",
+        "LOADING_CONVERSATIONS": "Loading conversations …",
         "ABOUT": "About",
         "SETTINGS": "Settings",
         "HELP": "Help",
@@ -182,7 +183,13 @@
         "MUTED_MENTION_ONLY": "Only show notification when mentioned",
         "MUTED_SILENT": "Silent notifications",
         "ALL": "All",
-        "LOADING_MESSAGES": "Loading messages…"
+        "LOADING_MESSAGES": "Loading messages…",
+        "PINNED_CONVERSATION": "Conversation is pinned. Click to unpin.",
+        "UNPINNED_CONVERSATION": "Conversation is not pinned. Click to pin.",
+        "PINNED_CONVERSATION_OK": "Conversation pinned",
+        "PINNED_CONVERSATION_ERROR": "Conversation could not be pinned",
+        "UNPINNED_CONVERSATION_OK": "Conversation unpinned",
+        "UNPINNED_CONVERSATION_ERROR": "Conversation could not be unpinned"
     },
     "messageStates": {
         "WE_ACK": "You sent thumbs-up",
@@ -215,6 +222,7 @@
             "badRequest": "Invalid request (protocol error?)",
             "timeout": "Request timed out",
             "internalError": "An internal error occurred",
+            "invalidAvatar": "Invalid avatar",
             "invalidIdentity": "Invalid Threema-ID",
             "invalidContact": "Invalid contact ID",
             "invalidGroup": "Invalid group ID",
@@ -326,6 +334,7 @@
         "SESSION_STOPPED": "The session was stopped on your device.",
         "SESSION_DELETED": "The session was deleted on your device.",
         "WEBCLIENT_DISABLED": "Threema Web was disabled on your device.",
-        "SESSION_REPLACED": "This session was stopped because you started a Threema Web session in another browser window."
+        "SESSION_REPLACED": "This session was stopped because you started a Threema Web session in another browser window.",
+        "SESSION_ERROR": "The session was stopped due to a protocol error."
     }
 }

BIN
public/img/bg.jpg


+ 10 - 0
public/img/ic_pin.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg"
+  height="24"
+  width="24"
+  viewBox="0 0 24 24">
+  <path
+    fill="#000000"
+    fill-opacity="0.87"
+    d="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12Z"/>
+</svg>

+ 10 - 0
public/img/ic_unpin.svg

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg"
+  height="24"
+  width="24"
+  viewBox="0 0 24 24">
+  <path
+    fill="#000000"
+    fill-opacity="0.87"
+    d="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12M8.8,14L10,12.8V4H14V12.8L15.2,14H8.8Z" />
+</svg>

+ 7 - 1
public/libs/angular-inview/angular-inview.js

@@ -69,6 +69,11 @@ function inViewDirective ($parse) {
         viewportEventSignal = viewportEventSignal.merge(signalFromEvent(document, 'visibilitychange'));
       }
 
+      // Merged with the page focus/blur events
+      if (options.considerPageFocus) {
+        viewportEventSignal = viewportEventSignal.merge(signalFromEvent(window, 'focus blur'));
+      }
+
       // Merge with container's events signal
       if (container) {
         viewportEventSignal = viewportEventSignal.merge(container.eventsSignal);
@@ -100,8 +105,9 @@ function inViewDirective ($parse) {
         var elementRect = offsetRect(element[0].getBoundingClientRect(), options.offset);
         var isVisible = !!(element[0].offsetWidth || element[0].offsetHeight || element[0].getClientRects().length);
         var documentVisible = !options.considerPageVisibility || document.visibilityState === 'visible' || document.hidden === false;
+        var documentFocussed = !options.considerPageFocus || document.hasFocus();
         var info = {
-          inView: documentVisible && isVisible && intersectRect(elementRect, viewportRect),
+          inView: documentVisible && documentFocussed && isVisible && intersectRect(elementRect, viewportRect),
           event: event,
           element: element,
           elementRect: elementRect,

+ 70 - 0
public/libs/future.js

@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * A future similar to Python's asyncio.Future. Allows to resolve or reject
+ * outside of the executor and query the current status.
+ */
+class Future extends Promise {
+    constructor(executor) {
+        let resolve, reject;
+        super((resolve_, reject_) => {
+            resolve = resolve_;
+            reject = reject_;
+            if (executor) {
+                return executor(resolve_, reject_);
+            }
+        });
+
+        this._done = false;
+        this._resolve = resolve;
+        this._reject = reject;
+    }
+
+    /**
+     * Wrap a promise to ensure it does not resolve before a minimum
+     * duration.
+     *
+     * Note: The promise will still reject immediately. Furthermore, be
+     *       aware that the promise does not resolve/reject inside of
+     *       an AngularJS digest cycle.
+     *
+     * @param promise the promise or future to be wrapped
+     * @param minDuration the minimum duration before it should be resolved
+     * @returns {Future}
+     */
+    static withMinDuration(promise, minDuration) {
+        const start = new Date();
+        return new Future((resolve, reject) => {
+            promise
+                .then((result) => {
+                    const timediff = new Date() - start;
+                    const delay = Math.max(minDuration - timediff, 0);
+                    self.setTimeout(() => resolve(result), delay);
+                })
+                .catch((error) => reject(error));
+        });
+    }
+
+    /**
+     * Return whether the future is done (resolved or rejected).
+     */
+    get done() {
+        return this._done;
+    }
+
+    /**
+     * Resolve the future.
+     */
+    resolve(...args) {
+        this._done = true;
+        return this._resolve(...args);
+    }
+
+    /**
+     * Reject the future.
+     */
+    reject(...args) {
+        this._done = true;
+        return this._reject(...args);
+    }
+}

+ 36 - 0
src/app.ts

@@ -19,6 +19,7 @@
 
 import {AsyncEvent} from 'ts-events';
 
+import './components';
 import config from './config';
 import './controllers';
 import './directives';
@@ -26,6 +27,7 @@ import './filters';
 import './partials/messenger';
 import './partials/welcome';
 import './services';
+import {BrowserService} from './services/browser';
 import './threema/container';
 
 // Configure asynchronous events
@@ -39,6 +41,7 @@ angular.module('3ema', [
     // Angular
     'ngAnimate',
     'ngSanitize',
+    'ngAria',
 
     // 3rd party
     'ui.router',
@@ -50,6 +53,7 @@ angular.module('3ema', [
 
     // Own
     '3ema.filters',
+    '3ema.components',
     '3ema.directives',
     '3ema.container',
     '3ema.services',
@@ -125,6 +129,7 @@ angular.module('3ema', [
             request: (conf) => {
                 if (conf.url.indexOf('partials/') !== -1 ||
                     conf.url.indexOf('directives/') !== -1 ||
+                    conf.url.indexOf('components/') !== -1 ||
                     conf.url.indexOf('i18n/') !== -1) {
                     const separator = conf.url.indexOf('?') === -1 ? '?' : '&';
                     conf.url = conf.url + separator + CACHE_BUST;
@@ -135,4 +140,35 @@ angular.module('3ema', [
     }]);
 }])
 
+.run([
+    '$log', 'CONFIG', 'BrowserService',
+    function($log: ng.ILogService, CONFIG: threema.Config, browserService: BrowserService) {
+        // For Safari (when in DEBUG mode), monkey-patch $log to show timestamps.
+
+        if (!(CONFIG.DEBUG && browserService.getBrowser().isSafari(false))) {
+            return;
+        }
+
+        const oldLog = $log.log;
+        const oldInfo = $log.info;
+        const oldWarn = $log.warn;
+        const oldDebug = $log.debug;
+        const oldError = $log.error;
+
+        function enhanceLogging(wrapped) {
+            return function(data) {
+                const now = new Date();
+                const currentDate = `[${now.toISOString()}.${now.getMilliseconds()}]`;
+                wrapped.apply(this, [currentDate, ...arguments]);
+            };
+        }
+
+        $log.log = enhanceLogging(oldLog);
+        $log.info = enhanceLogging(oldInfo);
+        $log.warn = enhanceLogging(oldWarn);
+        $log.debug = enhanceLogging(oldDebug);
+        $log.error = enhanceLogging(oldError);
+    },
+])
+
 ;

+ 3 - 13
src/controllers/android_ios_only.ts → src/components.ts

@@ -15,18 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import {Transition, TransitionService} from '@uirouter/angularjs';
+angular.module('3ema.components', []);
 
-/**
- * Controller to show or hide the "Android / iOS only" note at the bottom of the welcome screen.
- */
-export class AndroidIosOnlyController {
-    public show: boolean = false;
+import toggleButton from './components/toggle_button';
 
-    public static $inject = ['$transitions'];
-    constructor($transitions: TransitionService) {
-        $transitions.onStart({}, (trans: Transition) => {
-            this.show = trans.to().name === 'welcome';
-        });
-    }
-}
+angular.module('3ema.components').component('toggleButton', toggleButton);

+ 51 - 0
src/components/toggle_button.ts

@@ -0,0 +1,51 @@
+/**
+ * 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/>.
+ */
+
+/**
+ * A generic toggle button.
+ *
+ * The toggle button has a boolean flag, which sets it to enabled/disabled. The
+ * caller needs to provide labels and icons for both states, as well as
+ * transition functions (onEnable and onDisable).
+ */
+export default {
+    bindings: {
+        flag: '<',
+        onEnable: '&',
+        onDisable: '&',
+        labelEnabled: '@',
+        labelDisabled: '@',
+        iconEnabled: '@',
+        iconDisabled: '@',
+    },
+    controller: function() {
+        this.getLabel = () => this.flag ? this.labelEnabled : this.labelDisabled;
+        this.getIcon = () => this.flag ? this.iconEnabled : this.iconDisabled;
+        this.action = () => this.flag ? this.onDisable() : this.onEnable();
+    },
+    template: `
+        <md-button
+            class="md-icon-button"
+            translate-attr="{'aria-label': $ctrl.getLabel(), 'title': $ctrl.getLabel()}"
+            role="button"
+            tabindex="0"
+            ng-click="$ctrl.action()"
+            aria-pressed="{{ $ctrl.flag }}">
+            <md-icon><img ng-src="{{ $ctrl.getIcon() }}"></md-icon>
+        </md-button>
+    `,
+};

+ 10 - 7
src/config.ts

@@ -3,23 +3,26 @@
  *
  * The various options are explained in the `README.md` file.
  */
+// tslint:disable:max-line-length
 export default {
 
     // General
     SELF_HOSTED: false,
-    VERSION_MOUNTAIN: 'Grosser Mythen',
-    VERSION_MOUNTAIN_URL: 'https://de.wikipedia.org/wiki/Mythen',
-    VERSION_MOUNTAIN_IMAGE_URL: 'https://commons.wikimedia.org/wiki/File:Die_Mythen.jpg',
-    VERSION_MOUNTAIN_HEIGHT: 1898,
+    VERSION_MOUNTAIN: 'Glärnisch',
+    VERSION_MOUNTAIN_URL: 'https://de.wikipedia.org/wiki/Gl%C3%A4rnisch',
+    VERSION_MOUNTAIN_IMAGE_URL: 'https://commons.wikimedia.org/wiki/File:Glarus_mit_Gl%C3%A4rnisch,_Sicht_Ennetberge_(18948043634).jpg',
+    VERSION_MOUNTAIN_IMAGE_COPYRIGHT: 'CC BY Hans Bühler',
+    VERSION_MOUNTAIN_HEIGHT: 2915,
     PREV_PROTOCOL_LAST_VERSION: '1.8.2',
-    GIT_BRANCH: 'master',
+    GIT_BRANCH: 'beta',
 
     // SaltyRTC
-    SALTYRTC_HOST: null,
+    SALTYRTC_HOST: 'saltyrtc-beta.threema.ch',
     SALTYRTC_HOST_PREFIX: 'saltyrtc-',
     SALTYRTC_HOST_SUFFIX: '.threema.ch',
     SALTYRTC_PORT: 443,
     SALTYRTC_SERVER_KEY: 'b1337fc8402f7db8ea639e05ed05d65463e24809792f91eca29e88101b4a2171',
+    SALTYRTC_LOG_LEVEL: 'warn',
 
     // ICE
     ICE_SERVERS: [{
@@ -33,7 +36,7 @@ export default {
     }],
 
     // Push
-    PUSH_URL: 'https://push-web.threema.ch/push',
+    PUSH_URL: 'https://push-web-beta.threema.ch/push',
 
     // Debugging options
     DEBUG: false,

+ 1 - 1
src/controller_model/avatar.ts

@@ -39,7 +39,7 @@ export class AvatarControllerModel {
                 $log.debug(this.logTag, 'loadAvatar: Requesting high res avatar from app');
                 webClientService.requestAvatar(receiver, true)
                     .then((data: ArrayBuffer) => resolve(data))
-                    .catch(() => reject());
+                    .catch((error) => reject(error));
             } else {
                 $log.debug(this.logTag, 'loadAvatar: Returning cached version');
                 resolve(receiver.avatar.high);

+ 3 - 1
src/controller_model/contact.ts

@@ -141,7 +141,9 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
             .then(() => {
                 this.isLoading = false;
             })
-            .catch(() => {
+            .catch((error) => {
+                // TODO: Handle this properly / show an error message
+                this.$log.error(`Cleaning receiver conversation failed: ${error}`);
                 this.isLoading = false;
             });
     }

+ 6 - 2
src/controller_model/distributionList.ts

@@ -125,7 +125,9 @@ export class DistributionListControllerModel implements threema.ControllerModel<
             .then(() => {
                 this.isLoading = false;
             })
-            .catch(() => {
+            .catch((error) => {
+                // TODO: Handle this properly / show an error message
+                this.$log.error(`Cleaning receiver conversation failed: ${error}`);
                 this.isLoading = false;
             });
     }
@@ -163,7 +165,9 @@ export class DistributionListControllerModel implements threema.ControllerModel<
         this.isLoading = true;
         this.webClientService.deleteDistributionList(this.distributionList).then(() => {
             this.isLoading = false;
-        }).catch(() => {
+        }).catch((error) => {
+            // TODO: Handle this properly / show an error message
+            this.$log.error(`Deleting distribution list failed: ${error}`);
             this.isLoading = false;
         });
     }

+ 9 - 3
src/controller_model/group.ts

@@ -140,7 +140,9 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
             .then(() => {
                 this.isLoading = false;
             })
-            .catch(() => {
+            .catch((error) => {
+                // TODO: Handle this properly / show an error message
+                this.$log.error(`Cleaning receiver conversation failed: ${error}`);
                 this.isLoading = false;
             });
     }
@@ -178,7 +180,9 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
             .then(() => {
                 this.isLoading = false;
             })
-            .catch(() => {
+            .catch((error) => {
+                // TODO: Handle this properly / show an error message
+                this.$log.error(`Leaving group failed: ${error}`);
                 this.isLoading = false;
             });
     }
@@ -212,7 +216,9 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
                     this.onRemovedCallback(this.group.id);
                 }
             })
-            .catch(() => {
+            .catch((error) => {
+                // TODO: Handle this properly / show an error message
+                this.$log.error(`Deleting group failed: ${error}`);
                 this.isLoading = false;
             });
     }

+ 1 - 1
src/controller_model/me.ts

@@ -156,7 +156,7 @@ export class MeControllerModel implements threema.ControllerModel<threema.MeRece
                 return this.webClientService.modifyProfile(
                     this.nickname,
                     this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
-                ).then((val) => {
+                ).then(() => {
                     // Profile was successfully updated. Update local data.
                     this.webClientService.me.publicNickname = this.nickname;
                     if (this.avatarController.avatarChanged) {

+ 0 - 2
src/controllers.ts

@@ -15,13 +15,11 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import {AndroidIosOnlyController} from './controllers/android_ios_only';
 import {FooterController} from './controllers/footer';
 import {StatusController} from './controllers/status';
 
 angular.module('3ema.controllers', ['3ema.services'])
 
-.controller('AndroidIosOnlyController', AndroidIosOnlyController)
 .controller('FooterController', FooterController)
 .controller('StatusController', StatusController)
 

+ 91 - 47
src/controllers/status.ts

@@ -19,16 +19,18 @@ import {StateService as UiStateService} from '@uirouter/angularjs';
 
 import {ControllerService} from '../services/controller';
 import {StateService} from '../services/state';
+import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
 
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 /**
  * This controller handles state changes globally.
  *
  * It also controls auto-reconnecting and the connection status indicator bar.
  *
- * Status updates should be done through the status service.
+ * Status updates should be done through the state service.
  */
 export class StatusController {
 
@@ -51,15 +53,18 @@ export class StatusController {
     private $state: UiStateService;
 
     // Custom services
+    private controllerService: ControllerService;
     private stateService: StateService;
+    private timeoutService: TimeoutService;
     private webClientService: WebClientService;
-    private controllerService: ControllerService;
 
-    public static $inject = ['$scope', '$timeout', '$log', '$state', 'StateService',
-        'WebClientService', 'ControllerService'];
+    public static $inject = [
+        '$scope', '$timeout', '$log', '$state',
+        'ControllerService', 'StateService', 'TimeoutService', 'WebClientService',
+    ];
     constructor($scope, $timeout: ng.ITimeoutService, $log: ng.ILogService, $state: UiStateService,
-                stateService: StateService, webClientService: WebClientService,
-                controllerService: ControllerService) {
+                controllerService: ControllerService, stateService: StateService,
+                timeoutService: TimeoutService, webClientService: WebClientService) {
 
         // Angular services
         this.$timeout = $timeout;
@@ -67,9 +72,10 @@ export class StatusController {
         this.$state = $state;
 
         // Custom services
+        this.controllerService = controllerService;
         this.stateService = stateService;
+        this.timeoutService = timeoutService;
         this.webClientService = webClientService;
-        this.controllerService = controllerService;
 
         // Register event handlers
         this.stateService.evtGlobalConnectionStateChange.attach(
@@ -122,6 +128,9 @@ export class StatusController {
                     }
                     this.reconnectAndroid();
                 }
+                if (this.stateService.wasConnected && isRelayedData) {
+                    this.reconnectIos();
+                }
                 break;
             default:
                 this.$log.error(this.logTag, 'Invalid state change: From', oldValue, 'to', newValue);
@@ -132,9 +141,9 @@ export class StatusController {
      * Show full status bar with a certain delay.
      */
     private scheduleStatusBar(): void {
-        this.expandStatusBarTimer = this.$timeout(() => {
+        this.expandStatusBarTimer = this.timeoutService.register(() => {
             this.expandStatusBar = true;
-        }, this.expandStatusBarTimeout);
+        }, this.expandStatusBarTimeout, true, 'expandStatusBar');
     }
 
     /**
@@ -143,7 +152,7 @@ export class StatusController {
     private collapseStatusBar(): void {
         this.expandStatusBar = false;
         if (this.expandStatusBarTimer !== null) {
-            this.$timeout.cancel(this.expandStatusBarTimer);
+            this.timeoutService.cancel(this.expandStatusBarTimer);
         }
     }
 
@@ -169,15 +178,13 @@ export class StatusController {
             // Collapse status bar
             this.collapseStatusBar();
 
-            // Reset state
-            this.stateService.reset();
-
-            // Redirect to welcome page
-            this.$state.go('welcome', {
-                initParams: {
-                    keyStore: originalKeyStore,
-                    peerTrustedKey: originalPeerPermanentKeyBytes,
-                },
+            // Reset connection & state
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionError,
+                send: false,
+                // TODO: Use welcome.error once we have it
+                close: 'welcome',
+                connectionBuildupState: 'reconnect_failed',
             });
         };
 
@@ -197,10 +204,16 @@ export class StatusController {
 
         // Function to soft-reconnect. Does not reset the loaded data.
         const doSoftReconnect = () => {
-            const resetPush = false;
-            const redirect = false;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionStopped,
+                send: true,
+                close: false,
+            });
+            this.webClientService.init({
+                keyStore: originalKeyStore,
+                peerTrustedKey: originalPeerPermanentKeyBytes,
+                resume: true,
+            });
             this.webClientService.start().then(
                 () => {
                     // Cancel timeout
@@ -244,40 +257,71 @@ export class StatusController {
      * Attempt to reconnect an iOS device after a connection loss.
      */
     private reconnectIos(): void {
-        this.$log.warn(this.logTag, 'Connection lost (iOS). Attempting to reconnect...');
+        this.$log.info(this.logTag, 'Connection lost (iOS). Attempting to reconnect...');
 
         // Get original keys
         const originalKeyStore = this.webClientService.salty.keyStore;
         const originalPeerPermanentKeyBytes = this.webClientService.salty.peerPermanentKeyBytes;
 
-        // Handler for failed reconnection attempts
-        const reconnectionFailed = () => {
-            // Reset state
-            this.stateService.reset();
-
-            // Redirect to welcome page
-            this.$state.go('welcome', {
-                initParams: {
-                    keyStore: originalKeyStore,
-                    peerTrustedKey: originalPeerPermanentKeyBytes,
-                },
-            });
-        };
-
-        const resetPush = false;
-        const skipPush = true;
-        const redirect = false;
-        const startTimeout = 500; // Delay connecting a bit to wait for old websocket to close
+        // Delay connecting a bit to wait for old websocket to close
+        // TODO: Make this more robust and hopefully faster
+        const startTimeout = 500;
         this.$log.debug(this.logTag, 'Stopping old connection');
-        this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
+            send: true,
+            close: false,
+            connectionBuildupState: 'push',
+        });
+
+        // Only send a push...
+        const push = ((): { send: boolean, reason?: string } => {
+            // ... if never left the 'welcome' page.
+            if (this.$state.includes('welcome')) {
+                return {
+                    send: true,
+                    reason: 'still on welcome page',
+                };
+            }
+
+            // ... if there is at least one unacknowledged wire message.
+            const pendingWireMessages = this.webClientService.unacknowledgedWireMessages;
+            if (pendingWireMessages > 0) {
+                return {
+                    send: true,
+                    reason: `${pendingWireMessages} unacknowledged wire messages`,
+                };
+            }
+
+            // ... otherwise, don't push!
+            return {
+                send: false,
+            };
+        })();
+
         this.$timeout(() => {
-            this.$log.debug(this.logTag, 'Starting new connection');
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
-            this.webClientService.start(skipPush).then(
+            if (push.send) {
+                this.$log.debug(`Starting new connection with push, reason: ${push.reason}`);
+            } else {
+                this.$log.debug('Starting new connection without push');
+            }
+            this.webClientService.init({
+                keyStore: originalKeyStore,
+                peerTrustedKey: originalPeerPermanentKeyBytes,
+                resume: true,
+            });
+
+            this.webClientService.start(!push.send).then(
                 () => { /* ok */ },
                 (error) => {
                     this.$log.error(this.logTag, 'Error state:', error);
-                    reconnectionFailed();
+                    this.webClientService.stop({
+                        reason: DisconnectReason.SessionError,
+                        send: false,
+                        // TODO: Use welcome.error once we have it
+                        close: 'welcome',
+                        connectionBuildupState: 'reconnect_failed',
+                    });
                 },
                 // Progress
                 (progress: threema.ConnectionBuildupStateChange) => {

+ 83 - 15
src/directives/avatar.ts

@@ -15,19 +15,20 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import {bufferToUrl, logAdapter} from '../helpers';
+import {bufferToUrl, hasValue, logAdapter} from '../helpers';
 import {isEchoContact, isGatewayContact} from '../receiver_helpers';
+import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
 import {isContactReceiver} from '../typeguards';
 
 export default [
     '$rootScope',
-    '$timeout',
     '$log',
+    'TimeoutService',
     'WebClientService',
     function($rootScope: ng.IRootScopeService,
-             $timeout: ng.ITimeoutService,
              $log: ng.ILogService,
+             timeoutService: TimeoutService,
              webClientService: WebClientService) {
         return {
             restrict: 'E',
@@ -36,6 +37,48 @@ export default [
                 receiver: '=eeeReceiver',
                 resolution: '=eeeResolution',
             },
+            link: function(scope, elem, attrs) {
+                scope.$watch(
+                    () => scope.ctrl.receiver,
+                    (newReceiver: threema.Receiver, oldReceiver: threema.Receiver) => {
+                        // Register for receiver changes. When something relevant changes, call the update function.
+                        // This prevents processing the avatar more often than necessary.
+
+                        if (!hasValue(newReceiver)) {
+                            // New receiver has no value
+                            return;
+                        }
+                        if (!hasValue(oldReceiver)) {
+                            // New receiver has value, old receiver doesn't
+                            scope.ctrl.update(false);
+                            return;
+                        }
+
+                        // Check for changes in relevant attributes
+                        if (newReceiver.id !== oldReceiver.id ||
+                            newReceiver.type !== oldReceiver.type ||
+                            newReceiver.color !== oldReceiver.color ||
+                            newReceiver.displayName !== oldReceiver.displayName) {
+                            scope.ctrl.update(false);
+                            return;
+                        }
+
+                        // Check for changes in the avatar itself
+                        if (hasValue(newReceiver.avatar)) {
+                            if (hasValue(oldReceiver.avatar)) {
+                                if (newReceiver.avatar.high !== oldReceiver.avatar.high ||
+                                    newReceiver.avatar.low !== oldReceiver.avatar.low) {
+                                    scope.ctrl.update(false);
+                                    return;
+                                }
+                            } else {
+                                scope.ctrl.update(false);
+                                return;
+                            }
+                        }
+                    },
+                );
+            },
             controllerAs: 'ctrl',
             controller: [function() {
                 this.logTag = '[Directives.Avatar]';
@@ -50,7 +93,7 @@ export default [
                     low: null,
                 };
                 this.avatarToUri = (data: ArrayBuffer, res: 'high' | 'low') => {
-                    if (data === null || data === undefined) {
+                    if (!hasValue(data)) {
                         return '';
                     }
                     if (avatarUri[res] === null) {
@@ -64,16 +107,35 @@ export default [
                     return avatarUri[res];
                 };
 
-                this.$onInit = function() {
+                /**
+                 * Update data when the receiver changes.
+                 */
+                this.update = (initial: boolean) => {
+                    // Reset avatar cache
+                    avatarUri.high = null;
+                    avatarUri.low = null;
+
+                    // Get receiver
+                    const receiver: threema.Receiver = this.receiver;
 
+                    // Set initial values
                     this.highResolution = this.resolution === 'high';
                     this.isLoading = this.highResolution;
-                    this.backgroundColor = (this.receiver as threema.Receiver).color;
-                    this.receiverName = (this.receiver as threema.Receiver).displayName;
-                    this.avatarClass = () => {
-                        return 'avatar-' + this.resolution + (this.isLoading ? ' is-loading' : '');
-                    };
+                    this.backgroundColor = receiver.color;
+                    this.receiverName = receiver.displayName;
+                };
+
+                this.$onInit = function() {
+                    this.update(true);
+
+                    /**
+                     * Return the CSS class for the avatar.
+                     */
+                    this.avatarClass = () => 'avatar-' + this.resolution + (this.isLoading ? ' is-loading' : '');
 
+                    /**
+                     * Return whether or not an avatar is available.
+                     */
                     this.avatarExists = () => {
                         if (this.receiver.avatar === undefined
                             || this.receiver.avatar[this.resolution] === undefined
@@ -144,25 +206,31 @@ export default [
                             if (loadingPromise === null) {
                                 // Do not wait on high resolution avatar
                                 const loadingTimeout = this.highResolution ? 0 : 500;
-                                loadingPromise = $timeout(() => {
+                                loadingPromise = timeoutService.register(() => {
                                     // show loading only on high res images!
                                     webClientService.requestAvatar({
                                         type: this.receiver.type,
                                         id: this.receiver.id,
-                                    } as threema.Receiver, this.highResolution).then((avatar) => {
+                                    } as threema.Receiver, this.highResolution)
+                                    .then((avatar) => {
                                         $rootScope.$apply(() => {
                                             this.isLoading = false;
                                         });
-                                    }).catch(() => {
+                                        loadingPromise = null;
+                                    })
+                                    .catch((error) => {
+                                        // TODO: Handle this properly / show an error message
+                                        $log.error(this.logTag, `Avatar request has been rejected: ${error}`);
                                         $rootScope.$apply(() => {
                                             this.isLoading = false;
                                         });
+                                        loadingPromise = null;
                                     });
-                                }, loadingTimeout);
+                                }, loadingTimeout, false, 'avatar');
                             }
                         } else if (loadingPromise !== null) {
                             // Cancel pending avatar loading
-                            $timeout.cancel(loadingPromise);
+                            timeoutService.cancel(loadingPromise);
                             loadingPromise = null;
                         }
                     };

+ 2 - 6
src/directives/avatar_editor.ts

@@ -23,12 +23,8 @@ import {bufferToUrl, logAdapter} from '../helpers';
  * Support uploading and resizing avatar
  */
 export default [
-    '$window',
-    '$timeout',
-    '$translate',
     '$log',
-    '$mdDialog',
-    function($window, $timeout: ng.ITimeoutService, $translate, $log: ng.ILogService, $mdDialog) {
+    function($log: ng.ILogService) {
         return {
             restrict: 'EA',
             scope: {
@@ -67,7 +63,7 @@ export default [
                                 clearTimeout(updateTimeout);
                             }
 
-                            updateTimeout = setTimeout(() => {
+                            updateTimeout = self.setTimeout(() => {
                                 croppieInstance.result({
                                     type: 'blob',
                                     // max allowed size on device

+ 6 - 1
src/directives/click_action.ts

@@ -25,7 +25,12 @@ export default [
     '$state',
     'UriService',
     'WebClientService',
-    function($timeout, $state: UiStateService, uriService: UriService, webClientService: WebClientService) {
+    function(
+        $timeout: ng.ITimeoutService,
+        $state: UiStateService,
+        uriService: UriService,
+        webClientService: WebClientService,
+    ) {
 
         const validateThreemaId = (id: string): boolean => {
             return id !== undefined && id !== null && /^[0-9A-Z]{8}/.test(id);

+ 10 - 15
src/directives/compose_area.ts

@@ -18,6 +18,7 @@
 import {isActionTrigger} from '../helpers';
 import {BrowserService} from '../services/browser';
 import {StringService} from '../services/string';
+import {TimeoutService} from '../services/timeout';
 
 /**
  * The compose area where messages are written.
@@ -25,7 +26,7 @@ import {StringService} from '../services/string';
 export default [
     'BrowserService',
     'StringService',
-    '$window',
+    'TimeoutService',
     '$timeout',
     '$translate',
     '$mdDialog',
@@ -34,7 +35,8 @@ export default [
     '$rootScope',
     function(browserService: BrowserService,
              stringService: StringService,
-             $window, $timeout: ng.ITimeoutService,
+             timeoutService: TimeoutService,
+             $timeout: ng.ITimeoutService,
              $translate: ng.translate.ITranslateService,
              $mdDialog: ng.material.IDialogService,
              $filter: ng.IFilterService,
@@ -130,7 +132,7 @@ export default [
                     // that we started typing earlier)
                     if (stopTypingTimer !== null) {
                         // Cancel timer
-                        $timeout.cancel(stopTypingTimer);
+                        timeoutService.cancel(stopTypingTimer);
                         stopTypingTimer = null;
 
                         // Send stop typing message
@@ -144,11 +146,11 @@ export default [
                         scope.startTyping();
                     } else {
                         // Cancel timer, we'll re-create it
-                        $timeout.cancel(stopTypingTimer);
+                        timeoutService.cancel(stopTypingTimer);
                     }
 
                     // Define a timeout to send the stopTyping event
-                    stopTypingTimer = $timeout(stopTyping, 10000);
+                    stopTypingTimer = timeoutService.register(stopTyping, 10000, true, 'stopTyping');
                 }
 
                 // Process a DOM node recursively and extract text from compose area.
@@ -159,9 +161,9 @@ export default [
                         //
                         // - Firefox and chrome insert a <br> between two text nodes
                         // - Safari creates two <div>s without any line break in between
+                        //   (except for the first line, which stays plain text)
                         //
-                        // Thus, for Safari, we need to detect adjacent <div>s and insert a newline.
-                        let lastNodeType = null;
+                        // Thus, for Safari, we need to detect <div>s and insert a newline.
 
                         // tslint:disable-next-line: prefer-for-of (see #98)
                         for (let i = 0; i < parentNode.childNodes.length; i++) {
@@ -170,28 +172,21 @@ export default [
                                 case Node.TEXT_NODE:
                                     // Append text, but strip leading and trailing newlines
                                     text += node.nodeValue.replace(/(^[\r\n]*|[\r\n]*$)/g, '');
-                                    lastNodeType = 'text';
                                     break;
                                 case Node.ELEMENT_NODE:
                                     const tag = node.tagName.toLowerCase();
                                     if (tag === 'div') {
-                                        if (lastNodeType === 'div') {
-                                            text += '\n';
-                                        }
+                                        text += '\n';
                                         visitChildNodes(node);
-                                        lastNodeType = 'div';
                                         break;
                                     } else if (tag === 'img') {
                                         text += (node as HTMLImageElement).alt;
-                                        lastNodeType = 'img';
                                         break;
                                     } else if (tag === 'br') {
                                         text += '\n';
-                                        lastNodeType = 'br';
                                         break;
                                     } else if (tag === 'span' && node.hasAttribute('text')) {
                                         text += node.getAttributeNode('text').value;
-                                        lastNodeType = 'span';
                                         break;
                                     }
                                 default:

+ 37 - 9
src/directives/contact_badge.ts

@@ -19,6 +19,7 @@
 
 import {StateService as UiStateService} from '@uirouter/angularjs';
 
+import {hasValue} from '../helpers';
 import {WebClientService} from '../services/webclient';
 
 /**
@@ -37,23 +38,50 @@ export default [
                 linked: '=?eeeLinked',
                 onRemove: '=?eeeOnRemove',
             },
+            link: function(scope, elem, attrs) {
+                // Manual change detection: Identity
+                scope.$watch(
+                    () => scope.ctrl.identity,
+                    (newIdentity, oldIdentity) => {
+                        if (hasValue(newIdentity) && newIdentity !== oldIdentity) {
+                            scope.ctrl.updateReceiverData();
+                        }
+                    },
+                );
+                // Manual change detection: Contact receiver
+                scope.$watch(
+                    () => scope.ctrl.contactReceiver,
+                    (newReceiver, oldReceiver) => {
+                        if (hasValue(newReceiver)) {
+                            if (!hasValue(oldReceiver) || newReceiver.id !== oldReceiver.id) {
+                                scope.ctrl.updateReceiverData();
+                            }
+                        }
+                    },
+                );
+            },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.$onInit = function() {
+                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;
+
+                this.updateReceiverData = () => {
+                    // Either a receiver or an identity is set
                     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.showActions = this.onRemove !== undefined;
+                this.$onInit = function() {
+                    this.updateReceiverData();
                 };
             }],
             template: `

+ 1 - 5
src/directives/drag_file.ts

@@ -19,12 +19,8 @@
  * Allow to drag and drop elements, set class to parent object
  */
 export default [
-    '$window',
-    '$timeout',
-    '$translate',
-    '$filter',
     '$log',
-    function($window, $timeout: ng.ITimeoutService, $translate, $filter: ng.IFilterService, $log: ng.ILogService) {
+    function($log: ng.ILogService) {
         return {
             restrict: 'EA',
             scope: {

+ 13 - 13
src/directives/latest_message.html

@@ -10,27 +10,27 @@
     </div>
 
     <!-- Left aligned message content -->
-    <div class="left no-draft no-typing no-hidden" ng-if="ctrl.message">
+    <div class="left no-draft no-typing no-hidden" ng-if="ctrl.conversation.latestMessage">
 
         <!-- If this receiver is a group, show contact name that sent last message. -->
         <span eee-message-contact
-              ng-if="ctrl.isGroup" eee-contact="ctrl.contact"></span>
+              ng-if="ctrl.isGroup" eee-contact="ctrl.getContact()"></span>
 
         <!-- For non-text-messages, show an icon. -->
-        <span eee-message-icon
-              ng-show="ctrl.showIcon" class="message-icon"
-              eee-message="ctrl.message"></span>
+        <eee-message-icon
+                ng-show="ctrl.showIcon()"
+                class="message-icon"
+                eee-message="ctrl.conversation.latestMessage"></eee-message-icon>
 
         <!-- For voip status messages -->
         <eee-message-voip-status
-                ng-if="ctrl.showVoipInfo"
+                ng-if="ctrl.showVoipInfo()"
                 class="message-voip-status"
-                eee-message="ctrl.message">
+                eee-message="ctrl.conversation.latestMessage">
         </eee-message-voip-status>
 
         <!-- For text-messages, show message text excerpt. -->
-        <span eee-message-text class="message-text" eee-message="ctrl.message"
-            eee-multi-line="false"></span>
+        <span eee-message-text class="message-text" message="ctrl.conversation.latestMessage" multi-line="false" linkify="false"></span>
 
     </div>
     <div class="left hidden no-typing">
@@ -44,11 +44,11 @@
     <div class="right">
         <span class="no-draft no-hidden">
             <span eee-message-date
-                  class="message-date" eee-message="ctrl.message"></span>
+                  class="message-date" eee-message="ctrl.conversation.latestMessage"></span>
 
-            <span class="message-state" ng-show="ctrl.statusIcon">
-                 <i class="material-icons md-medium-dark md-14 {{ctrl.message.state}}">
-                     {{ ctrl.statusIcon }}
+            <span class="message-state" ng-show="ctrl.getStatusIcon()">
+                 <i class="material-icons md-medium-dark md-14 {{ctrl.conversation.latestMessage.state}}">
+                     {{ ctrl.getStatusIcon() }}
                  </i>
             </span>
         </span>

+ 39 - 45
src/directives/latest_message.ts

@@ -28,71 +28,65 @@ export default [
             restrict: 'EA',
             scope: {},
             bindToController: {
-                type: '=eeeType',
-                message: '=eeeMessage',
-                receiver: '=eeeReceiver',
+                conversation: '<',
             },
             controllerAs: 'ctrl',
             controller: [function() {
                 this.$onInit = function() {
-
                     // Conversation properties
-                    this.isGroup = this.type as threema.ReceiverType === 'group';
-                    this.isDistributionList = !this.isGroup
-                        && this.type as threema.ReceiverType === 'distributionList';
+                    this.isGroup = this.conversation.type === 'group';
+                    this.isDistributionList = !this.isGroup && this.conversation.type === 'distributionList';
 
-                    this.showVoipInfo = this.message
-                        && (this.message as threema.Message).type === 'voipStatus';
+                    // Voip status
+                    this.showVoipInfo = () => this.conversation.latestMessage.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;
-                    }
+                    this.getStatusIcon = () => {
+                        if (this.showVoipInfo()) {
+                            return 'phone_locked';
+                        } else if (this.isGroup) {
+                            return 'group';
+                        } else if (this.isDistributionList) {
+                            return 'forum';
+                        } else if (!this.conversation.latestMessage.isOutbox) {
+                            return 'reply';
+                        } else {
+                            const showStatusIcon = messageService.showStatusIcon(
+                                this.conversation.latestMessage,
+                                this.conversation.receiver,
+                            );
+                            return showStatusIcon ? $filter('messageStateIcon')(this.conversation.latestMessage) : null;
+                        }
+                    };
 
                     // Find sender of latest message
-                    this.contact = null;
-                    if (this.message) {
-                        this.contact = webClientService.contacts.get(
-                            getSenderIdentity(this.message, webClientService.me.id),
+                    this.getContact = () => {
+                        return webClientService.contacts.get(
+                            getSenderIdentity(
+                                (this.conversation as threema.Conversation).latestMessage,
+                                webClientService.me.id,
+                            ),
                         );
-                    }
+                    };
+                    const contact = this.getContact();
 
                     // Typing indicator
                     this.isTyping = () => false;
-                    if (this.isGroup === false
-                        && this.isDistributionList === false
-                        && this.contact !== null) {
-                        this.isTyping = () => {
-                            return webClientService.isTyping(this.contact);
-                        };
+                    if (this.isGroup === false && this.isDistributionList === false && contact !== null) {
+                        this.isTyping = () => webClientService.isTyping(contact);
                     }
 
-                    this.isHidden = () => {
-                        return this.receiver.locked;
-                    };
+                    this.isHidden = () => this.conversation.receiver.locked;
 
                     // Show...
-                    this.showIcon = this.message
-                        && this.message.type !== 'text'
-                        && this.message.type !== 'status';
-
-                    this.getDraft = () => {
-                        return webClientService.getDraft(this.receiver);
+                    this.showIcon = () => {
+                        const message = (this.conversation as threema.Conversation).latestMessage;
+                        return message.type !== 'text' && message.type !== 'status';
                     };
 
+                    // Drafts
+                    this.getDraft = () => webClientService.getDraft(this.conversation.receiver);
                     this.showDraft = () => {
-                        if (receiverService.isConversationActive(this.receiver)) {
+                        if (receiverService.isConversationActive(this.conversation.receiver)) {
                             // Don't show draft if conversation is active
                             return false;
                         }

+ 17 - 6
src/directives/mediabox.ts

@@ -15,16 +15,19 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {saveAs} from 'file-saver';
+
+import {bufferToUrl, logAdapter} from '../helpers';
 import {MediaboxService} from '../services/mediabox';
 
 export default [
     '$rootScope',
-    '$filter',
     '$document',
+    '$log',
     'MediaboxService',
     function($rootScope: ng.IRootScopeService,
-             $filter: ng.IFilterService,
              $document: ng.IDocumentService,
+             $log: ng.ILogService,
              mediaboxService: MediaboxService) {
         return {
             restrict: 'E',
@@ -32,6 +35,8 @@ export default [
             bindToController: {},
             controllerAs: 'ctrl',
             controller: [function() {
+                this.logTag = '[MediaboxDirective]';
+
                 // Data attributes
                 this.imageDataUrl = null;
                 this.caption = '';
@@ -53,12 +58,18 @@ export default [
                 };
 
                 // Listen to Mediabox service events
-                const bufferToUrl = $filter('bufferToUrl') as
-                    (buffer: ArrayBuffer, mimeType: string, trust: boolean) => string;
                 mediaboxService.evtMediaChanged.attach((dataAvailable: boolean) => {
                     $rootScope.$apply(() => {
-                        this.imageDataUrl = bufferToUrl(mediaboxService.data, mediaboxService.mimetype, true);
-                        this.caption = mediaboxService.caption || mediaboxService.filename;
+                        if (dataAvailable) {
+                            this.imageDataUrl = bufferToUrl(
+                                mediaboxService.data,
+                                mediaboxService.mimetype,
+                                logAdapter($log.debug, this.logTag),
+                            );
+                            this.caption = mediaboxService.caption || mediaboxService.filename;
+                        } else {
+                            this.close();
+                        }
                     });
                 });
             }],

+ 2 - 2
src/directives/message.html

@@ -41,7 +41,7 @@
         <eee-message-text
             ng-if="ctrl.showText"
             class="message-text"
-            eee-message="ctrl.message">
+            message="ctrl.message">
         </eee-message-text>
 
         <div class="message-info">
@@ -60,7 +60,7 @@
 <!-- Status messages -->
 <article ng-if="ctrl.isStatusMessage" class="message message-status">
     <div ng-if="ctrl.message.statusType == 'text'" class="message-body">
-        <eee-message-text class="message-text" eee-message="ctrl.message"></eee-message-text>
+        <eee-message-text class="message-text" message="ctrl.message"></eee-message-text>
     </div>
     <div ng-if="ctrl.message.statusType == 'firstUnreadMessage'" class="unread-separator">
         <div class="line"></div>

+ 31 - 6
src/directives/message.ts

@@ -17,23 +17,29 @@
 
 // tslint:disable:max-line-length
 
+import {saveAs} from 'file-saver';
+
+import {BrowserInfo} from '../helpers/browser_info';
 import {getSenderIdentity} from '../helpers/messages';
+import {BrowserService} from '../services/browser';
 import {MessageService} from '../services/message';
 import {ReceiverService} from '../services/receiver';
 import {WebClientService} from '../services/webclient';
 
 export default [
-    'WebClientService',
+    'BrowserService',
     'MessageService',
     'ReceiverService',
+    'WebClientService',
     '$mdDialog',
     '$mdToast',
     '$translate',
     '$rootScope',
     '$log',
-    function(webClientService: WebClientService,
+    function(browserService: BrowserService,
              messageService: MessageService,
              receiverService: ReceiverService,
+             webClientService: WebClientService,
              $mdDialog: ng.material.IDialogService,
              $mdToast: ng.material.IToastService,
              $translate: ng.translate.ITranslateService,
@@ -53,6 +59,9 @@ export default [
             controller: [function() {
                 this.logTag = '[MessageDirective]';
 
+                // Determine browser
+                this.browserInfo = browserService.getBrowser();
+
                 this.$onInit = function() {
 
                     // Defaults and variables
@@ -115,11 +124,26 @@ export default [
                         // 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();
+
+                        if ((this.browserInfo as BrowserInfo).isSafari()) {
+                            // Safari: Create a selection range.
+                            // Inspiration: https://stackoverflow.com/a/34046084/284318
+                            textArea.contentEditable = 'true';
+                            textArea.readOnly = false;
+                            const range = document.createRange();
+                            const selection = self.getSelection();
+                            selection.removeAllRanges();
+                            selection.addRange(range);
+                            textArea.setSelectionRange(0, 999999);
+                        } else {
+                            textArea.focus();
+                            textArea.select();
+                        }
+
+                        // Copy selection to clipboard
+                        let toastString = 'messenger.COPY_ERROR';
                         try {
                             const successful = document.execCommand('copy');
                             if (!successful) {
@@ -159,7 +183,8 @@ export default [
                                 });
                             })
                             .catch((error) => {
-                                $log.error(this.logTag, 'Error downloading blob:', error);
+                                // TODO: Handle this properly / show an error message
+                                $log.error(this.logTag, `Error downloading blob: ${error}`);
                                 this.downloading = false;
                             });
                     };

+ 19 - 1
src/directives/message_icon.ts

@@ -15,6 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {hasValue} from '../helpers';
+
 export default [
     function() {
         return {
@@ -23,6 +25,18 @@ export default [
             bindToController: {
                 message: '=eeeMessage',
             },
+            link: function(scope, elem, attrs) {
+                scope.$watch(
+                    () => scope.ctrl.message.id,
+                    (newId, oldId) => {
+                        // Register for message changes. When the ID changes, update the icon.
+                        // This prevents processing the message more than once.
+                        if (hasValue(newId) && newId !== oldId) {
+                            scope.ctrl.update();
+                        }
+                    },
+                );
+            },
             controllerAs: 'ctrl',
             controller: [function() {
                 // Return icon depending on message type.
@@ -48,10 +62,14 @@ export default [
                     }
                 };
 
-                this.$onInit = function() {
+                this.update = () => {
                     this.icon = getIcon(this.message.type);
                     this.altText = this.message.type + ' icon';
                 };
+
+                this.$onInit = function() {
+                    this.update();
+                };
             }],
             template: `
                 <img ng-if="ctrl.icon !== null" ng-src="img/{{ ctrl.icon }}" alt="{{ ctrl.altText }}">

+ 25 - 5
src/directives/message_media.ts

@@ -15,9 +15,13 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Transition as UiTransition, TransitionService as UiTransitionService} from '@uirouter/angularjs';
+import {saveAs} from 'file-saver';
+
 import {bufferToUrl, hasValue, logAdapter} from '../helpers';
 import {MediaboxService} from '../services/mediabox';
 import {MessageService} from '../services/message';
+import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
 
 function showAudioDialog(
@@ -62,9 +66,11 @@ export default [
     'WebClientService',
     'MediaboxService',
     'MessageService',
+    'TimeoutService',
     '$rootScope',
     '$mdDialog',
     '$timeout',
+    '$transitions',
     '$translate',
     '$log',
     '$filter',
@@ -72,9 +78,11 @@ export default [
     function(webClientService: WebClientService,
              mediaboxService: MediaboxService,
              messageService: MessageService,
+             timeoutService: TimeoutService,
              $rootScope: ng.IRootScopeService,
              $mdDialog: ng.material.IDialogService,
              $timeout: ng.ITimeoutService,
+             $transitions: UiTransitionService,
              $translate: ng.translate.ITranslateService,
              $log: ng.ILogService,
              $filter: ng.IFilterService,
@@ -91,6 +99,11 @@ export default [
             controller: [function() {
                 this.logTag = '[MessageMedia]';
 
+                // On state transitions, clear mediabox
+                $transitions.onStart({}, function(trans: UiTransition) {
+                    mediaboxService.clearMedia();
+                });
+
                 this.$onInit = function() {
                     this.type = this.message.type;
 
@@ -139,7 +152,7 @@ export default [
                         };
                     }
 
-                    let loadingThumbnailTimeout = null;
+                    let loadingThumbnailTimeout: ng.IPromise<void> = null;
 
                     this.wasInView = false;
                     this.thumbnailInView = (inView: boolean) => {
@@ -151,7 +164,9 @@ export default [
                         this.wasInView = inView;
 
                         if (!inView) {
-                            $timeout.cancel(loadingThumbnailTimeout);
+                            if (loadingThumbnailTimeout !== null) {
+                                timeoutService.cancel(loadingThumbnailTimeout);
+                            }
                             this.thumbnailDownloading = false;
                             this.thumbnail = null;
                         } else {
@@ -169,14 +184,19 @@ export default [
                                     return;
                                 } else {
                                     this.thumbnailDownloading = true;
-                                    loadingThumbnailTimeout = $timeout(() => {
+                                    loadingThumbnailTimeout = timeoutService.register(() => {
                                         webClientService
                                             .requestThumbnail(this.receiver, this.message)
                                             .then((img) => $timeout(() => {
                                                 setThumbnail(img);
                                                 this.thumbnailDownloading = false;
-                                            }));
-                                    }, 1000);
+                                            }))
+                                            .catch((error) => {
+                                                // TODO: Handle this properly / show an error message
+                                                const message = `Thumbnail request has been rejected: ${error}`;
+                                                this.$log.error(this.logTag, message);
+                                            });
+                                    }, 1000, false, 'thumbnail');
                                 }
                             }
                         }

+ 73 - 26
src/directives/message_text.ts

@@ -17,36 +17,64 @@
 
 // tslint:disable:max-line-length
 
+import {hasValue} from '../helpers';
 import {WebClientService} from '../services/webclient';
 
+// Get text depending on type
+function getText(message: threema.Message): string {
+    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;
+}
+
 export default [
     function() {
         return {
             restrict: 'EA',
             scope: {},
             bindToController: {
-                message: '=eeeMessage',
-                multiLine: '=?eeeMultiLine',
+                message: '=',
+                multiLine: '@?multiLine',
+                linkify: '@?linkify',
+            },
+            link: function(scope, elem, attrs) {
+                scope.$watch(
+                    () => scope.ctrl.message.id,
+                    (newId, oldId) => {
+                        // Register for message ID changes. When it changes, update the text.
+                        // This prevents processing the text more than once.
+                        if (hasValue(newId) && newId !== oldId) {
+                            scope.ctrl.updateText();
+                        }
+                    },
+                );
+                scope.$watch(
+                    () => scope.ctrl.message.caption,
+                    (newCaption, oldCaption) => {
+                        // Register for message caption changes. When it changes, update the text.
+                        //
+                        // Background: The caption may change because image messages may be sent from the
+                        // app before the image has been downloaded and parsed. That information
+                        // (thumbnail + caption) is sent later on as an update.
+                        if (hasValue(newCaption) && newCaption !== oldCaption) {
+                            scope.ctrl.updateText();
+                        }
+                    },
+                );
+
             },
             controllerAs: 'ctrl',
             controller: ['WebClientService', '$filter', function(webClientService: WebClientService, $filter: ng.IFilterService) {
-                // Get text depending on type
-                function getText(message: threema.Message): string {
-                    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;
-                }
-
                 // TODO: Extract filters into helper functions
                 const escapeHtml = $filter('escapeHtml') as any;
                 const markify = $filter('markify') as any;
@@ -59,17 +87,36 @@ export default [
                 /**
                  * Apply filters to text.
                  */
-                function processText(text: string, largeSingleEmoji: boolean, multiLine: boolean): string {
-                    return nlToBr(linkify(mentionify(enlargeSingleEmoji(emojify(markify(escapeHtml(text))), enlargeSingleEmoji))), multiLine);
+                function processText(text: string, largeSingleEmoji: boolean, multiLine: boolean, linkifyText: boolean): string {
+                    const nonLinkified = mentionify(enlargeSingleEmoji(emojify(markify(escapeHtml(text))), largeSingleEmoji));
+                    const maybeLinkified = linkifyText ? linkify(nonLinkified) : nonLinkified;
+                    return nlToBr(maybeLinkified, multiLine);
                 }
 
-                this.enlargeSingleEmoji = webClientService.appConfig.largeSingleEmoji;
+                /**
+                 * Text update function.
+                 */
+                this.updateText = () => {
+                    // Because this.multiLine and this.linkify are bound using an `@` binding,
+                    // they are either undefined or a string. Convert to boolean.
+                    const multiLine = (this.multiLine === undefined || this.multiLine !== 'false');
+                    const linkifyText = (this.linkify === undefined || this.linkify !== 'false');
+
+                    // Process text once, apply all filter functions
+                    const text = getText(this.message);
+                    this.text = processText(
+                        text,
+                        this.largeSingleEmoji,
+                        multiLine,
+                        linkifyText,
+                    );
+                };
+
+                this.largeSingleEmoji = webClientService.appConfig.largeSingleEmoji;
 
                 this.$onInit = function() {
-                    if (this.multiLine === undefined) {
-                        this.multiLine = true;
-                    }
-                    this.text = processText(getText(this.message), this.largeSingleEmoji, this.multiLine);
+                    // Process initial text
+                    this.updateText();
                 };
             }],
             template: `

+ 9 - 12
src/filters.ts

@@ -15,7 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import {bufferToUrl, escapeRegExp, filter, logAdapter} from './helpers';
+import {bufferToUrl, escapeRegExp, filter, hasValue, logAdapter} from './helpers';
+import {markify} from './markup_parser';
 import {MimeService} from './services/mime';
 import {NotificationService} from './services/notification';
 import {WebClientService} from './services/webclient';
@@ -137,15 +138,7 @@ angular.module('3ema.filters', [])
  * Convert markdown elements to html elements
  */
 .filter('markify', function() {
-    return function(text) {
-        if (text !== null) {
-            text = text.replace(/\B\*([^\r\n]+?)\*\B/g, '<span class="text-bold">$1</span>');
-            text = text.replace(/\b_([^\r\n]+?)_\b/g, '<span class="text-italic">$1</span>');
-            text = text.replace(/\B~([^\r\n]+?)~\B/g, '<span class="text-strike">$1</span>');
-            return text;
-        }
-        return text;
-    };
+    return markify;
 })
 
 /**
@@ -394,8 +387,12 @@ angular.module('3ema.filters', [])
     return(ids: string[]) => {
         const names: string[] = [];
         for (const id of ids) {
-            this.contactReceiver = webClientService.contacts.get(id);
-            names.push(this.contactReceiver.displayName);
+            const contactReceiver = webClientService.contacts.get(id);
+            if (hasValue(contactReceiver)) {
+                names.push(contactReceiver.displayName);
+            } else {
+                names.push('Unknown');
+            }
         }
         return names.join(', ');
     };

+ 31 - 1
src/helpers.ts

@@ -258,7 +258,7 @@ export function escapeRegExp(str: string) {
  * msgpack encoded data.
  */
 export function msgpackVisualizer(bytes: Uint8Array): string {
-    return 'https://msgpack.dbrgn.ch#base64=' + encodeURIComponent(btoa(bytes as any));
+    return 'https://msgpack.dbrgn.ch#base64=' + encodeURIComponent(btoa(String.fromCharCode.apply(null, bytes)));
 }
 
 /**
@@ -340,6 +340,22 @@ export function sleep(ms: number): Promise<void> {
 }
 
 /**
+ * Compare two Uint8Array instances. Return true if all elements are equal
+ * (compared using ===).
+ */
+export function arraysAreEqual(a1: Uint8Array, a2: Uint8Array): boolean {
+    if (a1.length !== a2.length) {
+        return false;
+    }
+    for (let i = 0; i < a1.length; i++) {
+        if (a1[i] !== a2[i]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/*
  * Return whether this key event should trigger a button.
  * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
  */
@@ -355,3 +371,17 @@ export function isActionTrigger(ev: KeyboardEvent): boolean {
             return false;
     }
 }
+
+/*
+ * Create a shallow copy of an object.
+ */
+export function copyShallow<T extends object>(obj: T): T {
+    return Object.assign({}, obj);
+}
+
+/**
+ * Create a deep copy of an object by serializing and deserializing it.
+ */
+export function copyDeep<T extends object>(obj: T): T {
+    return JSON.parse(JSON.stringify(obj));
+}

+ 2 - 2
src/helpers/browser_info.ts

@@ -89,11 +89,11 @@ export class BrowserInfo {
         }
     }
 
-    public isFirefox(requireVersion: boolean): boolean {
+    public isFirefox(requireVersion: boolean = false): boolean {
         return this.name === threema.BrowserName.Firefox && (!requireVersion || this.version !== null);
     }
 
-    public isSafari(requireVersion: boolean): boolean {
+    public isSafari(requireVersion: boolean = false): boolean {
         return this.name === threema.BrowserName.Safari && (!requireVersion || this.version !== null);
     }
 }

+ 232 - 0
src/markup_parser.ts

@@ -0,0 +1,232 @@
+/**
+ * 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/>.
+ */
+
+export const enum TokenType {
+    Text,
+    Newline,
+    Asterisk,
+    Underscore,
+    Tilde,
+}
+
+export interface Token {
+    kind: TokenType;
+    value?: string;
+}
+
+// The markup characters.
+const markupChars = {
+    [TokenType.Asterisk]: '*',
+    [TokenType.Underscore]: '_',
+    [TokenType.Tilde]: '~',
+};
+
+// CSS classes for the HTML markup.
+const cssClasses = {
+    [TokenType.Asterisk]: 'text-bold',
+    [TokenType.Underscore]: 'text-italic',
+    [TokenType.Tilde]: 'text-strike',
+};
+
+/**
+ * Return whether the specified token type is a markup token.
+ */
+function isMarkupToken(tokenType: TokenType) {
+    return markupChars.hasOwnProperty(tokenType);
+}
+
+/**
+ * Return whether the specified character is a boundary character.
+ * When `character` is undefined, the function will return true.
+ */
+function isBoundary(character?: string) {
+    return character === undefined || /[\s.,!?¡¿‽⸮;:&(){}\[\]⟨⟩‹›«»'"‘’“”*~\-_…⋯᠁]/.test(character);
+}
+
+/**
+ * This function accepts a string and returns a list of tokens.
+ */
+export function tokenize(text: string): Token[] {
+    const tokens = [];
+    let textBuf = '';
+
+    const pushTextBufToken = () => {
+        if (textBuf.length > 0) {
+            tokens.push({ kind: TokenType.Text, value: textBuf });
+            textBuf = '';
+        }
+    };
+
+    for (let i = 0; i < text.length; i++) {
+        const currentChar = text[i];
+        const prevIsBoundary = isBoundary(text[i - 1]);
+        const nextIsBoundary = isBoundary(text[i + 1]);
+
+        if (currentChar === '*' && (prevIsBoundary || nextIsBoundary)) {
+            pushTextBufToken();
+            tokens.push({ kind: TokenType.Asterisk });
+        } else if (currentChar === '_' && (prevIsBoundary || nextIsBoundary)) {
+            pushTextBufToken();
+            tokens.push({ kind: TokenType.Underscore });
+        } else if (currentChar === '~' && (prevIsBoundary || nextIsBoundary)) {
+            pushTextBufToken();
+            tokens.push({ kind: TokenType.Tilde });
+        } else if (currentChar === '\n') {
+            pushTextBufToken();
+            tokens.push({ kind: TokenType.Newline });
+        } else {
+            textBuf += currentChar;
+        }
+    }
+
+    pushTextBufToken();
+
+    return tokens;
+}
+
+export function parse(tokens: Token[]): string {
+    const stack: Token[] = [];
+
+    // Booleans to avoid searching the stack.
+    // This is used for optimization.
+    const tokensPresent = {
+        [TokenType.Asterisk]: false,
+        [TokenType.Underscore]: false,
+        [TokenType.Tilde]: false,
+    };
+
+    // Helper: When called with a value, mark the token type as present or not.
+    // When called without a value, return whether this token type is present.
+    const hasToken = (token: TokenType, value?: boolean) => {
+        if (value === undefined) {
+            return tokensPresent[token];
+        }
+        tokensPresent[token] = value;
+    };
+
+    // Helper: Consume the stack, return a string.
+    const consumeStack = () => {
+        let textBuf = '';
+        for (const token of stack) {
+            switch (token.kind) {
+                case TokenType.Text:
+                    textBuf += token.value;
+                    break;
+                case TokenType.Asterisk:
+                case TokenType.Underscore:
+                case TokenType.Tilde:
+                    textBuf += markupChars[token.kind];
+                    break;
+                case TokenType.Newline:
+                    throw new Error('Unexpected newline token on stack');
+                default:
+                    throw new Error('Unknown token on stack: ' + token.kind);
+            }
+        }
+        // Clear stack
+        // https://stackoverflow.com/a/1232046
+        stack.splice(0, stack.length);
+        return textBuf;
+    };
+
+    // Helper: Pop the stack, throw an exception if it's empty
+    const popStack = () => {
+        const stackTop = stack.pop();
+        if (stackTop === undefined) {
+            throw new Error('Stack is empty');
+        }
+        return stackTop;
+    };
+
+    // Helper: Add markup HTML to the stack
+    const pushMarkup = (textParts: string[], cssClass: string) => {
+        let html = `<span class="${cssClass}">`;
+        for (let i = textParts.length - 1; i >= 0; i--) {
+            html += textParts[i];
+        }
+        html += '</span>';
+        stack.push({ kind: TokenType.Text, value: html });
+    };
+
+    // Process the tokens. Add them to a stack. When a token pair is complete
+    // (e.g. the second asterisk is found), pop the stack until you find the
+    // matching token and convert everything in between to formatted text.
+    for (const token of tokens) {
+        switch (token.kind) {
+
+            // Keep text as-is
+            case TokenType.Text:
+                stack.push(token);
+                break;
+
+            // If a markup token is found, try to find a matching token.
+            case TokenType.Asterisk:
+            case TokenType.Underscore:
+            case TokenType.Tilde:
+                // Optimization: Only search the stack if a token with this token type exists
+                if (hasToken(token.kind)) {
+                    // Pop tokens from the stack. If a matching token was found, apply
+                    // markup to the text parts in between those two tokens.
+                    const textParts = [];
+                    while (true) {
+                        const stackTop = popStack();
+                        if (stackTop.kind === TokenType.Text) {
+                            textParts.push(stackTop.value);
+                        } else if (stackTop.kind === token.kind) {
+                            if (textParts.length > 0) {
+                                pushMarkup(textParts, cssClasses[token.kind]);
+                            } else {
+                                // If this happens, then two markup chars were following each other (e.g. **hello).
+                                // In that case, just keep them as regular text characters, without applying any markup.
+                                const markupChar = markupChars[token.kind];
+                                stack.push({ kind: TokenType.Text, value: markupChar + markupChar });
+                            }
+                            hasToken(token.kind, false);
+                            break;
+                        } else if (isMarkupToken(stackTop.kind)) {
+                            textParts.push(markupChars[stackTop.kind]);
+                        } else {
+                            throw new Error('Unknown token on stack: ' + token.kind);
+                        }
+                        hasToken(stackTop.kind, false);
+                    }
+                } else {
+                    stack.push(token);
+                    hasToken(token.kind, true);
+                }
+                break;
+
+            // Don't apply formatting across newlines, consume the current stack!
+            case TokenType.Newline:
+                stack.push({ kind: TokenType.Text, value: consumeStack() + '\n' });
+                hasToken(TokenType.Asterisk, false);
+                hasToken(TokenType.Underscore, false);
+                hasToken(TokenType.Tilde, false);
+                break;
+
+            default:
+                throw new Error('Invalid token kind: ' + token.kind);
+        }
+    }
+
+    // Concatenate processed tokens
+    return consumeStack();
+}
+
+export function markify(text: string): string {
+    return parse(tokenize(text));
+}

+ 1 - 1
src/partials/dialog.version.html

@@ -15,7 +15,7 @@
                 <p>{{ ctrl.version }}</p>
 
                 <h2 translate>welcome.BACKGROUND_IMAGE</h2>
-                <p><a ng-href="{{ ctrl.config.VERSION_MOUNTAIN_URL }}" target="_blank" rel="noopener noreferrer">{{ ctrl.config.VERSION_MOUNTAIN }} ({{ ctrl.config.VERSION_MOUNTAIN_HEIGHT }}m)</a> / <a ng-href="{{ ctrl.config.VERSION_MOUNTAIN_IMAGE_URL }}" target="_blank" rel="noopener noreferrer">Source Image</a></p>
+                <p><a ng-href="{{ ctrl.config.VERSION_MOUNTAIN_URL }}" target="_blank" rel="noopener noreferrer">{{ ctrl.config.VERSION_MOUNTAIN }} ({{ ctrl.config.VERSION_MOUNTAIN_HEIGHT }}m)</a> / <a ng-href="{{ ctrl.config.VERSION_MOUNTAIN_IMAGE_URL }}" target="_blank" rel="noopener noreferrer">Source Image</a> ({{ ctrl.config.VERSION_MOUNTAIN_IMAGE_COPYRIGHT }})</p>
 
                 <h2 translate>about.CHANGELOG</h2>
                 <p>

+ 17 - 2
src/partials/messenger.conversation.html

@@ -6,6 +6,8 @@
 
     <div id="conversation-header" class="detail-header">
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
+
+        <!-- Conversation details -->
         <div class="header-avatar" ng-click="ctrl.showReceiver()">
             <eee-avatar eee-receiver="ctrl.receiver"
                         eee-resolution="'low'"></eee-avatar>
@@ -22,7 +24,20 @@
                 <span ng-bind-html="ctrl.receiver.members | idsToNames | escapeHtml | emojify"></span>
             </div>
         </div>
+
+        <!-- Menu -->
+        <div class="header-buttons">
+            <toggle-button
+                flag="ctrl.conversation.isStarred"
+                on-enable="ctrl.pinConversation()"
+                on-disable="ctrl.unpinConversation()"
+                label-enabled="messenger.PINNED_CONVERSATION"
+                label-disabled="messenger.UNPINNED_CONVERSATION"
+                icon-enabled="img/ic_pin.svg"
+                icon-disabled="img/ic_unpin.svg">
+        </div>
     </div>
+
     <div id="conversation-is-private" ng-if="ctrl.locked">
         <md-card>
             <md-toolbar class="md-warn">
@@ -40,13 +55,13 @@
         <ul class="chat">
             <li in-view="$inview && !ctrl.locked && ctrl.topOfChat()" class="load-more">
                 <div ng-if="ctrl.hasMoreMessages()" class="loading">
-                    <img ng-src="img/spinner.gif" alt="..." translate translate-attr-aria-label="messenger.LOADING_MESSAGES">
+                    <img src="img/spinner.gif" alt="..." translate translate-attr-aria-label="messenger.LOADING_MESSAGES">
                 </div>
             </li>
             <li ng-repeat="message in ctrl.messages" id="message-{{message.id}}">
                 <eee-message eee-receiver="ctrl.receiver" eee-type="ctrl.type" eee-message="message"
                              in-view="$inview  && !ctrl.locked && ctrl.msgRead(message)"
-                             in-view-options="{ considerPageVisibility: true }"></eee-message>
+                             in-view-options="{ considerPageFocus: true }"></eee-message>
             </li>
             <li ng-if="ctrl.isTyping()" class="typing-indicator">
                 <!-- Non status messages -->

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

@@ -64,7 +64,8 @@
 
 <!-- Conversations -->
 <div id="navigation-conversations" class="tab-content" ng-if="ctrl.activeTab == 'conversations'" in-view-container>
-    <p class="empty" ng-if="ctrl.conversations().length === 0" translate>messenger.NO_CONVERSATIONS_FOUND</p>
+    <p class="empty" ng-if="ctrl.conversations().length === 0 && !ctrl.startupDone()" translate>messenger.LOADING_CONVERSATIONS</p>
+    <p class="empty" ng-if="ctrl.conversations().length === 0 && ctrl.startupDone()" translate>messenger.NO_CONVERSATIONS_FOUND</p>
     <ul>
         <li ng-repeat="conversation in ctrl.conversations() | filter:ctrl.searchConversation"
             ng-init="dndModeSimplified = ctrl.dndModeSimplified(conversation)"
@@ -105,9 +106,7 @@
                         <eee-latest-message
                             ng-if="!conversation.receiver.isTyping() && conversation.latestMessage"
                             ng-class="latest-message-text"
-                            eee-type="conversation.type"
-                            eee-receiver="conversation.receiver"
-                            eee-message="conversation.latestMessage"></eee-latest-message>
+                            conversation="conversation"></eee-latest-message>
                     </section>
                 </section>
 

+ 129 - 58
src/partials/messenger.ts

@@ -24,11 +24,10 @@ import {
 } from '@uirouter/angularjs';
 
 import {ContactControllerModel} from '../controller_model/contact';
-import {bufferToUrl, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
+import {bufferToUrl, hasValue, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
 import {ContactService} from '../services/contact';
 import {ControllerService} from '../services/controller';
 import {ControllerModelService} from '../services/controller_model';
-import {ExecuteService} from '../services/execute';
 import {FingerPrintService} from '../services/fingerprint';
 import {TrustedKeyStoreService} from '../services/keystore';
 import {MimeService} from '../services/mime';
@@ -36,6 +35,7 @@ import {NotificationService} from '../services/notification';
 import {ReceiverService} from '../services/receiver';
 import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
+import {TimeoutService} from '../services/timeout';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 import {isContactReceiver} from '../typeguards';
@@ -204,7 +204,6 @@ class ConversationController {
 
     // Angular services
     private $stateParams;
-    private $timeout: ng.ITimeoutService;
     private $state: UiStateService;
     private $log: ng.ILogService;
     private $scope: ng.IScope;
@@ -217,6 +216,7 @@ class ConversationController {
     private receiverService: ReceiverService;
     private stateService: StateService;
     private mimeService: MimeService;
+    private timeoutService: TimeoutService;
 
     // Third party services
     private $mdDialog: ng.material.IDialogService;
@@ -233,6 +233,7 @@ class ConversationController {
 
     // The conversation receiver
     public receiver: threema.Receiver;
+    public conversation: threema.Conversation;
     public type: threema.ReceiverType;
 
     // The conversation messages
@@ -268,14 +269,13 @@ class ConversationController {
     };
 
     public static $inject = [
-        '$stateParams', '$timeout', '$log', '$scope', '$rootScope',
+        '$stateParams', '$log', '$scope', '$rootScope',
         '$mdDialog', '$mdToast', '$translate', '$filter',
         '$state', '$transitions',
         'WebClientService', 'StateService', 'ReceiverService', 'MimeService', 'VersionService',
-        'ControllerModelService',
+        'ControllerModelService', 'TimeoutService',
     ];
     constructor($stateParams: ConversationStateParams,
-                $timeout: ng.ITimeoutService,
                 $log: ng.ILogService,
                 $scope: ng.IScope,
                 $rootScope: ng.IRootScopeService,
@@ -290,14 +290,15 @@ class ConversationController {
                 receiverService: ReceiverService,
                 mimeService: MimeService,
                 versionService: VersionService,
-                controllerModelService: ControllerModelService) {
+                controllerModelService: ControllerModelService,
+                timeoutService: TimeoutService) {
         this.$stateParams = $stateParams;
-        this.$timeout = $timeout;
         this.$log = $log;
         this.webClientService = webClientService;
         this.receiverService = receiverService;
         this.stateService = stateService;
         this.mimeService = mimeService;
+        this.timeoutService = timeoutService;
 
         this.$state = $state;
         this.$scope = $scope;
@@ -342,9 +343,10 @@ class ConversationController {
             }, 100, this), supportsPassive() ? {passive: true} : false);
         }
 
-        // Set receiver and type
+        // Set receiver, conversation and type
         try {
             this.receiver = webClientService.receivers.getData({type: $stateParams.type, id: $stateParams.id});
+            this.conversation = this.webClientService.conversations.find(this.receiver);
             this.type = $stateParams.type;
 
             if (this.receiver.type === undefined) {
@@ -384,7 +386,7 @@ class ConversationController {
                 return;
             }
 
-            // initial set locked state
+            // Initial set locked state
             this.locked = this.receiver.locked;
 
             this.receiverService.setActive(this.receiver);
@@ -442,14 +444,22 @@ class ConversationController {
                     });
                 }
 
+                // Set initial data
                 this.initialData = {
                     draft: webClientService.getDraft(this.receiver),
                     initialText: $stateParams.initParams ? $stateParams.initParams.text : '',
                 };
 
+                // Set isTyping function for contacts
                 if (isContactReceiver(this.receiver)) {
                     this.isTyping = () => this.webClientService.isTyping(this.receiver as threema.ContactReceiver);
                 }
+
+                // Due to a bug in Safari, sometimes the in-view element does not trigger when initially loading a chat.
+                // As a workaround, manually trigger the initial message loading.
+                if (this.webClientService.messages.getList(this.receiver).length === 0) {
+                    this.requestMessages();
+                }
             }
         } catch (error) {
             $log.error('Could not set receiver and type');
@@ -485,15 +495,25 @@ class ConversationController {
         this.webClientService.setQuote(this.receiver);
     }
 
-    public showError(errorMessage: string, toastLength = 4000) {
+    public showError(errorMessage?: string, hideDelayMs = 3000) {
         if (errorMessage === undefined || errorMessage.length === 0) {
             errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
         }
         this.$mdToast.show(
             this.$mdToast.simple()
                 .textContent(errorMessage)
-                .position('bottom center'));
+                .position('bottom center')
+                .hideDelay(hideDelayMs));
     }
+
+    public showMessage(msgTranslation: string, hideDelayMs = 3000) {
+        this.$mdToast.show(
+            this.$mdToast.simple()
+                .textContent(this.$translate.instant(msgTranslation))
+                .position('bottom center')
+                .hideDelay(hideDelayMs));
+    }
+
     /**
      * Submit function for input field. Can contain text or file data.
      * Return whether sending was successful.
@@ -585,6 +605,9 @@ class ConversationController {
                         `,
                         // tslint:enable:max-line-length
                     }).then((data) => {
+                        // TODO: This should probably be moved into the
+                        //       WebClientService as a specific method for the
+                        //       type.
                         const caption = data.caption;
                         const sendAsFile = data.sendAsFile;
                         contents.forEach((msg: threema.FileMessageData, index: number) => {
@@ -598,6 +621,7 @@ class ConversationController {
                                 })
                                 .catch((error) => {
                                     this.$log.error(error);
+                                    // TODO: Should probably be an alert instead of a toast
                                     this.showError(error);
                                     success = false;
                                     nextCallback(index);
@@ -612,12 +636,16 @@ class ConversationController {
                         // remove quote
                         this.webClientService.setQuote(this.receiver);
                         // send message
+                        // TODO: This should probably be moved into the
+                        //       WebClientService as a specific method for the
+                        //       type.
                         this.webClientService.sendMessage(this.$stateParams, type, msg)
                             .then(() => {
                                 nextCallback(index);
                             })
                             .catch((error) => {
                                 this.$log.error(error);
+                                // TODO: Should probably be an alert instead of a toast
                                 this.showError(error);
                                 success = false;
                                 nextCallback(index);
@@ -762,9 +790,10 @@ class ConversationController {
     public requestMessages(): void {
         const refMsgId = this.webClientService.requestMessages(this.$stateParams);
 
-        if (refMsgId !== null
-            && refMsgId !== undefined) {
-            // new message are requested, scroll to refMsgId
+        // TODO: Couldn't this cause a race condition when called twice in parallel?
+        //       Might be related to #277.
+        if (hasValue(refMsgId)) {
+            // New messages are requested, scroll to refMsgId
             this.latestRefMsgId = refMsgId;
         } else {
             this.latestRefMsgId = null;
@@ -790,7 +819,7 @@ class ConversationController {
         }
 
         // Update lastReadMsg
-        if (this.lastReadMsg === null || message.sortKey > this.lastReadMsg.sortKey) {
+        if (this.lastReadMsg === null || message.sortKey >= this.lastReadMsg.sortKey) {
             this.lastReadMsg = message;
         }
 
@@ -810,10 +839,10 @@ class ConversationController {
             this.msgReadReportPending = true;
             const receiver = angular.copy(this.receiver);
             receiver.type = this.type;
-            this.$timeout(() => {
+            this.timeoutService.register(() => {
                 this.webClientService.requestRead(receiver, this.lastReadMsg);
                 this.msgReadReportPending = false;
-            }, 300);
+            }, 300, false, 'requestRead');
         }
     }
 
@@ -838,6 +867,32 @@ class ConversationController {
         const chat = this.domChatElement;
         this.showScrollJump = chat.scrollHeight - (chat.scrollTop + chat.offsetHeight) > 10;
     }
+
+    /**
+     * Mark the current conversation as pinned.
+     */
+    public pinConversation(): void {
+        this.webClientService
+            .modifyConversation(this.conversation, true)
+            .then(() => this.showMessage('messenger.PINNED_CONVERSATION_OK'))
+            .catch((msg) => {
+                this.showMessage('messenger.PINNED_CONVERSATION_ERROR');
+                this.$log.error(this.logTag, 'Pinning conversation failed: ' + msg);
+            });
+    }
+
+    /**
+     * Mark the current conversation as not pinned.
+     */
+    public unpinConversation(): void {
+        this.webClientService
+            .modifyConversation(this.conversation, false)
+            .then(() => this.showMessage('messenger.UNPINNED_CONVERSATION_OK'))
+            .catch((msg) => {
+                this.showMessage('messenger.UNPINNED_CONVERSATION_ERROR');
+                this.$log.error(this.logTag, 'Unpinning conversation failed: ' + msg);
+            });
+    }
 }
 
 class NavigationController {
@@ -933,6 +988,10 @@ class NavigationController {
         return this.receiverService.isConversationActive(value);
     }
 
+    public startupDone(): boolean {
+        return this.webClientService.startupDone;
+    }
+
     /**
      * Return true if the app wants to hide inactive contacts.
      */
@@ -1003,10 +1062,13 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const resetPush = true;
-            const redirect = true;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
-            this.receiverService.setActive(undefined);
+            this.webClientService.stop({
+                reason: threema.DisconnectReason.SessionStopped,
+                send: true,
+                // TODO: Use welcome.stopped once we have it
+                close: 'welcome',
+                connectionBuildupState: 'closed',
+            });
         }, () => {
             // do nothing
         });
@@ -1023,10 +1085,13 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const resetPush = true;
-            const redirect = true;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
-            this.receiverService.setActive(undefined);
+            this.webClientService.stop({
+                reason: threema.DisconnectReason.SessionDeleted,
+                send: true,
+                // TODO: Use welcome.deleted once we have it
+                close: 'welcome',
+                connectionBuildupState: 'closed',
+            });
         }, () => {
             // do nothing
         });
@@ -1210,11 +1275,14 @@ class ReceiverDetailController {
 
             this.contactService.requiredDetails(contactReceiver)
                 .then(() => {
-                    this.hasSystemEmails = contactReceiver.systemContact.emails.length > 0;
-                    this.hasSystemPhones = contactReceiver.systemContact.phoneNumbers.length > 0;
+                    this.hasSystemEmails = contactReceiver.systemContact !== undefined
+                        && contactReceiver.systemContact.emails.length > 0;
+                    this.hasSystemPhones = contactReceiver.systemContact !== undefined &&
+                        contactReceiver.systemContact.phoneNumbers.length > 0;
                 })
-                .catch(() => {
-                    // do nothing
+                .catch((error) => {
+                    // TODO: Redirect or show an alert?
+                    $log.error(this.logTag, `Contact detail request has been rejected: ${error}`);
                 });
 
             this.isWorkReceiver = contactReceiver.identityType === threema.IdentityType.Work;
@@ -1356,25 +1424,26 @@ class ReceiverEditController {
     private logTag: string = '[ReceiverEditController]';
 
     public $mdDialog: any;
+    private $scope: ng.IScope;
     public $state: UiStateService;
     private $translate: ng.translate.ITranslateService;
 
     public title: string;
     private $timeout: ng.ITimeoutService;
-    private execute: ExecuteService;
-    public loading = false;
+    private future: Future<threema.Receiver>;
 
     private controllerModel: threema.ControllerModel<threema.Receiver>;
     public type: string;
 
     public static $inject = [
-        '$log', '$stateParams', '$state', '$mdDialog',
+        '$log', '$scope', '$stateParams', '$state', '$mdDialog',
         '$timeout', '$translate', 'WebClientService', 'ControllerModelService',
     ];
-    constructor($log: ng.ILogService, $stateParams, $state: UiStateService,
+    constructor($log: ng.ILogService, $scope: ng.IScope, $stateParams, $state: UiStateService,
                 $mdDialog, $timeout: ng.ITimeoutService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, controllerModelService: ControllerModelService) {
 
+        this.$scope = $scope;
         this.$mdDialog = $mdDialog;
         this.$state = $state;
         this.$timeout = $timeout;
@@ -1413,8 +1482,6 @@ class ReceiverEditController {
                 return;
         }
         this.type = receiver.type;
-
-        this.execute = new ExecuteService($log, $timeout, 1000);
     }
 
     public keypress($event: KeyboardEvent): void {
@@ -1424,22 +1491,22 @@ class ReceiverEditController {
     }
 
     public save(): void {
-        // show loading
-        this.loading = true;
-
-        // validate first
-        this.execute.execute(this.controllerModel.save())
-            .then((receiver: threema.Receiver) => {
-                this.goBack();
+        this.future = Future.withMinDuration(this.controllerModel.save(), 100);
+        this.future
+            .then(() => {
+                this.$scope.$apply(() => {
+                    this.goBack();
+                });
             })
             .catch((errorCode) => {
-                this.showEditError(errorCode);
+                this.$scope.$apply(() => {
+                    this.showEditError(errorCode);
+                });
             });
     }
 
     public isSaving(): boolean {
-        return this.execute !== undefined
-            && this.execute.isRunning();
+        return this.future !== undefined && !this.future.done;
     }
 
     private showEditError(errorCode: string): void {
@@ -1473,7 +1540,7 @@ class ReceiverCreateController {
     private logTag: string = '[ReceiverEditController]';
 
     public $mdDialog: any;
-    private loading = false;
+    private $scope: ng.IScope;
     private $timeout: ng.ITimeoutService;
     private $log: ng.ILogService;
     private $state: UiStateService;
@@ -1481,16 +1548,17 @@ class ReceiverCreateController {
     public identity = '';
     private $translate: any;
     public type: string;
-    private execute: ExecuteService;
+    private future: Future<threema.Receiver>;
 
     public controllerModel: threema.ControllerModel<threema.Receiver>;
 
-    public static $inject = ['$stateParams', '$mdDialog', '$mdToast', '$translate',
+    public static $inject = ['$stateParams', '$mdDialog', '$scope', '$mdToast', '$translate',
         '$timeout', '$state', '$log', 'ControllerModelService'];
-    constructor($stateParams: CreateReceiverStateParams, $mdDialog, $mdToast, $translate,
+    constructor($stateParams: CreateReceiverStateParams, $mdDialog, $scope: ng.IScope, $mdToast, $translate,
                 $timeout: ng.ITimeoutService, $state: UiStateService, $log: ng.ILogService,
                 controllerModelService: ControllerModelService) {
         this.$mdDialog = $mdDialog;
+        this.$scope = $scope;
         this.$timeout = $timeout;
         this.$state = $state;
         this.$log = $log;
@@ -1519,11 +1587,10 @@ class ReceiverCreateController {
             default:
                 this.$log.error('invalid type', this.type);
         }
-        this.execute = new ExecuteService($log, $timeout, 1000);
     }
 
     public isSaving(): boolean {
-        return this.execute.isRunning();
+        return this.future !== undefined && !this.future.done;
     }
 
     public goBack(): void {
@@ -1552,15 +1619,19 @@ class ReceiverCreateController {
     }
 
     public create(): void {
-        // Show loading indicator
-        this.loading = true;
-
         // Save, then go to receiver detail page
-        this.execute.execute(this.controllerModel.save())
+        this.future = Future.withMinDuration(this.controllerModel.save(), 100);
+        this.future
             .then((receiver: threema.Receiver) => {
-                this.$state.go('messenger.home.detail', receiver, {location: 'replace'});
+                this.$scope.$apply(() => {
+                    this.$state.go('messenger.home.detail', receiver, {location: 'replace'});
+                });
             })
-            .catch(this.showAddError.bind(this));
+            .catch((errorCode) => {
+                this.$scope.$apply(() => {
+                    this.showAddError(errorCode);
+                });
+            });
     }
 }
 

+ 29 - 14
src/partials/welcome.html

@@ -17,12 +17,12 @@
                     size="{{ ctrl.qrCode.size }}" data="{{ ctrl.qrCode.data }}"></qrcode>
             <div class="password-entry">
                 <label>
-                    <p translate>welcome.CHOOSE_PASSWORD</p>
+                    <p translate id="aria-label-password-create">welcome.CHOOSE_PASSWORD</p>
                     <form autocomplete="off">
                         <md-input-container md-no-float class="md-block">
                             <input type="password"
                                    ng-model="ctrl.password"
-                                   aria-label="Password"
+                                   aria-labelledby="aria-label-password-create"
                                    translate-attr="{'placeholder': 'welcome.PASSWORD', 'aria-label': 'welcome.PASSWORD'}"
                                    autocomplete="new-password">
                         </md-input-container>
@@ -31,19 +31,23 @@
             </div>
         </div>
 
-        <div ng-if="ctrl.state === 'connecting' && ctrl.mode === 'unlock'" class="unlock">
+        <div ng-if="(ctrl.state === 'new' || ctrl.state === 'connecting') && ctrl.mode === 'unlock'" class="unlock">
+            <div class="notification">
+                <p translate>welcome.NOTIFICATION_IOS_BETA</p>
+            </div>
+
             <h2 class="instructions" translate>welcome.PLEASE_UNLOCK</h2>
             <div class="password-entry">
                 <label>
-                    <p translate>welcome.ENTER_PASSWORD</p>
+                    <p translate id="aria-label-password-reconnecte">welcome.ENTER_PASSWORD</p>
                     <form ng-submit="ctrl.unlockConfirm()">
                         <md-input-container md-no-float class="md-block">
                             <input type="password"
                                    ng-model="ctrl.password"
                                    ng-disabled="ctrl.formLocked"
                                    autofocus
-                                   aria-label="Password"
-                                   translate-attr="{'placeholder': 'welcome.PASSWORD', 'aria-label': 'welcome.PASSWORD'}"
+                                   aria-labelledby="aria-label-password-reconnect"
+                                   translate-attr="{'placeholder': 'welcome.PASSWORD'}"
                                    autocomplete="current-password">
                         </md-input-container>
                         <md-button type="submit" class="md-raised md-primary" translate translate-attr-aria-label="welcome.BTN_RECONNECT">
@@ -61,9 +65,9 @@
         <div ng-if="ctrl.showLoadingIndicator">
             <h2 class="instructions" translate>welcome.CONNECTING</h2>
             <div class="loading">
-                <md-progress-circular md-mode="determinate" value="{{ ctrl.progress }}" md-diameter="250">
+                <md-progress-circular md-mode="determinate" value="{{ ctrl.progress }}" md-diameter="250" ng-aria-disable>
                 </md-progress-circular>
-                <div class="info">
+                <div class="info" aria-live="polite" ng-aria-disable>
                     <p class="percentage">{{ ctrl.progress }}%</p>
                     <p ng-if="ctrl.state === 'push'" translate>welcome.WAITING_FOR_PUSH</p>
                     <p ng-if="ctrl.state === 'peer_handshake'" translate>welcome.CONNECTING_TO_APP</p>
@@ -87,20 +91,20 @@
                                 <i class="material-icons md-dark md-14">help</i>
                                 <span translate>troubleshooting.SESSION_DELETED</span>
                             </li>
-                            <li ng-if="ctrl.state === 'peer_handshake'">
+                            <li ng-if="ctrl.state === 'peer_handshake' && ctrl.showWebrtcTroubleshooting">
                                 <i class="material-icons md-dark md-14">help</i>
                                 <span translate>troubleshooting.PLUGIN</span>
                             </li>
-                            <li ng-if="ctrl.state === 'peer_handshake'">
+                            <li ng-if="ctrl.state === 'peer_handshake' && ctrl.showWebrtcTroubleshooting">
                                 <i class="material-icons md-dark md-14">help</i>
                                 <span translate>troubleshooting.ADBLOCKER</span>
                             </li>
                         </ul>
-                        <md-button class="md-raised md-primary reload-btn" ng-click="ctrl.deleteSession()" ng-if="ctrl.state === 'push'">
-                        	<span translate>welcome.FORGET_SESSION_BTN</span>
+                        <md-button role="button" class="md-raised md-primary reload-btn" ng-click="ctrl.deleteSession()" ng-if="ctrl.state === 'push'" aria-labelledby="aria-label-forget">
+                           <span translate id="aria-label-forget">welcome.FORGET_SESSION_BTN</span>
                         </md-button>
-                        <md-button class="md-raised md-primary reload-btn" ng-click="ctrl.reload()">
-                        	<i class="material-icons">refresh</i> <span translate>welcome.RELOAD</span>
+                        <md-button role="button" class="md-raised md-primary reload-btn" ng-click="ctrl.reload()" aria-labelledby="aria-label-reload">
+                            <i class="material-icons">refresh</i> <span translate id="aria-label-reload">welcome.RELOAD</span>
                         </md-button>
                     </div>
                 </div>
@@ -140,5 +144,16 @@
             </md-button>
         </div>
 
+        <div ng-if="ctrl.state === 'reconnect_failed'">
+            <p class="state error">
+                <strong><span translate>common.ERROR</span>:</strong> <span translate>connecting.RECONNECT_FAILED</span><br>
+                <span translate>welcome.PLEASE_RELOAD</span>
+            </p>
+            <br>
+            <md-button class="md-raised md-primary" ng-click="ctrl.reload()">
+                <i class="material-icons">refresh</i> <span translate>welcome.RELOAD</span>
+            </md-button>
+        </div>
+
     </div>
 </div>

+ 73 - 45
src/partials/welcome.ts

@@ -20,7 +20,6 @@
 /// <reference path="../types/broadcastchannel.d.ts" />
 
 import {
-    StateParams as UiStateParams,
     StateProvider as UiStateProvider,
     StateService as UiStateService,
 } from '@uirouter/angularjs';
@@ -32,10 +31,12 @@ import {TrustedKeyStoreService} from '../services/keystore';
 import {PushService} from '../services/push';
 import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
+import {TimeoutService} from '../services/timeout';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 class DialogController {
     // TODO: This is also used in partials/messenger.ts. We could somehow
@@ -53,10 +54,6 @@ class DialogController {
     }
 }
 
-interface WelcomeStateParams extends UiStateParams {
-    initParams: null | {keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array};
-}
-
 class WelcomeController {
 
     private static REDIRECT_DELAY = 500;
@@ -65,8 +62,6 @@ class WelcomeController {
 
     // Angular services
     private $scope: ng.IScope;
-    private $timeout: ng.ITimeoutService;
-    private $interval: ng.IIntervalService;
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
     private $state: UiStateService;
@@ -81,6 +76,7 @@ class WelcomeController {
     private pushService: PushService;
     private stateService: StateService;
     private settingsService: SettingsService;
+    private timeoutService: TimeoutService;
     private config: threema.Config;
 
     // Other
@@ -94,13 +90,12 @@ class WelcomeController {
     private browserWarningShown: boolean = false;
 
     public static $inject = [
-        '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
+        '$scope', '$state', '$log', '$window', '$mdDialog', '$translate',
         'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
-        'VersionService', 'SettingsService', 'ControllerService',
+        'VersionService', 'SettingsService', 'TimeoutService', 'ControllerService',
         'BROWSER_MIN_VERSIONS', 'CONFIG',
     ];
-    constructor($scope: ng.IScope, $state: UiStateService, $stateParams: WelcomeStateParams,
-                $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
+    constructor($scope: ng.IScope, $state: UiStateService,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
@@ -108,6 +103,7 @@ class WelcomeController {
                 browserService: BrowserService,
                 versionService: VersionService,
                 settingsService: SettingsService,
+                timeoutService: TimeoutService,
                 controllerService: ControllerService,
                 minVersions: threema.BrowserMinVersions,
                 config: threema.Config) {
@@ -115,8 +111,6 @@ class WelcomeController {
         // Angular services
         this.$scope = $scope;
         this.$state = $state;
-        this.$timeout = $timeout;
-        this.$interval = $interval;
         this.$log = $log;
         this.$window = $window;
         this.$mdDialog = $mdDialog;
@@ -128,8 +122,11 @@ class WelcomeController {
         this.stateService = stateService;
         this.pushService = pushService;
         this.settingsService = settingsService;
+        this.timeoutService = timeoutService;
         this.config = config;
 
+        // TODO: Allow to trigger below behaviour by using state parameters
+
         // Determine whether browser warning should be shown
         this.browser = browserService.getBrowser();
         const version = this.browser.version;
@@ -198,12 +195,7 @@ class WelcomeController {
         }
 
         // Determine connection mode
-        if ($stateParams.initParams !== null) {
-            this.mode = 'unlock';
-            const keyStore = $stateParams.initParams.keyStore;
-            const peerTrustedKey = $stateParams.initParams.peerTrustedKey;
-            this.reconnect(keyStore, peerTrustedKey);
-        } else if (hasTrustedKey) {
+        if (hasTrustedKey) {
             this.mode = 'unlock';
             this.unlock();
         } else {
@@ -254,14 +246,29 @@ class WelcomeController {
         return this.stateService.slowConnect;
     }
 
+    /**
+     * Whether to show troubleshooting hints related to WebRTC.
+     */
+    public get showWebrtcTroubleshooting(): boolean {
+        return this.webClientService.chosenTask === threema.ChosenTask.WebRTC;
+    }
+
     /**
      * Initiate a new session by scanning a new QR code.
      */
-    private scan(): void {
+    private scan(stopArguments?: threema.WebClientServiceStopArguments): void {
         this.$log.info(this.logTag, 'Initialize session by scanning QR code...');
 
         // Initialize webclient with new keystore
-        this.webClientService.init();
+        this.webClientService.stop(stopArguments !== undefined ? stopArguments : {
+            reason: DisconnectReason.SessionStopped,
+            send: false,
+            close: 'welcome',
+            connectionBuildupState: this.stateService.connectionBuildupState,
+        });
+        this.webClientService.init({
+            resume: false,
+        });
 
         // Set up the broadcast channel that checks whether we're already connected in another tab
         this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);
@@ -280,6 +287,7 @@ class WelcomeController {
      * Initiate a new session by unlocking a trusted key.
      */
     private unlock(): void {
+        this.stateService.reset('new');
         this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...');
     }
 
@@ -302,14 +310,8 @@ class WelcomeController {
         // Set up the broadcast channel that checks whether we're already connected in another tab
         this.setupBroadcastChannel(keyStore.publicKeyHex);
 
-        // Initialize push service
-        if (decrypted.pushToken !== null && decrypted.pushTokenType !== null) {
-            this.webClientService.updatePushToken(decrypted.pushToken, decrypted.pushTokenType);
-            this.pushService.init(decrypted.pushToken, decrypted.pushTokenType);
-        }
-
         // Reconnect
-        this.reconnect(keyStore, decrypted.peerPublicKey);
+        this.reconnect(keyStore, decrypted);
     }
 
     /**
@@ -360,10 +362,10 @@ class WelcomeController {
                     // is already active.
                     if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') {
                         this.$log.error(this.logTag, 'Session already connected in another tab or window');
-                        this.$timeout(() => {
+                        this.timeoutService.register(() => {
                             this.stateService.updateConnectionBuildupState('already_connected');
                             this.stateService.state = GlobalConnectionState.Error;
-                        }, 500);
+                        }, 500, true, 'alreadyConnected');
                     }
                     break;
                 default:
@@ -381,10 +383,30 @@ class WelcomeController {
     }
 
     /**
-     * Reconnect using a specific keypair and peer public key.
+     * Reconnect using a specific keypair and the decrypted data from the trusted keystore.
      */
-    private reconnect(keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array): void {
-        this.webClientService.init(keyStore, peerTrustedKey);
+    private reconnect(keyStore: saltyrtc.KeyStore, decrypted: threema.TrustedKeyStoreData): void {
+        // Reset state
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
+            send: false,
+            close: 'welcome',
+            connectionBuildupState: this.stateService.connectionBuildupState,
+        });
+
+        // Initialize push service
+        if (decrypted.pushToken !== null && decrypted.pushTokenType !== null) {
+            this.webClientService.updatePushToken(decrypted.pushToken, decrypted.pushTokenType);
+            this.pushService.init(decrypted.pushToken, decrypted.pushTokenType);
+        }
+
+        // Initialize webclient service
+        this.webClientService.init({
+            keyStore: keyStore,
+            peerTrustedKey: decrypted.peerPublicKey,
+            resume: false,
+        });
+
         this.start();
     }
 
@@ -475,21 +497,17 @@ class WelcomeController {
              .cancel(this.$translate.instant('common.CANCEL'));
 
         this.$mdDialog.show(confirm).then(() =>  {
-            // Force-stop the webclient
-            const resetPush = true;
-            const redirect = false;
-            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
-
-            // Reset state
-            this.stateService.updateConnectionBuildupState('new');
-
             // Go back to scan mode
             this.mode = 'scan';
             this.password = '';
             this.formLocked = false;
 
-            // Initiate scan
-            this.scan();
+            // Force-stop the webclient and initiate scan
+            this.scan({
+                reason: DisconnectReason.SessionDeleted,
+                send: true,
+                close: 'welcome',
+            });
         }, () => {
             // do nothing
         });
@@ -553,14 +571,24 @@ class WelcomeController {
                 this.formLocked = false;
 
                 // Redirect to home
-                this.$timeout(() => this.$state.go('messenger.home'), WelcomeController.REDIRECT_DELAY);
+                this.timeoutService.register(
+                    () => this.$state.go('messenger.home'),
+                    WelcomeController.REDIRECT_DELAY,
+                    true,
+                    'redirectToHome',
+                );
             },
 
             // If an error occurs...
             (error) => {
                 this.$log.error(this.logTag, 'Error state:', error);
                 // TODO: should probably show an error message instead
-                this.$timeout(() => this.$state.reload(), WelcomeController.REDIRECT_DELAY);
+                this.timeoutService.register(
+                    () => this.$state.reload(),
+                    WelcomeController.REDIRECT_DELAY,
+                    true,
+                    'reloadStateError',
+                );
             },
 
             // State updates

+ 115 - 0
src/protocol/cache.ts

@@ -0,0 +1,115 @@
+/**
+ * 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 {SequenceNumber} from './sequence_number';
+
+export type CachedChunk = Uint8Array | null;
+
+/**
+ * Contains chunks that have not yet been acknowledged.
+ */
+export class ChunkCache {
+    private _sequenceNumber: SequenceNumber;
+    private _byteLength = 0;
+    private cache: CachedChunk[] = [];
+
+    constructor(sequenceNumber: SequenceNumber) {
+        this._sequenceNumber = sequenceNumber;
+    }
+
+    /**
+     * Get the current sequence number (e.g. of the **next** chunk to be added).
+     */
+    public get sequenceNumber(): SequenceNumber {
+        return this._sequenceNumber;
+    }
+
+    /**
+     * Get the total size of currently cached chunks in bytes.
+     */
+    public get byteLength(): number {
+        return this._byteLength;
+    }
+
+    /**
+     * Get a reference to the currently cached chunks.
+     *
+     * Note: Blacklisted chunks will be filtered automatically.
+     */
+    public get chunks(): CachedChunk[] {
+        return this.cache.filter((chunk) => chunk !== null);
+    }
+
+    /**
+     * Transfer an array of cached chunks to this cache instance and return the
+     * amount of chunks that have been transferred.
+     */
+    public transfer(cache: CachedChunk[]): number {
+        // Add chunks but remove all which should not be retransmitted
+        cache = cache.filter((chunk) => chunk !== null);
+        const count = cache.length;
+        for (const chunk of cache) {
+            this.append(chunk);
+        }
+        return count;
+    }
+
+    /**
+     * Append a chunk to the chunk cache.
+     */
+    public append(chunk: CachedChunk): void {
+        // Update sequence number, update size & append chunk
+        this._sequenceNumber.increment();
+        if (chunk !== null) {
+            this._byteLength += chunk.byteLength;
+        }
+        this.cache.push(chunk);
+    }
+
+    /**
+     * Prune cached chunks that have been acknowledged. Return the
+     * amount of chunks which have been acknowledged and the amount of
+     * chunks left in the cache.
+     */
+    public prune(theirSequenceNumber: number): { acknowledged: number, left: number } {
+        try {
+            this._sequenceNumber.validate(theirSequenceNumber);
+        } catch (error) {
+            throw new Error(`Remote sent us an invalid sequence number: ${theirSequenceNumber}`);
+        }
+
+        // Calculate the slice start index for the chunk cache
+        // Important: Our sequence number is one chunk ahead!
+        const beginOffset = theirSequenceNumber - this._sequenceNumber.get();
+        if (beginOffset > 0) {
+            throw new Error('Remote travelled through time and acknowledged a chunk which is in the future');
+        } else if (-beginOffset > this.cache.length) {
+            throw new Error('Remote travelled back in time and acknowledged a chunk it has already acknowledged');
+        }
+
+        // Slice our cache & recalculate size
+        const chunkCountBefore = this.cache.length;
+        this.cache = beginOffset === 0 ? [] : this.cache.slice(beginOffset);
+        this._byteLength = this.cache
+            .filter((chunk) => chunk !== null)
+            .reduce((sum, chunk) => sum + chunk.byteLength, 0);
+        return {
+            acknowledged: chunkCountBefore + beginOffset,
+            left: this.cache.length,
+        };
+    }
+}

+ 73 - 0
src/protocol/sequence_number.ts

@@ -0,0 +1,73 @@
+/**
+ * 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/>.
+ */
+
+/**
+ * A generic sequence number with specific boundaries.
+ *
+ * Does not allow for wrapping.
+ */
+export class SequenceNumber {
+    private readonly minValue: number;
+    private readonly maxValue: number;
+    private value: number;
+
+    constructor(initialValue: number = 0, minValue: number, maxValue: number) {
+        this.minValue = minValue;
+        this.maxValue = maxValue;
+        this.value = initialValue;
+    }
+
+    /**
+     * Validate a specific sequence number.
+     */
+    public validate(other: number) {
+        if (other < this.minValue) {
+            throw new Error(`Invalid sequence number: ${other} < 0`);
+        }
+        if (other > this.maxValue) {
+            throw new Error(`Invalid sequence number: ${other} > ${this.maxValue}`);
+        }
+    }
+
+    /**
+     * Get the current value of the sequence number.
+     */
+    public get(): number {
+        return this.value;
+    }
+
+    /**
+     * Set the new value of the sequence number.
+     */
+    public set(value: number): void {
+        this.validate(value);
+        this.value = value;
+    }
+
+    /**
+     * Increment the sequence number and return the sequence number as it was
+     * before it has been incremented.
+     */
+    public increment(by: number = 1): number {
+        if (by < 0) {
+            throw new Error('Cannot decrement the sequence number');
+        }
+        const value = this.value;
+        this.set(value + by);
+        return value;
+    }
+}

+ 1 - 0
src/sass/sections/_conversation.scss

@@ -14,6 +14,7 @@
         .header-details {
             @include mouse-hand;
             overflow: hidden;
+            flex-grow: 1;
 
             & > *:first-child {
                 font-weight: bold;

+ 1 - 1
src/sass/sections/_navigation.scss

@@ -98,7 +98,7 @@
     .empty {
         color: $material-grey;
         margin-top: 1em;
-        margin-left: 4px;
+        margin-left: 16px;
         font-size: 1.2em;
         font-weight: 300;
     }

+ 2 - 0
src/services.ts

@@ -32,6 +32,7 @@ import {ReceiverService} from './services/receiver';
 import {SettingsService} from './services/settings';
 import {StateService} from './services/state';
 import {StringService} from './services/string';
+import {TimeoutService} from './services/timeout';
 import {TitleService} from './services/title';
 import {UriService} from './services/uri';
 import {VersionService} from './services/version';
@@ -51,6 +52,7 @@ angular.module('3ema.services', [])
 .service('QrCodeService', QrCodeService)
 .service('ReceiverService', ReceiverService)
 .service('StateService', StateService)
+.service('TimeoutService', TimeoutService)
 .service('TitleService', TitleService)
 .service('TrustedKeyStore', TrustedKeyStoreService)
 .service('WebClientService', WebClientService)

+ 0 - 58
src/services/browser.ts

@@ -25,7 +25,6 @@ export class BrowserService {
     private browser: BrowserInfo;
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
-    private isPageVisible = true;
     private supportsExtendedLocaleCompareCache: boolean;
 
     public static $inject = ['$log', '$window'];
@@ -33,59 +32,6 @@ export class BrowserService {
         // Angular services
         this.$log = $log;
         this.$window = $window;
-        this.initializePageVisibility();
-    }
-
-    private initializePageVisibility() {
-        const onChange = (isVisible: any) => {
-            if (this.isPageVisible !== isVisible) {
-                this.isPageVisible = isVisible;
-            }
-        };
-
-        let pageHiddenKey = 'hidden';
-
-        // add default visibility change listener
-        let defaultListener;
-        if (pageHiddenKey in document) {
-            defaultListener = 'visibilitychange';
-        } else if ('mozHidden' in document) {
-            pageHiddenKey = 'mozHidden';
-            defaultListener = 'mozvisibilitychange';
-        } else if ('webkitHidden' in document) {
-            pageHiddenKey = 'webkitHidden';
-            defaultListener = 'webkitvisibilitychange';
-        } else if ('msHidden' in document) {
-            pageHiddenKey = 'msHidden';
-            defaultListener = 'msvisibilitychange';
-        }
-
-        document.addEventListener(defaultListener, function() {
-            onChange(!this[pageHiddenKey]);
-        });
-
-        // configure other document and window events
-        const map = {
-            focus: true,
-            blur: false,
-        };
-
-        for (const event in map) {
-            if (map[event] !== undefined) {
-                document.addEventListener(event, () => {
-                    onChange(map[event]);
-                }, false);
-
-                window.addEventListener(event, () => {
-                    onChange(map[event]);
-                }, false);
-            }
-        }
-
-        // initial visible state set
-        if (document[pageHiddenKey] !== undefined ) {
-            onChange(!document[pageHiddenKey]);
-        }
     }
 
     public getBrowser(): BrowserInfo {
@@ -183,10 +129,6 @@ export class BrowserService {
         return this.browser;
     }
 
-    public isVisible() {
-        return this.isPageVisible;
-    }
-
     /**
      * Return whether the current browser supports the WebRTC task or not.
      */

+ 0 - 62
src/services/execute.ts

@@ -1,62 +0,0 @@
-/**
- * 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/>.
- */
-
-/**
- * Execute a promise and wait x (timeout) seconds for end the process
- * Used for a better user experience on saving forms
- */
-export class ExecuteService {
-    private $log: ng.ILogService;
-    private $timeoutService: ng.ITimeoutService;
-
-    private timeout: number;
-    private started = false;
-
-    public static $inject = ['$log', '$timeout'];
-    constructor($log: ng.ILogService, $timeout: ng.ITimeoutService, timeout = 0) {
-        // Angular services
-        this.$log = $log;
-        this.$timeoutService = $timeout;
-        this.timeout = timeout;
-    }
-
-    public execute(runnable: Promise<any>): Promise<any> {
-        if (this.started) {
-            this.$log.error('execute already in progress');
-            return null;
-        }
-
-        this.started = true;
-        return new Promise((a, e) => {
-            runnable
-                .then((arg: any) => {
-                    this.$timeoutService(() => {
-                        this.started = false;
-                        a(arg);
-                    }, this.timeout);
-                })
-                .catch((arg: any) => {
-                    this.started = false;
-                    e(arg);
-                });
-        });
-    }
-
-    public isRunning(): boolean {
-        return this.started;
-    }
-}

+ 17 - 12
src/services/message.ts

@@ -17,6 +17,7 @@
 
 import {isContactReceiver} from '../typeguards';
 import {ReceiverService} from './receiver';
+import {TimeoutService} from './timeout';
 
 export class MessageAccess {
     public quote = false;
@@ -31,22 +32,22 @@ export class MessageService {
 
     // Angular services
     private $log: ng.ILogService;
-    private $timeout: ng.ITimeoutService;
 
     // Own services
     private receiverService: ReceiverService;
+    private timeoutService: TimeoutService;
 
     // Other
     private timeoutDelaySeconds = 30;
 
-    public static $inject = ['$log', '$timeout', 'ReceiverService'];
-    constructor($log: ng.ILogService, $timeout: ng.ITimeoutService, receiverService: ReceiverService) {
+    public static $inject = ['$log', 'ReceiverService', 'TimeoutService'];
+    constructor($log: ng.ILogService, receiverService: ReceiverService, timeoutService: TimeoutService) {
         this.$log = $log;
-        this.$timeout = $timeout;
         this.receiverService = receiverService;
+        this.timeoutService = timeoutService;
     }
 
-    public getAccess(message: threema.Message, receiver: threema.Receiver): MessageAccess  {
+    public getAccess(message: threema.Message, receiver: threema.Receiver): MessageAccess {
         const access = new MessageAccess();
 
         if (message !== undefined) {
@@ -140,13 +141,16 @@ export class MessageService {
     }
 
     /**
-     * Create a message object with a temporaryId
+     * Create a message object with a temporary id
      */
-    public createTemporary(receiver: threema.Receiver, msgType: string,
-                           messageData: threema.MessageData): threema.Message {
-        const now = new Date();
+    public createTemporary(
+        temporaryId: string,
+        receiver: threema.Receiver,
+        msgType: string,
+        messageData: threema.MessageData,
+    ): threema.Message {
         const message = {
-            temporaryId: receiver.type + receiver.id + Math.random(),
+            temporaryId: temporaryId,
             type: msgType,
             isOutbox: true,
             state: 'pending',
@@ -165,7 +169,8 @@ export class MessageService {
         }
 
         // Add delay for timeout checking
-        this.$timeout(() => {
+        // TODO: This should be removed once Android has reliable message delivery.
+        this.timeoutService.register(() => {
             // Set the state to timeout if it is still pending.
             // Note: If sending the message worked, by now the message object
             // will have been replaced by a new one and the state change would
@@ -173,7 +178,7 @@ export class MessageService {
             if (message.state === 'pending') {
                 message.state = 'timeout';
             }
-        }, this.timeoutDelaySeconds * 1000);
+        }, this.timeoutDelaySeconds * 1000, true, 'messageTimeout');
 
         return message;
     }

+ 14 - 54
src/services/peerconnection.ts

@@ -39,9 +39,6 @@ export class PeerConnectionHelper {
     public connectionState: TaskConnectionState = TaskConnectionState.New;
     public onConnectionStateChange: (state: TaskConnectionState) => void = null;
 
-    // Internal callback when connection closes
-    private onConnectionClosed: () => void = null;
-
     // Debugging
     private censorCandidates: boolean;
 
@@ -179,14 +176,10 @@ export class PeerConnectionHelper {
     /**
      * Create a new secure data channel.
      */
-    public createSecureDataChannel(label: string, onopenHandler?): saltyrtc.tasks.webrtc.SecureDataChannel {
+    public createSecureDataChannel(label: string): saltyrtc.tasks.webrtc.SecureDataChannel {
         const dc: RTCDataChannel = this.pc.createDataChannel(label);
         dc.binaryType = 'arraybuffer';
-        const sdc: saltyrtc.tasks.webrtc.SecureDataChannel = this.webrtcTask.wrapDataChannel(dc);
-        if (onopenHandler !== undefined) {
-            sdc.onopen = onopenHandler;
-        }
-        return sdc;
+        return this.webrtcTask.wrapDataChannel(dc);
     }
 
     /**
@@ -198,56 +191,23 @@ export class PeerConnectionHelper {
             if (this.onConnectionStateChange !== null) {
                 this.$timeout(() => this.onConnectionStateChange(state), 0);
             }
-            if (this.onConnectionClosed !== null && state === TaskConnectionState.Disconnected) {
-                this.$timeout(() => this.onConnectionClosed(), 0);
-            }
         }
     }
 
     /**
-     * Close the peer connection.
-     *
-     * Return a promise that resolves once the connection is actually closed.
+     * Unbind all event handler and abruptly close the peer connection.
      */
-    public close(): ng.IPromise<{}> {
-        return this.$q((resolve, reject) => {
-            const signalingClosed = this.pc.signalingState as string === 'closed'; // Legacy
-            const connectionClosed = this.pc.connectionState === 'closed';
-            if (!signalingClosed && !connectionClosed) {
-
-                // If connection state is not yet "disconnected", register a callback
-                // for the disconnect event.
-                if (this.connectionState !== 'disconnected') {
-                    // Disconnect timeout
-                    let timeout: ng.IPromise<any>;
-
-                    // Handle connection closed event
-                    this.onConnectionClosed = () => {
-                        this.$timeout.cancel(timeout);
-                        this.onConnectionClosed = null;
-                        resolve();
-                    };
-
-                    // Launch timeout
-                    timeout = this.$timeout(() => {
-                        this.onConnectionClosed = null;
-                        reject('Timeout');
-                    }, 2000);
-                }
-
-                // Close connection
-                setTimeout(() => {
-                    this.pc.close();
-                }, 0);
-
-                // If connection state is already "disconnected", resolve immediately.
-                if (this.connectionState === 'disconnected') {
-                    resolve();
-                }
-            } else {
-                resolve();
-            }
-        });
+    public close(): void {
+        this.webrtcTask.off();
+        this.pc.onnegotiationneeded = null;
+        this.pc.onconnectionstatechange = null;
+        this.pc.onsignalingstatechange = null;
+        this.pc.onicecandidate = null;
+        this.pc.onicecandidateerror = null;
+        this.pc.oniceconnectionstatechange = null;
+        this.pc.onicegatheringstatechange = null;
+        this.pc.ondatachannel = null;
+        this.pc.close();
     }
 
     /**

+ 1 - 3
src/services/push.ts

@@ -22,7 +22,6 @@ export class PushService {
     private static ARG_TOKEN = 'token';
     private static ARG_SESSION = 'session';
     private static ARG_VERSION = 'version';
-    private static ARG_WAKEUP_TYPE = 'wakeup';
     private static ARG_ENDPOINT = 'endpoint';
     private static ARG_BUNDLE_ID = 'bundleid';
 
@@ -74,7 +73,7 @@ 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 async sendPush(session: Uint8Array, wakeupType: threema.WakeupType): Promise<boolean> {
+    public async sendPush(session: Uint8Array): Promise<boolean> {
         if (!this.isAvailable()) {
             return false;
         }
@@ -87,7 +86,6 @@ export class PushService {
             [PushService.ARG_TYPE]: this.pushType,
             [PushService.ARG_SESSION]: sessionHash,
             [PushService.ARG_VERSION]: this.version,
-            [PushService.ARG_WAKEUP_TYPE]: wakeupType,
         };
         if (this.pushType === threema.PushTokenType.Apns) {
             // APNS token format: "<hex-deviceid>;<endpoint>;<bundle-id>"

+ 8 - 7
src/services/state.ts

@@ -43,7 +43,7 @@ export class StateService {
     public taskConnectionState: TaskConnectionState;
 
     // Connection buildup state
-    public connectionBuildupState: threema.ConnectionBuildupState = 'connecting';
+    public connectionBuildupState: threema.ConnectionBuildupState;
     public progress = 0;
     private progressInterval: ng.IPromise<any> = null;
     public slowConnect = false;
@@ -206,7 +206,7 @@ export class StateService {
                 this.progress = 60;
                 this.progressInterval = this.$interval(() => {
                     if (this.progress < 80) {
-                        this.progress += 5;
+                        this.progress += 4;
                     } else if (this.progress < 90) {
                         this.progress += 2;
                     } else if (this.progress < 99) {
@@ -214,7 +214,7 @@ export class StateService {
                     } else {
                         this.slowConnect = true;
                     }
-                }, 500);
+                }, 600);
                 break;
             case 'done':
                 this.progress = 100;
@@ -228,7 +228,7 @@ export class StateService {
     public readyToSubmit(chosenTask: ChosenTask): boolean {
         switch (chosenTask) {
             case ChosenTask.RelayedData:
-                return this.state === GlobalConnectionState.Ok || this.state === GlobalConnectionState.Warning;
+                return true;
             case ChosenTask.WebRTC:
             default:
                 return this.state === GlobalConnectionState.Ok;
@@ -238,8 +238,8 @@ export class StateService {
     /**
      * Reset all states.
      */
-    public reset(): void {
-        this.$log.debug(this.logTag, 'Reset');
+    public reset(connectionBuildupState: threema.ConnectionBuildupState = 'new'): void {
+        this.$log.debug(this.logTag, 'Reset states');
 
         // Reset state
         this.signalingConnectionState = 'new';
@@ -247,6 +247,7 @@ export class StateService {
         this.stage = Stage.Signaling;
         this.state = GlobalConnectionState.Error;
         this.wasConnected = false;
-        this.connectionBuildupState = 'connecting';
+        this.connectionBuildupState = connectionBuildupState;
+        this.progress = 0;
     }
 }

+ 96 - 0
src/services/timeout.ts

@@ -0,0 +1,96 @@
+/**
+ * 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/>.
+ */
+
+export class TimeoutService {
+    private logTag: string = '[TimeoutService]';
+
+    // Config
+    private config: threema.Config;
+
+    // Angular services
+    private $log: ng.ILogService;
+    private $timeout: ng.ITimeoutService;
+
+    // List of registered timeouts
+    private timeouts: Set<ng.IPromise<any>> = new Set();
+
+    public static $inject = ['CONFIG', '$log', '$timeout'];
+    constructor(config: threema.Config, $log: ng.ILogService, $timeout: ng.ITimeoutService) {
+        this.config = config;
+        this.$log = $log;
+        this.$timeout = $timeout;
+    }
+
+    /**
+     * Log a message on debug log level, but only if the `DEBUG` flag is enabled.
+     */
+    private logDebug(msg: string): void {
+        if (this.config.DEBUG) {
+            this.$log.debug(this.logTag, msg);
+        }
+    }
+
+    /**
+     * Register a timeout.
+     */
+    public register<T>(fn: (...args: any[]) => T, delay: number, invokeApply: boolean, name?: string): ng.IPromise<T> {
+        this.logDebug('Registering timeout' + (name === undefined ? '' : ` (${name})`));
+        const timeout = this.$timeout(fn, delay, invokeApply);
+        timeout
+            .then(() => this.timeouts.delete(timeout))
+            .catch((reason) => {
+                if (reason !== 'canceled') { // We can safely ignore cancellation
+                    this.$log.error(this.logTag, 'Registered timeout promise rejected:', reason);
+                }
+            });
+
+        // Stick name onto promise for debugging purposes
+        // tslint:disable-next-line: no-string-literal
+        timeout['_timeout_name'] = name;
+
+        this.timeouts.add(timeout);
+        return timeout;
+    }
+
+    /**
+     * Cancel the specified timeout.
+     *
+     * Return true if the task hasn't executed yet and was successfully canceled.
+     */
+    public cancel<T>(timeout: ng.IPromise<T>): boolean {
+        // Retrieve name from promise for debugging purposes
+        // tslint:disable-next-line: no-string-literal
+        const name = timeout['_timeout_name'];
+
+        this.logDebug('Cancelling timeout' + (name === undefined ? '' : ` (${name})`));
+        const cancelled = this.$timeout.cancel(timeout);
+
+        this.timeouts.delete(timeout);
+        return cancelled;
+    }
+
+    /**
+     * Cancel all pending timeouts.
+     */
+    public cancelAll() {
+        this.$log.debug(this.logTag, 'Cancelling ' + this.timeouts.size + ' timeouts');
+        for (const timeout of this.timeouts) {
+            this.$timeout.cancel(timeout);
+        }
+        this.timeouts.clear();
+    }
+}

File diff suppressed because it is too large
+ 596 - 146
src/services/webclient.ts


+ 24 - 32
src/threema.d.ts

@@ -26,12 +26,20 @@ declare namespace threema {
         high?: ArrayBuffer;
     }
 
+    interface WireMessageAcknowledgement {
+        id: string,
+        success: boolean,
+        error?: string,
+    }
+
     /**
      * Messages that are sent through the secure data channel as encrypted msgpack bytes.
      */
     interface WireMessage {
         type: string;
         subType: string;
+        id?: string;
+        ack?: WireMessageAcknowledgement;
         args?: any;
         data?: any;
     }
@@ -401,10 +409,11 @@ declare namespace threema {
      * - loading: Loading initial data
      * - done: Initial loading is finished
      * - closed: Connection is closed
+     * - reconnect_failed: Reconnecting failed after several attempts
      *
      */
     type ConnectionBuildupState = 'new' | 'connecting' | 'push' | 'manual_start' | 'already_connected'
-        | 'waiting' | 'peer_handshake' | 'loading' | 'done' | 'closed';
+        | 'waiting' | 'peer_handshake' | 'loading' | 'done' | 'closed' | 'reconnect_failed';
 
     interface ConnectionBuildupStateChange {
         state: ConnectionBuildupState;
@@ -487,13 +496,6 @@ declare namespace threema {
         Apns = 'a',
     }
 
-    const enum WakeupType {
-        // A full reconnect (by entering the password on the main screen).
-        FullReconnect = '0',
-        // A wakeup, as implemented by the iOS app.
-        Wakeup = '1',
-    }
-
     interface TrustedKeyStoreData {
         ownPublicKey: Uint8Array;
         ownSecretKey: Uint8Array;
@@ -513,26 +515,6 @@ declare namespace threema {
         Safari = 'safari',
     }
 
-    interface BrowserInfo {
-        chrome: boolean;
-        chromeIos: boolean;
-        firefox: boolean;
-        firefoxIos: boolean;
-        ie: boolean;
-        edge: boolean;
-        opera: boolean;
-        safari: boolean;
-        name?: BrowserName;
-        mobile?: boolean;
-        version?: number;
-        textInfo?: string;
-    }
-
-    interface PromiseCallbacks {
-        resolve: (arg: any) => void;
-        reject: (arg: any) => void;
-    }
-
     interface PromiseRequestResult<T> {
         success: boolean;
         error?: string;
@@ -639,6 +621,7 @@ declare namespace threema {
         VERSION_MOUNTAIN: string;
         VERSION_MOUNTAIN_URL: string;
         VERSION_MOUNTAIN_IMAGE_URL: string;
+        VERSION_MOUNTAIN_IMAGE_COPYRIGHT: string;
         VERSION_MOUNTAIN_HEIGHT: number;
         GIT_BRANCH: string;
         SALTYRTC_PORT: number;
@@ -646,6 +629,7 @@ declare namespace threema {
         SALTYRTC_HOST: string | null;
         SALTYRTC_HOST_PREFIX: string | null;
         SALTYRTC_HOST_SUFFIX: string | null;
+        SALTYRTC_LOG_LEVEL: saltyrtc.LogLevel;
         ICE_SERVERS: RTCIceServer[];
         PUSH_URL: string;
         DEBUG: boolean;
@@ -742,6 +726,13 @@ declare namespace threema {
         realLength: number;
     }
 
+    interface WebClientServiceStopArguments {
+        reason: DisconnectReason,
+        send: boolean,
+        close: boolean | string,
+        connectionBuildupState?: ConnectionBuildupState,
+    }
+
     const enum ChosenTask {
         None = 'none',
         WebRTC = 'webrtc',
@@ -753,6 +744,7 @@ declare namespace threema {
         SessionDeleted = 'delete',
         WebclientDisabled = 'disable',
         SessionReplaced = 'replace',
+        SessionError = 'error',
     }
 
     namespace Container {
@@ -795,7 +787,7 @@ declare namespace threema {
             set(data: Conversation[]): void;
             find(pattern: Conversation | Receiver): Conversation | null;
             add(conversation: Conversation): void;
-            updateOrAdd(conversation: Conversation): Conversation | null;
+            updateOrAdd(conversation: Conversation, returnOld?: boolean): Conversation | null;
             remove(conversation: Conversation): void;
             setFilter(filter: (data: Conversation[]) => Conversation[]): void;
             setConverter(converter: (data: Conversation) => Conversation): void;
@@ -826,10 +818,10 @@ declare namespace threema {
         }
 
         interface Typing {
-            setTyping(receiver: ContactReceiver): void;
-            unsetTyping(receiver: ContactReceiver): void;
+            setTyping(receiver: BaseReceiver): void;
+            unsetTyping(receiver: BaseReceiver): void;
             clearAll(): void;
-            isTyping(receiver: ContactReceiver): boolean;
+            isTyping(receiver: BaseReceiver): boolean;
         }
 
         interface Drafts {

+ 35 - 11
src/threema/container.ts

@@ -15,6 +15,7 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {copyShallow} from '../helpers';
 import {isFirstUnreadStatusMessage} from '../message_helpers';
 import {ReceiverService} from '../services/receiver';
 
@@ -140,7 +141,7 @@ class Receivers implements threema.Container.Receivers {
     public setContacts(data: threema.ContactReceiver[]): void {
         this.contacts = new Map(data.map((c) => {
             c.type = 'contact';
-            setDefault(c, 'color', '#ff00ff');
+            setDefault(c, 'color', '#f0f0f0');
             return [c.id, c];
         }) as any) as ContactMap;
         if (this.me !== undefined) {
@@ -236,7 +237,7 @@ class Receivers implements threema.Container.Receivers {
         let contactReceiver = this.contacts.get(data.id);
         if (contactReceiver === undefined) {
             data.type = 'contact';
-            setDefault(data, 'color', '#ff00ff');
+            setDefault(data, 'color', '#f0f0f0');
             this.contacts.set(data.id, data);
             return data;
         }
@@ -285,6 +286,7 @@ export class Conversations implements threema.Container.Conversations {
             if (conversation.position !== undefined) {
                 delete conversation.position;
             }
+            setDefault(conversation, 'isStarred', false);
         }
         this.conversations = data;
     }
@@ -315,17 +317,39 @@ export class Conversations implements threema.Container.Conversations {
 
     /**
      * Add a conversation at the correct position.
-     * If a conversation already exists, replace it and return the old conversation.
+     * If a conversation already exists, update it and – in case returnOld is set –
+     * return a copy of the old conversation.
      */
-    public updateOrAdd(conversation: threema.ConversationWithPosition): threema.Conversation | null {
-        let replaced = null;
+    public updateOrAdd(
+        conversation: threema.ConversationWithPosition,
+        returnOld: boolean = false,
+    ): threema.Conversation | null {
         for (const i of this.conversations.keys()) {
             if (this.receiverService.compare(this.conversations[i], conversation)) {
-                replaced = this.conversations.splice(i, 1)[0];
+                // Conversation already exists!
+                // If `returnOld` is set, create a copy of the old conversation
+                let previousConversation = null;
+                if (returnOld) {
+                    previousConversation = copyShallow(this.conversations[i]);
+                }
+
+                // Explicitly set defaults, to be able to override old values
+                setDefault(conversation, 'isStarred', false);
+
+                // Copy properties from new conversation to old conversation
+                Object.assign(this.conversations[i], conversation);
+
+                // If the position changed, re-sort.
+                if (this.conversations[i].position !== i) {
+                    const tmp = this.conversations.splice(i, 1)[0];
+                    this.conversations.splice(conversation.position, 0, tmp);
+                }
+
+                return previousConversation;
             }
         }
         this.add(conversation);
-        return replaced;
+        return null;
     }
 
     /**
@@ -753,15 +777,15 @@ class Converter {
 class Typing implements threema.Container.Typing {
     private set = new StringHashSet();
 
-    private getReceiverUid(receiver: threema.ContactReceiver): string {
+    private getReceiverUid(receiver: threema.BaseReceiver): string {
         return receiver.type + '-' + receiver.id;
     }
 
-    public setTyping(receiver: threema.ContactReceiver): void {
+    public setTyping(receiver: threema.BaseReceiver): void {
         this.set.add(this.getReceiverUid(receiver));
     }
 
-    public unsetTyping(receiver: threema.ContactReceiver): void {
+    public unsetTyping(receiver: threema.BaseReceiver): void {
         this.set.remove(this.getReceiverUid(receiver));
     }
 
@@ -769,7 +793,7 @@ class Typing implements threema.Container.Typing {
         this.set.clearAll();
     }
 
-    public isTyping(receiver: threema.ContactReceiver): boolean {
+    public isTyping(receiver: threema.BaseReceiver): boolean {
         return this.set.contains(this.getReceiverUid(receiver));
     }
 }

+ 46 - 0
src/types/future.d.ts

@@ -0,0 +1,46 @@
+/**
+ * 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/>.
+ */
+
+/**
+ * A future similar to Python's asyncio.Future. Allows to resolve or reject
+ * outside of the executor and query the current status.
+ */
+interface Future<T> extends Promise<T> {
+    /**
+     * Return whether the future is done (resolved or rejected).
+     */
+    readonly done: boolean;
+
+    /**
+     * Resolve the future.
+     */
+    resolve(value?: T | PromiseLike<T>): void;
+
+    /**
+     * Reject the future.
+     */
+    reject(reason?: any): void;
+}
+
+interface FutureStatic {
+    new<T>(executor?: (resolveFn: (value?: T | PromiseLike<T>) => void,
+                       rejectFn: (reason?: any) => void) => void,
+    ): Future<T>
+    withMinDuration<T>(promise: Promise<T>, minDuration: Number): Future<T>
+}
+
+declare var Future: FutureStatic;

+ 0 - 82
tests/filters.js

@@ -71,87 +71,6 @@ describe('Filters', function() {
         };
     };
 
-    describe('markify', function() {
-
-        this.testPatterns = (cases) => testPatterns('markify', cases);
-
-        it('detects bold text', () => {
-            this.testPatterns([
-                ['*bold text (not italic)*',
-                 '<span class="text-bold">bold text (not italic)</span>'],
-            ]);
-        });
-
-        it('detects italic text', () => {
-            this.testPatterns([
-                ['This text is not italic.',
-                 'This text is not italic.'],
-                ['_This text is italic._',
-                 '<span class="text-italic">This text is italic.</span>'],
-                ['This text is _partially_ italic',
-                 'This text is <span class="text-italic">partially</span> italic'],
-                ['This text has _two_ _italic_ bits',
-                 'This text has <span class="text-italic">two</span> <span class="text-italic">italic</span> bits'],
-            ]);
-
-        });
-
-        it('detects strikethrough text', () => {
-            this.testPatterns([
-                ['so ~strikethrough~', 'so <span class="text-strike">strikethrough</span>'],
-            ]);
-        });
-
-        it('detects mixed markup', () => {
-            this.testPatterns([
-                ['*bold text with _italic_ *',
-                 '<span class="text-bold">bold text with <span class="text-italic">italic</span> </span>'],
-                ['*part bold,* _part italic_',
-                 '<span class="text-bold">part bold,</span> <span class="text-italic">part italic</span>'],
-                ['_italic text with *bold* _',
-                 '<span class="text-italic">italic text with <span class="text-bold">bold</span> </span>'],
-            ]);
-        });
-
-        it('is only applied on word boundaries', () => {
-            this.testPatterns([
-                ['so not_really_italic',
-                 'so not_really_italic'],
-                ['invalid*bold*stuff',
-                 'invalid*bold*stuff'],
-                ['no~strike~through',
-                 'no~strike~through'],
-                ['*bold_but_no~strike~through*',
-                 '<span class="text-bold">bold_but_no~strike~through</span>'],
-            ]);
-        });
-
-        it('does not break URLs', () => {
-            this.testPatterns([
-                ['https://en.wikipedia.org/wiki/Java_class_file *nice*',
-                 'https://en.wikipedia.org/wiki/Java_class_file <span class="text-bold">nice</span>'],
-                ['<a href="https://threema.ch">_Threema_</a>',
-                 '<a href="https://threema.ch"><span class="text-italic">Threema</span></a>'],
-            ]);
-        });
-
-        it('ignores invalid markup', () => {
-            this.testPatterns([
-                ['*invalid markup (do not parse)_', '*invalid markup (do not parse)_'],
-                ['random *asterisk', 'random *asterisk'],
-            ]);
-        });
-
-        it('ignores markup with \\n (newline)', () => {
-            this.testPatterns([
-                ['*First line\n and a new one. (do not parse)*', '*First line\n and a new one. (do not parse)*'],
-                ['*\nbegins with linebreak. (do not parse)*', '*\nbegins with linebreak. (do not parse)*'],
-                ['*Just some text. But it ends with newline (do not parse)\n*', '*Just some text. But it ends with newline (do not parse)\n*'],
-            ]);
-        });
-
-    });
-
     describe('escapeHtml', function() {
 
         this.testPatterns = (cases) => testPatterns('escapeHtml', cases);
@@ -168,7 +87,6 @@ describe('Filters', function() {
 
     describe('mentionify', function() {
 
-
         this.testPatterns = (cases) => testPatterns('mentionify', cases);
 
         it('no mentions', () => {

+ 8 - 1
tests/service/message.js

@@ -7,7 +7,14 @@ describe('MessageService', function() {
 
     beforeEach(function() {
 
-        // load threema services
+        // Inject constants
+        module(($provide) => {
+            $provide.constant('CONFIG', {
+                'DEBUG': true,
+            });
+        });
+
+        // Load threema services
         module('3ema.services');
 
         // Inject the MessageService

+ 12 - 0
tests/ts/containers.ts

@@ -36,6 +36,7 @@ function makeContactConversation(id: string, position?: number): threema.Convers
         messageCount: 5,
         unreadCount: 0,
         latestMessage: null,
+        isStarred: false,
     };
 }
 
@@ -84,6 +85,17 @@ describe('Container', () => {
                 delete expected.position;
                 expect((conversations as any).conversations).toEqual([expected]);
             });
+
+            it('sets defaults', function() {
+                const conversations = getConversations();
+
+                const conversation = makeContactConversation('1', 7);
+                delete conversation.isStarred;
+                conversations.set([conversation]);
+
+                const expected = makeContactConversation('1');
+                expect((conversations as any).conversations[0].isStarred).toEqual(false);
+            });
         });
 
         describe('add', function() {

+ 1 - 0
tests/ts/main.ts

@@ -23,4 +23,5 @@
 import './containers';
 import './crypto_helpers';
 import './helpers';
+import './markup_parser';
 import './receiver_helpers';

+ 271 - 0
tests/ts/markup_parser.ts

@@ -0,0 +1,271 @@
+/**
+ * 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/>.
+ */
+// tslint:disable:max-line-length
+
+import {markify, parse, tokenize, TokenType} from '../../src/markup_parser';
+
+describe('Markup Parser', () => {
+    describe('tokenizer', () => {
+        it('simple', function() {
+            const text = 'hello *there*!';
+            const tokens = tokenize(text);
+            expect(tokens).toEqual([
+                { kind: TokenType.Text, value: 'hello ' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: 'there' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: '!' },
+            ]);
+        });
+
+        it('nested', function() {
+            const text = 'this is *_nested_*!';
+            const tokens = tokenize(text);
+            expect(tokens).toEqual([
+                { kind: TokenType.Text, value: 'this is ' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Underscore },
+                { kind: TokenType.Text, value: 'nested' },
+                { kind: TokenType.Underscore },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: '!' },
+            ]);
+        });
+
+        it('ignore if not along boundary', function() {
+            const text = 'this*is_not~at-boundary';
+            const tokens = tokenize(text);
+            expect(tokens).toEqual([
+                { kind: TokenType.Text, value: 'this*is_not~at-boundary' },
+            ]);
+        });
+
+        it('ignore in URLs', function() {
+            const text = 'ignore if *in* a link: https://example.com/_pub_/horse.jpg';
+            const tokens = tokenize(text);
+            expect(tokens).toEqual([
+                { kind: TokenType.Text, value: 'ignore if ' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: 'in' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: ' a link: https://example.com/_pub_/horse.jpg' },
+            ]);
+        });
+
+        it('with newlines', function() {
+            const text = 'hello\n*world*\n';
+            const tokens = tokenize(text);
+            expect(tokens).toEqual([
+                { kind: TokenType.Text, value: 'hello' },
+                { kind: TokenType.Newline },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: 'world' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Newline },
+            ]);
+        });
+    });
+
+    describe('parser', () => {
+        it('simple text without formatting', () => {
+            const tokens = [{ kind: TokenType.Text, value: 'hello world' }];
+            const html = parse(tokens);
+            expect(html).toEqual('hello world');
+        });
+
+        it('simple bold text', () => {
+            const tokens = [
+                { kind: TokenType.Text, value: 'hello ' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: 'bold' },
+                { kind: TokenType.Asterisk },
+            ];
+            const html = parse(tokens);
+            expect(html).toEqual('hello <span class="text-bold">bold</span>');
+        });
+
+        it('simple italic text', () => {
+            const tokens = [
+                { kind: TokenType.Text, value: 'hello ' },
+                { kind: TokenType.Underscore },
+                { kind: TokenType.Text, value: 'italic' },
+                { kind: TokenType.Underscore },
+            ];
+            const html = parse(tokens);
+            expect(html).toEqual('hello <span class="text-italic">italic</span>');
+        });
+
+        it('simple strikethrough text', () => {
+            const tokens = [
+                { kind: TokenType.Text, value: 'hello ' },
+                { kind: TokenType.Tilde },
+                { kind: TokenType.Text, value: 'strikethrough' },
+                { kind: TokenType.Tilde },
+            ];
+            const html = parse(tokens);
+            expect(html).toEqual('hello <span class="text-strike">strikethrough</span>');
+        });
+
+        it('correct nesting', () => {
+            const tokens = [
+                { kind: TokenType.Text, value: 'hello ' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: 'bold and ' },
+                { kind: TokenType.Underscore },
+                { kind: TokenType.Text, value: 'italic' },
+                { kind: TokenType.Underscore },
+                { kind: TokenType.Asterisk },
+            ];
+            const html = parse(tokens);
+            expect(html).toEqual('hello <span class="text-bold">bold and <span class="text-italic">italic</span></span>');
+        });
+
+        it('incorrect nesting', () => {
+            const tokens = [
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Text, value: 'hi ' },
+                { kind: TokenType.Underscore },
+                { kind: TokenType.Text, value: 'there' },
+                { kind: TokenType.Asterisk },
+                { kind: TokenType.Underscore },
+            ];
+            const html = parse(tokens);
+            expect(html).toEqual('<span class="text-bold">hi _there</span>_');
+        });
+    });
+
+    function testPatterns(cases) {
+        for (const testcase of cases) {
+            const input = testcase[0];
+            const expected = testcase[1];
+            expect(markify(input)).toEqual(expected);
+        }
+    }
+
+    describe('markify', () => {
+
+        it('detects bold text', () => {
+            testPatterns([
+                ['*bold text (not italic)*',
+                 '<span class="text-bold">bold text (not italic)</span>'],
+            ]);
+        });
+
+        it('detects italic text', () => {
+            testPatterns([
+                ['This text is not italic.',
+                 'This text is not italic.'],
+                ['_This text is italic._',
+                 '<span class="text-italic">This text is italic.</span>'],
+                ['This text is _partially_ italic',
+                 'This text is <span class="text-italic">partially</span> italic'],
+                ['This text has _two_ _italic_ bits',
+                 'This text has <span class="text-italic">two</span> <span class="text-italic">italic</span> bits'],
+            ]);
+
+        });
+
+        it('detects strikethrough text', () => {
+            testPatterns([
+                ['so ~strikethrough~', 'so <span class="text-strike">strikethrough</span>'],
+            ]);
+        });
+
+        it('detects mixed markup', () => {
+            testPatterns([
+                ['*bold text with _italic_ *',
+                 '<span class="text-bold">bold text with <span class="text-italic">italic</span> </span>'],
+                ['*part bold,* _part italic_',
+                 '<span class="text-bold">part bold,</span> <span class="text-italic">part italic</span>'],
+                ['_italic text with *bold* _',
+                 '<span class="text-italic">italic text with <span class="text-bold">bold</span> </span>'],
+            ]);
+        });
+
+        it('is applied on word boundaries', () => {
+            testPatterns([
+                ['(*bold*)',
+                 '(<span class="text-bold">bold</span>)'],
+                ['¡*Threema* es fantástico!',
+                 '¡<span class="text-bold">Threema</span> es fantástico!'],
+                ['«_great_ service»',
+                 '«<span class="text-italic">great</span> service»'],
+                ['"_great_" service',
+                 '"<span class="text-italic">great</span>" service'],
+                ['*bold*…',
+                 '<span class="text-bold">bold</span>…'],
+                ['_<a href="https://threema.ch">Threema</a>_',
+                 '<span class="text-italic"><a href="https://threema.ch">Threema</a></span>'],
+            ]);
+        });
+
+        it('is only applied on word boundaries', () => {
+            testPatterns([
+                ['so not_really_italic',
+                 'so not_really_italic'],
+                ['invalid*bold*stuff',
+                 'invalid*bold*stuff'],
+                ['no~strike~through',
+                 'no~strike~through'],
+                ['*bold_but_no~strike~through*',
+                 '<span class="text-bold">bold_but_no~strike~through</span>'],
+                ['<_< >_>',
+                 '<_< >_>'],
+                ['<a href="https://threema.ch">_Threema_</a>',
+                 '<a href="https://threema.ch">_Threema_</a>'],
+            ]);
+        });
+
+        it('does not break URLs', () => {
+            testPatterns([
+                ['https://en.wikipedia.org/wiki/Java_class_file *nice*',
+                 'https://en.wikipedia.org/wiki/Java_class_file <span class="text-bold">nice</span>'],
+                ['https://example.com/_output_/',
+                 'https://example.com/_output_/'],
+                ['https://example.com/*output*/',
+                 'https://example.com/*output*/'],
+                ['https://example.com?_twitter_impression=true',
+                 'https://example.com?_twitter_impression=true'],
+                ['https://example.com?__twitter_impression=true',
+                 'https://example.com?__twitter_impression=true'],
+                ['https://example.com?___twitter_impression=true',
+                 'https://example.com?___twitter_impression=true'],
+            ]);
+        });
+
+        it('ignores invalid markup', () => {
+            testPatterns([
+                ['*invalid markup (do not parse)_', '*invalid markup (do not parse)_'],
+                ['random *asterisk', 'random *asterisk'],
+                ['***three asterisks', '***three asterisks'],
+                ['***three asterisks*', '**<span class="text-bold">three asterisks</span>'],
+            ]);
+        });
+
+        it('ignores markup with \\n (newline)', () => {
+            testPatterns([
+                ['*First line\n and a new one. (do not parse)*', '*First line\n and a new one. (do not parse)*'],
+                ['*\nbegins with linebreak. (do not parse)*', '*\nbegins with linebreak. (do not parse)*'],
+                ['*Just some text. But it ends with newline (do not parse)\n*', '*Just some text. But it ends with newline (do not parse)\n*'],
+            ]);
+        });
+
+    });
+
+});

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