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.
 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)
 ### [v2.0.3][v2.0.3] (2018-08-23)
 
 
 Changes:
 Changes:
@@ -491,6 +572,7 @@ First public release.
 [i357]: https://github.com/threema-ch/threema-web/issues/357
 [i357]: https://github.com/threema-ch/threema-web/issues/357
 [i358]: https://github.com/threema-ch/threema-web/issues/358
 [i358]: https://github.com/threema-ch/threema-web/issues/358
 [i359]: https://github.com/threema-ch/threema-web/issues/359
 [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
 [i362]: https://github.com/threema-ch/threema-web/issues/362
 [i363]: https://github.com/threema-ch/threema-web/issues/363
 [i363]: https://github.com/threema-ch/threema-web/issues/363
 [i364]: https://github.com/threema-ch/threema-web/issues/364
 [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
 [i439]: https://github.com/threema-ch/threema-web/issues/439
 [i441]: https://github.com/threema-ch/threema-web/issues/441
 [i441]: https://github.com/threema-ch/threema-web/issues/441
 [i445]: https://github.com/threema-ch/threema-web/issues/445
 [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
 [i472]: https://github.com/threema-ch/threema-web/issues/472
 [i480]: https://github.com/threema-ch/threema-web/issues/480
 [i480]: https://github.com/threema-ch/threema-web/issues/480
 [i503]: https://github.com/threema-ch/threema-web/issues/503
 [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
 [i519]: https://github.com/threema-ch/threema-web/issues/519
 [i522]: https://github.com/threema-ch/threema-web/issues/522
 [i522]: https://github.com/threema-ch/threema-web/issues/522
 [i528]: https://github.com/threema-ch/threema-web/issues/528
 [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
 [i545]: https://github.com/threema-ch/threema-web/issues/545
 [i547]: https://github.com/threema-ch/threema-web/issues/547
 [i547]: https://github.com/threema-ch/threema-web/issues/547
 [i550]: https://github.com/threema-ch/threema-web/issues/550
 [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
 [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
 [i563]: https://github.com/threema-ch/threema-web/issues/563
 [i567]: https://github.com/threema-ch/threema-web/issues/567
 [i567]: https://github.com/threema-ch/threema-web/issues/567
 [i569]: https://github.com/threema-ch/threema-web/issues/569
 [i569]: https://github.com/threema-ch/threema-web/issues/569
 [i572]: https://github.com/threema-ch/threema-web/issues/572
 [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.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.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
 [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
 [@heckenmann]: https://github.com/heckenmann
 [@iasdeoupxe]: https://github.com/iasdeoupxe
 [@iasdeoupxe]: https://github.com/iasdeoupxe
 [@SirTyson]: https://github.com/SirTyson
 [@SirTyson]: https://github.com/SirTyson
+[@MarcoZehe]: https://github.com/MarcoZehe

+ 3 - 0
RELEASING.md

@@ -1,5 +1,8 @@
 # Releasing
 # Releasing
 
 
+Major release with backwards incompatible changes? Check for `TODO` comments
+with deprecations. Remove them if possible.
+
 Set variables:
 Set variables:
 
 
     $ export VERSION=X.Y.Z
     $ 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"
 DIR="release/threema-web-$VERSION"
 
 
 echo "+ Create release directory..."
 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..."
 echo "+ Copy code..."
 cp -R index.html $DIR/
 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/*.html $DIR/partials/
 cp -R src/partials/messenger.receiver/*.html $DIR/partials/messenger.receiver/
 cp -R src/partials/messenger.receiver/*.html $DIR/partials/messenger.receiver/
 cp -R src/directives/*.html $DIR/directives/
 cp -R src/directives/*.html $DIR/directives/
+cp -R src/components/*.html $DIR/components/ 2>/dev/null || :
 
 
 echo "+ Copy dependencies..."
 echo "+ Copy dependencies..."
 targets=(
 targets=(
@@ -55,7 +56,6 @@ targets=(
     babel-es6-polyfill/browser-polyfill.min.js
     babel-es6-polyfill/browser-polyfill.min.js
     msgpack-lite/dist/msgpack.min.js
     msgpack-lite/dist/msgpack.min.js
     tweetnacl/nacl-fast.min.js
     tweetnacl/nacl-fast.min.js
-    file-saver/FileSaver.min.js
     @saltyrtc/chunked-dc/dist/chunked-dc.es5.js
     @saltyrtc/chunked-dc/dist/chunked-dc.es5.js
     @saltyrtc/client/dist/saltyrtc-client.es5.js
     @saltyrtc/client/dist/saltyrtc-client.es5.js
     @saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js
     @saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js

+ 2 - 8
index.html

@@ -57,7 +57,7 @@
 </head>
 </head>
 
 
 <body ng-controller="StatusController as ctrl" class="{{ ctrl.statusClass }}" ng-class="{expanded: ctrl.expandStatusBar}">
 <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>
     <noscript>
         <img id="logo-noscript" src="img/logo.svg?v=[[VERSION]]"/>
         <img id="logo-noscript" src="img/logo.svg?v=[[VERSION]]"/>
@@ -83,12 +83,6 @@
             </div>
             </div>
             <div id="main-content" ui-view></div>
             <div id="main-content" ui-view></div>
         </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">
         <footer ng-controller="FooterController as ctrl">
             <ul>
             <ul>
                 <li><a ng-click="ctrl.showVersionInfo('[[VERSION]]')" ng-keypress="ctrl.showVersionInfo('[[VERSION]]', $event)" tabindex="0">Version [[VERSION]] {{ ctrl.config.VERSION_MOUNTAIN }}</a></li>
                 <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="libs/emojione/emojione.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/angularjs-scroll-glue/src/scrollglue.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/angular-inview/angular-inview.js?v=[[VERSION]]"></script>
+    <script src="libs/future.js?v=[[VERSION]]"></script>
 
 
     <!-- Translation -->
     <!-- Translation -->
     <script src="node_modules/messageformat/messageformat.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/messageformat/messageformat.min.js?v=[[VERSION]]"></script>
@@ -128,7 +123,6 @@
     <!-- Other -->
     <!-- Other -->
     <script src="node_modules/msgpack-lite/dist/msgpack.min.js?v=[[VERSION]]"></script>
     <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/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/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/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>
     <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",
   "name": "threema-web",
-  "version": "2.0.3",
+  "version": "2.1.0-rc.1",
   "lockfileVersion": 1,
   "lockfileVersion": 1,
   "requires": true,
   "requires": true,
   "dependencies": {
   "dependencies": {
@@ -10,9 +10,9 @@
       "integrity": "sha512-im7GXKhUsNKTbppZOA0Jqx0Yku+3FILe/CENMlX5PT1tP95Dfu1VadIaBgNevxstnCadrYPTtxVeXc2MwxO3jw=="
       "integrity": "sha512-im7GXKhUsNKTbppZOA0Jqx0Yku+3FILe/CENMlX5PT1tP95Dfu1VadIaBgNevxstnCadrYPTtxVeXc2MwxO3jw=="
     },
     },
     "@saltyrtc/client": {
     "@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": {
     "@saltyrtc/task-relayed-data": {
       "version": "0.3.1",
       "version": "0.3.1",
@@ -20,9 +20,9 @@
       "integrity": "sha512-TgucXvVHKKS40nMk+xoLdo4rqDP4seby0iE19gUj+oYytuwf58rc00DBRguwf7KLlf1IUVoJIXEt4TAvUe97lA=="
       "integrity": "sha512-TgucXvVHKKS40nMk+xoLdo4rqDP4seby0iE19gUj+oYytuwf58rc00DBRguwf7KLlf1IUVoJIXEt4TAvUe97lA=="
     },
     },
     "@saltyrtc/task-webrtc": {
     "@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": {
       "requires": {
         "@saltyrtc/chunked-dc": "^1.1.1"
         "@saltyrtc/chunked-dc": "^1.1.1"
       }
       }
@@ -56,10 +56,10 @@
         "@types/angular": "*"
         "@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": {
     "@types/jasmine": {
       "version": "2.8.8",
       "version": "2.8.8",
@@ -3224,9 +3224,9 @@
       "optional": true
       "optional": true
     },
     },
     "file-saver": {
     "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": {
     "file-uri-to-path": {
       "version": "1.0.0",
       "version": "1.0.0",
@@ -3445,7 +3445,8 @@
         "ansi-regex": {
         "ansi-regex": {
           "version": "2.1.1",
           "version": "2.1.1",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "aproba": {
         "aproba": {
           "version": "1.2.0",
           "version": "1.2.0",
@@ -3466,12 +3467,14 @@
         "balanced-match": {
         "balanced-match": {
           "version": "1.0.0",
           "version": "1.0.0",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "brace-expansion": {
         "brace-expansion": {
           "version": "1.1.11",
           "version": "1.1.11",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "balanced-match": "^1.0.0",
             "balanced-match": "^1.0.0",
             "concat-map": "0.0.1"
             "concat-map": "0.0.1"
@@ -3486,17 +3489,20 @@
         "code-point-at": {
         "code-point-at": {
           "version": "1.1.0",
           "version": "1.1.0",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "concat-map": {
         "concat-map": {
           "version": "0.0.1",
           "version": "0.0.1",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "console-control-strings": {
         "console-control-strings": {
           "version": "1.1.0",
           "version": "1.1.0",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "core-util-is": {
         "core-util-is": {
           "version": "1.0.2",
           "version": "1.0.2",
@@ -3613,7 +3619,8 @@
         "inherits": {
         "inherits": {
           "version": "2.0.3",
           "version": "2.0.3",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "ini": {
         "ini": {
           "version": "1.3.5",
           "version": "1.3.5",
@@ -3625,6 +3632,7 @@
           "version": "1.0.0",
           "version": "1.0.0",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "number-is-nan": "^1.0.0"
             "number-is-nan": "^1.0.0"
           }
           }
@@ -3639,6 +3647,7 @@
           "version": "3.0.4",
           "version": "3.0.4",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "brace-expansion": "^1.1.7"
             "brace-expansion": "^1.1.7"
           }
           }
@@ -3646,12 +3655,14 @@
         "minimist": {
         "minimist": {
           "version": "0.0.8",
           "version": "0.0.8",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "minipass": {
         "minipass": {
           "version": "2.2.4",
           "version": "2.2.4",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "safe-buffer": "^5.1.1",
             "safe-buffer": "^5.1.1",
             "yallist": "^3.0.0"
             "yallist": "^3.0.0"
@@ -3670,6 +3681,7 @@
           "version": "0.5.1",
           "version": "0.5.1",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "minimist": "0.0.8"
             "minimist": "0.0.8"
           }
           }
@@ -3750,7 +3762,8 @@
         "number-is-nan": {
         "number-is-nan": {
           "version": "1.0.1",
           "version": "1.0.1",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "object-assign": {
         "object-assign": {
           "version": "4.1.1",
           "version": "4.1.1",
@@ -3762,6 +3775,7 @@
           "version": "1.4.0",
           "version": "1.4.0",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "wrappy": "1"
             "wrappy": "1"
           }
           }
@@ -3847,7 +3861,8 @@
         "safe-buffer": {
         "safe-buffer": {
           "version": "5.1.1",
           "version": "5.1.1",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "safer-buffer": {
         "safer-buffer": {
           "version": "2.1.2",
           "version": "2.1.2",
@@ -3883,6 +3898,7 @@
           "version": "1.0.2",
           "version": "1.0.2",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "code-point-at": "^1.0.0",
             "code-point-at": "^1.0.0",
             "is-fullwidth-code-point": "^1.0.0",
             "is-fullwidth-code-point": "^1.0.0",
@@ -3902,6 +3918,7 @@
           "version": "3.0.1",
           "version": "3.0.1",
           "bundled": true,
           "bundled": true,
           "dev": true,
           "dev": true,
+          "optional": true,
           "requires": {
           "requires": {
             "ansi-regex": "^2.0.0"
             "ansi-regex": "^2.0.0"
           }
           }
@@ -3945,12 +3962,14 @@
         "wrappy": {
         "wrappy": {
           "version": "1.0.2",
           "version": "1.0.2",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         },
         },
         "yallist": {
         "yallist": {
           "version": "3.0.2",
           "version": "3.0.2",
           "bundled": true,
           "bundled": true,
-          "dev": true
+          "dev": true,
+          "optional": true
         }
         }
       }
       }
     },
     },
@@ -4013,7 +4032,7 @@
         },
         },
         "chalk": {
         "chalk": {
           "version": "0.5.1",
           "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=",
           "integrity": "sha1-Zjs6ZItotV0EaQ1JFnqoN4WPIXQ=",
           "dev": true,
           "dev": true,
           "requires": {
           "requires": {

+ 5 - 5
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "threema-web",
   "name": "threema-web",
-  "version": "2.0.3",
+  "version": "2.1.0-rc.1",
   "description": "Threema Webclient",
   "description": "Threema Webclient",
   "scripts": {
   "scripts": {
     "build": "npm run build:js && npm run build:css",
     "build": "npm run build:js && npm run build:css",
@@ -26,14 +26,14 @@
   "private": true,
   "private": true,
   "homepage": "https://threema.ch/",
   "homepage": "https://threema.ch/",
   "dependencies": {
   "dependencies": {
-    "@saltyrtc/client": "^0.12.4",
+    "@saltyrtc/client": "^0.13.1",
     "@saltyrtc/task-relayed-data": "^0.3.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": "^1.6.50",
     "@types/angular-material": "^1.1.59",
     "@types/angular-material": "^1.1.59",
     "@types/angular-sanitize": "^1.3.7",
     "@types/angular-sanitize": "^1.3.7",
     "@types/angular-translate": "^2.16.0",
     "@types/angular-translate": "^2.16.0",
-    "@types/filesaver": "~0.0.30",
+    "@types/file-saver": "^1.3.0",
     "@types/jquery": "^3.3.6",
     "@types/jquery": "^3.3.6",
     "@types/msgpack-lite": "^0.1.6",
     "@types/msgpack-lite": "^0.1.6",
     "@types/webrtc": "0.0.23",
     "@types/webrtc": "0.0.23",
@@ -55,7 +55,7 @@
     "browserify": "~16",
     "browserify": "~16",
     "browserify-header": "^0.9.4",
     "browserify-header": "^0.9.4",
     "croppie": "~2.6.0",
     "croppie": "~2.6.0",
-    "file-saver": "^1.3.8",
+    "file-saver": "2.0.0-rc.3",
     "messageformat": "^2.0.4",
     "messageformat": "^2.0.4",
     "msgpack-lite": "~0.1.26",
     "msgpack-lite": "~0.1.26",
     "node-sass": "^4.9.3",
     "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_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.",
         "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.",
         "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",
         "CONNECTING": "Verbindung wird aufgebaut",
         "WAITING_FOR_PUSH": "Threema-App wird<br>aufgeweckt …",
         "WAITING_FOR_PUSH": "Threema-App wird<br>aufgeweckt …",
         "CONNECTING_TO_APP": "Verbindung zu App<br>wird aufgebaut …",
         "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!",
         "ALREADY_CONNECTED_DETAILS": "Sie sind bereits in einem anderen Tab oder Fenster mit Threema Web verbunden!",
         "VERSION": "Version",
         "VERSION": "Version",
         "BACKGROUND_IMAGE": "Hintergrundbild",
         "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": {
     "connecting": {
         "CONNECTION_PROBLEMS": "Verbindungsprobleme",
         "CONNECTION_PROBLEMS": "Verbindungsprobleme",
@@ -52,7 +51,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services nicht installiert. Bitte starten Sie die Sitzung manuell.",
         "WAITING_FOR_APP_MANUAL": "Google Play Services nicht installiert. Bitte starten Sie die Sitzung manuell.",
         "CONNECTING_TO_SERVER": "Verbinden mit Server\u2026",
         "CONNECTING_TO_SERVER": "Verbinden mit Server\u2026",
         "CONNECTING_TO_APP": "Verbindung zu App wird aufgebaut\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": {
     "troubleshooting": {
         "SLOW_CONNECT": "Verbindungsaufbau scheint länger zu dauern<br>als normal …",
         "SLOW_CONNECT": "Verbindungsaufbau scheint länger zu dauern<br>als normal …",
@@ -101,6 +101,7 @@
         "CONVERSATIONS": "Chats",
         "CONVERSATIONS": "Chats",
         "CONTACTS": "Kontakte",
         "CONTACTS": "Kontakte",
         "NO_CONVERSATIONS_FOUND": "Keine Chats gefunden.",
         "NO_CONVERSATIONS_FOUND": "Keine Chats gefunden.",
+        "LOADING_CONVERSATIONS": "Chats werden geladen …",
         "ABOUT": "\u00dcber",
         "ABOUT": "\u00dcber",
         "SETTINGS": "Einstellungen",
         "SETTINGS": "Einstellungen",
         "HELP": "Hilfe",
         "HELP": "Hilfe",
@@ -183,7 +184,13 @@
         "MUTED_MENTION_ONLY": "Nur bei Erwähnung benachrichtigen",
         "MUTED_MENTION_ONLY": "Nur bei Erwähnung benachrichtigen",
         "MUTED_SILENT": "Stumme Benachrichtigungen",
         "MUTED_SILENT": "Stumme Benachrichtigungen",
         "ALL": "Alle",
         "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": {
     "messageStates": {
         "WE_ACK": "Sie haben ein Daumen-Hoch gesendet",
         "WE_ACK": "Sie haben ein Daumen-Hoch gesendet",
@@ -216,6 +223,7 @@
             "badRequest": "Ungültige Anfrage (Protokollfehler?)",
             "badRequest": "Ungültige Anfrage (Protokollfehler?)",
             "timeout": "Timeout",
             "timeout": "Timeout",
             "internalError": "Ein interner Fehler ist aufgetreten",
             "internalError": "Ein interner Fehler ist aufgetreten",
+            "invalidAvatar": "Ungültiger Avatar",
             "invalidIdentity": "Ungültige Threema-ID",
             "invalidIdentity": "Ungültige Threema-ID",
             "invalidContact": "Ungültiger Kontakt",
             "invalidContact": "Ungültiger Kontakt",
             "invalidGroup": "Ungültige Gruppe",
             "invalidGroup": "Ungültige Gruppe",
@@ -323,10 +331,11 @@
         }
         }
     },
     },
     "connection": {
     "connection": {
-        "SESSION_CLOSED_TITLE": "Sitzung Geschlossen",
+        "SESSION_CLOSED_TITLE": "Sitzung geschlossen",
         "SESSION_STOPPED": "Die Sitzung wurde auf Ihrem Gerät gestoppt.",
         "SESSION_STOPPED": "Die Sitzung wurde auf Ihrem Gerät gestoppt.",
         "SESSION_DELETED": "Die Sitzung wurde auf Ihrem Gerät gelöscht.",
         "SESSION_DELETED": "Die Sitzung wurde auf Ihrem Gerät gelöscht.",
         "WEBCLIENT_DISABLED": "Threema Web wurde auf Ihrem Gerät deaktiviert.",
         "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_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.",
         "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.",
         "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",
         "CONNECTING": "Connecting",
         "WAITING_FOR_PUSH": "Waiting for<br>app wakeup …",
         "WAITING_FOR_PUSH": "Waiting for<br>app wakeup …",
         "CONNECTING_TO_APP": "Connection to app is<br>being established …",
         "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!",
         "ALREADY_CONNECTED_DETAILS": "You are already connected to Threema Web in another tab or window!",
         "VERSION": "Version",
         "VERSION": "Version",
         "BACKGROUND_IMAGE": "Background Image",
         "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": {
     "connecting": {
         "CONNECTION_PROBLEMS": "Connection problems",
         "CONNECTION_PROBLEMS": "Connection problems",
@@ -52,7 +51,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services not installed. Please start the session manually.",
         "WAITING_FOR_APP_MANUAL": "Google Play Services not installed. Please start the session manually.",
         "CONNECTING_TO_SERVER": "Connecting to server\u2026",
         "CONNECTING_TO_SERVER": "Connecting to server\u2026",
         "CONNECTING_TO_APP": "Connection to app is being established\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": {
     "troubleshooting": {
         "SLOW_CONNECT": "Connecting seems to take longer than usual …",
         "SLOW_CONNECT": "Connecting seems to take longer than usual …",
@@ -101,6 +101,7 @@
         "CONVERSATIONS": "Conversations",
         "CONVERSATIONS": "Conversations",
         "CONTACTS": "Contacts",
         "CONTACTS": "Contacts",
         "NO_CONVERSATIONS_FOUND": "No conversations found.",
         "NO_CONVERSATIONS_FOUND": "No conversations found.",
+        "LOADING_CONVERSATIONS": "Loading conversations …",
         "ABOUT": "About",
         "ABOUT": "About",
         "SETTINGS": "Settings",
         "SETTINGS": "Settings",
         "HELP": "Help",
         "HELP": "Help",
@@ -182,7 +183,13 @@
         "MUTED_MENTION_ONLY": "Only show notification when mentioned",
         "MUTED_MENTION_ONLY": "Only show notification when mentioned",
         "MUTED_SILENT": "Silent notifications",
         "MUTED_SILENT": "Silent notifications",
         "ALL": "All",
         "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": {
     "messageStates": {
         "WE_ACK": "You sent thumbs-up",
         "WE_ACK": "You sent thumbs-up",
@@ -215,6 +222,7 @@
             "badRequest": "Invalid request (protocol error?)",
             "badRequest": "Invalid request (protocol error?)",
             "timeout": "Request timed out",
             "timeout": "Request timed out",
             "internalError": "An internal error occurred",
             "internalError": "An internal error occurred",
+            "invalidAvatar": "Invalid avatar",
             "invalidIdentity": "Invalid Threema-ID",
             "invalidIdentity": "Invalid Threema-ID",
             "invalidContact": "Invalid contact ID",
             "invalidContact": "Invalid contact ID",
             "invalidGroup": "Invalid group ID",
             "invalidGroup": "Invalid group ID",
@@ -326,6 +334,7 @@
         "SESSION_STOPPED": "The session was stopped on your device.",
         "SESSION_STOPPED": "The session was stopped on your device.",
         "SESSION_DELETED": "The session was deleted on your device.",
         "SESSION_DELETED": "The session was deleted on your device.",
         "WEBCLIENT_DISABLED": "Threema Web was disabled 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'));
         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
       // Merge with container's events signal
       if (container) {
       if (container) {
         viewportEventSignal = viewportEventSignal.merge(container.eventsSignal);
         viewportEventSignal = viewportEventSignal.merge(container.eventsSignal);
@@ -100,8 +105,9 @@ function inViewDirective ($parse) {
         var elementRect = offsetRect(element[0].getBoundingClientRect(), options.offset);
         var elementRect = offsetRect(element[0].getBoundingClientRect(), options.offset);
         var isVisible = !!(element[0].offsetWidth || element[0].offsetHeight || element[0].getClientRects().length);
         var isVisible = !!(element[0].offsetWidth || element[0].offsetHeight || element[0].getClientRects().length);
         var documentVisible = !options.considerPageVisibility || document.visibilityState === 'visible' || document.hidden === false;
         var documentVisible = !options.considerPageVisibility || document.visibilityState === 'visible' || document.hidden === false;
+        var documentFocussed = !options.considerPageFocus || document.hasFocus();
         var info = {
         var info = {
-          inView: documentVisible && isVisible && intersectRect(elementRect, viewportRect),
+          inView: documentVisible && documentFocussed && isVisible && intersectRect(elementRect, viewportRect),
           event: event,
           event: event,
           element: element,
           element: element,
           elementRect: elementRect,
           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 {AsyncEvent} from 'ts-events';
 
 
+import './components';
 import config from './config';
 import config from './config';
 import './controllers';
 import './controllers';
 import './directives';
 import './directives';
@@ -26,6 +27,7 @@ import './filters';
 import './partials/messenger';
 import './partials/messenger';
 import './partials/welcome';
 import './partials/welcome';
 import './services';
 import './services';
+import {BrowserService} from './services/browser';
 import './threema/container';
 import './threema/container';
 
 
 // Configure asynchronous events
 // Configure asynchronous events
@@ -39,6 +41,7 @@ angular.module('3ema', [
     // Angular
     // Angular
     'ngAnimate',
     'ngAnimate',
     'ngSanitize',
     'ngSanitize',
+    'ngAria',
 
 
     // 3rd party
     // 3rd party
     'ui.router',
     'ui.router',
@@ -50,6 +53,7 @@ angular.module('3ema', [
 
 
     // Own
     // Own
     '3ema.filters',
     '3ema.filters',
+    '3ema.components',
     '3ema.directives',
     '3ema.directives',
     '3ema.container',
     '3ema.container',
     '3ema.services',
     '3ema.services',
@@ -125,6 +129,7 @@ angular.module('3ema', [
             request: (conf) => {
             request: (conf) => {
                 if (conf.url.indexOf('partials/') !== -1 ||
                 if (conf.url.indexOf('partials/') !== -1 ||
                     conf.url.indexOf('directives/') !== -1 ||
                     conf.url.indexOf('directives/') !== -1 ||
+                    conf.url.indexOf('components/') !== -1 ||
                     conf.url.indexOf('i18n/') !== -1) {
                     conf.url.indexOf('i18n/') !== -1) {
                     const separator = conf.url.indexOf('?') === -1 ? '?' : '&';
                     const separator = conf.url.indexOf('?') === -1 ? '?' : '&';
                     conf.url = conf.url + separator + CACHE_BUST;
                     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/>.
  * 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.
  * The various options are explained in the `README.md` file.
  */
  */
+// tslint:disable:max-line-length
 export default {
 export default {
 
 
     // General
     // General
     SELF_HOSTED: false,
     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',
     PREV_PROTOCOL_LAST_VERSION: '1.8.2',
-    GIT_BRANCH: 'master',
+    GIT_BRANCH: 'beta',
 
 
     // SaltyRTC
     // SaltyRTC
-    SALTYRTC_HOST: null,
+    SALTYRTC_HOST: 'saltyrtc-beta.threema.ch',
     SALTYRTC_HOST_PREFIX: 'saltyrtc-',
     SALTYRTC_HOST_PREFIX: 'saltyrtc-',
     SALTYRTC_HOST_SUFFIX: '.threema.ch',
     SALTYRTC_HOST_SUFFIX: '.threema.ch',
     SALTYRTC_PORT: 443,
     SALTYRTC_PORT: 443,
     SALTYRTC_SERVER_KEY: 'b1337fc8402f7db8ea639e05ed05d65463e24809792f91eca29e88101b4a2171',
     SALTYRTC_SERVER_KEY: 'b1337fc8402f7db8ea639e05ed05d65463e24809792f91eca29e88101b4a2171',
+    SALTYRTC_LOG_LEVEL: 'warn',
 
 
     // ICE
     // ICE
     ICE_SERVERS: [{
     ICE_SERVERS: [{
@@ -33,7 +36,7 @@ export default {
     }],
     }],
 
 
     // Push
     // Push
-    PUSH_URL: 'https://push-web.threema.ch/push',
+    PUSH_URL: 'https://push-web-beta.threema.ch/push',
 
 
     // Debugging options
     // Debugging options
     DEBUG: false,
     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');
                 $log.debug(this.logTag, 'loadAvatar: Requesting high res avatar from app');
                 webClientService.requestAvatar(receiver, true)
                 webClientService.requestAvatar(receiver, true)
                     .then((data: ArrayBuffer) => resolve(data))
                     .then((data: ArrayBuffer) => resolve(data))
-                    .catch(() => reject());
+                    .catch((error) => reject(error));
             } else {
             } else {
                 $log.debug(this.logTag, 'loadAvatar: Returning cached version');
                 $log.debug(this.logTag, 'loadAvatar: Returning cached version');
                 resolve(receiver.avatar.high);
                 resolve(receiver.avatar.high);

+ 3 - 1
src/controller_model/contact.ts

@@ -141,7 +141,9 @@ export class ContactControllerModel implements threema.ControllerModel<threema.C
             .then(() => {
             .then(() => {
                 this.isLoading = false;
                 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;
                 this.isLoading = false;
             });
             });
     }
     }

+ 6 - 2
src/controller_model/distributionList.ts

@@ -125,7 +125,9 @@ export class DistributionListControllerModel implements threema.ControllerModel<
             .then(() => {
             .then(() => {
                 this.isLoading = false;
                 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;
                 this.isLoading = false;
             });
             });
     }
     }
@@ -163,7 +165,9 @@ export class DistributionListControllerModel implements threema.ControllerModel<
         this.isLoading = true;
         this.isLoading = true;
         this.webClientService.deleteDistributionList(this.distributionList).then(() => {
         this.webClientService.deleteDistributionList(this.distributionList).then(() => {
             this.isLoading = false;
             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;
             this.isLoading = false;
         });
         });
     }
     }

+ 9 - 3
src/controller_model/group.ts

@@ -140,7 +140,9 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
             .then(() => {
             .then(() => {
                 this.isLoading = false;
                 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;
                 this.isLoading = false;
             });
             });
     }
     }
@@ -178,7 +180,9 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
             .then(() => {
             .then(() => {
                 this.isLoading = false;
                 this.isLoading = false;
             })
             })
-            .catch(() => {
+            .catch((error) => {
+                // TODO: Handle this properly / show an error message
+                this.$log.error(`Leaving group failed: ${error}`);
                 this.isLoading = false;
                 this.isLoading = false;
             });
             });
     }
     }
@@ -212,7 +216,9 @@ export class GroupControllerModel implements threema.ControllerModel<threema.Gro
                     this.onRemovedCallback(this.group.id);
                     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;
                 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(
                 return this.webClientService.modifyProfile(
                     this.nickname,
                     this.nickname,
                     this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
                     this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
-                ).then((val) => {
+                ).then(() => {
                     // Profile was successfully updated. Update local data.
                     // Profile was successfully updated. Update local data.
                     this.webClientService.me.publicNickname = this.nickname;
                     this.webClientService.me.publicNickname = this.nickname;
                     if (this.avatarController.avatarChanged) {
                     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/>.
  * 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 {FooterController} from './controllers/footer';
 import {StatusController} from './controllers/status';
 import {StatusController} from './controllers/status';
 
 
 angular.module('3ema.controllers', ['3ema.services'])
 angular.module('3ema.controllers', ['3ema.services'])
 
 
-.controller('AndroidIosOnlyController', AndroidIosOnlyController)
 .controller('FooterController', FooterController)
 .controller('FooterController', FooterController)
 .controller('StatusController', StatusController)
 .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 {ControllerService} from '../services/controller';
 import {StateService} from '../services/state';
 import {StateService} from '../services/state';
+import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
 import GlobalConnectionState = threema.GlobalConnectionState;
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 
 /**
 /**
  * This controller handles state changes globally.
  * This controller handles state changes globally.
  *
  *
  * It also controls auto-reconnecting and the connection status indicator bar.
  * 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 {
 export class StatusController {
 
 
@@ -51,15 +53,18 @@ export class StatusController {
     private $state: UiStateService;
     private $state: UiStateService;
 
 
     // Custom services
     // Custom services
+    private controllerService: ControllerService;
     private stateService: StateService;
     private stateService: StateService;
+    private timeoutService: TimeoutService;
     private webClientService: WebClientService;
     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,
     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
         // Angular services
         this.$timeout = $timeout;
         this.$timeout = $timeout;
@@ -67,9 +72,10 @@ export class StatusController {
         this.$state = $state;
         this.$state = $state;
 
 
         // Custom services
         // Custom services
+        this.controllerService = controllerService;
         this.stateService = stateService;
         this.stateService = stateService;
+        this.timeoutService = timeoutService;
         this.webClientService = webClientService;
         this.webClientService = webClientService;
-        this.controllerService = controllerService;
 
 
         // Register event handlers
         // Register event handlers
         this.stateService.evtGlobalConnectionStateChange.attach(
         this.stateService.evtGlobalConnectionStateChange.attach(
@@ -122,6 +128,9 @@ export class StatusController {
                     }
                     }
                     this.reconnectAndroid();
                     this.reconnectAndroid();
                 }
                 }
+                if (this.stateService.wasConnected && isRelayedData) {
+                    this.reconnectIos();
+                }
                 break;
                 break;
             default:
             default:
                 this.$log.error(this.logTag, 'Invalid state change: From', oldValue, 'to', newValue);
                 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.
      * Show full status bar with a certain delay.
      */
      */
     private scheduleStatusBar(): void {
     private scheduleStatusBar(): void {
-        this.expandStatusBarTimer = this.$timeout(() => {
+        this.expandStatusBarTimer = this.timeoutService.register(() => {
             this.expandStatusBar = true;
             this.expandStatusBar = true;
-        }, this.expandStatusBarTimeout);
+        }, this.expandStatusBarTimeout, true, 'expandStatusBar');
     }
     }
 
 
     /**
     /**
@@ -143,7 +152,7 @@ export class StatusController {
     private collapseStatusBar(): void {
     private collapseStatusBar(): void {
         this.expandStatusBar = false;
         this.expandStatusBar = false;
         if (this.expandStatusBarTimer !== null) {
         if (this.expandStatusBarTimer !== null) {
-            this.$timeout.cancel(this.expandStatusBarTimer);
+            this.timeoutService.cancel(this.expandStatusBarTimer);
         }
         }
     }
     }
 
 
@@ -169,15 +178,13 @@ export class StatusController {
             // Collapse status bar
             // Collapse status bar
             this.collapseStatusBar();
             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.
         // Function to soft-reconnect. Does not reset the loaded data.
         const doSoftReconnect = () => {
         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(
             this.webClientService.start().then(
                 () => {
                 () => {
                     // Cancel timeout
                     // Cancel timeout
@@ -244,40 +257,71 @@ export class StatusController {
      * Attempt to reconnect an iOS device after a connection loss.
      * Attempt to reconnect an iOS device after a connection loss.
      */
      */
     private reconnectIos(): void {
     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
         // Get original keys
         const originalKeyStore = this.webClientService.salty.keyStore;
         const originalKeyStore = this.webClientService.salty.keyStore;
         const originalPeerPermanentKeyBytes = this.webClientService.salty.peerPermanentKeyBytes;
         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.$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.$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 */ },
                 () => { /* ok */ },
                 (error) => {
                 (error) => {
                     this.$log.error(this.logTag, 'Error state:', 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
                 (progress: threema.ConnectionBuildupStateChange) => {
                 (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/>.
  * 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 {isEchoContact, isGatewayContact} from '../receiver_helpers';
+import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 import {isContactReceiver} from '../typeguards';
 import {isContactReceiver} from '../typeguards';
 
 
 export default [
 export default [
     '$rootScope',
     '$rootScope',
-    '$timeout',
     '$log',
     '$log',
+    'TimeoutService',
     'WebClientService',
     'WebClientService',
     function($rootScope: ng.IRootScopeService,
     function($rootScope: ng.IRootScopeService,
-             $timeout: ng.ITimeoutService,
              $log: ng.ILogService,
              $log: ng.ILogService,
+             timeoutService: TimeoutService,
              webClientService: WebClientService) {
              webClientService: WebClientService) {
         return {
         return {
             restrict: 'E',
             restrict: 'E',
@@ -36,6 +37,48 @@ export default [
                 receiver: '=eeeReceiver',
                 receiver: '=eeeReceiver',
                 resolution: '=eeeResolution',
                 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',
             controllerAs: 'ctrl',
             controller: [function() {
             controller: [function() {
                 this.logTag = '[Directives.Avatar]';
                 this.logTag = '[Directives.Avatar]';
@@ -50,7 +93,7 @@ export default [
                     low: null,
                     low: null,
                 };
                 };
                 this.avatarToUri = (data: ArrayBuffer, res: 'high' | 'low') => {
                 this.avatarToUri = (data: ArrayBuffer, res: 'high' | 'low') => {
-                    if (data === null || data === undefined) {
+                    if (!hasValue(data)) {
                         return '';
                         return '';
                     }
                     }
                     if (avatarUri[res] === null) {
                     if (avatarUri[res] === null) {
@@ -64,16 +107,35 @@ export default [
                     return avatarUri[res];
                     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.highResolution = this.resolution === 'high';
                     this.isLoading = this.highResolution;
                     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 = () => {
                     this.avatarExists = () => {
                         if (this.receiver.avatar === undefined
                         if (this.receiver.avatar === undefined
                             || this.receiver.avatar[this.resolution] === undefined
                             || this.receiver.avatar[this.resolution] === undefined
@@ -144,25 +206,31 @@ export default [
                             if (loadingPromise === null) {
                             if (loadingPromise === null) {
                                 // Do not wait on high resolution avatar
                                 // Do not wait on high resolution avatar
                                 const loadingTimeout = this.highResolution ? 0 : 500;
                                 const loadingTimeout = this.highResolution ? 0 : 500;
-                                loadingPromise = $timeout(() => {
+                                loadingPromise = timeoutService.register(() => {
                                     // show loading only on high res images!
                                     // show loading only on high res images!
                                     webClientService.requestAvatar({
                                     webClientService.requestAvatar({
                                         type: this.receiver.type,
                                         type: this.receiver.type,
                                         id: this.receiver.id,
                                         id: this.receiver.id,
-                                    } as threema.Receiver, this.highResolution).then((avatar) => {
+                                    } as threema.Receiver, this.highResolution)
+                                    .then((avatar) => {
                                         $rootScope.$apply(() => {
                                         $rootScope.$apply(() => {
                                             this.isLoading = false;
                                             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(() => {
                                         $rootScope.$apply(() => {
                                             this.isLoading = false;
                                             this.isLoading = false;
                                         });
                                         });
+                                        loadingPromise = null;
                                     });
                                     });
-                                }, loadingTimeout);
+                                }, loadingTimeout, false, 'avatar');
                             }
                             }
                         } else if (loadingPromise !== null) {
                         } else if (loadingPromise !== null) {
                             // Cancel pending avatar loading
                             // Cancel pending avatar loading
-                            $timeout.cancel(loadingPromise);
+                            timeoutService.cancel(loadingPromise);
                             loadingPromise = null;
                             loadingPromise = null;
                         }
                         }
                     };
                     };

+ 2 - 6
src/directives/avatar_editor.ts

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

+ 6 - 1
src/directives/click_action.ts

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

+ 37 - 9
src/directives/contact_badge.ts

@@ -19,6 +19,7 @@
 
 
 import {StateService as UiStateService} from '@uirouter/angularjs';
 import {StateService as UiStateService} from '@uirouter/angularjs';
 
 
+import {hasValue} from '../helpers';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
 /**
 /**
@@ -37,23 +38,50 @@ export default [
                 linked: '=?eeeLinked',
                 linked: '=?eeeLinked',
                 onRemove: '=?eeeOnRemove',
                 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',
             controllerAs: 'ctrl',
             controller: [function() {
             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) {
                     if (this.contactReceiver === undefined) {
                         this.contactReceiver = webClientService.contacts.get(this.identity);
                         this.contactReceiver = webClientService.contacts.get(this.identity);
                     } else {
                     } else {
                         this.identity = this.contactReceiver.id;
                         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: `
             template: `

+ 1 - 5
src/directives/drag_file.ts

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

+ 13 - 13
src/directives/latest_message.html

@@ -10,27 +10,27 @@
     </div>
     </div>
 
 
     <!-- Left aligned message content -->
     <!-- 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. -->
         <!-- If this receiver is a group, show contact name that sent last message. -->
         <span eee-message-contact
         <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. -->
         <!-- 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 -->
         <!-- For voip status messages -->
         <eee-message-voip-status
         <eee-message-voip-status
-                ng-if="ctrl.showVoipInfo"
+                ng-if="ctrl.showVoipInfo()"
                 class="message-voip-status"
                 class="message-voip-status"
-                eee-message="ctrl.message">
+                eee-message="ctrl.conversation.latestMessage">
         </eee-message-voip-status>
         </eee-message-voip-status>
 
 
         <!-- For text-messages, show message text excerpt. -->
         <!-- 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>
     <div class="left hidden no-typing">
     <div class="left hidden no-typing">
@@ -44,11 +44,11 @@
     <div class="right">
     <div class="right">
         <span class="no-draft no-hidden">
         <span class="no-draft no-hidden">
             <span eee-message-date
             <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>
                  </i>
             </span>
             </span>
         </span>
         </span>

+ 39 - 45
src/directives/latest_message.ts

@@ -28,71 +28,65 @@ export default [
             restrict: 'EA',
             restrict: 'EA',
             scope: {},
             scope: {},
             bindToController: {
             bindToController: {
-                type: '=eeeType',
-                message: '=eeeMessage',
-                receiver: '=eeeReceiver',
+                conversation: '<',
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             controller: [function() {
                 this.$onInit = function() {
                 this.$onInit = function() {
-
                     // Conversation properties
                     // 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
                     // 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
                     // Typing indicator
                     this.isTyping = () => false;
                     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...
                     // 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 = () => {
                     this.showDraft = () => {
-                        if (receiverService.isConversationActive(this.receiver)) {
+                        if (receiverService.isConversationActive(this.conversation.receiver)) {
                             // Don't show draft if conversation is active
                             // Don't show draft if conversation is active
                             return false;
                             return false;
                         }
                         }

+ 17 - 6
src/directives/mediabox.ts

@@ -15,16 +15,19 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  * 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';
 import {MediaboxService} from '../services/mediabox';
 
 
 export default [
 export default [
     '$rootScope',
     '$rootScope',
-    '$filter',
     '$document',
     '$document',
+    '$log',
     'MediaboxService',
     'MediaboxService',
     function($rootScope: ng.IRootScopeService,
     function($rootScope: ng.IRootScopeService,
-             $filter: ng.IFilterService,
              $document: ng.IDocumentService,
              $document: ng.IDocumentService,
+             $log: ng.ILogService,
              mediaboxService: MediaboxService) {
              mediaboxService: MediaboxService) {
         return {
         return {
             restrict: 'E',
             restrict: 'E',
@@ -32,6 +35,8 @@ export default [
             bindToController: {},
             bindToController: {},
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             controller: [function() {
+                this.logTag = '[MediaboxDirective]';
+
                 // Data attributes
                 // Data attributes
                 this.imageDataUrl = null;
                 this.imageDataUrl = null;
                 this.caption = '';
                 this.caption = '';
@@ -53,12 +58,18 @@ export default [
                 };
                 };
 
 
                 // Listen to Mediabox service events
                 // Listen to Mediabox service events
-                const bufferToUrl = $filter('bufferToUrl') as
-                    (buffer: ArrayBuffer, mimeType: string, trust: boolean) => string;
                 mediaboxService.evtMediaChanged.attach((dataAvailable: boolean) => {
                 mediaboxService.evtMediaChanged.attach((dataAvailable: boolean) => {
                     $rootScope.$apply(() => {
                     $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
         <eee-message-text
             ng-if="ctrl.showText"
             ng-if="ctrl.showText"
             class="message-text"
             class="message-text"
-            eee-message="ctrl.message">
+            message="ctrl.message">
         </eee-message-text>
         </eee-message-text>
 
 
         <div class="message-info">
         <div class="message-info">
@@ -60,7 +60,7 @@
 <!-- Status messages -->
 <!-- Status messages -->
 <article ng-if="ctrl.isStatusMessage" class="message message-status">
 <article ng-if="ctrl.isStatusMessage" class="message message-status">
     <div ng-if="ctrl.message.statusType == 'text'" class="message-body">
     <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>
     <div ng-if="ctrl.message.statusType == 'firstUnreadMessage'" class="unread-separator">
     <div ng-if="ctrl.message.statusType == 'firstUnreadMessage'" class="unread-separator">
         <div class="line"></div>
         <div class="line"></div>

+ 31 - 6
src/directives/message.ts

@@ -17,23 +17,29 @@
 
 
 // tslint:disable:max-line-length
 // tslint:disable:max-line-length
 
 
+import {saveAs} from 'file-saver';
+
+import {BrowserInfo} from '../helpers/browser_info';
 import {getSenderIdentity} from '../helpers/messages';
 import {getSenderIdentity} from '../helpers/messages';
+import {BrowserService} from '../services/browser';
 import {MessageService} from '../services/message';
 import {MessageService} from '../services/message';
 import {ReceiverService} from '../services/receiver';
 import {ReceiverService} from '../services/receiver';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
 export default [
 export default [
-    'WebClientService',
+    'BrowserService',
     'MessageService',
     'MessageService',
     'ReceiverService',
     'ReceiverService',
+    'WebClientService',
     '$mdDialog',
     '$mdDialog',
     '$mdToast',
     '$mdToast',
     '$translate',
     '$translate',
     '$rootScope',
     '$rootScope',
     '$log',
     '$log',
-    function(webClientService: WebClientService,
+    function(browserService: BrowserService,
              messageService: MessageService,
              messageService: MessageService,
              receiverService: ReceiverService,
              receiverService: ReceiverService,
+             webClientService: WebClientService,
              $mdDialog: ng.material.IDialogService,
              $mdDialog: ng.material.IDialogService,
              $mdToast: ng.material.IToastService,
              $mdToast: ng.material.IToastService,
              $translate: ng.translate.ITranslateService,
              $translate: ng.translate.ITranslateService,
@@ -53,6 +59,9 @@ export default [
             controller: [function() {
             controller: [function() {
                 this.logTag = '[MessageDirective]';
                 this.logTag = '[MessageDirective]';
 
 
+                // Determine browser
+                this.browserInfo = browserService.getBrowser();
+
                 this.$onInit = function() {
                 this.$onInit = function() {
 
 
                     // Defaults and variables
                     // Defaults and variables
@@ -115,11 +124,26 @@ export default [
                         // In order to copy the text to the clipboard,
                         // In order to copy the text to the clipboard,
                         // put it into a temporary textarea element.
                         // put it into a temporary textarea element.
                         const textArea = document.createElement('textarea');
                         const textArea = document.createElement('textarea');
-                        let toastString = 'messenger.COPY_ERROR';
                         textArea.value = text;
                         textArea.value = text;
                         document.body.appendChild(textArea);
                         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 {
                         try {
                             const successful = document.execCommand('copy');
                             const successful = document.execCommand('copy');
                             if (!successful) {
                             if (!successful) {
@@ -159,7 +183,8 @@ export default [
                                 });
                                 });
                             })
                             })
                             .catch((error) => {
                             .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;
                                 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/>.
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
  */
 
 
+import {hasValue} from '../helpers';
+
 export default [
 export default [
     function() {
     function() {
         return {
         return {
@@ -23,6 +25,18 @@ export default [
             bindToController: {
             bindToController: {
                 message: '=eeeMessage',
                 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',
             controllerAs: 'ctrl',
             controller: [function() {
             controller: [function() {
                 // Return icon depending on message type.
                 // Return icon depending on message type.
@@ -48,10 +62,14 @@ export default [
                     }
                     }
                 };
                 };
 
 
-                this.$onInit = function() {
+                this.update = () => {
                     this.icon = getIcon(this.message.type);
                     this.icon = getIcon(this.message.type);
                     this.altText = this.message.type + ' icon';
                     this.altText = this.message.type + ' icon';
                 };
                 };
+
+                this.$onInit = function() {
+                    this.update();
+                };
             }],
             }],
             template: `
             template: `
                 <img ng-if="ctrl.icon !== null" ng-src="img/{{ ctrl.icon }}" alt="{{ ctrl.altText }}">
                 <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/>.
  * 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 {bufferToUrl, hasValue, logAdapter} from '../helpers';
 import {MediaboxService} from '../services/mediabox';
 import {MediaboxService} from '../services/mediabox';
 import {MessageService} from '../services/message';
 import {MessageService} from '../services/message';
+import {TimeoutService} from '../services/timeout';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
 function showAudioDialog(
 function showAudioDialog(
@@ -62,9 +66,11 @@ export default [
     'WebClientService',
     'WebClientService',
     'MediaboxService',
     'MediaboxService',
     'MessageService',
     'MessageService',
+    'TimeoutService',
     '$rootScope',
     '$rootScope',
     '$mdDialog',
     '$mdDialog',
     '$timeout',
     '$timeout',
+    '$transitions',
     '$translate',
     '$translate',
     '$log',
     '$log',
     '$filter',
     '$filter',
@@ -72,9 +78,11 @@ export default [
     function(webClientService: WebClientService,
     function(webClientService: WebClientService,
              mediaboxService: MediaboxService,
              mediaboxService: MediaboxService,
              messageService: MessageService,
              messageService: MessageService,
+             timeoutService: TimeoutService,
              $rootScope: ng.IRootScopeService,
              $rootScope: ng.IRootScopeService,
              $mdDialog: ng.material.IDialogService,
              $mdDialog: ng.material.IDialogService,
              $timeout: ng.ITimeoutService,
              $timeout: ng.ITimeoutService,
+             $transitions: UiTransitionService,
              $translate: ng.translate.ITranslateService,
              $translate: ng.translate.ITranslateService,
              $log: ng.ILogService,
              $log: ng.ILogService,
              $filter: ng.IFilterService,
              $filter: ng.IFilterService,
@@ -91,6 +99,11 @@ export default [
             controller: [function() {
             controller: [function() {
                 this.logTag = '[MessageMedia]';
                 this.logTag = '[MessageMedia]';
 
 
+                // On state transitions, clear mediabox
+                $transitions.onStart({}, function(trans: UiTransition) {
+                    mediaboxService.clearMedia();
+                });
+
                 this.$onInit = function() {
                 this.$onInit = function() {
                     this.type = this.message.type;
                     this.type = this.message.type;
 
 
@@ -139,7 +152,7 @@ export default [
                         };
                         };
                     }
                     }
 
 
-                    let loadingThumbnailTimeout = null;
+                    let loadingThumbnailTimeout: ng.IPromise<void> = null;
 
 
                     this.wasInView = false;
                     this.wasInView = false;
                     this.thumbnailInView = (inView: boolean) => {
                     this.thumbnailInView = (inView: boolean) => {
@@ -151,7 +164,9 @@ export default [
                         this.wasInView = inView;
                         this.wasInView = inView;
 
 
                         if (!inView) {
                         if (!inView) {
-                            $timeout.cancel(loadingThumbnailTimeout);
+                            if (loadingThumbnailTimeout !== null) {
+                                timeoutService.cancel(loadingThumbnailTimeout);
+                            }
                             this.thumbnailDownloading = false;
                             this.thumbnailDownloading = false;
                             this.thumbnail = null;
                             this.thumbnail = null;
                         } else {
                         } else {
@@ -169,14 +184,19 @@ export default [
                                     return;
                                     return;
                                 } else {
                                 } else {
                                     this.thumbnailDownloading = true;
                                     this.thumbnailDownloading = true;
-                                    loadingThumbnailTimeout = $timeout(() => {
+                                    loadingThumbnailTimeout = timeoutService.register(() => {
                                         webClientService
                                         webClientService
                                             .requestThumbnail(this.receiver, this.message)
                                             .requestThumbnail(this.receiver, this.message)
                                             .then((img) => $timeout(() => {
                                             .then((img) => $timeout(() => {
                                                 setThumbnail(img);
                                                 setThumbnail(img);
                                                 this.thumbnailDownloading = false;
                                                 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
 // tslint:disable:max-line-length
 
 
+import {hasValue} from '../helpers';
 import {WebClientService} from '../services/webclient';
 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 [
 export default [
     function() {
     function() {
         return {
         return {
             restrict: 'EA',
             restrict: 'EA',
             scope: {},
             scope: {},
             bindToController: {
             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',
             controllerAs: 'ctrl',
             controller: ['WebClientService', '$filter', function(webClientService: WebClientService, $filter: ng.IFilterService) {
             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
                 // TODO: Extract filters into helper functions
                 const escapeHtml = $filter('escapeHtml') as any;
                 const escapeHtml = $filter('escapeHtml') as any;
                 const markify = $filter('markify') as any;
                 const markify = $filter('markify') as any;
@@ -59,17 +87,36 @@ export default [
                 /**
                 /**
                  * Apply filters to text.
                  * 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() {
                 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: `
             template: `

+ 9 - 12
src/filters.ts

@@ -15,7 +15,8 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  * 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 {MimeService} from './services/mime';
 import {NotificationService} from './services/notification';
 import {NotificationService} from './services/notification';
 import {WebClientService} from './services/webclient';
 import {WebClientService} from './services/webclient';
@@ -137,15 +138,7 @@ angular.module('3ema.filters', [])
  * Convert markdown elements to html elements
  * Convert markdown elements to html elements
  */
  */
 .filter('markify', function() {
 .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[]) => {
     return(ids: string[]) => {
         const names: string[] = [];
         const names: string[] = [];
         for (const id of ids) {
         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(', ');
         return names.join(', ');
     };
     };

+ 31 - 1
src/helpers.ts

@@ -258,7 +258,7 @@ export function escapeRegExp(str: string) {
  * msgpack encoded data.
  * msgpack encoded data.
  */
  */
 export function msgpackVisualizer(bytes: Uint8Array): string {
 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.
  * Return whether this key event should trigger a button.
  * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
  * 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;
             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);
         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);
         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>
                 <p>{{ ctrl.version }}</p>
 
 
                 <h2 translate>welcome.BACKGROUND_IMAGE</h2>
                 <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>
                 <h2 translate>about.CHANGELOG</h2>
                 <p>
                 <p>

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

@@ -6,6 +6,8 @@
 
 
     <div id="conversation-header" class="detail-header">
     <div id="conversation-header" class="detail-header">
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
+
+        <!-- Conversation details -->
         <div class="header-avatar" ng-click="ctrl.showReceiver()">
         <div class="header-avatar" ng-click="ctrl.showReceiver()">
             <eee-avatar eee-receiver="ctrl.receiver"
             <eee-avatar eee-receiver="ctrl.receiver"
                         eee-resolution="'low'"></eee-avatar>
                         eee-resolution="'low'"></eee-avatar>
@@ -22,7 +24,20 @@
                 <span ng-bind-html="ctrl.receiver.members | idsToNames | escapeHtml | emojify"></span>
                 <span ng-bind-html="ctrl.receiver.members | idsToNames | escapeHtml | emojify"></span>
             </div>
             </div>
         </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>
+
     <div id="conversation-is-private" ng-if="ctrl.locked">
     <div id="conversation-is-private" ng-if="ctrl.locked">
         <md-card>
         <md-card>
             <md-toolbar class="md-warn">
             <md-toolbar class="md-warn">
@@ -40,13 +55,13 @@
         <ul class="chat">
         <ul class="chat">
             <li in-view="$inview && !ctrl.locked && ctrl.topOfChat()" class="load-more">
             <li in-view="$inview && !ctrl.locked && ctrl.topOfChat()" class="load-more">
                 <div ng-if="ctrl.hasMoreMessages()" class="loading">
                 <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>
                 </div>
             </li>
             </li>
             <li ng-repeat="message in ctrl.messages" id="message-{{message.id}}">
             <li ng-repeat="message in ctrl.messages" id="message-{{message.id}}">
                 <eee-message eee-receiver="ctrl.receiver" eee-type="ctrl.type" eee-message="message"
                 <eee-message eee-receiver="ctrl.receiver" eee-type="ctrl.type" eee-message="message"
                              in-view="$inview  && !ctrl.locked && ctrl.msgRead(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>
             <li ng-if="ctrl.isTyping()" class="typing-indicator">
             <li ng-if="ctrl.isTyping()" class="typing-indicator">
                 <!-- Non status messages -->
                 <!-- Non status messages -->

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

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

+ 129 - 58
src/partials/messenger.ts

@@ -24,11 +24,10 @@ import {
 } from '@uirouter/angularjs';
 } from '@uirouter/angularjs';
 
 
 import {ContactControllerModel} from '../controller_model/contact';
 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 {ContactService} from '../services/contact';
 import {ControllerService} from '../services/controller';
 import {ControllerService} from '../services/controller';
 import {ControllerModelService} from '../services/controller_model';
 import {ControllerModelService} from '../services/controller_model';
-import {ExecuteService} from '../services/execute';
 import {FingerPrintService} from '../services/fingerprint';
 import {FingerPrintService} from '../services/fingerprint';
 import {TrustedKeyStoreService} from '../services/keystore';
 import {TrustedKeyStoreService} from '../services/keystore';
 import {MimeService} from '../services/mime';
 import {MimeService} from '../services/mime';
@@ -36,6 +35,7 @@ import {NotificationService} from '../services/notification';
 import {ReceiverService} from '../services/receiver';
 import {ReceiverService} from '../services/receiver';
 import {SettingsService} from '../services/settings';
 import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
 import {StateService} from '../services/state';
+import {TimeoutService} from '../services/timeout';
 import {VersionService} from '../services/version';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 import {isContactReceiver} from '../typeguards';
 import {isContactReceiver} from '../typeguards';
@@ -204,7 +204,6 @@ class ConversationController {
 
 
     // Angular services
     // Angular services
     private $stateParams;
     private $stateParams;
-    private $timeout: ng.ITimeoutService;
     private $state: UiStateService;
     private $state: UiStateService;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private $scope: ng.IScope;
     private $scope: ng.IScope;
@@ -217,6 +216,7 @@ class ConversationController {
     private receiverService: ReceiverService;
     private receiverService: ReceiverService;
     private stateService: StateService;
     private stateService: StateService;
     private mimeService: MimeService;
     private mimeService: MimeService;
+    private timeoutService: TimeoutService;
 
 
     // Third party services
     // Third party services
     private $mdDialog: ng.material.IDialogService;
     private $mdDialog: ng.material.IDialogService;
@@ -233,6 +233,7 @@ class ConversationController {
 
 
     // The conversation receiver
     // The conversation receiver
     public receiver: threema.Receiver;
     public receiver: threema.Receiver;
+    public conversation: threema.Conversation;
     public type: threema.ReceiverType;
     public type: threema.ReceiverType;
 
 
     // The conversation messages
     // The conversation messages
@@ -268,14 +269,13 @@ class ConversationController {
     };
     };
 
 
     public static $inject = [
     public static $inject = [
-        '$stateParams', '$timeout', '$log', '$scope', '$rootScope',
+        '$stateParams', '$log', '$scope', '$rootScope',
         '$mdDialog', '$mdToast', '$translate', '$filter',
         '$mdDialog', '$mdToast', '$translate', '$filter',
         '$state', '$transitions',
         '$state', '$transitions',
         'WebClientService', 'StateService', 'ReceiverService', 'MimeService', 'VersionService',
         'WebClientService', 'StateService', 'ReceiverService', 'MimeService', 'VersionService',
-        'ControllerModelService',
+        'ControllerModelService', 'TimeoutService',
     ];
     ];
     constructor($stateParams: ConversationStateParams,
     constructor($stateParams: ConversationStateParams,
-                $timeout: ng.ITimeoutService,
                 $log: ng.ILogService,
                 $log: ng.ILogService,
                 $scope: ng.IScope,
                 $scope: ng.IScope,
                 $rootScope: ng.IRootScopeService,
                 $rootScope: ng.IRootScopeService,
@@ -290,14 +290,15 @@ class ConversationController {
                 receiverService: ReceiverService,
                 receiverService: ReceiverService,
                 mimeService: MimeService,
                 mimeService: MimeService,
                 versionService: VersionService,
                 versionService: VersionService,
-                controllerModelService: ControllerModelService) {
+                controllerModelService: ControllerModelService,
+                timeoutService: TimeoutService) {
         this.$stateParams = $stateParams;
         this.$stateParams = $stateParams;
-        this.$timeout = $timeout;
         this.$log = $log;
         this.$log = $log;
         this.webClientService = webClientService;
         this.webClientService = webClientService;
         this.receiverService = receiverService;
         this.receiverService = receiverService;
         this.stateService = stateService;
         this.stateService = stateService;
         this.mimeService = mimeService;
         this.mimeService = mimeService;
+        this.timeoutService = timeoutService;
 
 
         this.$state = $state;
         this.$state = $state;
         this.$scope = $scope;
         this.$scope = $scope;
@@ -342,9 +343,10 @@ class ConversationController {
             }, 100, this), supportsPassive() ? {passive: true} : false);
             }, 100, this), supportsPassive() ? {passive: true} : false);
         }
         }
 
 
-        // Set receiver and type
+        // Set receiver, conversation and type
         try {
         try {
             this.receiver = webClientService.receivers.getData({type: $stateParams.type, id: $stateParams.id});
             this.receiver = webClientService.receivers.getData({type: $stateParams.type, id: $stateParams.id});
+            this.conversation = this.webClientService.conversations.find(this.receiver);
             this.type = $stateParams.type;
             this.type = $stateParams.type;
 
 
             if (this.receiver.type === undefined) {
             if (this.receiver.type === undefined) {
@@ -384,7 +386,7 @@ class ConversationController {
                 return;
                 return;
             }
             }
 
 
-            // initial set locked state
+            // Initial set locked state
             this.locked = this.receiver.locked;
             this.locked = this.receiver.locked;
 
 
             this.receiverService.setActive(this.receiver);
             this.receiverService.setActive(this.receiver);
@@ -442,14 +444,22 @@ class ConversationController {
                     });
                     });
                 }
                 }
 
 
+                // Set initial data
                 this.initialData = {
                 this.initialData = {
                     draft: webClientService.getDraft(this.receiver),
                     draft: webClientService.getDraft(this.receiver),
                     initialText: $stateParams.initParams ? $stateParams.initParams.text : '',
                     initialText: $stateParams.initParams ? $stateParams.initParams.text : '',
                 };
                 };
 
 
+                // Set isTyping function for contacts
                 if (isContactReceiver(this.receiver)) {
                 if (isContactReceiver(this.receiver)) {
                     this.isTyping = () => this.webClientService.isTyping(this.receiver as threema.ContactReceiver);
                     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) {
         } catch (error) {
             $log.error('Could not set receiver and type');
             $log.error('Could not set receiver and type');
@@ -485,15 +495,25 @@ class ConversationController {
         this.webClientService.setQuote(this.receiver);
         this.webClientService.setQuote(this.receiver);
     }
     }
 
 
-    public showError(errorMessage: string, toastLength = 4000) {
+    public showError(errorMessage?: string, hideDelayMs = 3000) {
         if (errorMessage === undefined || errorMessage.length === 0) {
         if (errorMessage === undefined || errorMessage.length === 0) {
             errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
             errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
         }
         }
         this.$mdToast.show(
         this.$mdToast.show(
             this.$mdToast.simple()
             this.$mdToast.simple()
                 .textContent(errorMessage)
                 .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.
      * Submit function for input field. Can contain text or file data.
      * Return whether sending was successful.
      * Return whether sending was successful.
@@ -585,6 +605,9 @@ class ConversationController {
                         `,
                         `,
                         // tslint:enable:max-line-length
                         // tslint:enable:max-line-length
                     }).then((data) => {
                     }).then((data) => {
+                        // TODO: This should probably be moved into the
+                        //       WebClientService as a specific method for the
+                        //       type.
                         const caption = data.caption;
                         const caption = data.caption;
                         const sendAsFile = data.sendAsFile;
                         const sendAsFile = data.sendAsFile;
                         contents.forEach((msg: threema.FileMessageData, index: number) => {
                         contents.forEach((msg: threema.FileMessageData, index: number) => {
@@ -598,6 +621,7 @@ class ConversationController {
                                 })
                                 })
                                 .catch((error) => {
                                 .catch((error) => {
                                     this.$log.error(error);
                                     this.$log.error(error);
+                                    // TODO: Should probably be an alert instead of a toast
                                     this.showError(error);
                                     this.showError(error);
                                     success = false;
                                     success = false;
                                     nextCallback(index);
                                     nextCallback(index);
@@ -612,12 +636,16 @@ class ConversationController {
                         // remove quote
                         // remove quote
                         this.webClientService.setQuote(this.receiver);
                         this.webClientService.setQuote(this.receiver);
                         // send message
                         // 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)
                         this.webClientService.sendMessage(this.$stateParams, type, msg)
                             .then(() => {
                             .then(() => {
                                 nextCallback(index);
                                 nextCallback(index);
                             })
                             })
                             .catch((error) => {
                             .catch((error) => {
                                 this.$log.error(error);
                                 this.$log.error(error);
+                                // TODO: Should probably be an alert instead of a toast
                                 this.showError(error);
                                 this.showError(error);
                                 success = false;
                                 success = false;
                                 nextCallback(index);
                                 nextCallback(index);
@@ -762,9 +790,10 @@ class ConversationController {
     public requestMessages(): void {
     public requestMessages(): void {
         const refMsgId = this.webClientService.requestMessages(this.$stateParams);
         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;
             this.latestRefMsgId = refMsgId;
         } else {
         } else {
             this.latestRefMsgId = null;
             this.latestRefMsgId = null;
@@ -790,7 +819,7 @@ class ConversationController {
         }
         }
 
 
         // Update lastReadMsg
         // Update lastReadMsg
-        if (this.lastReadMsg === null || message.sortKey > this.lastReadMsg.sortKey) {
+        if (this.lastReadMsg === null || message.sortKey >= this.lastReadMsg.sortKey) {
             this.lastReadMsg = message;
             this.lastReadMsg = message;
         }
         }
 
 
@@ -810,10 +839,10 @@ class ConversationController {
             this.msgReadReportPending = true;
             this.msgReadReportPending = true;
             const receiver = angular.copy(this.receiver);
             const receiver = angular.copy(this.receiver);
             receiver.type = this.type;
             receiver.type = this.type;
-            this.$timeout(() => {
+            this.timeoutService.register(() => {
                 this.webClientService.requestRead(receiver, this.lastReadMsg);
                 this.webClientService.requestRead(receiver, this.lastReadMsg);
                 this.msgReadReportPending = false;
                 this.msgReadReportPending = false;
-            }, 300);
+            }, 300, false, 'requestRead');
         }
         }
     }
     }
 
 
@@ -838,6 +867,32 @@ class ConversationController {
         const chat = this.domChatElement;
         const chat = this.domChatElement;
         this.showScrollJump = chat.scrollHeight - (chat.scrollTop + chat.offsetHeight) > 10;
         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 {
 class NavigationController {
@@ -933,6 +988,10 @@ class NavigationController {
         return this.receiverService.isConversationActive(value);
         return this.receiverService.isConversationActive(value);
     }
     }
 
 
+    public startupDone(): boolean {
+        return this.webClientService.startupDone;
+    }
+
     /**
     /**
      * Return true if the app wants to hide inactive contacts.
      * Return true if the app wants to hide inactive contacts.
      */
      */
@@ -1003,10 +1062,13 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
         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
             // do nothing
         });
         });
@@ -1023,10 +1085,13 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
         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
             // do nothing
         });
         });
@@ -1210,11 +1275,14 @@ class ReceiverDetailController {
 
 
             this.contactService.requiredDetails(contactReceiver)
             this.contactService.requiredDetails(contactReceiver)
                 .then(() => {
                 .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;
             this.isWorkReceiver = contactReceiver.identityType === threema.IdentityType.Work;
@@ -1356,25 +1424,26 @@ class ReceiverEditController {
     private logTag: string = '[ReceiverEditController]';
     private logTag: string = '[ReceiverEditController]';
 
 
     public $mdDialog: any;
     public $mdDialog: any;
+    private $scope: ng.IScope;
     public $state: UiStateService;
     public $state: UiStateService;
     private $translate: ng.translate.ITranslateService;
     private $translate: ng.translate.ITranslateService;
 
 
     public title: string;
     public title: string;
     private $timeout: ng.ITimeoutService;
     private $timeout: ng.ITimeoutService;
-    private execute: ExecuteService;
-    public loading = false;
+    private future: Future<threema.Receiver>;
 
 
     private controllerModel: threema.ControllerModel<threema.Receiver>;
     private controllerModel: threema.ControllerModel<threema.Receiver>;
     public type: string;
     public type: string;
 
 
     public static $inject = [
     public static $inject = [
-        '$log', '$stateParams', '$state', '$mdDialog',
+        '$log', '$scope', '$stateParams', '$state', '$mdDialog',
         '$timeout', '$translate', 'WebClientService', 'ControllerModelService',
         '$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,
                 $mdDialog, $timeout: ng.ITimeoutService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, controllerModelService: ControllerModelService) {
                 webClientService: WebClientService, controllerModelService: ControllerModelService) {
 
 
+        this.$scope = $scope;
         this.$mdDialog = $mdDialog;
         this.$mdDialog = $mdDialog;
         this.$state = $state;
         this.$state = $state;
         this.$timeout = $timeout;
         this.$timeout = $timeout;
@@ -1413,8 +1482,6 @@ class ReceiverEditController {
                 return;
                 return;
         }
         }
         this.type = receiver.type;
         this.type = receiver.type;
-
-        this.execute = new ExecuteService($log, $timeout, 1000);
     }
     }
 
 
     public keypress($event: KeyboardEvent): void {
     public keypress($event: KeyboardEvent): void {
@@ -1424,22 +1491,22 @@ class ReceiverEditController {
     }
     }
 
 
     public save(): void {
     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) => {
             .catch((errorCode) => {
-                this.showEditError(errorCode);
+                this.$scope.$apply(() => {
+                    this.showEditError(errorCode);
+                });
             });
             });
     }
     }
 
 
     public isSaving(): boolean {
     public isSaving(): boolean {
-        return this.execute !== undefined
-            && this.execute.isRunning();
+        return this.future !== undefined && !this.future.done;
     }
     }
 
 
     private showEditError(errorCode: string): void {
     private showEditError(errorCode: string): void {
@@ -1473,7 +1540,7 @@ class ReceiverCreateController {
     private logTag: string = '[ReceiverEditController]';
     private logTag: string = '[ReceiverEditController]';
 
 
     public $mdDialog: any;
     public $mdDialog: any;
-    private loading = false;
+    private $scope: ng.IScope;
     private $timeout: ng.ITimeoutService;
     private $timeout: ng.ITimeoutService;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private $state: UiStateService;
     private $state: UiStateService;
@@ -1481,16 +1548,17 @@ class ReceiverCreateController {
     public identity = '';
     public identity = '';
     private $translate: any;
     private $translate: any;
     public type: string;
     public type: string;
-    private execute: ExecuteService;
+    private future: Future<threema.Receiver>;
 
 
     public controllerModel: threema.ControllerModel<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'];
         '$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,
                 $timeout: ng.ITimeoutService, $state: UiStateService, $log: ng.ILogService,
                 controllerModelService: ControllerModelService) {
                 controllerModelService: ControllerModelService) {
         this.$mdDialog = $mdDialog;
         this.$mdDialog = $mdDialog;
+        this.$scope = $scope;
         this.$timeout = $timeout;
         this.$timeout = $timeout;
         this.$state = $state;
         this.$state = $state;
         this.$log = $log;
         this.$log = $log;
@@ -1519,11 +1587,10 @@ class ReceiverCreateController {
             default:
             default:
                 this.$log.error('invalid type', this.type);
                 this.$log.error('invalid type', this.type);
         }
         }
-        this.execute = new ExecuteService($log, $timeout, 1000);
     }
     }
 
 
     public isSaving(): boolean {
     public isSaving(): boolean {
-        return this.execute.isRunning();
+        return this.future !== undefined && !this.future.done;
     }
     }
 
 
     public goBack(): void {
     public goBack(): void {
@@ -1552,15 +1619,19 @@ class ReceiverCreateController {
     }
     }
 
 
     public create(): void {
     public create(): void {
-        // Show loading indicator
-        this.loading = true;
-
         // Save, then go to receiver detail page
         // 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) => {
             .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>
                     size="{{ ctrl.qrCode.size }}" data="{{ ctrl.qrCode.data }}"></qrcode>
             <div class="password-entry">
             <div class="password-entry">
                 <label>
                 <label>
-                    <p translate>welcome.CHOOSE_PASSWORD</p>
+                    <p translate id="aria-label-password-create">welcome.CHOOSE_PASSWORD</p>
                     <form autocomplete="off">
                     <form autocomplete="off">
                         <md-input-container md-no-float class="md-block">
                         <md-input-container md-no-float class="md-block">
                             <input type="password"
                             <input type="password"
                                    ng-model="ctrl.password"
                                    ng-model="ctrl.password"
-                                   aria-label="Password"
+                                   aria-labelledby="aria-label-password-create"
                                    translate-attr="{'placeholder': 'welcome.PASSWORD', 'aria-label': 'welcome.PASSWORD'}"
                                    translate-attr="{'placeholder': 'welcome.PASSWORD', 'aria-label': 'welcome.PASSWORD'}"
                                    autocomplete="new-password">
                                    autocomplete="new-password">
                         </md-input-container>
                         </md-input-container>
@@ -31,19 +31,23 @@
             </div>
             </div>
         </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>
             <h2 class="instructions" translate>welcome.PLEASE_UNLOCK</h2>
             <div class="password-entry">
             <div class="password-entry">
                 <label>
                 <label>
-                    <p translate>welcome.ENTER_PASSWORD</p>
+                    <p translate id="aria-label-password-reconnecte">welcome.ENTER_PASSWORD</p>
                     <form ng-submit="ctrl.unlockConfirm()">
                     <form ng-submit="ctrl.unlockConfirm()">
                         <md-input-container md-no-float class="md-block">
                         <md-input-container md-no-float class="md-block">
                             <input type="password"
                             <input type="password"
                                    ng-model="ctrl.password"
                                    ng-model="ctrl.password"
                                    ng-disabled="ctrl.formLocked"
                                    ng-disabled="ctrl.formLocked"
                                    autofocus
                                    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">
                                    autocomplete="current-password">
                         </md-input-container>
                         </md-input-container>
                         <md-button type="submit" class="md-raised md-primary" translate translate-attr-aria-label="welcome.BTN_RECONNECT">
                         <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">
         <div ng-if="ctrl.showLoadingIndicator">
             <h2 class="instructions" translate>welcome.CONNECTING</h2>
             <h2 class="instructions" translate>welcome.CONNECTING</h2>
             <div class="loading">
             <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>
                 </md-progress-circular>
-                <div class="info">
+                <div class="info" aria-live="polite" ng-aria-disable>
                     <p class="percentage">{{ ctrl.progress }}%</p>
                     <p class="percentage">{{ ctrl.progress }}%</p>
                     <p ng-if="ctrl.state === 'push'" translate>welcome.WAITING_FOR_PUSH</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>
                     <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>
                                 <i class="material-icons md-dark md-14">help</i>
                                 <span translate>troubleshooting.SESSION_DELETED</span>
                                 <span translate>troubleshooting.SESSION_DELETED</span>
                             </li>
                             </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>
                                 <i class="material-icons md-dark md-14">help</i>
                                 <span translate>troubleshooting.PLUGIN</span>
                                 <span translate>troubleshooting.PLUGIN</span>
                             </li>
                             </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>
                                 <i class="material-icons md-dark md-14">help</i>
                                 <span translate>troubleshooting.ADBLOCKER</span>
                                 <span translate>troubleshooting.ADBLOCKER</span>
                             </li>
                             </li>
                         </ul>
                         </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>
-                        <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>
                         </md-button>
                     </div>
                     </div>
                 </div>
                 </div>
@@ -140,5 +144,16 @@
             </md-button>
             </md-button>
         </div>
         </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>
 </div>
 </div>

+ 73 - 45
src/partials/welcome.ts

@@ -20,7 +20,6 @@
 /// <reference path="../types/broadcastchannel.d.ts" />
 /// <reference path="../types/broadcastchannel.d.ts" />
 
 
 import {
 import {
-    StateParams as UiStateParams,
     StateProvider as UiStateProvider,
     StateProvider as UiStateProvider,
     StateService as UiStateService,
     StateService as UiStateService,
 } from '@uirouter/angularjs';
 } from '@uirouter/angularjs';
@@ -32,10 +31,12 @@ import {TrustedKeyStoreService} from '../services/keystore';
 import {PushService} from '../services/push';
 import {PushService} from '../services/push';
 import {SettingsService} from '../services/settings';
 import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
 import {StateService} from '../services/state';
+import {TimeoutService} from '../services/timeout';
 import {VersionService} from '../services/version';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
 import GlobalConnectionState = threema.GlobalConnectionState;
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 
 class DialogController {
 class DialogController {
     // TODO: This is also used in partials/messenger.ts. We could somehow
     // 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 {
 class WelcomeController {
 
 
     private static REDIRECT_DELAY = 500;
     private static REDIRECT_DELAY = 500;
@@ -65,8 +62,6 @@ class WelcomeController {
 
 
     // Angular services
     // Angular services
     private $scope: ng.IScope;
     private $scope: ng.IScope;
-    private $timeout: ng.ITimeoutService;
-    private $interval: ng.IIntervalService;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
     private $window: ng.IWindowService;
     private $state: UiStateService;
     private $state: UiStateService;
@@ -81,6 +76,7 @@ class WelcomeController {
     private pushService: PushService;
     private pushService: PushService;
     private stateService: StateService;
     private stateService: StateService;
     private settingsService: SettingsService;
     private settingsService: SettingsService;
+    private timeoutService: TimeoutService;
     private config: threema.Config;
     private config: threema.Config;
 
 
     // Other
     // Other
@@ -94,13 +90,12 @@ class WelcomeController {
     private browserWarningShown: boolean = false;
     private browserWarningShown: boolean = false;
 
 
     public static $inject = [
     public static $inject = [
-        '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
+        '$scope', '$state', '$log', '$window', '$mdDialog', '$translate',
         'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
         'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
-        'VersionService', 'SettingsService', 'ControllerService',
+        'VersionService', 'SettingsService', 'TimeoutService', 'ControllerService',
         'BROWSER_MIN_VERSIONS', 'CONFIG',
         '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,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
                 $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
                 webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
@@ -108,6 +103,7 @@ class WelcomeController {
                 browserService: BrowserService,
                 browserService: BrowserService,
                 versionService: VersionService,
                 versionService: VersionService,
                 settingsService: SettingsService,
                 settingsService: SettingsService,
+                timeoutService: TimeoutService,
                 controllerService: ControllerService,
                 controllerService: ControllerService,
                 minVersions: threema.BrowserMinVersions,
                 minVersions: threema.BrowserMinVersions,
                 config: threema.Config) {
                 config: threema.Config) {
@@ -115,8 +111,6 @@ class WelcomeController {
         // Angular services
         // Angular services
         this.$scope = $scope;
         this.$scope = $scope;
         this.$state = $state;
         this.$state = $state;
-        this.$timeout = $timeout;
-        this.$interval = $interval;
         this.$log = $log;
         this.$log = $log;
         this.$window = $window;
         this.$window = $window;
         this.$mdDialog = $mdDialog;
         this.$mdDialog = $mdDialog;
@@ -128,8 +122,11 @@ class WelcomeController {
         this.stateService = stateService;
         this.stateService = stateService;
         this.pushService = pushService;
         this.pushService = pushService;
         this.settingsService = settingsService;
         this.settingsService = settingsService;
+        this.timeoutService = timeoutService;
         this.config = config;
         this.config = config;
 
 
+        // TODO: Allow to trigger below behaviour by using state parameters
+
         // Determine whether browser warning should be shown
         // Determine whether browser warning should be shown
         this.browser = browserService.getBrowser();
         this.browser = browserService.getBrowser();
         const version = this.browser.version;
         const version = this.browser.version;
@@ -198,12 +195,7 @@ class WelcomeController {
         }
         }
 
 
         // Determine connection mode
         // 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.mode = 'unlock';
             this.unlock();
             this.unlock();
         } else {
         } else {
@@ -254,14 +246,29 @@ class WelcomeController {
         return this.stateService.slowConnect;
         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.
      * 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...');
         this.$log.info(this.logTag, 'Initialize session by scanning QR code...');
 
 
         // Initialize webclient with new keystore
         // 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
         // Set up the broadcast channel that checks whether we're already connected in another tab
         this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);
         this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);
@@ -280,6 +287,7 @@ class WelcomeController {
      * Initiate a new session by unlocking a trusted key.
      * Initiate a new session by unlocking a trusted key.
      */
      */
     private unlock(): void {
     private unlock(): void {
+        this.stateService.reset('new');
         this.$log.info(this.logTag, 'Initialize session by unlocking trusted key...');
         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
         // Set up the broadcast channel that checks whether we're already connected in another tab
         this.setupBroadcastChannel(keyStore.publicKeyHex);
         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
         // Reconnect
-        this.reconnect(keyStore, decrypted.peerPublicKey);
+        this.reconnect(keyStore, decrypted);
     }
     }
 
 
     /**
     /**
@@ -360,10 +362,10 @@ class WelcomeController {
                     // is already active.
                     // is already active.
                     if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') {
                     if (message.key === publicKeyHex && this.stateService.connectionBuildupState !== 'done') {
                         this.$log.error(this.logTag, 'Session already connected in another tab or window');
                         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.updateConnectionBuildupState('already_connected');
                             this.stateService.state = GlobalConnectionState.Error;
                             this.stateService.state = GlobalConnectionState.Error;
-                        }, 500);
+                        }, 500, true, 'alreadyConnected');
                     }
                     }
                     break;
                     break;
                 default:
                 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();
         this.start();
     }
     }
 
 
@@ -475,21 +497,17 @@ class WelcomeController {
              .cancel(this.$translate.instant('common.CANCEL'));
              .cancel(this.$translate.instant('common.CANCEL'));
 
 
         this.$mdDialog.show(confirm).then(() =>  {
         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
             // Go back to scan mode
             this.mode = 'scan';
             this.mode = 'scan';
             this.password = '';
             this.password = '';
             this.formLocked = false;
             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
             // do nothing
         });
         });
@@ -553,14 +571,24 @@ class WelcomeController {
                 this.formLocked = false;
                 this.formLocked = false;
 
 
                 // Redirect to home
                 // 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...
             // If an error occurs...
             (error) => {
             (error) => {
                 this.$log.error(this.logTag, 'Error state:', error);
                 this.$log.error(this.logTag, 'Error state:', error);
                 // TODO: should probably show an error message instead
                 // 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
             // 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 {
         .header-details {
             @include mouse-hand;
             @include mouse-hand;
             overflow: hidden;
             overflow: hidden;
+            flex-grow: 1;
 
 
             & > *:first-child {
             & > *:first-child {
                 font-weight: bold;
                 font-weight: bold;

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

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

+ 2 - 0
src/services.ts

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

+ 0 - 58
src/services/browser.ts

@@ -25,7 +25,6 @@ export class BrowserService {
     private browser: BrowserInfo;
     private browser: BrowserInfo;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
     private $window: ng.IWindowService;
-    private isPageVisible = true;
     private supportsExtendedLocaleCompareCache: boolean;
     private supportsExtendedLocaleCompareCache: boolean;
 
 
     public static $inject = ['$log', '$window'];
     public static $inject = ['$log', '$window'];
@@ -33,59 +32,6 @@ export class BrowserService {
         // Angular services
         // Angular services
         this.$log = $log;
         this.$log = $log;
         this.$window = $window;
         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 {
     public getBrowser(): BrowserInfo {
@@ -183,10 +129,6 @@ export class BrowserService {
         return this.browser;
         return this.browser;
     }
     }
 
 
-    public isVisible() {
-        return this.isPageVisible;
-    }
-
     /**
     /**
      * Return whether the current browser supports the WebRTC task or not.
      * 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 {isContactReceiver} from '../typeguards';
 import {ReceiverService} from './receiver';
 import {ReceiverService} from './receiver';
+import {TimeoutService} from './timeout';
 
 
 export class MessageAccess {
 export class MessageAccess {
     public quote = false;
     public quote = false;
@@ -31,22 +32,22 @@ export class MessageService {
 
 
     // Angular services
     // Angular services
     private $log: ng.ILogService;
     private $log: ng.ILogService;
-    private $timeout: ng.ITimeoutService;
 
 
     // Own services
     // Own services
     private receiverService: ReceiverService;
     private receiverService: ReceiverService;
+    private timeoutService: TimeoutService;
 
 
     // Other
     // Other
     private timeoutDelaySeconds = 30;
     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.$log = $log;
-        this.$timeout = $timeout;
         this.receiverService = receiverService;
         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();
         const access = new MessageAccess();
 
 
         if (message !== undefined) {
         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 = {
         const message = {
-            temporaryId: receiver.type + receiver.id + Math.random(),
+            temporaryId: temporaryId,
             type: msgType,
             type: msgType,
             isOutbox: true,
             isOutbox: true,
             state: 'pending',
             state: 'pending',
@@ -165,7 +169,8 @@ export class MessageService {
         }
         }
 
 
         // Add delay for timeout checking
         // 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.
             // Set the state to timeout if it is still pending.
             // Note: If sending the message worked, by now the message object
             // Note: If sending the message worked, by now the message object
             // will have been replaced by a new one and the state change would
             // will have been replaced by a new one and the state change would
@@ -173,7 +178,7 @@ export class MessageService {
             if (message.state === 'pending') {
             if (message.state === 'pending') {
                 message.state = 'timeout';
                 message.state = 'timeout';
             }
             }
-        }, this.timeoutDelaySeconds * 1000);
+        }, this.timeoutDelaySeconds * 1000, true, 'messageTimeout');
 
 
         return message;
         return message;
     }
     }

+ 14 - 54
src/services/peerconnection.ts

@@ -39,9 +39,6 @@ export class PeerConnectionHelper {
     public connectionState: TaskConnectionState = TaskConnectionState.New;
     public connectionState: TaskConnectionState = TaskConnectionState.New;
     public onConnectionStateChange: (state: TaskConnectionState) => void = null;
     public onConnectionStateChange: (state: TaskConnectionState) => void = null;
 
 
-    // Internal callback when connection closes
-    private onConnectionClosed: () => void = null;
-
     // Debugging
     // Debugging
     private censorCandidates: boolean;
     private censorCandidates: boolean;
 
 
@@ -179,14 +176,10 @@ export class PeerConnectionHelper {
     /**
     /**
      * Create a new secure data channel.
      * 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);
         const dc: RTCDataChannel = this.pc.createDataChannel(label);
         dc.binaryType = 'arraybuffer';
         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) {
             if (this.onConnectionStateChange !== null) {
                 this.$timeout(() => this.onConnectionStateChange(state), 0);
                 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_TOKEN = 'token';
     private static ARG_SESSION = 'session';
     private static ARG_SESSION = 'session';
     private static ARG_VERSION = 'version';
     private static ARG_VERSION = 'version';
-    private static ARG_WAKEUP_TYPE = 'wakeup';
     private static ARG_ENDPOINT = 'endpoint';
     private static ARG_ENDPOINT = 'endpoint';
     private static ARG_BUNDLE_ID = 'bundleid';
     private static ARG_BUNDLE_ID = 'bundleid';
 
 
@@ -74,7 +73,7 @@ export class PushService {
      * Send a push notification for the specified session (public permanent key
      * Send a push notification for the specified session (public permanent key
      * of the initiator). The promise is always resolved to a boolean.
      * 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()) {
         if (!this.isAvailable()) {
             return false;
             return false;
         }
         }
@@ -87,7 +86,6 @@ export class PushService {
             [PushService.ARG_TYPE]: this.pushType,
             [PushService.ARG_TYPE]: this.pushType,
             [PushService.ARG_SESSION]: sessionHash,
             [PushService.ARG_SESSION]: sessionHash,
             [PushService.ARG_VERSION]: this.version,
             [PushService.ARG_VERSION]: this.version,
-            [PushService.ARG_WAKEUP_TYPE]: wakeupType,
         };
         };
         if (this.pushType === threema.PushTokenType.Apns) {
         if (this.pushType === threema.PushTokenType.Apns) {
             // APNS token format: "<hex-deviceid>;<endpoint>;<bundle-id>"
             // APNS token format: "<hex-deviceid>;<endpoint>;<bundle-id>"

+ 8 - 7
src/services/state.ts

@@ -43,7 +43,7 @@ export class StateService {
     public taskConnectionState: TaskConnectionState;
     public taskConnectionState: TaskConnectionState;
 
 
     // Connection buildup state
     // Connection buildup state
-    public connectionBuildupState: threema.ConnectionBuildupState = 'connecting';
+    public connectionBuildupState: threema.ConnectionBuildupState;
     public progress = 0;
     public progress = 0;
     private progressInterval: ng.IPromise<any> = null;
     private progressInterval: ng.IPromise<any> = null;
     public slowConnect = false;
     public slowConnect = false;
@@ -206,7 +206,7 @@ export class StateService {
                 this.progress = 60;
                 this.progress = 60;
                 this.progressInterval = this.$interval(() => {
                 this.progressInterval = this.$interval(() => {
                     if (this.progress < 80) {
                     if (this.progress < 80) {
-                        this.progress += 5;
+                        this.progress += 4;
                     } else if (this.progress < 90) {
                     } else if (this.progress < 90) {
                         this.progress += 2;
                         this.progress += 2;
                     } else if (this.progress < 99) {
                     } else if (this.progress < 99) {
@@ -214,7 +214,7 @@ export class StateService {
                     } else {
                     } else {
                         this.slowConnect = true;
                         this.slowConnect = true;
                     }
                     }
-                }, 500);
+                }, 600);
                 break;
                 break;
             case 'done':
             case 'done':
                 this.progress = 100;
                 this.progress = 100;
@@ -228,7 +228,7 @@ export class StateService {
     public readyToSubmit(chosenTask: ChosenTask): boolean {
     public readyToSubmit(chosenTask: ChosenTask): boolean {
         switch (chosenTask) {
         switch (chosenTask) {
             case ChosenTask.RelayedData:
             case ChosenTask.RelayedData:
-                return this.state === GlobalConnectionState.Ok || this.state === GlobalConnectionState.Warning;
+                return true;
             case ChosenTask.WebRTC:
             case ChosenTask.WebRTC:
             default:
             default:
                 return this.state === GlobalConnectionState.Ok;
                 return this.state === GlobalConnectionState.Ok;
@@ -238,8 +238,8 @@ export class StateService {
     /**
     /**
      * Reset all states.
      * 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
         // Reset state
         this.signalingConnectionState = 'new';
         this.signalingConnectionState = 'new';
@@ -247,6 +247,7 @@ export class StateService {
         this.stage = Stage.Signaling;
         this.stage = Stage.Signaling;
         this.state = GlobalConnectionState.Error;
         this.state = GlobalConnectionState.Error;
         this.wasConnected = false;
         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;
         high?: ArrayBuffer;
     }
     }
 
 
+    interface WireMessageAcknowledgement {
+        id: string,
+        success: boolean,
+        error?: string,
+    }
+
     /**
     /**
      * Messages that are sent through the secure data channel as encrypted msgpack bytes.
      * Messages that are sent through the secure data channel as encrypted msgpack bytes.
      */
      */
     interface WireMessage {
     interface WireMessage {
         type: string;
         type: string;
         subType: string;
         subType: string;
+        id?: string;
+        ack?: WireMessageAcknowledgement;
         args?: any;
         args?: any;
         data?: any;
         data?: any;
     }
     }
@@ -401,10 +409,11 @@ declare namespace threema {
      * - loading: Loading initial data
      * - loading: Loading initial data
      * - done: Initial loading is finished
      * - done: Initial loading is finished
      * - closed: Connection is closed
      * - closed: Connection is closed
+     * - reconnect_failed: Reconnecting failed after several attempts
      *
      *
      */
      */
     type ConnectionBuildupState = 'new' | 'connecting' | 'push' | 'manual_start' | 'already_connected'
     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 {
     interface ConnectionBuildupStateChange {
         state: ConnectionBuildupState;
         state: ConnectionBuildupState;
@@ -487,13 +496,6 @@ declare namespace threema {
         Apns = 'a',
         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 {
     interface TrustedKeyStoreData {
         ownPublicKey: Uint8Array;
         ownPublicKey: Uint8Array;
         ownSecretKey: Uint8Array;
         ownSecretKey: Uint8Array;
@@ -513,26 +515,6 @@ declare namespace threema {
         Safari = 'safari',
         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> {
     interface PromiseRequestResult<T> {
         success: boolean;
         success: boolean;
         error?: string;
         error?: string;
@@ -639,6 +621,7 @@ declare namespace threema {
         VERSION_MOUNTAIN: string;
         VERSION_MOUNTAIN: string;
         VERSION_MOUNTAIN_URL: string;
         VERSION_MOUNTAIN_URL: string;
         VERSION_MOUNTAIN_IMAGE_URL: string;
         VERSION_MOUNTAIN_IMAGE_URL: string;
+        VERSION_MOUNTAIN_IMAGE_COPYRIGHT: string;
         VERSION_MOUNTAIN_HEIGHT: number;
         VERSION_MOUNTAIN_HEIGHT: number;
         GIT_BRANCH: string;
         GIT_BRANCH: string;
         SALTYRTC_PORT: number;
         SALTYRTC_PORT: number;
@@ -646,6 +629,7 @@ declare namespace threema {
         SALTYRTC_HOST: string | null;
         SALTYRTC_HOST: string | null;
         SALTYRTC_HOST_PREFIX: string | null;
         SALTYRTC_HOST_PREFIX: string | null;
         SALTYRTC_HOST_SUFFIX: string | null;
         SALTYRTC_HOST_SUFFIX: string | null;
+        SALTYRTC_LOG_LEVEL: saltyrtc.LogLevel;
         ICE_SERVERS: RTCIceServer[];
         ICE_SERVERS: RTCIceServer[];
         PUSH_URL: string;
         PUSH_URL: string;
         DEBUG: boolean;
         DEBUG: boolean;
@@ -742,6 +726,13 @@ declare namespace threema {
         realLength: number;
         realLength: number;
     }
     }
 
 
+    interface WebClientServiceStopArguments {
+        reason: DisconnectReason,
+        send: boolean,
+        close: boolean | string,
+        connectionBuildupState?: ConnectionBuildupState,
+    }
+
     const enum ChosenTask {
     const enum ChosenTask {
         None = 'none',
         None = 'none',
         WebRTC = 'webrtc',
         WebRTC = 'webrtc',
@@ -753,6 +744,7 @@ declare namespace threema {
         SessionDeleted = 'delete',
         SessionDeleted = 'delete',
         WebclientDisabled = 'disable',
         WebclientDisabled = 'disable',
         SessionReplaced = 'replace',
         SessionReplaced = 'replace',
+        SessionError = 'error',
     }
     }
 
 
     namespace Container {
     namespace Container {
@@ -795,7 +787,7 @@ declare namespace threema {
             set(data: Conversation[]): void;
             set(data: Conversation[]): void;
             find(pattern: Conversation | Receiver): Conversation | null;
             find(pattern: Conversation | Receiver): Conversation | null;
             add(conversation: Conversation): void;
             add(conversation: Conversation): void;
-            updateOrAdd(conversation: Conversation): Conversation | null;
+            updateOrAdd(conversation: Conversation, returnOld?: boolean): Conversation | null;
             remove(conversation: Conversation): void;
             remove(conversation: Conversation): void;
             setFilter(filter: (data: Conversation[]) => Conversation[]): void;
             setFilter(filter: (data: Conversation[]) => Conversation[]): void;
             setConverter(converter: (data: Conversation) => Conversation): void;
             setConverter(converter: (data: Conversation) => Conversation): void;
@@ -826,10 +818,10 @@ declare namespace threema {
         }
         }
 
 
         interface Typing {
         interface Typing {
-            setTyping(receiver: ContactReceiver): void;
-            unsetTyping(receiver: ContactReceiver): void;
+            setTyping(receiver: BaseReceiver): void;
+            unsetTyping(receiver: BaseReceiver): void;
             clearAll(): void;
             clearAll(): void;
-            isTyping(receiver: ContactReceiver): boolean;
+            isTyping(receiver: BaseReceiver): boolean;
         }
         }
 
 
         interface Drafts {
         interface Drafts {

+ 35 - 11
src/threema/container.ts

@@ -15,6 +15,7 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
  */
 
 
+import {copyShallow} from '../helpers';
 import {isFirstUnreadStatusMessage} from '../message_helpers';
 import {isFirstUnreadStatusMessage} from '../message_helpers';
 import {ReceiverService} from '../services/receiver';
 import {ReceiverService} from '../services/receiver';
 
 
@@ -140,7 +141,7 @@ class Receivers implements threema.Container.Receivers {
     public setContacts(data: threema.ContactReceiver[]): void {
     public setContacts(data: threema.ContactReceiver[]): void {
         this.contacts = new Map(data.map((c) => {
         this.contacts = new Map(data.map((c) => {
             c.type = 'contact';
             c.type = 'contact';
-            setDefault(c, 'color', '#ff00ff');
+            setDefault(c, 'color', '#f0f0f0');
             return [c.id, c];
             return [c.id, c];
         }) as any) as ContactMap;
         }) as any) as ContactMap;
         if (this.me !== undefined) {
         if (this.me !== undefined) {
@@ -236,7 +237,7 @@ class Receivers implements threema.Container.Receivers {
         let contactReceiver = this.contacts.get(data.id);
         let contactReceiver = this.contacts.get(data.id);
         if (contactReceiver === undefined) {
         if (contactReceiver === undefined) {
             data.type = 'contact';
             data.type = 'contact';
-            setDefault(data, 'color', '#ff00ff');
+            setDefault(data, 'color', '#f0f0f0');
             this.contacts.set(data.id, data);
             this.contacts.set(data.id, data);
             return data;
             return data;
         }
         }
@@ -285,6 +286,7 @@ export class Conversations implements threema.Container.Conversations {
             if (conversation.position !== undefined) {
             if (conversation.position !== undefined) {
                 delete conversation.position;
                 delete conversation.position;
             }
             }
+            setDefault(conversation, 'isStarred', false);
         }
         }
         this.conversations = data;
         this.conversations = data;
     }
     }
@@ -315,17 +317,39 @@ export class Conversations implements threema.Container.Conversations {
 
 
     /**
     /**
      * Add a conversation at the correct position.
      * 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()) {
         for (const i of this.conversations.keys()) {
             if (this.receiverService.compare(this.conversations[i], conversation)) {
             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);
         this.add(conversation);
-        return replaced;
+        return null;
     }
     }
 
 
     /**
     /**
@@ -753,15 +777,15 @@ class Converter {
 class Typing implements threema.Container.Typing {
 class Typing implements threema.Container.Typing {
     private set = new StringHashSet();
     private set = new StringHashSet();
 
 
-    private getReceiverUid(receiver: threema.ContactReceiver): string {
+    private getReceiverUid(receiver: threema.BaseReceiver): string {
         return receiver.type + '-' + receiver.id;
         return receiver.type + '-' + receiver.id;
     }
     }
 
 
-    public setTyping(receiver: threema.ContactReceiver): void {
+    public setTyping(receiver: threema.BaseReceiver): void {
         this.set.add(this.getReceiverUid(receiver));
         this.set.add(this.getReceiverUid(receiver));
     }
     }
 
 
-    public unsetTyping(receiver: threema.ContactReceiver): void {
+    public unsetTyping(receiver: threema.BaseReceiver): void {
         this.set.remove(this.getReceiverUid(receiver));
         this.set.remove(this.getReceiverUid(receiver));
     }
     }
 
 
@@ -769,7 +793,7 @@ class Typing implements threema.Container.Typing {
         this.set.clearAll();
         this.set.clearAll();
     }
     }
 
 
-    public isTyping(receiver: threema.ContactReceiver): boolean {
+    public isTyping(receiver: threema.BaseReceiver): boolean {
         return this.set.contains(this.getReceiverUid(receiver));
         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() {
     describe('escapeHtml', function() {
 
 
         this.testPatterns = (cases) => testPatterns('escapeHtml', cases);
         this.testPatterns = (cases) => testPatterns('escapeHtml', cases);
@@ -168,7 +87,6 @@ describe('Filters', function() {
 
 
     describe('mentionify', function() {
     describe('mentionify', function() {
 
 
-
         this.testPatterns = (cases) => testPatterns('mentionify', cases);
         this.testPatterns = (cases) => testPatterns('mentionify', cases);
 
 
         it('no mentions', () => {
         it('no mentions', () => {

+ 8 - 1
tests/service/message.js

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

+ 12 - 0
tests/ts/containers.ts

@@ -36,6 +36,7 @@ function makeContactConversation(id: string, position?: number): threema.Convers
         messageCount: 5,
         messageCount: 5,
         unreadCount: 0,
         unreadCount: 0,
         latestMessage: null,
         latestMessage: null,
+        isStarred: false,
     };
     };
 }
 }
 
 
@@ -84,6 +85,17 @@ describe('Container', () => {
                 delete expected.position;
                 delete expected.position;
                 expect((conversations as any).conversations).toEqual([expected]);
                 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() {
         describe('add', function() {

+ 1 - 0
tests/ts/main.ts

@@ -23,4 +23,5 @@
 import './containers';
 import './containers';
 import './crypto_helpers';
 import './crypto_helpers';
 import './helpers';
 import './helpers';
+import './markup_parser';
 import './receiver_helpers';
 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