Przeglądaj źródła

Merge pull request #475 from threema-ch/ios

Threema Web v2.0.0
Danilo Bargen 7 lat temu
rodzic
commit
0a38dbab87
100 zmienionych plików z 3167 dodań i 1421 usunięć
  1. 1 0
      .circleci/config.yml
  2. 126 1
      CHANGELOG.md
  3. 26 64
      LICENSE-3RD-PARTY.txt
  4. 4 4
      README.md
  5. 6 6
      dist/package.sh
  6. 3 3
      docs/self_hosting.md
  7. 1 3
      gather-licenses.sh
  8. 25 17
      index.html
  9. 7 0
      karma.conf.js
  10. 361 245
      package-lock.json
  11. 40 41
      package.json
  12. 9 0
      public/browserconfig.xml
  13. 6 3
      public/fonts/material.css
  14. 95 15
      public/i18n/de.json
  15. 92 13
      public/i18n/en.json
  16. 4 0
      public/img/apple.svg
  17. BIN
      public/img/bg.jpg
  18. BIN
      public/img/bg1.jpg
  19. BIN
      public/img/favicon.ico
  20. BIN
      public/img/favicon/android-chrome-192x192.png
  21. BIN
      public/img/favicon/android-chrome-512x512.png
  22. BIN
      public/img/favicon/apple-touch-icon-120x120.png
  23. BIN
      public/img/favicon/apple-touch-icon-152x152.png
  24. BIN
      public/img/favicon/apple-touch-icon-180x180.png
  25. BIN
      public/img/favicon/apple-touch-icon-60x60.png
  26. BIN
      public/img/favicon/apple-touch-icon-76x76.png
  27. BIN
      public/img/favicon/apple-touch-icon.png
  28. BIN
      public/img/favicon/favicon-16x16.png
  29. BIN
      public/img/favicon/favicon-32x32.png
  30. BIN
      public/img/favicon/favicon.ico
  31. BIN
      public/img/favicon/mstile-150x150.png
  32. 1 0
      public/img/favicon/safari-pinned-tab.svg
  33. 35 0
      public/img/ic_dnd_mention.svg
  34. 38 0
      public/img/ic_dnd_total_silence.svg
  35. 77 0
      public/img/ic_home_round.svg
  36. 1 0
      public/img/ic_notifications_off.svg
  37. 3 0
      public/img/ic_qr.svg
  38. 3 0
      public/img/safari.svg
  39. BIN
      public/img/threema-576x576.png
  40. 11 5
      public/manifest.webmanifest
  41. 3 2
      src/app.ts
  42. 10 3
      src/config.ts
  43. 25 5
      src/controller_model/avatar.ts
  44. 15 11
      src/controller_model/contact.ts
  45. 14 10
      src/controller_model/distributionList.ts
  46. 38 17
      src/controller_model/group.ts
  47. 172 0
      src/controller_model/me.ts
  48. 4 2
      src/controllers.ts
  49. 9 10
      src/controllers/android_ios_only.ts
  50. 64 0
      src/controllers/footer.ts
  51. 77 19
      src/controllers/status.ts
  52. 2 4
      src/directives.ts
  53. 154 66
      src/directives/avatar.ts
  54. 87 95
      src/directives/avatar_area.ts
  55. 15 13
      src/directives/avatar_editor.ts
  56. 4 1
      src/directives/click_action.ts
  57. 17 12
      src/directives/compose_area.ts
  58. 18 15
      src/directives/contact_badge.ts
  59. 4 4
      src/directives/distribution_list_badge.ts
  60. 7 7
      src/directives/drag_file.ts
  61. 19 20
      src/directives/group_badge.ts
  62. 6 2
      src/directives/latest_message.html
  63. 57 63
      src/directives/latest_message.ts
  64. 6 7
      src/directives/location.ts
  65. 3 2
      src/directives/mediabox.ts
  66. 12 9
      src/directives/member_list_editor.ts
  67. 1 2
      src/directives/message.html
  68. 160 84
      src/directives/message.ts
  69. 1 1
      src/directives/message_date.ts
  70. 6 2
      src/directives/message_icon.ts
  71. 8 8
      src/directives/message_media.html
  72. 221 152
      src/directives/message_media.ts
  73. 14 2
      src/directives/message_menu.html
  74. 14 12
      src/directives/message_meta.ts
  75. 1 1
      src/directives/message_state.ts
  76. 28 27
      src/directives/message_text.ts
  77. 7 5
      src/directives/message_voip_status.ts
  78. 0 65
      src/directives/my_identity.ts
  79. 34 31
      src/directives/verification_level.ts
  80. 172 10
      src/filters.ts
  81. 86 0
      src/helpers.ts
  82. 36 0
      src/helpers/crypto.ts
  83. 28 0
      src/helpers/messages.ts
  84. 8 5
      src/message_helpers.ts
  85. 2 2
      src/partials/dialog.about.html
  86. 3 9
      src/partials/dialog.qr.html
  87. 34 0
      src/partials/dialog.version.html
  88. 4 5
      src/partials/messenger.conversation.html
  89. 23 15
      src/partials/messenger.navigation.html
  90. 6 2
      src/partials/messenger.receiver.html
  91. 3 4
      src/partials/messenger.receiver/contact.html
  92. 2 4
      src/partials/messenger.receiver/distributionList.html
  93. 2 3
      src/partials/messenger.receiver/group.html
  94. 18 0
      src/partials/messenger.receiver/me.edit.html
  95. 24 0
      src/partials/messenger.receiver/me.html
  96. 268 119
      src/partials/messenger.ts
  97. 17 0
      src/partials/welcome.html
  98. 86 31
      src/partials/welcome.ts
  99. 33 0
      src/receiver_helpers.ts
  100. 0 1
      src/sass/app.scss

+ 1 - 0
.circleci/config.yml

@@ -8,6 +8,7 @@ references:
           - v1-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ arch }}-{{ checksum "package.json" }}
     - run: npm install
     - run: npm run build
+    - run: npm run build:tests
     - run: npm test
     - save_cache:
         key: v1-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ arch }}-{{ checksum "package.json" }}

+ 126 - 1
CHANGELOG.md

@@ -3,6 +3,105 @@
 This changelog lists the most important changes for each released version. For
 the full log, please refer to the git commit history.
 
+### [v2.0.0-beta.8][v2.0.0-beta.8] (2018-07-24)
+
+Changes:
+
+* [change] Upgraded a lot of dependencies ([#522][i522])
+* [change] Some minor protocol changes ([#528][i528])
+
+### [v2.0.0-beta.7][v2.0.0-beta.7] (2018-07-03)
+
+Changes:
+
+* [feature] Show a message when copying text to the clipboard ([#517][i517])
+* [change] Hide battery icon after 1 minute without connectivity
+* [change] Disable flashing of "low battery" icon due to browser
+  performance issues ([#519][i519])
+* [bug] Fix a lot of UI performance issues ([#480][i480])
+
+### [v2.0.0-beta.6][v2.0.0-beta.6] (2018-06-26)
+
+Changes:
+
+* [feature] Show three blue dots for verified Threema Work contacts
+* [feature] Implement non-work indicator for Threema Work users
+* [feature] Add message timeout state
+* [feature] Show a title text for all message state icons
+* [bug] Ignore status messages when marking as unread
+* [bug] Fix "unread messages" indicator
+
+### [v2.0.0-beta.5][v2.0.0-beta.5] (2018-06-12)
+
+Changes:
+
+* [feature] Implement "copy to clipboard" functionality
+* [bug] Fix closing of chat when deleting conversation
+* [bug] Fix bug when processing messages that were sent when not connected
+* [bug] Improve scrolling behavior
+
+Contributors:
+
+- [@heckenmann][@heckenmann]
+
+### [v2.0.0-beta.4][v2.0.0-beta.4] (2018-06-11)
+
+Changes:
+
+* [change] iOS: Status bar should stay green on connection loss
+* [bug] When entering the wrong password, re-enable the input field
+* [bug] Clear all "isTyping" flags when connection is lost
+* [bug] Handle unknown battery status
+* [bug] German translation fixes
+
+Contributors:
+
+- [@JanRei][@JanRei]
+
+### [v2.0.0-beta.3][v2.0.0-beta.3] (2018-06-05)
+
+Changes:
+
+* [feature] Hide inactive IDs in contact list ([#4][i4])
+* [change] Link version number at the bottom of the webapp to changelog
+* [bug] Properly sort contacts
+* [bug] Fix error message when adding an invalid contact identity
+
+### [v2.0.0-beta.2][v2.0.0-beta.2] (2018-06-04)
+
+Changes:
+
+* [change] Update icon / favicon for various platforms ([#503][i503])
+* [bug] Fix mime type handling for audio messages
+* [bug] Prevent double-download of audio messages
+
+### [v2.0.0-beta.1][v2.0.0-beta.1] (2018-05-29)
+
+This is the first beta release with support for Threema Web on iOS devices. It
+also changes the protocol version from 1 to 2.
+
+Changes:
+
+* [feature] Add support for iOS ([#58][i58])
+* [feature] Allow viewing and editing your own profile ([#221][i221])
+* [feature] Show message events ([#32][i32])
+* [feature] Large single emoji ([#97][i97])
+* [feature] Show distribution list members ([#472][i472])
+* [feature] Add "Navigate to" entry to location message context menu
+* [feature] Add support for Safari 11+ (with iOS devices only)
+* [feature] Implement support for new per-conversation notification settings
+* [change] Threema Web protocol version upgrade from 1 to 2
+* [change] When downloading media, filename now contains timestamp
+* [bug] Saving profile without setting picture won't reset it anymore ([#154][i154])
+* [bug] Fix race condition in password field ([#445][i445])
+* [bug] Fix broken conversation preview ([#393][i393])
+* [bug] Make message caption mouse-selectable ([#303][i303])
+
+Contributors:
+
+- [@IndianaDschones][@IndianaDschones]
+- [@ovalseven8][@ovalseven8]
+
 ### [v1.8.2][v1.8.2] (2018-02-21)
 
 Changes:
@@ -285,12 +384,14 @@ Contributors:
 
 First public release.
 
+[i4]: https://github.com/threema-ch/threema-web/issues/4
 [i6]: https://github.com/threema-ch/threema-web/issues/6
 [i8]: https://github.com/threema-ch/threema-web/issues/8
 [i11]: https://github.com/threema-ch/threema-web/issues/11
 [i17]: https://github.com/threema-ch/threema-web/issues/17
 [i20]: https://github.com/threema-ch/threema-web/issues/20
 [i29]: https://github.com/threema-ch/threema-web/issues/29
+[i32]: https://github.com/threema-ch/threema-web/issues/32
 [i38]: https://github.com/threema-ch/threema-web/issues/38
 [i39]: https://github.com/threema-ch/threema-web/issues/39
 [i41]: https://github.com/threema-ch/threema-web/issues/41
@@ -302,6 +403,7 @@ First public release.
 [i50]: https://github.com/threema-ch/threema-web/issues/50
 [i54]: https://github.com/threema-ch/threema-web/issues/54
 [i57]: https://github.com/threema-ch/threema-web/issues/57
+[i58]: https://github.com/threema-ch/threema-web/issues/58
 [i59]: https://github.com/threema-ch/threema-web/issues/59
 [i61]: https://github.com/threema-ch/threema-web/issues/61
 [i64]: https://github.com/threema-ch/threema-web/issues/64
@@ -313,6 +415,7 @@ First public release.
 [i83]: https://github.com/threema-ch/threema-web/issues/83
 [i86]: https://github.com/threema-ch/threema-web/issues/86
 [i90]: https://github.com/threema-ch/threema-web/issues/90
+[i97]: https://github.com/threema-ch/threema-web/issues/97
 [i93]: https://github.com/threema-ch/threema-web/issues/93
 [i102]: https://github.com/threema-ch/threema-web/issues/102
 [i108]: https://github.com/threema-ch/threema-web/issues/108
@@ -332,6 +435,7 @@ First public release.
 [i148]: https://github.com/threema-ch/threema-web/issues/148
 [i150]: https://github.com/threema-ch/threema-web/issues/150
 [i153]: https://github.com/threema-ch/threema-web/issues/153
+[i154]: https://github.com/threema-ch/threema-web/issues/154
 [i156]: https://github.com/threema-ch/threema-web/issues/156
 [i161]: https://github.com/threema-ch/threema-web/issues/161
 [i167]: https://github.com/threema-ch/threema-web/issues/167
@@ -348,6 +452,7 @@ First public release.
 [i205]: https://github.com/threema-ch/threema-web/issues/205
 [i211]: https://github.com/threema-ch/threema-web/issues/211
 [i215]: https://github.com/threema-ch/threema-web/issues/215
+[i221]: https://github.com/threema-ch/threema-web/issues/221
 [i231]: https://github.com/threema-ch/threema-web/issues/231
 [i235]: https://github.com/threema-ch/threema-web/issues/235
 [i237]: https://github.com/threema-ch/threema-web/issues/237
@@ -367,6 +472,7 @@ First public release.
 [i289]: https://github.com/threema-ch/threema-web/issues/289
 [i291]: https://github.com/threema-ch/threema-web/issues/291
 [i296]: https://github.com/threema-ch/threema-web/issues/296
+[i303]: https://github.com/threema-ch/threema-web/issues/303
 [i310]: https://github.com/threema-ch/threema-web/issues/310
 [i311]: https://github.com/threema-ch/threema-web/issues/311
 [i315]: https://github.com/threema-ch/threema-web/issues/315
@@ -403,6 +509,7 @@ First public release.
 [i382]: https://github.com/threema-ch/threema-web/issues/382
 [i385]: https://github.com/threema-ch/threema-web/issues/385
 [i390]: https://github.com/threema-ch/threema-web/issues/390
+[i393]: https://github.com/threema-ch/threema-web/issues/393
 [i396]: https://github.com/threema-ch/threema-web/issues/396
 [i401]: https://github.com/threema-ch/threema-web/issues/401
 [i402]: https://github.com/threema-ch/threema-web/issues/402
@@ -416,7 +523,23 @@ First public release.
 [i435]: https://github.com/threema-ch/threema-web/issues/435
 [i439]: https://github.com/threema-ch/threema-web/issues/439
 [i441]: https://github.com/threema-ch/threema-web/issues/441
-
+[i445]: https://github.com/threema-ch/threema-web/issues/445
+[i472]: https://github.com/threema-ch/threema-web/issues/472
+[i480]: https://github.com/threema-ch/threema-web/issues/480
+[i503]: https://github.com/threema-ch/threema-web/issues/503
+[i517]: https://github.com/threema-ch/threema-web/issues/517
+[i519]: https://github.com/threema-ch/threema-web/issues/519
+[i522]: https://github.com/threema-ch/threema-web/issues/522
+[i528]: https://github.com/threema-ch/threema-web/issues/528
+
+[v2.0.0-beta.8]: https://github.com/threema-ch/threema-web/compare/v2.0.0-beta.7...v2.0.0-beta.8
+[v2.0.0-beta.7]: https://github.com/threema-ch/threema-web/compare/v2.0.0-beta.6...v2.0.0-beta.7
+[v2.0.0-beta.6]: https://github.com/threema-ch/threema-web/compare/v2.0.0-beta.5...v2.0.0-beta.6
+[v2.0.0-beta.5]: https://github.com/threema-ch/threema-web/compare/v2.0.0-beta.4...v2.0.0-beta.5
+[v2.0.0-beta.4]: https://github.com/threema-ch/threema-web/compare/v2.0.0-beta.3...v2.0.0-beta.4
+[v2.0.0-beta.3]: https://github.com/threema-ch/threema-web/compare/v2.0.0-beta.2...v2.0.0-beta.3
+[v2.0.0-beta.2]: https://github.com/threema-ch/threema-web/compare/v2.0.0-beta.1...v2.0.0-beta.2
+[v2.0.0-beta.1]: https://github.com/threema-ch/threema-web/compare/v1.8.2...v2.0.0-beta.1
 [v1.8.2]: https://github.com/threema-ch/threema-web/compare/v1.8.1...v1.8.2
 [v1.8.1]: https://github.com/threema-ch/threema-web/compare/v1.8.0...v1.8.1
 [v1.8.0]: https://github.com/threema-ch/threema-web/compare/v1.7.0...v1.8.0
@@ -452,3 +575,5 @@ First public release.
 [@bluec0re]: https://github.com/bluec0re/
 [@Octoate]: https://github.com/Octoate/
 [@joelfischerr]: https://github.com/joelfischerr/
+[@JanRei]: https://github.com/JanRei/
+[@heckenmann]: https://github.com/heckenmann

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

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

+ 4 - 4
README.md

@@ -35,11 +35,11 @@ Install development dependencies:
 
 Run the dev server:
 
-    npm run serve:live
+    npm run devserver
 
 Then open the URL in your browser:
 
-    chromium http://localhost:9966
+    firefox http://localhost:9966
 
 *(Note that this setup should not be used in production. To run Threema
 Web on a server, please follow the instructions at
@@ -50,8 +50,8 @@ Web on a server, please follow the instructions at
 
 To run tests:
 
-    npm run build
-    chromium tests/testsuite.html
+    npm run build && npm run build:tests
+    firefox tests/testsuite.html
 
 To run linting checks:
 

+ 6 - 6
dist/package.sh

@@ -56,13 +56,14 @@ targets=(
     msgpack-lite/dist/msgpack.min.js
     tweetnacl/nacl-fast.min.js
     file-saver/FileSaver.min.js
-    js-sha256/build/sha256.min.js
     @saltyrtc/chunked-dc/dist/chunked-dc.es5.js
     @saltyrtc/client/dist/saltyrtc-client.es5.js
     @saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js
+    @saltyrtc/task-relayed-data/dist/saltyrtc-task-relayed-data.es5.js
     webrtc-adapter/out/adapter_no_edge.js
     webrtc-adapter/out/adapter.js
-    qrcode-generator/js/qrcode.js
+    qrcode-generator/qrcode.js
+    qrcode-generator/qrcode_UTF8.js
     angular-qrcode/angular-qrcode.js
     angularjs-scroll-glue/src/scrollglue.js
     angular-material/angular-material.min.js
@@ -70,12 +71,11 @@ targets=(
     croppie/croppie.min.js
     croppie/croppie.css
     autolinker/dist/Autolinker.min.js
-    angular-ui-router/release/angular-ui-router.min.js
+    @uirouter/angularjs/release/angular-ui-router.min.js
     messageformat/messageformat.min.js
     angular-translate/dist/angular-translate.min.js
     angular-translate/dist/angular-translate-loader-static-files/angular-translate-loader-static-files.min.js
     angular-translate/dist/angular-translate-interpolation-messageformat/angular-translate-interpolation-messageformat.min.js
-    angular-messages/angular-messages.min.js
     sdp/sdp.js
 )
 
@@ -90,8 +90,8 @@ done
 echo "+ Update version number..."
 # Note: The `-i.bak` notation is requires so that the sed command works both on Linux
 # and on macOS: https://stackoverflow.com/q/5694228/284318
-sed -i.bak -e "s/\[\[VERSION\]\]/${VERSION}/g" $DIR/index.html $DIR/troubleshoot/index.html $DIR/dist/app.js $DIR/version.txt
-rm $DIR/index.html.bak $DIR/troubleshoot/index.html.bak $DIR/dist/app.js.bak $DIR/version.txt.bak
+sed -i.bak -e "s/\[\[VERSION\]\]/${VERSION}/g" $DIR/index.html $DIR/troubleshoot/index.html $DIR/dist/app.js $DIR/manifest.webmanifest $DIR/browserconfig.xml $DIR/version.txt
+rm $DIR/index.html.bak $DIR/troubleshoot/index.html.bak $DIR/dist/app.js.bak $DIR/manifest.webmanifest.bak $DIR/browserconfig.xml.bak $DIR/version.txt.bak
 
 echo "+ Update permissions..."
 find $DIR/ -type f -exec chmod 644 {} \;

+ 3 - 3
docs/self_hosting.md

@@ -10,7 +10,7 @@ The following components can be self-hosted:
 - STUN / TURN Server
 
 The push relay server could in theory be self-hosted too, but it won't help as
-the GCM API Key required to dispatch push notifications is not public.
+the GCM / APNs API keys required to dispatch push notifications are not public.
 
 If you have questions, please [open an
 issue](https://github.com/threema-ch/threema-web/issues) on Github.
@@ -65,7 +65,7 @@ You can run any WebRTC-compliant STUN / TURN server, e.g.
 ## Push Relay
 
 While you could in theory host your own version of the push server, it won't
-help much since the GCM API Key required to dispatch push notifications to the
-Threema Android app is not public.
+help much since the GCM / APNs API Keys required to dispatch push notifications
+to the Threema app are not public.
 
 You can review the code on Github though: https://github.com/threema-ch/push-relay

+ 1 - 3
gather-licenses.sh

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

+ 25 - 17
index.html

@@ -27,14 +27,18 @@
 
     <title>Threema Web</title>
     <meta name="description" translate translate-attr-content="meta.DESCRIPTION"
-          content="Chat from your desktop with Threema Web and have full access to all chats, contacts and media files."/>
-
-    <!-- Webmanifest -->
-    <link rel="manifest" href="manifest.webmanifest">
-
-    <!-- Favicon -->
-    <link rel="icon" href="img/favicon.ico?v=[[VERSION]]" type="image/x-icon"/>
-    <link rel="shortcut icon" href="img/favicon.ico?v=[[VERSION]]" type="image/x-icon"/>
+          content="Chat from your desktop with Threema Web and have full access to all chats, contacts and media files.">
+
+    <!-- Favicon / Webmanifest / Browserconfig -->
+    <link rel="manifest" href="manifest.webmanifest?v=[[VERSION]]">
+    <link rel="apple-touch-icon" sizes="180x180" href="img/favicon/apple-touch-icon.png?v=[[VERSION]]">
+    <link rel="icon" type="image/png" sizes="32x32" href="img/favicon/favicon-32x32.png?v=[[VERSION]]">
+    <link rel="icon" type="image/png" sizes="16x16" href="img/favicon/favicon-16x16.png?v=[[VERSION]]">
+    <link rel="mask-icon" href="img/favicon/safari-pinned-tab.svg?v=[[VERSION]]" color="#5bbad5">
+    <link rel="shortcut icon" href="img/favicon/favicon.ico?v=[[VERSION]]">
+    <meta name="msapplication-TileColor" content="#313131">
+    <meta name="msapplication-config" content="browserconfig.xml?v=[[VERSION]]">
+    <meta name="theme-color" content="#4f4f4f">
 
     <!-- Angular -->
     <link rel="stylesheet" href="node_modules/angular/angular-csp.css?v=[[VERSION]]">
@@ -53,10 +57,10 @@
 </head>
 
 <body ng-controller="StatusController as ctrl" class="{{ ctrl.statusClass }}" ng-class="{expanded: ctrl.expandStatusBar}">
-    <img src="img/bg1.jpg?v=1" id="background-image" draggable="false"/>
+    <img src="img/bg.jpg?v=1" id="background-image" draggable="false">
 
     <noscript>
-        <img id="logo-noscript" src="img/logo.svg?v=[[VERSION]]"></img>
+        <img id="logo-noscript" src="img/logo.svg?v=[[VERSION]]"/>
         <div>
             <h2>Error: JavaScript not supported</h2>
             <p>Please enable JavaScript in your browser to be able to use Threema Web.</p>
@@ -79,12 +83,15 @@
             </div>
             <div id="main-content" ui-view></div>
         </div>
-        <div class="androidonly" ng-controller="AndroidOnlyController as ctrl" ng-show="ctrl.show">
-            <div><i class="material-icons md-24">android</i> <span translate>welcome.ANDROID_ONLY</span></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>
+        <footer ng-controller="FooterController as ctrl">
             <ul>
-                <li>Version [[VERSION]]</li>
+                <li><a ng-click="ctrl.showVersionInfo('[[VERSION]]')">Version [[VERSION]] {{ ctrl.config.VERSION_MOUNTAIN }}</a></li>
                 <li><a
                         href="https://threema.ch/threema-web"
                         target="_blank"
@@ -106,10 +113,11 @@
     <script src="node_modules/babel-es6-polyfill/browser-polyfill.min.js?v=[[VERSION]]"></script>
 
     <!-- Various libraries -->
-    <script src="node_modules/qrcode-generator/js/qrcode.js?v=[[VERSION]]"></script>
+    <script src="node_modules/qrcode-generator/qrcode.js?v=[[VERSION]]"></script>
+    <script src="node_modules/qrcode-generator/qrcode_UTF8.js?v=[[VERSION]]"></script>
     <script src="node_modules/angular-qrcode/angular-qrcode.js?v=[[VERSION]]"></script>
     <script src="node_modules/angular-material/angular-material.min.js?v=[[VERSION]]"></script>
-    <script src="node_modules/angular-ui-router/release/angular-ui-router.min.js?v=[[VERSION]]"></script>
+    <script src="node_modules/@uirouter/angularjs/release/angular-ui-router.min.js?v=[[VERSION]]"></script>
     <script src="libs/emojione/emojione.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/angularjs-scroll-glue/src/scrollglue.js?v=[[VERSION]]"></script>
     <script src="libs/angular-inview/angular-inview.js?v=[[VERSION]]"></script>
@@ -124,10 +132,10 @@
     <script src="node_modules/msgpack-lite/dist/msgpack.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/tweetnacl/nacl-fast.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/file-saver/FileSaver.min.js?v=[[VERSION]]"></script>
-    <script src="node_modules/js-sha256/build/sha256.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/chunked-dc/dist/chunked-dc.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/client/dist/saltyrtc-client.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js?v=[[VERSION]]"></script>
+    <script src="node_modules/@saltyrtc/task-relayed-data/dist/saltyrtc-task-relayed-data.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/croppie/croppie.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/autolinker/dist/Autolinker.min.js?v=[[VERSION]]"></script>
 

+ 7 - 0
karma.conf.js

@@ -9,6 +9,9 @@ module.exports = function(config) {
             'node_modules/angular-aria/angular-aria.min.js',
             'node_modules/angular-animate/angular-animate.min.js',
             'node_modules/angular-material/angular-material.min.js',
+            'node_modules/@saltyrtc/chunked-dc/dist/chunked-dc.es5.js',
+            'node_modules/autolinker/dist/Autolinker.min.js',
+            'node_modules/regenerator-runtime/runtime.js',
             'dist/app.js',
             'dist/ts-tests.js',
             'tests/filters.js',
@@ -17,6 +20,10 @@ module.exports = function(config) {
             'tests/service/qrcode.js',
             'tests/service/uri.js',
             'tests/service/webclient.js',
+            'tests/service/string.js',
+            'tests/service/browser.js',
+            'tests/service/keystore.js',
+            'tests/service/notification.js',
             'tests/helpers.js',
         ],
         customLaunchers: {

Plik diff jest za duży
+ 361 - 245
package-lock.json


+ 40 - 41
package.json

@@ -1,6 +1,6 @@
 {
   "name": "threema-web",
-  "version": "1.8.2",
+  "version": "2.0.0-beta.8",
   "description": "Threema Webclient",
   "scripts": {
     "build": "npm run build:js && npm run build:css",
@@ -9,8 +9,8 @@
     "build:css:watch": "node-sass -w -r --source-map true --source-map-embed true -o public/css/ --output-style compressed src/sass/",
     "build:tests": "browserify -p tsify tests/ts/main.ts -t [ babelify --presets [ es2015 ] --extensions .ts ] -o dist/ts-tests.js",
     "dist": "npm run build && echo \"\" && node dist/build-package.js",
-    "serve": "npm run build:css && budo src/app.ts:dist/app.js -- -d -p tsify -t [ babelify --presets [ es2015 ] --extensions .ts ]",
-    "serve:live": "npm run build:css && concurrently --kill-others --names \"css,server\" -p name \"npm run build:css:watch\" \"budo src/app.ts:dist/app.js -d . -d public -d src --live -- -d -p tsify -t [ babelify --presets [ es2015 ] --extensions .ts ]\"",
+    "serve:live": "echo 'NOTE: serve:live command has been renamed to devserver'",
+    "devserver": "npm run build:css && concurrently --kill-others --names \"css,server\" -p name \"npm run build:css:watch\" \"budo src/app.ts:dist/app.js -d . -d public -d src --live -- -d -p tsify -t [ babelify --presets [ es2015 ] --extensions .ts ]\"",
     "test": "npm run build:tests && karma start --single-run --log-level=debug --colors",
     "lint": "tslint -c tslint.json --project tsconfig.json --exclude \"**/src/config.ts\"",
     "clean": "rm -rf js/ build/ dist/app*"
@@ -26,58 +26,57 @@
   "private": true,
   "homepage": "https://threema.ch/",
   "dependencies": {
-    "@saltyrtc/client": "^0.10.0",
-    "@saltyrtc/task-webrtc": "^0.10.0",
-    "@types/angular": "^1.6.43",
-    "@types/angular-material": "^1.1.58",
+    "@saltyrtc/client": "^0.12.0",
+    "@saltyrtc/task-relayed-data": "^0.3.0",
+    "@saltyrtc/task-webrtc": "^0.12.0",
+    "@types/angular": "^1.6.48",
+    "@types/angular-material": "^1.1.59",
     "@types/angular-sanitize": "^1.3.7",
-    "@types/angular-translate": "~2.4.34",
-    "@types/angular-ui-router": "^1.1.40",
+    "@types/angular-translate": "^2.16.0",
     "@types/filesaver": "~0.0.30",
-    "@types/jquery": "^2.0.48",
+    "@types/jquery": "^3.3.4",
     "@types/msgpack-lite": "^0.1.6",
     "@types/webrtc": "0.0.23",
-    "angular": "~1.5.10",
-    "angular-animate": "~1.5.10",
-    "angular-aria": "~1.5.10",
-    "angular-material": "=1.1.1",
-    "angular-messages": "^1.6.9",
-    "angular-qrcode": "~6.2.1",
-    "angular-route": "~1.5.10",
-    "angular-sanitize": "~1.5.10",
-    "angular-translate": "~2.13.1",
-    "angular-ui-router": "~0.3.2",
+    "@uirouter/angularjs": "~1.0.18",
+    "angular": "~1.7.2",
+    "angular-animate": "~1.7.2",
+    "angular-aria": "~1.7.2",
+    "angular-material": "=1.1.10",
+    "angular-messages": "^1.7.2",
+    "angular-qrcode": "~7.2",
+    "angular-route": "~1.7.2",
+    "angular-sanitize": "~1.7.2",
+    "angular-translate": "~2.18",
     "angularjs-scroll-glue": "~2.1.0",
-    "autolinker": "~0.27.0",
+    "autolinker": "~1.6.2",
     "babel-es6-polyfill": "~1.1.0",
     "babel-preset-es2015": "~6.14.0",
     "babelify": "~7.3.0",
-    "browserify": "~13.1.0",
-    "browserify-header": "~0.9.2",
-    "croppie": "~2.4.0",
-    "file-saver": "~1.3.3",
-    "js-sha256": "~0.3.2",
-    "messageformat": "~1.0.2",
+    "browserify": "~16",
+    "browserify-header": "^0.9.4",
+    "croppie": "~2.6.0",
+    "file-saver": "^1.3.8",
+    "messageformat": "~2",
     "msgpack-lite": "~0.1.26",
-    "node-sass": "^4.7.2",
-    "sdp": "~1.3.0",
+    "node-sass": "^4.9.2",
+    "sdp": "~2.7.0",
     "ts-events": "^3.1.5",
-    "tsify": "~2.0.1",
+    "tsify": "~4.0.0",
     "tweetnacl": "^1.0.0",
-    "typescript": "~2.6",
-    "webrtc-adapter": "~3.4.3"
+    "typescript": "^2.9.2",
+    "webrtc-adapter": "^6.3.0"
   },
   "devDependencies": {
-    "@types/jasmine": "^2.8.6",
-    "angular-mocks": "~1.5.10",
-    "budo": "~9.4.7",
+    "@types/jasmine": "^2.8.8",
+    "angular-mocks": "~1.7",
+    "budo": "^9",
     "concurrently": "~3.3.0",
     "jasmine": "^3.1.0",
-    "jasmine-core": "~2.5.2",
-    "karma": "~1.5.0",
-    "karma-chrome-launcher": "~2.0.0",
-    "karma-firefox-launcher": "~1.0.0",
-    "karma-jasmine": "^1.1.1",
-    "tslint": "~5.9"
+    "jasmine-core": "^3.1.0",
+    "karma": "^2.0.4",
+    "karma-chrome-launcher": "^2.2.0",
+    "karma-firefox-launcher": "^1.1.0",
+    "karma-jasmine": "^1.1.2",
+    "tslint": "~5.10"
   }
 }

+ 9 - 0
public/browserconfig.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+    <msapplication>
+        <tile>
+            <square150x150logo src="img/favicon/mstile-150x150.png?v=[[VERSION]]"/>
+            <TileColor>#313131</TileColor>
+        </tile>
+    </msapplication>
+</browserconfig>

+ 6 - 3
public/fonts/material.css

@@ -51,12 +51,15 @@
 .material-icons.md-light.md-inactive { color: rgba(255, 255, 255, 0.3); }
 
 /* specific color masks */
-.material-icons.user-dec {
+.material-icons.md-dark.user-dec,
+.material-icons.md-medium-dark.user-dec {
     color: #ff9800;
 }
-.material-icons.user-ack {
+.material-icons.md-dark.user-ack,
+.material-icons.md-medium-dark.user-ack {
     color: #4caf50;
 }
-.material-icons.send-failed {
+.material-icons.md-dark.send-failed,
+.material-icons.md-medium-dark.send-failed {
     color: #d50000;
 }

+ 95 - 15
public/i18n/de.json

@@ -4,7 +4,6 @@
     },
     "welcome": {
         "ABORT": "Abbrechen",
-        "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> oder <a href='https:\/\/www.mozilla.org\/' target='_blank' rel='noopener noreferrer'>Mozilla Firefox<\/a>, um den Webclient ohne Einschr\u00e4nkungen zu nutzen.",
         "CONTINUE_ANYWAY": "Trotzdem fortfahren",
         "PLEASE_SCAN": "Scannen Sie den QR-Code mit Ihrer Threema-App",
         "PLEASE_UNLOCK": "Verbindung wiederaufbauen",
@@ -12,17 +11,20 @@
         "PLEASE_RELOAD": "Bitte laden Sie die Seite neu.",
         "RELOAD": "Seite neu laden",
         "PASSWORD": "Passwort",
-        "CHOOSE_PASSWORD": "Legen Sie vor dem Scannen ein Passwort fest, um die<br>Sitzung sp\u00e4ter ohne Scannen wiederherzustellen.",
+        "CHOOSE_PASSWORD": "Falls Sie die Sitzung speichern möchten,<br>legen Sie vor dem Scannen ein Passwort fest.",
         "UNLOCK_FAILED_TEXT": "Sie haben das falsche Passwort eingegeben. Die Verbindung kann nicht aufgebaut werden.",
         "ENTER_PASSWORD": "Um die letzte Sitzung wiederherzustellen, <br> geben Sie bitte das entsprechende Passwort ein.",
         "UNLOCK_FAILED_TITLE": "Verbindung aufbauen fehlgeschlagen",
         "ALTERNATIVELY": "Alternativ:",
-        "UNLOCK_FAILED_FORGOTTEN": "Haben Sie Ihr Passwort vergessen? W\u00e4hlen Sie \"Verbindung abbrechen\" und setzen Sie ein neues Passwort.",
+        "UNLOCK_FAILED_FORGOTTEN": "Haben Sie Ihr Passwort vergessen? W\u00e4hlen Sie \"gespeicherte Sitzung l\u00f6schen\" und setzen Sie ein neues Passwort.",
         "IF_YOU_WANT": "Wenn Sie möchten, können Sie die",
         "FORGET_SESSION": "gespeicherte Sitzung löschen",
         "BTN_RECONNECT": "Verbindung aufbauen",
         "BROWSER_NOT_SUPPORTED": "Dieser Browser wird nicht unterst\u00fctzt",
-        "ANDROID_ONLY": "Kompatibel mit Threema für Android.",
+        "BROWSER_NOT_SUPPORTED_ANDROID": "Dieser Browser wird von Android-Geräten nicht unterst\u00fctzt",
+        "BROWSER_NOT_SUPPORTED_DETAILS": "Bitte verwenden Sie die aktuelle Version von <a href='https:\/\/www.google.com\/chrome\/browser\/desktop\/' target='_blank' rel='noopener noreferrer'>Google Chrome<\/a>, <a href='https:\/\/www.mozilla.org\/' target='_blank' rel='noopener noreferrer'>Mozilla Firefox<\/a>, <a href='https:\/\/www.opera.com\/' target='_blank' rel='noopener noreferrer'>Opera</a> oder <a href='https:\/\/www.apple.com\/safari\/' target='_blank' rel='noopener noreferrer'>Safari</a> (nur mit iOS), um den Webclient ohne Einschr\u00e4nkungen zu nutzen.",
+        "SAFARI": "Safari ist nur kompatibel mit Threema Web für iOS. Android-Nutzer verwenden bitte einen anderen Browser.",
+        "ANDROID_IOS_ONLY": "Kompatibel mit Threema für Android.",
         "CONNECTING": "Verbindung wird aufgebaut",
         "WAITING_FOR_PUSH": "Threema-App wird<br>aufgeweckt …",
         "CONNECTING_TO_APP": "Verbindung zu App<br>wird aufgebaut …",
@@ -37,7 +39,13 @@
         "LOCAL_STORAGE_MISSING_DETAILS": "Zugriff auf LocalStorage ist nicht möglich. Dieses Problem kann auftreten, wenn in Ihrem Browser Cookies blockiert werden, oder wenn ein Browser-Add-On installiert ist, welches den Zugriff auf LocalStorage blockiert. Bitte erlauben Sie die Nutzung von LocalStorage in Ihrem Browser oder deaktivieren Sie die installierten Browser-Add-Ons.",
         "LOCAL_STORAGE_EXCEPTION_DETAILS": "Kritischer Fehler beim Zugriff auf LocalStorage: {errorMsg}.<br>Bitte starten Sie Ihren Browser neu.",
         "ALREADY_CONNECTED": "Bereits verbunden",
-        "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",
+        "BACKGROUND_IMAGE": "Hintergrundbild",
+        "NEW_VERSION": "Neue Version von Threema Web",
+        "NEW_VERSION_DETAILS": "Diese Version von Threema Web setzt <strong>Threema 3.45 für Android</strong> voraus. Falls Sie eine ältere Threema-Version verwenden, <strong>aktualisieren Sie bitte die App</strong>.<br><br>Wenn Sie die Threema-App nicht aktualisieren können, nutzen Sie vorübergehend die archivierte Version von Threema Web: <a href=\"https://web.threema.ch/archive/1.8.2/\">web.threema.ch/archive/1.8.2</a>",
+        "NOTIFICATION_IOS_BETA": "iOS Beta-User? Bitte benutzen Sie <a href=\"https://web-beta.threema.ch/\">web-beta.threema.ch</a>.",
+        "NOTIFICATION_NEW_RELEASE": "Eine neue Version von Threema Web wurde veröffentlicht. Bitte aktualisieren Sie Ihre App oder verwenden Sie die <a href=\"https://web.threema.ch/archive/1.8.2/\">letzte Version</a>."
     },
     "connecting": {
         "CONNECTION_PROBLEMS": "Verbindungsprobleme",
@@ -79,7 +87,9 @@
         "ARE_YOU_SURE": "Sind Sie sicher?",
         "SAVE": "Speichern",
         "DONE": "Fertig",
-        "MODIFY": "Ändern"
+        "MODIFY": "Ändern",
+        "NOTE": "Hinweis:",
+        "UNDERSTOOD": "Verstanden"
     },
     "messenger": {
         "VERIFICATION_LEVEL": "Vertrauensstufe",
@@ -115,8 +125,18 @@
         "GROUP_ROLE_NORMAL": "Mitglied",
         "GROUP_ROLE_CREATOR": "Ersteller",
         "DOWNLOAD": "Herunterladen",
-        "COPY": "Kopieren",
         "DOWNLOADING": "Laden …",
+        "COPY": "Kopieren",
+        "COPIED": "Inhalt wurde in die Zwischenablage kopiert!",
+        "COPY_ERROR": "Fehler: Konnte Inhalt nicht in die Zwischenablage kopieren",
+        "MSG_HISTORY": "Nachrichten-Verlauf",
+        "MSG_HISTORY_CREATED": "Erstellt",
+        "MSG_HISTORY_SENT": "Gesendet",
+        "MSG_HISTORY_DELIVERED": "Zugestellt",
+        "MSG_HISTORY_READ": "Gelesen",
+        "MSG_HISTORY_ACKED": "Bestätigt",
+        "MSG_HISTORY_MODIFIED": "Geändert",
+        "NAVIGATE": "Navigieren",
         "CONFIRM_FILE_SEND": "Senden an «{senderName}»?",
         "CONFIRM_FILE_CAPTION": "Optionale Beschriftung",
         "CONFIRM_SEND_AS_FILE": "Als Datei senden",
@@ -130,7 +150,7 @@
         "SYSTEM_CONTACT": "Systemkontakt",
         "EMAIL_ADDRESSES": "E-Mail-Adressen",
         "PHONE_NUMBERS": "Telefonnummern",
-        "EDIT_RECEIVER": "{receiverName} bearbeiten",
+        "EDIT_RECEIVER": "Profil bearbeiten",
         "CREATE_GROUP": "Neue Gruppe",
         "GROUP_SELECT_CONTACTS": "Mitglieder wählen",
         "GROUP_DELETE": "Gruppe löschen",
@@ -154,16 +174,34 @@
         "PRIVATE_CHAT_DESCRIPTION": "Private Unterhaltungen werden in Threema Web nicht unterstützt.",
         "MESSAGE_TOO_LONG_SPLIT_SUBJECT": "Nachricht aufteilen.",
         "MESSAGE_TOO_LONG_SPLIT_BODY": "Es werden maximal {max} Zeichen pro Nachricht unterstützt, wollen Sie die Nachricht in {count} separate Nachrichten aufteilen?",
-        "BALLOT_MESSAGES_NOT_SUPPORTED": "Umfragen werden in Threema Web derzeit nicht untersützt.",
+        "BALLOT_MESSAGES_NOT_SUPPORTED": "Umfragen werden in Threema Web derzeit nicht unterstützt.",
         "UNKNOWN_MESSAGE_TYPE": "Unbekannter Nachrichtentyp",
         "NICKNAME": "Nickname",
         "THREEMA_WORK_CONTACT": "Threema Work Nutzer",
+        "THREEMA_HOME_CONTACT": "Privater Threema-Kontakt",
         "THREEMA_BLOCKED_RECEIVER": "blockiert",
         "DELETE_THREAD": "Chat löschen",
         "DELETE_THREAD_MESSAGE": "{count, plural, one {Möchten Sie wirklich diesen Chat löschen? Die Nachrichten können nicht wiederhergestellt werden.} other {Möchten Sie wirklich # Chats löschen? Die Nachrichten können nicht wiederhergestellt werden.}}",
         "MUTED": "Keine Benachrichtigungen",
+        "MUTED_NONE": "Keine Benachrichtigungen",
+        "MUTED_MENTION_ONLY": "Nur bei Erwähnung benachrichtigen",
+        "MUTED_SILENT": "Stumme Benachrichtigungen",
         "ALL": "Alle"
     },
+    "messageStates": {
+        "WE_ACK": "Sie haben ein Daumen-Hoch gesendet",
+        "WE_DEC": "Sie haben ein Daumen-Runter gesendet",
+        "USER_ACK": "Der Empfänger hat ein Daumen-Hoch gesendet",
+        "USER_DEC": "Der Empfänger hat ein Daumen-Runter gesendet",
+        "PENDING": "Die Nachricht wird auf Ihr Gerät übertragen",
+        "SENDING": "Die Nachricht wird an den Threema-Server übermittelt",
+        "SENT": "Die Nachricht wurde erfolgreich an den Server übermittelt",
+        "DELIVERED": "Die Nachricht ist beim Gerät des Empfängers angekommen",
+        "READ": "Die Nachricht wurde vom Empfänger gelesen",
+        "FAILED": "Die Nachricht konnte nicht gesendet werden",
+        "TIMEOUT": "Die Nachricht konnte nicht auf Ihr Gerät übertragen werden.",
+        "UNKNOWN": ""
+    },
     "messageTypes": {
         "AUDIO_MESSAGE": "Sprachnachricht",
         "FILE_MESSAGE": "Datei",
@@ -176,8 +214,22 @@
         "gif": "GIF"
     },
     "validationError": {
-        "createReceiver": {
-            "invalid_identity": "Ungültige Threema-ID"
+        "modifyReceiver": {
+            "unknown": "Ein unbekannter Fehler ist aufgetreten",
+            "badRequest": "Ungültige Anfrage (Protokollfehler?)",
+            "timeout": "Timeout",
+            "internalError": "Ein interner Fehler ist aufgetreten",
+            "invalidIdentity": "Ungültige Threema-ID",
+            "invalidContact": "Ungültiger Kontakt",
+            "invalidGroup": "Ungültige Gruppe",
+            "invalidDistributionList": "Ungültige Verteilerliste",
+            "notAllowed": "Bearbeitung nicht erlaubt",
+            "notAllowedLinked": "Systemkontakt kann nicht verändert werden",
+            "notAllowedBusiness": "Avatar eines Threema Gateway Kontaktes kann nicht geändert werden",
+            "disabledByPolicy": "Funktion wurde durch den Administrator deaktiviert",
+            "syncFailed": "Gruppen-Synchronisation fehlgeschlagen",
+            "noMembers": "Keine Mitglieder definiert",
+            "alreadyLeft": "Sie haben diese Gruppe bereits verlassen"
         }
     },
     "error": {
@@ -187,11 +239,15 @@
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» kann noch keine Dateien erhalten.",
         "CONTACT_BLOCKED":  "Sie können keine Nachrichten an blockierte Kontakte senden.",
         "ERROR_OCCURRED": "Es ist ein Fehler aufgetreten.",
-        "FILE_TOO_LARGE": "Aktuell können keine Dateien grösser als 15 MiB über Threema Web versendet werden",
+        "FILE_TOO_LARGE": "Dateien grösser als {maxmb} MiB können nicht versendet werden",
+        "FILE_TOO_LARGE_WEB": "Aktuell können keine Dateien grösser als 15 MiB über Threema Web versendet werden",
+        "FILE_TOO_LARGE_GENERIC": "Datei kann nicht gesendet werden, weil sie zu gross ist",
         "TEXT_TOO_LONG": "Diese Nachricht ist zu lang und kann nicht gesendet werden (Maximale Länge {max} Zeichen).",
         "NOTIFICATION_PERMISSION_DENIED": "Sie müssen die Benachrichtigungsberechtigung für Threema Web manuell erteilen, um Desktopbenachrichtigungen erhalten zu können.",
         "NOTIFICATION_PERMISSION_DENIED_LEARN_MORE": "Mehr erfahren.",
-        "NOTIFICATION_API_NOT_AVAILABLE": "Ihr Browser unterstützt keine Desktopbenachrichtigungen."
+        "NOTIFICATION_API_NOT_AVAILABLE": "Ihr Browser unterstützt keine Desktopbenachrichtigungen.",
+        "BLOB_DOWNLOAD_FAILED": "Datei konnte nicht heruntergeladen werden",
+        "BLOB_DECRYPT_FAILED": "Datei konnte nicht entschlüsselt werden"
     },
     "mimeTypes": {
         "apk": "Android-Paket",
@@ -217,7 +273,7 @@
         "SOURCE_CODE": "Der Quellcode und die Lizenzbedingungen finden sich auf GitHub:",
         "EMOJI_ART": "Verwendete Emoji stammen von <a target=\"_blank\" href=\"http://emojione.com\">EmojiOne</a>",
         "NOTIFICATION_SOUNDS": "Benachrichtigunstöne &copy; 2012 <a target=\"_blank\" href=\"https://www.soundsnap.com/licence\">soundsnap.com</a> - Licensed under the Soundsnap License",
-        "LICENSE_LINK_BEFORE": "Lizenzen von verwendeten Open Source Komponenten können",
+        "LICENSE_LINK_BEFORE": "Lizenzen von verwendeten Open-Source-Komponenten können",
         "LICENSE_LINK_TEXT": "im Quellcode",
         "LICENSE_LINK_AFTER": "gefunden werden",
         "CHANGELOG": "Änderungsprotokoll",
@@ -236,7 +292,7 @@
     },
     "version": {
         "NEW_VERSION": "Neue Version Verfügbar",
-        "NEW_VERSION_BODY": "Eine neue Version von Threema Web ({version}) ist verfügbar. Mehr Informationen finden Sie im {changelog}. Drücken Sie \"OK\" um das Update zu aktivieren."
+        "NEW_VERSION_BODY": "Eine neue Version von Threema Web ({version}) ist verfügbar. Mehr Informationen finden Sie im {changelog}. Drücken Sie \"OK\", um das Update zu aktivieren."
     },
     "voip": {
         "CALL_MISSED": "Verpasster Anruf",
@@ -251,5 +307,29 @@
         "ALERT": "Entladen: {percent}%",
         "LEVEL_LOW": "Der Akkustand Ihres Gerätes ist niedrig ({percent}%).",
         "LEVEL_CRITICAL": "Der Akkustand Ihres Gerätes ist kritisch ({percent}%)!"
+    },
+    "date": {
+        "YESTERDAY": "Gestern",
+        "month_short": {
+            "JAN": "Jan.",
+            "FEB": "Feb.",
+            "MAR": "Mär.",
+            "APR": "Apr.",
+            "MAY": "Mai",
+            "JUN": "Jun.",
+            "JUL": "Jul.",
+            "AUG": "Aug.",
+            "SEP": "Sep.",
+            "OCT": "Okt.",
+            "NOV": "Nov.",
+            "DEC": "Dez."
+        }
+    },
+    "connection": {
+        "SESSION_CLOSED_TITLE": "Sitzung Geschlossen",
+        "SESSION_STOPPED": "Die Sitzung wurde auf Ihrem Gerät gestoppt.",
+        "SESSION_DELETED": "Die Sitzung wurde auf Ihrem Gerät gelöscht.",
+        "WEBCLIENT_DISABLED": "Threema Web wurde auf Ihrem Gerät deaktiviert.",
+        "SESSION_REPLACED": "Die Sitzung wurde beendet, weil Sie eine andere Sitzung gestartet haben."
     }
 }

+ 92 - 13
public/i18n/en.json

@@ -4,7 +4,6 @@
     },
     "welcome": {
         "ABORT": "Abort",
-        "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> or <a href='https:\/\/www.mozilla.org\/' target='_blank' rel='noopener noreferrer'>Mozilla Firefox<\/a>, otherwise the web client might not work properly.",
         "CONTINUE_ANYWAY": "Continue anyway",
         "PLEASE_SCAN": "Scan this QR code with your Threema app",
         "PLEASE_UNLOCK": "Reconnecting session",
@@ -12,7 +11,7 @@
         "PLEASE_RELOAD": "Please reload the page to try again.",
         "RELOAD": "Reload page",
         "PASSWORD": "Password",
-        "CHOOSE_PASSWORD": "To stay logged in, please enter<br>a session password before scanning.",
+        "CHOOSE_PASSWORD": "If you want to stay logged in, please enter<br>a session password before scanning.",
         "UNLOCK_FAILED_TEXT": "You entered the wrong password, the session cannot be restored.",
         "ENTER_PASSWORD": "To reconnect to your previous session,<br>please enter the password:",
         "UNLOCK_FAILED_TITLE": "Unlocking failed",
@@ -22,7 +21,10 @@
         "FORGET_SESSION": "forget this session",
         "BTN_RECONNECT": "Reconnect",
         "BROWSER_NOT_SUPPORTED": "This browser is not supported",
-        "ANDROID_ONLY": "Compatible with Threema for 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.",
+        "SAFARI": "Safari is only compatible with Threema Web for iOS.<br>If you are using Android, please use another browser.",
+        "ANDROID_IOS_ONLY": "Compatible with Threema for Android.",
         "CONNECTING": "Connecting",
         "WAITING_FOR_PUSH": "Waiting for<br>app wakeup …",
         "CONNECTING_TO_APP": "Connection to app is<br>being established …",
@@ -37,7 +39,13 @@
         "LOCAL_STORAGE_MISSING_DETAILS": "Access to LocalStorage not possible. This can occur if your browser is configured to reject cookies, or if you installed a browser add-on that blocks access to LocalStorage. Please allow local storage in your browser settings or disable any add-ons you might have installed.",
         "LOCAL_STORAGE_EXCEPTION_DETAILS": "Critical error when accessing LocalStorage: {errorMsg}.<br>Try restarting your browser.",
         "ALREADY_CONNECTED": "Already connected",
-        "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",
+        "BACKGROUND_IMAGE": "Background Image",
+        "NEW_VERSION": "New Threema Web Version",
+        "NEW_VERSION_DETAILS": "This version of Threema Web requires <strong>Threema 3.45 for Android</strong>. If you are using an older Threema version, <strong>please update the app</strong>.<br><br>If you are unable to update the Threema app, you can temporarily use the archived version of Threema Web: <a href=\"https://web.threema.ch/archive/1.8.2/\">web.threema.ch/archive/1.8.2</a>",
+        "NOTIFICATION_IOS_BETA": "Using the iOS beta? Please go to <a href=\"https://web-beta.threema.ch/\">web-beta.threema.ch</a>.",
+        "NOTIFICATION_NEW_RELEASE": "A new version of Threema Web was released. Make sure to update your app or use the <a href=\"/archive/1.8.2/\">previous version of Threema Web</a>."
     },
     "connecting": {
         "CONNECTION_PROBLEMS": "Connection problems",
@@ -75,11 +83,13 @@
         "NO": "No",
         "OK": "Ok",
         "DELETE": "Delete",
-        "EDIT": "Bearbeiten",
+        "EDIT": "Edit",
         "ARE_YOU_SURE": "Are you sure?",
         "SAVE": "Save",
         "DONE": "Done",
-        "MODIFY": "Modify"
+        "MODIFY": "Modify",
+        "NOTE": "Note:",
+        "UNDERSTOOD": "Understood"
     },
     "messenger": {
         "VERIFICATION_LEVEL": "Verification level",
@@ -115,8 +125,18 @@
         "GROUP_ROLE_NORMAL": "Member",
         "GROUP_ROLE_CREATOR": "Creator",
         "DOWNLOAD": "Download",
-        "COPY": "Copy",
         "DOWNLOADING": "Downloading …",
+        "COPY": "Copy",
+        "COPIED": "Text copied to clipboard!",
+        "COPY_ERROR": "Error: Could not copy text to clipboard",
+        "MSG_HISTORY": "Message History",
+        "MSG_HISTORY_CREATED": "Created",
+        "MSG_HISTORY_SENT": "Sent",
+        "MSG_HISTORY_DELIVERED": "Delivered",
+        "MSG_HISTORY_READ": "Read",
+        "MSG_HISTORY_ACKED": "Acknowledged",
+        "MSG_HISTORY_MODIFIED": "Modified",
+        "NAVIGATE": "Navigate",
         "CONFIRM_FILE_SEND": "Send to «{senderName}»?",
         "CONFIRM_FILE_CAPTION": "Optional caption",
         "CONFIRM_SEND_AS_FILE": "Send as file message",
@@ -130,7 +150,7 @@
         "SYSTEM_CONTACT": "System contact",
         "EMAIL_ADDRESSES": "Emails",
         "PHONE_NUMBERS": "Phone numbers",
-        "EDIT_RECEIVER": "Edit {receiverName}",
+        "EDIT_RECEIVER": "Edit profile",
         "CREATE_GROUP": "New group",
         "GROUP_SELECT_CONTACTS": "Select members",
         "GROUP_DELETE": "Delete group",
@@ -158,12 +178,29 @@
         "UNKNOWN_MESSAGE_TYPE": "Unknown message type",
         "NICKNAME": "Nickname",
         "THREEMA_WORK_CONTACT": "Threema Work user",
+        "THREEMA_HOME_CONTACT": "Private Threema contact",
         "THREEMA_BLOCKED_RECEIVER": "blocked",
         "DELETE_THREAD": "Delete chat",
         "DELETE_THREAD_MESSAGE": "{count, plural, one {Do you really want to delete this chat? You will not be able to recover the messages.} other {Do you really want to delete # chat(s)? You will not be able to recover the messages.}}",
-        "MUTED": "No notifications",
+        "MUTED_NONE": "No notifications",
+        "MUTED_MENTION_ONLY": "Only show notification when mentioned",
+        "MUTED_SILENT": "Silent notifications",
         "ALL": "All"
     },
+    "messageStates": {
+        "WE_ACK": "You sent thumbs-up",
+        "WE_DEC": "You sent thumbs-down",
+        "USER_ACK": "Recipient sent thumbs-up",
+        "USER_DEC": "Recipient sent thumbs-down",
+        "PENDING": "The message is being transferred to your device",
+        "SENDING": "The message is being transferred to the Threema server",
+        "SENT": "The message was delivered to the Threema server",
+        "DELIVERED": "The message was delivered to the recipient's device",
+        "READ": "The message was read by the recipient",
+        "FAILED": "The message could not be sent",
+        "TIMEOUT": "The message could not be transferred to your device.",
+        "UNKNOWN": ""
+    },
     "messageTypes": {
         "AUDIO_MESSAGE": "Audio Message",
         "FILE_MESSAGE": "File Message",
@@ -176,8 +213,22 @@
         "gif": "GIF"
     },
     "validationError": {
-        "createReceiver": {
-            "invalid_identity": "Invalid Threema-ID"
+        "modifyReceiver": {
+            "unknown": "An unknown error occurred",
+            "badRequest": "Invalid request (protocol error?)",
+            "timeout": "Request timed out",
+            "internalError": "An internal error occurred",
+            "invalidIdentity": "Invalid Threema-ID",
+            "invalidContact": "Invalid contact ID",
+            "invalidGroup": "Invalid group ID",
+            "invalidDistributionList": "Invalid distribution list ID",
+            "notAllowed": "Modification not allowed",
+            "notAllowedLinked": "Contact cannot be changed: It's linked to a system contact",
+            "notAllowedBusiness": "Avatar cannot be changed: It's a Threema Gateway contact",
+            "disabledByPolicy": "Feature disabled by administrator",
+            "syncFailed": "Group synchronization failed",
+            "noMembers": "No members defined",
+            "alreadyLeft": "You have already left this group"
         }
     },
     "error": {
@@ -187,11 +238,15 @@
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» cannot receive files.",
         "CONTACT_BLOCKED":  "You cannot send messages to a blocked contact.",
         "ERROR_OCCURRED": "An error occurred.",
-        "FILE_TOO_LARGE": "Currently files larger than 15 MiB cannot be sent through Threema Web",
+        "FILE_TOO_LARGE": "Files larger than {maxmb} MiB cannot be sent",
+        "FILE_TOO_LARGE_WEB": "Currently files larger than 15 MiB cannot be sent through Threema Web",
+        "FILE_TOO_LARGE_GENERIC": "File is too large to be sent",
         "TEXT_TOO_LONG": "This message is too long and cannot be sent (Max length {max} characters).",
         "NOTIFICATION_PERMISSION_DENIED": "You have to grant the notification permission for Threema Web manually in order to receive desktop notifications.",
         "NOTIFICATION_PERMISSION_DENIED_LEARN_MORE": "Learn more.",
-        "NOTIFICATION_API_NOT_AVAILABLE": "Your browser does not support desktop notifications."
+        "NOTIFICATION_API_NOT_AVAILABLE": "Your browser does not support desktop notifications.",
+        "BLOB_DOWNLOAD_FAILED": "Could not download file.",
+        "BLOB_DECRYPT_FAILED": "Could not decrypt file."
     },
     "mimeTypes": {
         "apk": "Android package",
@@ -251,5 +306,29 @@
         "ALERT": "Discharging: {percent}%",
         "LEVEL_LOW": "Your device battery level is low ({percent}%).",
         "LEVEL_CRITICAL": "Your device battery level is critical ({percent}%)!"
+    },
+    "date": {
+        "YESTERDAY": "Yesterday",
+        "month_short": {
+            "JAN": "Jan.",
+            "FEB": "Feb.",
+            "MAR": "Mar.",
+            "APR": "Apr.",
+            "MAY": "May",
+            "JUN": "Jun.",
+            "JUL": "Jul.",
+            "AUG": "Aug.",
+            "SEP": "Sep.",
+            "OCT": "Oct.",
+            "NOV": "Nov.",
+            "DEC": "Dec."
+        }
+    },
+    "connection": {
+        "SESSION_CLOSED_TITLE": "Session Closed",
+        "SESSION_STOPPED": "The session was stopped on your device.",
+        "SESSION_DELETED": "The session was deleted on your device.",
+        "WEBCLIENT_DISABLED": "Threema Web was disabled on your device.",
+        "SESSION_REPLACED": "This session was stopped because you started a Threema Web session in another browser window."
     }
 }

+ 4 - 0
public/img/apple.svg

@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="30px" height="30px">
+    <path d="M25.565,9.785c-0.123,0.077-3.051,1.702-3.051,5.305c0.138,4.109,3.695,5.55,3.756,5.55 c-0.061,0.077-0.537,1.963-1.947,3.94C23.204,26.283,21.962,28,20.076,28c-1.794,0-2.438-1.135-4.508-1.135 c-2.223,0-2.852,1.135-4.554,1.135c-1.886,0-3.22-1.809-4.4-3.496c-1.533-2.208-2.836-5.673-2.882-9 c-0.031-1.763,0.307-3.496,1.165-4.968c1.211-2.055,3.373-3.45,5.734-3.496c1.809-0.061,3.419,1.242,4.523,1.242 c1.058,0,3.036-1.242,5.274-1.242C21.394,7.041,23.97,7.332,25.565,9.785z M15.001,6.688c-0.322-1.61,0.567-3.22,1.395-4.247 c1.058-1.242,2.729-2.085,4.17-2.085c0.092,1.61-0.491,3.189-1.533,4.339C18.098,5.937,16.488,6.872,15.001,6.688z"/>
+</svg>

BIN
public/img/bg.jpg


BIN
public/img/bg1.jpg


BIN
public/img/favicon.ico


BIN
public/img/favicon/android-chrome-192x192.png


BIN
public/img/favicon/android-chrome-512x512.png


BIN
public/img/favicon/apple-touch-icon-120x120.png


BIN
public/img/favicon/apple-touch-icon-152x152.png


BIN
public/img/favicon/apple-touch-icon-180x180.png


BIN
public/img/favicon/apple-touch-icon-60x60.png


BIN
public/img/favicon/apple-touch-icon-76x76.png


BIN
public/img/favicon/apple-touch-icon.png


BIN
public/img/favicon/favicon-16x16.png


BIN
public/img/favicon/favicon-32x32.png


BIN
public/img/favicon/favicon.ico


BIN
public/img/favicon/mstile-150x150.png


+ 1 - 0
public/img/favicon/safari-pinned-tab.svg

@@ -0,0 +1 @@
+<svg version="1" xmlns="http://www.w3.org/2000/svg" width="682.667" height="682.667" viewBox="0 0 512.000000 512.000000"><path d="M67 1.9C33.9 8.9 8.8 34.2 1.9 67.4.1 76.1 0 84.5 0 255.6 0 451-.2 442.3 5.6 457.9c9.9 26.2 33.8 46.4 61.9 52.2 8.6 1.8 17.5 1.9 188.1 1.9 158.6 0 180.1-.2 187.4-1.6 17.6-3.3 31.1-10.4 43.5-22.8 13.1-13.1 20-25.7 23.6-43.1 1.8-8.6 1.9-17.5 1.9-188.5 0-171.5-.1-179.9-1.9-188.6C503.2 34 478 8.8 444.6 1.9 435.9.1 427.6 0 255.5.1 86.2.1 75 .2 67 1.9zm224 75.5c69.8 12.1 121.4 52 136.3 105.4 1.8 6.6 2.1 10.4 2.1 26.2 0 17.3-.2 19.1-2.7 27.5-16.1 53.5-69.6 93.3-139.2 103.6-19.8 2.9-46.7 2.9-66.5-.1-17.1-2.6-33.3-2.8-44-.6-4.1.9-23.7 6.7-43.5 13-19.8 6.2-36.2 11.2-36.4 11-.2-.1 5.4-11.2 12.5-24.5 12.7-24 12.8-24.4 12.8-30.9 0-8.3-1.9-11.9-11.9-23-21-23.3-31.5-48.6-31.5-76 0-29.1 11.2-55.5 33.4-78.8 27.6-29 69.1-48.4 115.6-54.1 11.6-1.5 51.8-.6 63 1.3zM165 395.1c18 8.1 21.7 32 7 45.2-5.7 5.3-10.3 7-18.1 7.1-15.7.1-27.3-11.6-27.2-27.4 0-7.7 1.7-12.2 7-18 7.7-8.6 21-11.6 31.3-6.9zm101.5.3c5.7 2.6 11.7 8.7 14.1 14.3 2.3 5.5 2.2 15.8-.3 21.4-7.7 17.4-30.7 21.8-44.3 8.6-10.7-10.4-10.6-29.2.1-39.4 7.9-7.5 20.6-9.5 30.4-4.9zm101.9.1c9.6 4.6 15.1 13.8 15 25-.1 15.3-11.7 26.9-26.8 26.9-14.8-.1-26.2-10.5-27.4-25-.9-11.6 4.8-21.8 15.1-26.8 4.4-2.3 6.3-2.6 12.7-2.3 4.8.2 8.9 1 11.4 2.2z"/><path d="M237.7 127.1c-13.5 4.6-23.1 14.2-27.9 27.8-1.6 4.6-2.1 9.3-2.5 26.3-.6 19.6-.7 20.8-2.5 20.8-3.2 0-7.7 2.9-9.3 6-1.4 2.6-1.5 7.8-1.3 36.5.3 33.3.3 33.5 2.6 35.7l2.3 2.3 52.2.3c35.2.2 53.4 0 55.9-.8 2-.6 4.6-2.2 5.7-3.7 2-2.5 2.1-3.8 2.1-34.8 0-31.9 0-32.2-2.3-35.6-1.5-2.2-3.8-3.9-6.5-4.9l-4.2-1.4V184c0-19-1.2-26.5-5.4-34.8-5.3-10.4-15.3-18.8-26.7-22.3-8.5-2.7-24.2-2.6-32.2.2zm29.5 20.3c5.1 2.5 9.6 7.2 12.2 12.6 1.8 4 2.1 6.7 2.4 23.2l.4 18.8H228v-19.4c0-21 .5-23.4 5.7-29.6 1.5-1.8 5-4.4 7.8-5.7 4.3-2 6.2-2.3 13.5-2.1 5.7.2 9.7 1 12.2 2.2z"/></svg>

+ 35 - 0
public/img/ic_dnd_mention.svg

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   id="svg4550"
+   version="1.1"
+   viewBox="0 0 48 48"
+   height="48"
+   width="48">
+  <metadata
+     id="metadata4556">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs4554" />
+  <path
+     style="stroke-width:1.19999993;fill:#0000008a;"
+     id="path4548"
+     d="M 24,0 C 10.752,0 0,10.752 0,24 0,37.248 10.752,48 24,48 37.248,48 48,37.248 48,24 48,10.752 37.248,0 24,0 Z" />
+  <path
+     id="path2"
+     d="M 24,6.5 C 14.34,6.5 6.5,14.34 6.5,24 c 0,9.66 7.84,17.5 17.5,17.5 h 8.75 V 38 H 24 c -7.595,0 -14,-6.405 -14,-14 0,-7.595 6.405,-14 14,-14 7.595,0 14,6.405 14,14 v 2.5025 c 0,1.3825 -1.2425,2.7475 -2.625,2.7475 -1.3825,0 -2.625,-1.365 -2.625,-2.7475 V 24 c 0,-4.83 -3.92,-8.75 -8.75,-8.75 -4.83,0 -8.75,3.92 -8.75,8.75 0,4.83 3.92,8.75 8.75,8.75 2.415,0 4.62,-0.98 6.195,-2.5725 1.1375,1.5575 3.0975,2.5725 5.18,2.5725 3.4475,0 6.125,-2.8 6.125,-6.2475 V 24 C 41.5,14.34 33.66,6.5 24,6.5 Z m 0,22.75 c -2.905,0 -5.25,-2.345 -5.25,-5.25 0,-2.905 2.345,-5.25 5.25,-5.25 2.905,0 5.25,2.345 5.25,5.25 0,2.905 -2.345,5.25 -5.25,5.25 z"
+     style="fill:#ffffff;fill-opacity:1;stroke-width:1.75" />
+</svg>

+ 38 - 0
public/img/ic_dnd_total_silence.svg

@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   id="svg4550"
+   version="1.1"
+   viewBox="0 0 48 48"
+   height="48"
+   width="48">
+  <metadata
+     id="metadata4556">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs4554" />
+  <path
+     style="stroke-width:0.94999999;fill:#0000008a;"
+     id="path4548"
+     d="M 24,5 C 13.512,5 5,13.512 5,24 5,34.488 13.512,43 24,43 34.488,43 43,34.488 43,24 43,13.512 34.488,5 24,5 Z m 9.5,20.9 h -19 v -3.8 h 19 z" />
+  <ellipse
+     ry="23.040001"
+     rx="23.039999"
+     cy="24"
+     cx="24"
+     id="path5101"
+     style="fill:none;stroke:#0000008a;stroke-width:1.92;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:0.69999999;stroke-dasharray:none;stroke-dashoffset:38.53903961;stroke-opacity:1" />
+</svg>

+ 77 - 0
public/img/ic_home_round.svg

@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   id="svg8"
+   version="1.1"
+   viewBox="0 0 16.933333 16.933333"
+   height="64"
+   width="64"
+   sodipodi:docname="ic_home_round.svg"
+   inkscape:version="0.92.2 2405546, 2018-03-11">
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1054"
+     inkscape:window-height="1179"
+     id="namedview11"
+     showgrid="false"
+     units="px"
+     inkscape:zoom="5.5191157"
+     inkscape:cx="18.817688"
+     inkscape:cy="61.232164"
+     inkscape:window-x="1920"
+     inkscape:window-y="19"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg8" />
+  <defs
+     id="defs2" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     transform="matrix(0.06614583,0,0,0.06614583,2.8902839,-2.2054354)"
+     id="layer1">
+    <g
+       transform="translate(-335.64286,-16.630952)"
+       id="g4548">
+      <g
+         transform="matrix(1.0090631,0,0,1.0088594,275.77739,8.7290118)"
+         id="g4792">
+        <g
+           id="g4800">
+          <path
+             style="fill:#ffffff;fill-opacity:1;stroke-width:0.28222221"
+             d="M 132.1597,294.43335 C 106.23137,291.97852 82.927411,282.6136 63.147214,266.70003 39.316592,247.5278 23.207926,220.33188 17.732712,190.0277 c -1.46631,-8.11573 -1.708059,-11.27132 -1.708059,-22.29556 0,-11.02424 0.241749,-14.17983 1.708059,-22.29555 9.493423,-52.544153 50.302574,-93.353304 102.846728,-102.846727 8.11572,-1.46631 11.27131,-1.708059 22.29555,-1.708059 11.02424,0 14.17983,0.241749 22.29556,1.708059 52.54415,9.493423 93.3533,50.302574 102.84672,102.846727 1.46631,8.11572 1.70806,11.27131 1.70806,22.29555 0,11.02424 -0.24175,14.17983 -1.70806,22.29556 -5.47521,30.30418 -21.58388,57.5001 -45.4145,76.67233 -18.71611,15.05749 -40.77722,24.31412 -65.19333,27.35449 -4.28275,0.5333 -20.97329,0.78371 -25.24974,0.37883 z"
+             id="path4796"
+             inkscape:connector-curvature="0" />
+          <path
+             style="fill:#009487;stroke-width:0.28222221"
+             d="M 132.71499,280.30942 C 105.93165,277.66764 81.681408,266.29346 62.99754,247.60959 46.1371,230.74915 35.053824,209.01594 31.244925,185.34583 c -1.693958,-10.52697 -1.693958,-24.7004 0,-35.22737 3.808899,-23.67011 14.892175,-45.40333 31.752615,-62.263769 16.860441,-16.86044 38.59366,-27.943716 62.26377,-31.752615 10.52697,-1.693958 24.7004,-1.693958 35.22737,0 23.67011,3.808899 45.40332,14.892175 62.26376,31.752615 16.86044,16.860439 27.94372,38.593659 31.75262,62.263769 1.69396,10.52697 1.69396,24.7004 0,35.22737 -3.8089,23.67011 -14.89218,45.40332 -31.75262,62.26376 -18.54367,18.54368 -42.59193,29.9101 -68.95739,32.59273 -4.37465,0.44511 -17.0027,0.50927 -21.08006,0.1071 z m -5.43118,-72.57228 0.0721,-23.91833 h 15.51903 15.51904 l 0.0722,23.91833 0.0721,23.91834 h 20.03777 20.03778 l 0.14111,-31.75 0.14111,-31.75 11.56022,-0.0738 c 6.35812,-0.0406 11.66982,-0.18338 11.80379,-0.31734 0.13662,-0.13662 -3.32855,-3.45818 -7.89133,-7.56428 -4.47419,-4.02639 -16.4534,-14.81101 -26.62045,-23.96583 -10.16706,-9.15482 -24.37787,-21.94841 -31.57958,-28.4302 -7.20171,-6.4818 -13.25241,-11.724308 -13.44601,-11.650019 -0.19359,0.07429 -10.04929,8.867919 -21.90154,19.541389 -11.85224,10.67347 -29.652426,26.70342 -39.555954,35.62211 -9.903528,8.91868 -17.899626,16.32257 -17.769106,16.45309 0.130519,0.13052 5.439407,0.27051 11.797524,0.31108 l 11.560217,0.0738 0.06976,31.46777 c 0.03837,17.30728 0.129961,31.62738 0.203536,31.82246 0.106023,0.28109 4.277815,0.33964 20.109133,0.28222 l 19.97535,-0.0724 z"
+             id="path4794"
+             inkscape:connector-curvature="0" />
+        </g>
+      </g>
+    </g>
+  </g>
+</svg>

+ 1 - 0
public/img/ic_notifications_off.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path fill="#0000008a" d="M40 37.39L15.68 12.3l-5.13-5.29L8 9.55l5.6 5.6.01.01C12.56 17.14 12 19.48 12 22v10l-4 4v2h27.46l4 4L42 39.45l-2-2.06zM24 44c2.21 0 4-1.79 4-4h-8c0 2.21 1.79 4 4 4zm12-14.64V22c0-6.15-3.27-11.28-9-12.64V8c0-1.66-1.34-3-3-3s-3 1.34-3 3v1.36c-.29.07-.57.15-.85.24-.21.07-.41.14-.61.22 0 0-.01 0-.01.01-.01 0-.02.01-.03.01-.46.18-.91.39-1.35.62-.01 0-.02.01-.03.01L36 29.36z"/></svg>

+ 3 - 0
public/img/ic_qr.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 4 24 24" width="24" height="24" style="fill: rgba(0, 0, 0, 0.54);">
+    <path style="text-indent:0;text-align:start;line-height:normal;text-transform:none;block-progression:tb" d="M 5 5 L 5 6 L 5 12 L 5 13 L 6 13 L 7 13 L 7 15 L 9 15 L 9 13 L 12 13 L 13 13 L 13 12 L 13 6 L 13 5 L 12 5 L 6 5 L 5 5 z M 13 13 L 13 15 L 15 15 L 15 17 L 11 17 L 11 19 L 6 19 L 5 19 L 5 20 L 5 26 L 5 27 L 6 27 L 12 27 L 13 27 L 13 26 L 13 20 L 13 19 L 19 19 L 19 17 L 17 17 L 17 15 L 21 15 L 21 13 L 23 13 L 23 15 L 25 15 L 25 13 L 26 13 L 27 13 L 27 12 L 27 6 L 27 5 L 26 5 L 20 5 L 19 5 L 19 6 L 19 12 L 19 13 L 13 13 z M 25 15 L 25 17 L 27 17 L 27 15 L 25 15 z M 25 17 L 23 17 L 23 19 L 25 19 L 25 17 z M 25 19 L 25 21 L 27 21 L 27 19 L 25 19 z M 25 21 L 23 21 L 23 19 L 21 19 L 21 21 L 18 21 L 16 21 L 16 27 L 18 27 L 18 23 L 22 23 L 22 25 L 24 25 L 24 23 L 25 23 L 25 21 z M 22 25 L 20 25 L 20 27 L 22 27 L 22 25 z M 23 17 L 23 15 L 21 15 L 21 17 L 23 17 z M 11 17 L 11 15 L 9 15 L 9 17 L 11 17 z M 7 15 L 5 15 L 5 17 L 7 17 L 7 15 z M 15 5 L 15 9 L 14 9 L 14 11 L 15 11 L 15 12 L 17 12 L 17 9 L 18 9 L 18 7 L 17 7 L 17 5 L 15 5 z M 7 7 L 11 7 L 11 11 L 7 11 L 7 7 z M 21 7 L 25 7 L 25 11 L 21 11 L 21 7 z M 8 8 L 8 10 L 10 10 L 10 8 L 8 8 z M 22 8 L 22 10 L 24 10 L 24 8 L 22 8 z M 7 21 L 11 21 L 11 25 L 7 25 L 7 21 z M 8 22 L 8 24 L 10 24 L 10 22 L 8 22 z M 25 25 L 25 27 L 27 27 L 27 25 L 25 25 z"/>
+</svg>

Plik diff jest za duży
+ 3 - 0
public/img/safari.svg


BIN
public/img/threema-576x576.png


+ 11 - 5
public/manifest.webmanifest

@@ -1,20 +1,26 @@
 {
-  "background_color": "rgb(117,117,117)",
   "description": "Chat from your desktop with Threema Web and have full access to all chats, contacts and media files.",
   "display": "standalone",
   "icons": [
     {
-      "src": "img/favicon.ico",
+      "src": "img/favicon/favicon.ico?v=[[VERSION]]",
       "sizes": "64x64",
       "type": "image/x-icon"
     },
     {
-      "src": "img/threema-576x576.png",
-      "sizes": "576x576",
+      "src": "img/favicon/android-chrome-192x192.png?v=[[VERSION]]",
+      "sizes": "192x192",
+      "type": "image/png"
+    },
+    {
+      "src": "img/favicon/android-chrome-512x512.png?v=[[VERSION]]",
+      "sizes": "512x512",
       "type": "image/png"
     }
   ],
   "name": "Threema Web",
   "short_name": "Threema Web",
-  "start_url": "."
+  "start_url": ".",
+  "background_color": "rgb(117,117,117)",
+  "theme_color": "rgb(117,117,117)"
 }

+ 3 - 2
src/app.ts

@@ -59,8 +59,8 @@ angular.module('3ema', [
 ])
 
 // Set versions
-.value('VERSION', '0.0.1')
-.value('PROTOCOL_VERSION', 1)
+.value('VERSION', '[[VERSION]]')
+.value('PROTOCOL_VERSION', 2)
 
 // Configuration object
 .constant('CONFIG', config)
@@ -73,6 +73,7 @@ angular.module('3ema', [
     FF: 50,
     CHROME: 45,
     OPERA: 32,
+    SAFARI: 11,
 })
 
 // Set default route

+ 10 - 3
src/config.ts

@@ -7,10 +7,15 @@ export default {
 
     // General
     SELF_HOSTED: false,
-    PREV_PROTOCOL_LAST_VERSION: null,
+    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,
+    PREV_PROTOCOL_LAST_VERSION: '1.8.2',
+    GIT_BRANCH: 'ios',
 
     // SaltyRTC
-    SALTYRTC_HOST: null,
+    SALTYRTC_HOST: 'saltyrtc-beta.threema.ch',
     SALTYRTC_HOST_PREFIX: 'saltyrtc-',
     SALTYRTC_HOST_SUFFIX: '.threema.ch',
     SALTYRTC_PORT: 443,
@@ -31,7 +36,9 @@ export default {
     PUSH_URL: 'https://push-web.threema.ch/push',
 
     // Debugging options
-    MSG_DEBUGGING: false,
+    DEBUG: false,
+    MSG_DEBUGGING: false, // Log all incoming and outgoing messages
+    MSGPACK_DEBUGGING: false, // Log URLs to the msgpack visualizer
     ICE_DEBUGGING: false,
 
 } as threema.Config;

+ 25 - 5
src/controller_model/avatar.ts

@@ -18,10 +18,13 @@
 import {WebClientService} from '../services/webclient';
 
 export class AvatarControllerModel {
+    private logTag: string = '[AvatarControllerModel]';
+
     private $log: ng.ILogService;
     private avatar: ArrayBuffer = null;
-    private loadAvatar: Promise<string>;
-    public onChangeAvatar: (image: ArrayBuffer) => void;
+    private loadAvatar: Promise<ArrayBuffer | null>;
+    private onChangeAvatar: (image: ArrayBuffer) => void;
+    private _avatarChanged: boolean = false;
 
     constructor($log: ng.ILogService,
                 webClientService: WebClientService,
@@ -29,14 +32,16 @@ export class AvatarControllerModel {
         this.$log = $log;
         this.loadAvatar = new Promise((resolve, reject) => {
             if (receiver === null) {
+                $log.debug(this.logTag, 'loadAvatar: No receiver defined, no avatar');
                 resolve(null);
                 return;
-            }
-            if (receiver.avatar.high === undefined) {
+            } else if (receiver.avatar.high === undefined || receiver.avatar.high === null) {
+                $log.debug(this.logTag, 'loadAvatar: Requesting high res avatar from app');
                 webClientService.requestAvatar(receiver, true)
-                    .then((image: string) => resolve(image))
+                    .then((data: ArrayBuffer) => resolve(data))
                     .catch(() => reject());
             } else {
+                $log.debug(this.logTag, 'loadAvatar: Returning cached version');
                 resolve(receiver.avatar.high);
             }
         });
@@ -44,10 +49,25 @@ export class AvatarControllerModel {
         // bind to the editor
         this.onChangeAvatar = (image: ArrayBuffer) => {
             this.avatar = image;
+            this._avatarChanged = true;
         };
     }
 
+    /**
+     * Return the avatar bytes (or null if no avatar is defined).
+     */
     public getAvatar(): ArrayBuffer | null {
         return this.avatar;
     }
+
+    /**
+     * Return whether this avatar was changed.
+     *
+     * This will return true if an avatar was added or removed. It does not
+     * actually look at the content to determine whether the bytes of the
+     * avatar really changed.
+     */
+    public get avatarChanged(): boolean {
+        return this._avatarChanged;
+    }
 }

+ 15 - 11
src/controller_model/contact.ts

@@ -16,17 +16,19 @@
  */
 
 import {WebClientService} from '../services/webclient';
-import {ControllerModelMode} from '../types/enums';
 import {AvatarControllerModel} from './avatar';
 
-export class ContactControllerModel implements threema.ControllerModel {
+// Type aliases
+import ControllerModelMode = threema.ControllerModelMode;
+
+export class ContactControllerModel implements threema.ControllerModel<threema.ContactReceiver> {
 
     // Angular services
     private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
     private $mdDialog: ng.material.IDialogService;
 
-    private onRemovedCallback: any;
+    private onRemovedCallback: threema.OnRemovedCallback;
     public firstName: string;
     public lastName: string;
     public identity: string;
@@ -53,9 +55,7 @@ export class ContactControllerModel implements threema.ControllerModel {
 
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
-                this.subject = $translate.instant('messenger.EDIT_RECEIVER', {
-                    receiverName: '@NAME@',
-                }).replace('@NAME@', this.contact.displayName);
+                this.subject = $translate.instant('messenger.EDIT_RECEIVER');
                 this.firstName = this.contact.firstName;
                 this.lastName = this.contact.lastName;
                 this.avatarController = new AvatarControllerModel(
@@ -83,7 +83,7 @@ export class ContactControllerModel implements threema.ControllerModel {
         }
     }
 
-    public setOnRemoved(callback: any): void {
+    public setOnRemoved(callback: threema.OnRemovedCallback): void {
         this.onRemovedCallback = callback;
     }
 
@@ -99,7 +99,7 @@ export class ContactControllerModel implements threema.ControllerModel {
         return this.identity !== undefined && this.identity.length === 8;
     }
 
-    public canView(): boolean {
+    public canChat(): boolean {
         return this.contact.id !== this.webClientService.me.id;
     }
 
@@ -112,7 +112,7 @@ export class ContactControllerModel implements threema.ControllerModel {
     }
 
     public canClean(): boolean {
-        return this.canView();
+        return this.canChat();
     }
 
     public clean(ev: any): any {
@@ -146,6 +146,10 @@ export class ContactControllerModel implements threema.ControllerModel {
             });
     }
 
+    public canShowQr(): boolean {
+        return false;
+    }
+
     public save(): Promise<threema.ContactReceiver> {
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
@@ -153,7 +157,7 @@ export class ContactControllerModel implements threema.ControllerModel {
                     this.contact.id,
                     this.firstName,
                     this.lastName,
-                    this.avatarController.getAvatar(),
+                    this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
                 );
             case ControllerModelMode.NEW:
                 return this.webClientService.addContact(this.identity);
@@ -164,7 +168,7 @@ export class ContactControllerModel implements threema.ControllerModel {
     }
 
     public onChangeMembers(identities: string[]): void {
-        return null;
+        // Do nothing
     }
 
     public getMembers(): string[] {

+ 14 - 10
src/controller_model/distributionList.ts

@@ -16,9 +16,11 @@
  */
 
 import {WebClientService} from '../services/webclient';
-import {ControllerModelMode} from '../types/enums';
 
-export class DistributionListControllerModel implements threema.ControllerModel {
+// Type aliases
+import ControllerModelMode = threema.ControllerModelMode;
+
+export class DistributionListControllerModel implements threema.ControllerModel<threema.DistributionListReceiver> {
 
     private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
@@ -32,7 +34,7 @@ export class DistributionListControllerModel implements threema.ControllerModel
     private distributionList: threema.DistributionListReceiver;
     private webClientService: WebClientService;
     private mode: ControllerModelMode;
-    private onRemovedCallback: any;
+    private onRemovedCallback: threema.OnRemovedCallback;
 
     constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
                 webClientService: WebClientService,
@@ -49,9 +51,7 @@ export class DistributionListControllerModel implements threema.ControllerModel
 
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
-                this.subject = $translate.instant('messenger.EDIT_RECEIVER', {
-                    receiverName: '@NAME@',
-                }).replace('@NAME@', this.distributionList.displayName);
+                this.subject = $translate.instant('messenger.EDIT_RECEIVER');
                 this.name = this.distributionList.displayName;
                 this.members = this.distributionList.members;
                 break;
@@ -72,7 +72,7 @@ export class DistributionListControllerModel implements threema.ControllerModel
         }
     }
 
-    public setOnRemoved(callback: any): void {
+    public setOnRemoved(callback: threema.OnRemovedCallback): void {
         this.onRemovedCallback = callback;
     }
 
@@ -82,11 +82,11 @@ export class DistributionListControllerModel implements threema.ControllerModel
 
     public isValid(): boolean {
         return this.members.filter((identity: string) => {
-                return identity !== this.webClientService.getMyIdentity().identity;
-            }).length > 0;
+            return identity !== this.webClientService.me.id;
+        }).length > 0;
     }
 
-    public canView(): boolean {
+    public canChat(): boolean {
         return true;
     }
 
@@ -130,6 +130,10 @@ export class DistributionListControllerModel implements threema.ControllerModel
             });
     }
 
+    public canShowQr(): boolean {
+        return false;
+    }
+
     public delete(ev): void {
 
         const confirm = this.$mdDialog.confirm()

+ 38 - 17
src/controller_model/group.ts

@@ -16,10 +16,12 @@
  */
 
 import {WebClientService} from '../services/webclient';
-import {ControllerModelMode} from '../types/enums';
 import {AvatarControllerModel} from './avatar';
 
-export class GroupControllerModel implements threema.ControllerModel {
+// Type aliases
+import ControllerModelMode = threema.ControllerModelMode;
+
+export class GroupControllerModel implements threema.ControllerModel<threema.GroupReceiver> {
 
     private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
@@ -27,14 +29,14 @@ export class GroupControllerModel implements threema.ControllerModel {
     public members: string[];
     public name: string;
     public subject: string;
-    public isLoading = false;
+    public isLoading = false; // TODO: Show loading indicator
 
     private addContactPlaceholder: string;
     private group: threema.GroupReceiver;
     private webClientService: WebClientService;
     private avatarController: AvatarControllerModel;
     private mode: ControllerModelMode;
-    private onRemovedCallback: any;
+    private onRemovedCallback: threema.OnRemovedCallback;
 
     constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
                 webClientService: WebClientService,
@@ -51,9 +53,7 @@ export class GroupControllerModel implements threema.ControllerModel {
 
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
-                this.subject = $translate.instant('messenger.EDIT_RECEIVER', {
-                    receiverName: '@NAME@',
-                }).replace('@NAME@', this.group.displayName);
+                this.subject = $translate.instant('messenger.EDIT_RECEIVER');
                 this.name = this.group.displayName;
                 this.members = this.group.members;
                 this.avatarController = new AvatarControllerModel(
@@ -84,7 +84,7 @@ export class GroupControllerModel implements threema.ControllerModel {
         return this.webClientService.getMaxGroupMemberSize();
     }
 
-    public setOnRemoved(callback: any): void {
+    public setOnRemoved(callback: threema.OnRemovedCallback): void {
         this.onRemovedCallback = callback;
     }
 
@@ -94,11 +94,11 @@ export class GroupControllerModel implements threema.ControllerModel {
 
     public isValid(): boolean {
         return this.members.filter((identity: string) => {
-                return identity !== this.webClientService.getMyIdentity().identity;
+                return identity !== this.webClientService.me.id;
             }).length > 0;
     }
 
-    public canView(): boolean {
+    public canChat(): boolean {
         return true;
     }
 
@@ -111,7 +111,7 @@ export class GroupControllerModel implements threema.ControllerModel {
     }
 
     public canClean(): boolean {
-        return this.canView();
+        return this.canChat();
     }
 
     public clean(ev: any): any {
@@ -145,11 +145,15 @@ export class GroupControllerModel implements threema.ControllerModel {
             });
     }
 
+    public canShowQr(): boolean {
+        return false;
+    }
+
     public leave(ev): void {
         const confirm = this.$mdDialog.confirm()
             .title(this.$translate.instant('messenger.GROUP_LEAVE'))
             .textContent(this.$translate.instant(
-                this.group.administrator === this.webClientService.getMyIdentity().identity
+                this.group.administrator === this.webClientService.me.id
                     ? 'messenger.GROUP_REALLY_LEAVE_ADMIN'
                     : 'messenger.GROUP_REALLY_LEAVE'))
             .targetEvent(ev)
@@ -224,8 +228,9 @@ export class GroupControllerModel implements threema.ControllerModel {
             .then(() => {
                 this.isLoading = false;
             })
-            .catch(() => {
+            .catch((errorCode) => {
                 this.isLoading = false;
+                this.showError(errorCode);
             });
     }
 
@@ -236,14 +241,14 @@ export class GroupControllerModel implements threema.ControllerModel {
                     this.group.id,
                     this.members,
                     this.name,
-                    this.avatarController.getAvatar(),
+                    this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
                 );
             case ControllerModelMode.NEW:
-
                 return this.webClientService.createGroup(
                     this.members,
-                    this.name,
-                    this.avatarController.getAvatar());
+                    (this.name && this.name.length > 0) ? this.name : undefined,
+                    this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
+                );
             default:
                 this.$log.error('not allowed to save group');
 
@@ -257,4 +262,20 @@ export class GroupControllerModel implements threema.ControllerModel {
     public getMembers(): string[] {
         return this.members;
     }
+
+    /**
+     * Show an error message in a dialog.
+     */
+    private showError(errorCode: string): void {
+        if (errorCode === undefined) {
+            errorCode = 'unknown';
+        }
+        this.$mdDialog.show(
+            this.$mdDialog.alert()
+                .clickOutsideToClose(true)
+                .title(this.group.displayName)
+                .textContent(this.$translate.instant('validationError.modifyReceiver.' + errorCode))
+                .ok(this.$translate.instant('common.OK')),
+        );
+    }
 }

+ 172 - 0
src/controller_model/me.ts

@@ -0,0 +1,172 @@
+/**
+ * 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 {WebClientService} from '../services/webclient';
+import {AvatarControllerModel} from './avatar';
+
+import ControllerModelMode = threema.ControllerModelMode;
+
+export class MeControllerModel implements threema.ControllerModel<threema.MeReceiver> {
+    private logTag: string = '[MeControllerModel]';
+
+    // Angular services
+    private $log: ng.ILogService;
+    private $translate: ng.translate.ITranslateService;
+    private $mdDialog: ng.material.IDialogService;
+
+    // Own services
+    private webClientService: WebClientService;
+
+    // Own receiver instance
+    private me: threema.MeReceiver;
+
+    // Avatar controller
+    private avatarController: AvatarControllerModel;
+
+    // Controller model fields
+    public subject: string;
+    public isLoading = false;
+
+    // Profile data
+    public nickname: string;
+
+    // Editing mode
+    private mode = ControllerModelMode.VIEW;
+
+    constructor($log: ng.ILogService,
+                $translate: ng.translate.ITranslateService,
+                $mdDialog: ng.material.IDialogService,
+                webClientService: WebClientService,
+                mode: ControllerModelMode,
+                me?: threema.MeReceiver) {
+        this.$log = $log;
+        this.$translate = $translate;
+        this.$mdDialog = $mdDialog;
+        this.me = me;
+        this.webClientService = webClientService;
+        this.mode = mode;
+
+        this.nickname = webClientService.me.publicNickname;
+        switch (mode) {
+            case ControllerModelMode.EDIT:
+                this.subject = $translate.instant('messenger.EDIT_RECEIVER');
+                this.avatarController = new AvatarControllerModel(
+                    this.$log, this.webClientService, this.me,
+                );
+                break;
+            case ControllerModelMode.VIEW:
+                this.subject = $translate.instant('messenger.MY_THREEMA_ID');
+                break;
+            default:
+                $log.error(this.logTag, 'Invalid controller model mode: ', this.getMode());
+        }
+    }
+
+    /**
+     * Set the on removed callback.
+     */
+    public setOnRemoved(callback: threema.OnRemovedCallback): void {
+        // Not applicable
+    }
+
+    /**
+     * Callback called when the members change.
+     */
+    public onChangeMembers(identities: string[]): void {
+        // Not possible
+    }
+
+    /**
+     * Return the members of this receiver.
+     */
+    public getMembers(): string[] {
+        return [this.me.id];
+    }
+
+    /**
+     * The editing mode, e.g. view or edit this receiver.
+     */
+    public getMode(): ControllerModelMode {
+        return this.mode;
+    }
+
+    /**
+     * Can this receiver be cleaned?
+     */
+    public canClean(): boolean {
+        return false;
+    }
+
+    /**
+     * Delete all messages in this conversation.
+     */
+    public clean(ev: any): any {
+        // No-op
+    }
+
+    /**
+     * Validate this receiver.
+     */
+    public isValid(): boolean {
+        return (this.me !== null
+             && this.me !== undefined
+             && this.me.id === this.webClientService.me.id);
+    }
+
+    /*
+     * Return whether this receiver can be chatted with.
+     */
+    public canChat(): boolean {
+        // You cannot chat with yourself
+        return false;
+    }
+
+    /**
+     * The profile can be edited if there are no MDM restrictions.
+     */
+    public canEdit(): boolean {
+        const mdm: threema.MdmRestrictions = this.webClientService.appCapabilities.mdm;
+        return mdm === undefined || !mdm.readonlyProfile;
+    }
+
+    public canShowQr(): boolean {
+        return true;
+    }
+
+    /**
+     * Save the changes, return a promise.
+     */
+    public save(): Promise<threema.MeReceiver> {
+        switch (this.getMode()) {
+            case ControllerModelMode.EDIT:
+                return this.webClientService.modifyProfile(
+                    this.nickname,
+                    this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
+                ).then((val) => {
+                    // Profile was successfully updated. Update local data.
+                    this.webClientService.me.publicNickname = this.nickname;
+                    if (this.avatarController.avatarChanged) {
+                        this.webClientService.me.avatar.high = this.avatarController.getAvatar();
+                    }
+                    return this.me;
+                });
+            default:
+                this.$log.error(this.logTag, 'Not allowed to save profile: Invalid mode');
+                return Promise.reject('unknown');
+        }
+    }
+}

+ 4 - 2
src/controllers.ts

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

+ 9 - 10
src/controllers/android_only.ts → src/controllers/android_ios_only.ts

@@ -15,19 +15,18 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {Transition, TransitionService} from '@uirouter/angularjs';
+
 /**
- * Controller to show or hide the "Android only" note at the bottom of the welcome screen.
+ * Controller to show or hide the "Android / iOS only" note at the bottom of the welcome screen.
  */
-export class AndroidOnlyController {
+export class AndroidIosOnlyController {
     public show: boolean = false;
 
-    public static $inject = ['$rootScope'];
-    constructor($rootScope: ng.IRootScopeService) {
-        $rootScope.$on(
-            '$stateChangeStart',
-            (event, toState: ng.ui.IState, toParams, fromState: ng.ui.IState, fromParams) => {
-                this.show = toState.name === 'welcome';
-            },
-        );
+    public static $inject = ['$transitions'];
+    constructor($transitions: TransitionService) {
+        $transitions.onStart({}, (trans: Transition) => {
+            this.show = trans.to().name === 'welcome';
+        });
     }
 }

+ 64 - 0
src/controllers/footer.ts

@@ -0,0 +1,64 @@
+/**
+ * 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/>.
+ */
+
+/**
+ * Handle footer information.
+ */
+export class FooterController {
+    private $mdDialog: ng.material.IDialogService;
+
+    private config: threema.Config;
+
+    public static $inject = ['CONFIG', '$mdDialog'];
+    constructor(CONFIG: threema.Config, $mdDialog: ng.material.IDialogService) {
+        this.$mdDialog = $mdDialog;
+        this.config = CONFIG;
+    }
+
+    public showVersionInfo(version: string): void {
+        this.$mdDialog.show({
+            controller: [
+                '$mdDialog',
+                'CONFIG',
+                function($mdDialog: ng.material.IDialogService, CONFIG: threema.Config) {
+                    this.activeElement = null;
+                    this.version = version;
+                    this.fullVersion = `${version} ${CONFIG.VERSION_MOUNTAIN}`;
+                    this.config = CONFIG;
+                    this.cancel = () => {
+                        $mdDialog.cancel();
+                        if (this.activeElement !== null) {
+                            this.activeElement.focus(); // reset focus
+                        }
+                    };
+                },
+            ],
+            controllerAs: 'ctrl',
+            templateUrl: 'partials/dialog.version.html',
+            parent: angular.element(document.body),
+            clickOutsideToClose: true,
+            fullscreen: true,
+        });
+    }
+
+    /**
+     * Return the changelog URL.
+     */
+    public get changelogUrl(): string {
+        return 'https://github.com/threema-ch/threema-web/blob/' + this.config.GIT_BRANCH + '/CHANGELOG.md';
+    }
+}

+ 77 - 19
src/controllers/status.ts

@@ -15,10 +15,14 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 import {ControllerService} from '../services/controller';
 import {StateService} from '../services/state';
 import {WebClientService} from '../services/webclient';
 
+import GlobalConnectionState = threema.GlobalConnectionState;
+
 /**
  * This controller handles state changes globally.
  *
@@ -31,7 +35,7 @@ export class StatusController {
     private logTag: string = '[StatusController]';
 
     // State variable
-    private state: threema.GlobalConnectionState = 'error';
+    private state = GlobalConnectionState.Error;
 
     // Expanded status bar
     public expandStatusBar = false;
@@ -43,8 +47,8 @@ export class StatusController {
 
     // Angular services
     private $timeout: ng.ITimeoutService;
-    private $state: ng.ui.IStateService;
     private $log: ng.ILogService;
+    private $state: UiStateService;
 
     // Custom services
     private stateService: StateService;
@@ -53,7 +57,7 @@ export class StatusController {
 
     public static $inject = ['$scope', '$timeout', '$log', '$state', 'StateService',
         'WebClientService', 'ControllerService'];
-    constructor($scope, $timeout: ng.ITimeoutService, $log: ng.ILogService, $state: ng.ui.IStateService,
+    constructor($scope, $timeout: ng.ITimeoutService, $log: ng.ILogService, $state: UiStateService,
                 stateService: StateService, webClientService: WebClientService,
                 controllerService: ControllerService) {
 
@@ -67,13 +71,10 @@ export class StatusController {
         this.webClientService = webClientService;
         this.controllerService = controllerService;
 
-        // Watch state changes
-        $scope.$watch(
-            () => stateService.state,
-            (newValue: threema.GlobalConnectionState, oldValue: threema.GlobalConnectionState) => {
-                if (oldValue !== newValue) {
-                    this.onStateChange(newValue, oldValue);
-                }
+        // Register event handlers
+        this.stateService.evtGlobalConnectionStateChange.attach(
+            (stateChange: threema.GlobalConnectionStateChange) => {
+                this.onStateChange(stateChange.state, stateChange.prevState);
             },
         );
     }
@@ -82,7 +83,7 @@ export class StatusController {
      * Return the prefixed status.
      */
     public get statusClass(): string {
-        return 'status-' + this.state;
+        return 'status-task-' + this.webClientService.chosenTask + ' status-' + this.state;
     }
 
     /**
@@ -90,25 +91,36 @@ export class StatusController {
      */
     private onStateChange(newValue: threema.GlobalConnectionState,
                           oldValue: threema.GlobalConnectionState): void {
+        this.$log.debug(this.logTag, 'State change:', oldValue, '->', newValue);
         if (newValue === oldValue) {
             return;
         }
         this.state = newValue;
+
+        const isWebrtc = this.webClientService.chosenTask === threema.ChosenTask.WebRTC;
+        const isRelayedData = this.webClientService.chosenTask === threema.ChosenTask.RelayedData;
+
         switch (newValue) {
             case 'ok':
                 this.collapseStatusBar();
                 break;
             case 'warning':
-                if (oldValue === 'ok') {
+                if (oldValue === 'ok' && isWebrtc) {
                     this.scheduleStatusBar();
                 }
+                if (this.stateService.wasConnected) {
+                    this.webClientService.clearIsTypingFlags();
+                }
+                if (this.stateService.wasConnected && isRelayedData) {
+                    this.reconnectIos();
+                }
                 break;
             case 'error':
-                if (this.stateService.wasConnected) {
+                if (this.stateService.wasConnected && isWebrtc) {
                     if (oldValue === 'ok') {
                         this.scheduleStatusBar();
                     }
-                    this.reconnect();
+                    this.reconnectAndroid();
                 }
                 break;
             default:
@@ -136,10 +148,10 @@ export class StatusController {
     }
 
     /**
-     * Attempt to reconnect after a connection loss.
+     * Attempt to reconnect an Android device after a connection loss.
      */
-    private reconnect(): void {
-        this.$log.warn(this.logTag, 'Connection lost. Attempting to reconnect...');
+    private reconnectAndroid(): void {
+        this.$log.warn(this.logTag, 'Connection lost (Android). Attempting to reconnect...');
 
         // Get original keys
         const originalKeyStore = this.webClientService.salty.keyStore;
@@ -185,10 +197,9 @@ export class StatusController {
 
         // Function to soft-reconnect. Does not reset the loaded data.
         const doSoftReconnect = () => {
-            const deleteStoredData = false;
             const resetPush = false;
             const redirect = false;
-            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
+            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
             this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
             this.webClientService.start().then(
                 () => {
@@ -229,6 +240,53 @@ export class StatusController {
         // TODO: Handle server closing state
     }
 
+    /**
+     * Attempt to reconnect an iOS device after a connection loss.
+     */
+    private reconnectIos(): void {
+        this.$log.warn(this.logTag, 'Connection lost (iOS). Attempting to reconnect...');
+
+        // Get original keys
+        const originalKeyStore = this.webClientService.salty.keyStore;
+        const originalPeerPermanentKeyBytes = this.webClientService.salty.peerPermanentKeyBytes;
+
+        // Handler for failed reconnection attempts
+        const reconnectionFailed = () => {
+            // Reset state
+            this.stateService.reset();
+
+            // Redirect to welcome page
+            this.$state.go('welcome', {
+                initParams: {
+                    keyStore: originalKeyStore,
+                    peerTrustedKey: originalPeerPermanentKeyBytes,
+                },
+            });
+        };
+
+        const resetPush = false;
+        const skipPush = true;
+        const redirect = false;
+        const startTimeout = 500; // Delay connecting a bit to wait for old websocket to close
+        this.$log.debug(this.logTag, 'Stopping old connection');
+        this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
+        this.$timeout(() => {
+            this.$log.debug(this.logTag, 'Starting new connection');
+            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
+            this.webClientService.start(skipPush).then(
+                () => { /* ok */ },
+                (error) => {
+                    this.$log.error(this.logTag, 'Error state:', error);
+                    reconnectionFailed();
+                },
+                // Progress
+                (progress: threema.ConnectionBuildupStateChange) => {
+                    this.$log.debug(this.logTag, 'Connection buildup advanced:', progress);
+                },
+            );
+        }, startTimeout);
+    }
+
     public wide(): boolean {
         return this.controllerService.getControllerName() !== undefined
             && this.controllerService.getControllerName() === 'messenger';

+ 2 - 4
src/directives.ts

@@ -43,7 +43,6 @@ import messageQuote from './directives/message_quote';
 import messageState from './directives/message_state';
 import messageText from './directives/message_text';
 import messageVoipStatus from './directives/message_voip_status';
-import myIdentity from './directives/my_identity';
 import searchbox from './directives/searchbox';
 import statusBar from './directives/status_bar';
 import verificationLevel from './directives/verification_level';
@@ -54,6 +53,7 @@ angular.module('3ema.directives').directive('avatarEditor', avatarEditor);
 angular.module('3ema.directives').directive('batteryStatus', batteryStatus);
 angular.module('3ema.directives').directive('clickAction', clickAction);
 angular.module('3ema.directives').directive('composeArea', composeArea);
+angular.module('3ema.directives').directive('dragFile', dragFile);
 angular.module('3ema.directives').directive('eeeAvatar', avatar);
 angular.module('3ema.directives').directive('eeeContactBadge', contactBadge);
 angular.module('3ema.directives').directive('eeeDistributionListBadge', distributionListBadge);
@@ -69,12 +69,10 @@ angular.module('3ema.directives').directive('eeeMessageQuote', messageQuote);
 angular.module('3ema.directives').directive('eeeMessageState', messageState);
 angular.module('3ema.directives').directive('eeeMessageText', messageText);
 angular.module('3ema.directives').directive('eeeMessageVoipStatus', messageVoipStatus);
-angular.module('3ema.directives').directive('eeeMyIdentity', myIdentity);
 angular.module('3ema.directives').directive('eeeVerificationLevel', verificationLevel);
 angular.module('3ema.directives').directive('includeReplace', includeReplace);
 angular.module('3ema.directives').directive('location', location);
-angular.module('3ema.directives').directive('memberListEditor', memberListEditor);
 angular.module('3ema.directives').directive('mediabox', mediabox);
+angular.module('3ema.directives').directive('memberListEditor', memberListEditor);
 angular.module('3ema.directives').directive('searchbox', searchbox);
 angular.module('3ema.directives').directive('statusBar', statusBar);
-angular.module('3ema.directives').directive('dragFile', dragFile);

+ 154 - 66
src/directives/avatar.ts

@@ -15,97 +15,180 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {bufferToUrl, logAdapter} from '../helpers';
+import {isEchoContact, isGatewayContact} from '../receiver_helpers';
 import {WebClientService} from '../services/webclient';
+import {isContactReceiver} from '../typeguards';
 
 export default [
     '$rootScope',
     '$timeout',
+    '$log',
     'WebClientService',
     function($rootScope: ng.IRootScopeService,
              $timeout: ng.ITimeoutService,
+             $log: ng.ILogService,
              webClientService: WebClientService) {
         return {
             restrict: 'E',
             scope: {},
             bindToController: {
-                type: '=eeeType',
                 receiver: '=eeeReceiver',
                 resolution: '=eeeResolution',
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                this.highResolution = this.resolution === 'high';
-                this.isLoading = this.highResolution;
-                this.backgroundColor = this.receiver.color;
+                this.logTag = '[Directives.Avatar]';
+
                 let loadingPromise: ng.IPromise<any> = null;
-                this.avatarClass = () => {
-                    return 'avatar-' + this.resolution + (this.isLoading ? ' is-loading' : '');
-                };
 
-                this.avatarExists = () => {
-                    if (this.receiver.avatar === undefined
-                        || this.receiver.avatar[this.resolution] === undefined) {
-                        return false;
-                    }
-                    this.isLoading = false;
-                    // Reset background color
-                    this.backgroundColor = null;
-                    return true;
+                /**
+                 * Convert avatar bytes to an URI.
+                 */
+                const avatarUri = {
+                    high: null,
+                    low: null,
                 };
-
-                this.getAvatar = () => {
-                    if (this.avatarExists()) {
-                        return this.receiver.avatar[this.resolution];
-                    } else if (this.highResolution
-                        && this.receiver.avatar !== undefined
-                        && this.receiver.avatar.low !== undefined) {
-                        return this.receiver.avatar.low;
+                this.avatarToUri = (data: ArrayBuffer, res: 'high' | 'low') => {
+                    if (data === null || data === undefined) {
+                        return '';
+                    }
+                    if (avatarUri[res] === null) {
+                        // Cache avatar image URI
+                        avatarUri[res] = bufferToUrl(
+                            data,
+                            webClientService.appCapabilities.imageFormat.avatar,
+                            logAdapter($log.warn, this.logTag),
+                        );
                     }
-                    return webClientService.defaults.getAvatar(this.type, this.highResolution);
+                    return avatarUri[res];
                 };
 
-                this.requestAvatar = (inView: boolean) => {
-                    if (this.avatarExists()) {
-                        // do not request
-                        return;
-                    }
+                this.$onInit = function() {
 
-                    if (inView) {
-                        if (loadingPromise === null) {
-                            // Do not wait on high resolution avatar
-                            const loadingTimeout = this.highResolution ? 0 : 500;
-                            loadingPromise = $timeout(() => {
-                                // show loading only on high res images!
-                                webClientService.requestAvatar({
-                                    type: this.type,
-                                    id: this.receiver.id,
-                                } as threema.Receiver, this.highResolution).then((avatar) => {
-                                    $rootScope.$apply(() => {
-                                        this.isLoading = false;
-                                    });
-                                }).catch(() => {
-                                    $rootScope.$apply(() => {
-                                        this.isLoading = false;
+                    this.highResolution = this.resolution === 'high';
+                    this.isLoading = this.highResolution;
+                    this.backgroundColor = this.receiver.color;
+                    this.avatarClass = () => {
+                        return 'avatar-' + this.resolution + (this.isLoading ? ' is-loading' : '');
+                    };
+
+                    this.avatarExists = () => {
+                        if (this.receiver.avatar === undefined
+                            || this.receiver.avatar[this.resolution] === undefined
+                            || this.receiver.avatar[this.resolution] === null) {
+                            return false;
+                        }
+                        this.isLoading = false;
+                        // Reset background color
+                        this.backgroundColor = null;
+                        return true;
+                    };
+
+                    /**
+                     * Return path to the default avatar.
+                     */
+                    this.getDefaultAvatarUri = (type: threema.ReceiverType, highResolution: boolean) => {
+                        switch (type) {
+                            case 'group':
+                                return highResolution
+                                    ? 'img/ic_group_picture_big.png'
+                                    : 'img/ic_group_t.png';
+                            case 'distributionList':
+                                return highResolution
+                                    ? 'img/ic_distribution_list_t.png'
+                                    : 'img/ic_distribution_list_t.png';
+                            case 'contact':
+                            case 'me':
+                            default:
+                                return highResolution
+                                    ? 'img/ic_contact_picture_big.png'
+                                    : 'img/ic_contact_picture_t.png';
+                        }
+                    };
+
+                    /**
+                     * Return an avatar URI.
+                     *
+                     * This will fall back to a low resolution version or to the
+                     * default avatar if no avatar for the desired resolution could
+                     * be found.
+                     */
+                    this.getAvatarUri = () => {
+                        /// If an avatar for the chosen resolution exists, convert it to an URI and return
+                        if (this.avatarExists()) {
+                            return this.avatarToUri(this.receiver.avatar[this.resolution], this.resolution);
+                        }
+
+                        // Otherwise, if we requested a high res avatar but
+                        // there is only a low-res version, show that.
+                        if (this.highResolution
+                            && this.receiver.avatar !== undefined
+                            && this.receiver.avatar.low !== undefined
+                            && this.receiver.avatar.low !== null) {
+                            return this.avatarToUri(this.receiver.avatar.low, 'low');
+                        }
+
+                        // As a fallback, get the default avatar.
+                        return this.getDefaultAvatarUri(this.receiver.type, this.highResolution);
+                    };
+
+                    this.requestAvatar = (inView: boolean) => {
+                        if (this.avatarExists()) {
+                            // do not request
+                            return;
+                        }
+
+                        if (inView) {
+                            if (loadingPromise === null) {
+                                // Do not wait on high resolution avatar
+                                const loadingTimeout = this.highResolution ? 0 : 500;
+                                loadingPromise = $timeout(() => {
+                                    // show loading only on high res images!
+                                    webClientService.requestAvatar({
+                                        type: this.receiver.type,
+                                        id: this.receiver.id,
+                                    } as threema.Receiver, this.highResolution).then((avatar) => {
+                                        $rootScope.$apply(() => {
+                                            this.isLoading = false;
+                                        });
+                                    }).catch(() => {
+                                        $rootScope.$apply(() => {
+                                            this.isLoading = false;
+                                        });
                                     });
-                                });
-                            }, loadingTimeout);
+                                }, loadingTimeout);
+                            }
+                        } else if (loadingPromise !== null) {
+                            // Cancel pending avatar loading
+                            $timeout.cancel(loadingPromise);
+                            loadingPromise = null;
                         }
-                    } else if (loadingPromise !== null) {
-                        // Cancel pending avatar loading
-                        $timeout.cancel(loadingPromise);
-                        loadingPromise = null;
-                    }
-                };
+                    };
+
+                    const isWork = webClientService.clientInfo.isWork;
+                    this.showWorkIndicator = () => {
+                        if (!isContactReceiver(this.receiver)) { return false; }
+                        const contact: threema.ContactReceiver = this.receiver;
+                        return isWork === false
+                            && !this.highResolution
+                            && contact.identityType === threema.IdentityType.Work;
+                    };
+                    this.showHomeIndicator = () => {
+                        if (!isContactReceiver(this.receiver)) { return false; }
+                        const contact: threema.ContactReceiver = this.receiver;
+                        return isWork === true
+                            && !isGatewayContact(contact)
+                            && !isEchoContact(contact)
+                            && contact.identityType === threema.IdentityType.Regular
+                            && !this.highResolution;
+                    };
+                    this.showBlocked = () => {
+                        if (!isContactReceiver(this.receiver)) { return false; }
+                        const contact: threema.ContactReceiver = this.receiver;
+                        return !this.highResolution && contact.isBlocked;
+                    };
 
-                this.showWorkIndicator = () => {
-                    return this.type === 'contact'
-                        && !this.highResolution
-                        && (this.receiver as threema.ContactReceiver).identityType === threema.IdentityType.Work;
-                };
-                this.showBlocked = () => {
-                    return this.type === 'contact'
-                        && !this.highResolution
-                        && (this.receiver as threema.ContactReceiver).isBlocked;
                 };
             }],
             template: `
@@ -118,16 +201,21 @@ export default [
                             'title': 'messenger.THREEMA_WORK_CONTACT'}">
                         <img src="img/ic_work_round.svg" alt="Threema Work user">
                     </div>
+                    <div class="home-indicator" ng-if="ctrl.showHomeIndicator()"
+                        translate-attr="{'aria-label': 'messenger.THREEMA_HOME_CONTACT',
+                            'title': 'messenger.THREEMA_HOME_CONTACT'}">
+                        <img src="img/ic_home_round.svg" alt="Private Threema contact">
+                    </div>
                     <div class="blocked-indicator"  ng-if="ctrl.showBlocked()"
                         translate-attr="{'aria-label': 'messenger.THREEMA_BLOCKED_RECEIVER',
                             'title': 'messenger.THREEMA_BLOCKED_RECEIVER'}">
-                        <img src="img/ic_blocked_24px.svg" alt="blocked icon"/>
+                        <img src="img/ic_blocked_24px.svg" alt="blocked icon">
                     </div>
                     <img
                          ng-class="ctrl.avatarClass()"
                          ng-style="{ 'background-color': ctrl.backgroundColor }"
-                         ng-src="{{ ctrl.getAvatar() }}"
-                         in-view="ctrl.requestAvatar($inview)"/>
+                         ng-src="{{ ctrl.getAvatarUri() }}"
+                         in-view="ctrl.requestAvatar($inview)">
                </div>
             `,
         };

+ 87 - 95
src/directives/avatar_area.ts

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

+ 15 - 13
src/directives/avatar_editor.ts

@@ -17,6 +17,8 @@
 
 // tslint:disable:max-line-length
 
+import {bufferToUrl, logAdapter} from '../helpers';
+
 /**
  * Support uploading and resizing avatar
  */
@@ -24,10 +26,9 @@ export default [
     '$window',
     '$timeout',
     '$translate',
-    '$filter',
     '$log',
     '$mdDialog',
-    function($window, $timeout: ng.ITimeoutService, $translate, $filter: any, $log: ng.ILogService, $mdDialog) {
+    function($window, $timeout: ng.ITimeoutService, $translate, $log: ng.ILogService, $mdDialog) {
         return {
             restrict: 'EA',
             scope: {
@@ -53,7 +54,7 @@ export default [
                     if (croppieInstance !== null) {
                         return croppieInstance;
                     }
-                    croppieInstance = new Croppie(element[0].querySelector('.croppie-container'), {
+                    croppieInstance = new Croppie(element[0].querySelector('.croppie-target'), {
                         viewport: {
                             type: 'square',
                             width: VIEWPORT_SIZE,
@@ -71,7 +72,8 @@ export default [
                                     type: 'blob',
                                     // max allowed size on device
                                     size: [512, 512],
-                                    circle: 'false',
+                                    circle: false,
+                                    format: 'png',
                                 })
                                     .then((blob: Blob) => {
                                         const fileReader = new FileReader();
@@ -102,15 +104,15 @@ export default [
                 function fetchFileContent(file: File): Promise<ArrayBuffer> {
                     return new Promise((resolve, reject) => {
                         const reader = new FileReader();
-                        reader.onload = (ev: Event) => {
-                            resolve((ev.target as FileReader).result);
+                        reader.onload = function(ev: FileReaderProgressEvent) {
+                            resolve(ev.target.result);
                         };
-                        reader.onerror = (ev: ErrorEvent) => {
+                        reader.onerror = function(ev: FileReaderProgressEvent) {
                             // set a null object
                             reject(ev);
                         };
-                        reader.onprogress = function(data) {
-                            if (data.lengthComputable) {
+                        reader.onprogress = function(ev: FileReaderProgressEvent) {
+                            if (ev.lengthComputable) {
                                 // TODO implement progress?
                                 // let progress = ((data.loaded / data.total) * 100);
                             }
@@ -125,7 +127,7 @@ export default [
                     }
                     // get first
                     fetchFileContent(fileList[0]).then((data: ArrayBuffer) => {
-                        const image = $filter('bufferToUrl')(data, 'image/jpg', false);
+                        const image = bufferToUrl(data, 'image/jpeg', logAdapter($log.warn, logTag));
                         setImage(image);
                     }).catch((ev: ErrorEvent) => {
                         $log.error(logTag, 'Could not load file:', ev.message);
@@ -254,9 +256,9 @@ export default [
             },
             template: `
                 <div class="avatar-editor">
-                    <div class="avatar-editor-drag croppie-container"></div>
-                    <div class="avatar-editor-navigation"  layout="column" layout-wrap layout-margin layout-align="center center">
-                        <input class="file-input" type="file" style="visibility: hidden" multiple/>
+                    <div class="avatar-editor-drag croppie-target"></div>
+                    <div class="avatar-editor-navigation" layout="column" layout-wrap layout-margin layout-align="center center">
+                        <input class="file-input" type="file" style="visibility: hidden" multiple>
                           <md-button type="submit" class="file-trigger md-raised">
                             <span translate>messenger.UPLOAD_AVATAR</span>
                            </md-button>

+ 4 - 1
src/directives/click_action.ts

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

+ 17 - 12
src/directives/compose_area.ts

@@ -87,7 +87,8 @@ export default [
                     from?: number,
                     to?: number,
                     fromBytes?: number,
-                    toBytes?: number } = null;
+                    toBytes?: number,
+                } = null;
 
                 /**
                  * Stop propagation of click events and hold htmlElement of the emojipicker
@@ -293,7 +294,7 @@ export default [
                         const text = getText(false);
                         if (text === '\n') {
                             composeDiv[0].innerText = '';
-                        } else if (ev.keyCode === 190) {
+                        } else if (ev.keyCode === 190 && caretPosition !== null) {
                             // A ':' is pressed, try to parse
                             const currentWord = stringService.getWord(text, caretPosition.fromBytes, [':']);
                             if (currentWord.realLength > 2
@@ -308,7 +309,7 @@ export default [
                         }
 
                         // Update typing information (use text instead method)
-                        if (text.trim().length === 0) {
+                        if (text.trim().length === 0 || caretPosition === null) {
                             stopTyping();
                             scope.onTyping('');
                         } else {
@@ -335,16 +336,16 @@ export default [
                         for (let n = 0; n < fileCounter; n++) {
                             const reader = new FileReader();
                             const file = fileList.item(n);
-                            reader.onload = (ev: Event) => {
-                                next(file, (ev.target as FileReader).result, ev);
+                            reader.onload = function(ev: FileReaderProgressEvent) {
+                                next(file, ev.target.result, ev);
                             };
-                            reader.onerror = (ev: ErrorEvent) => {
+                            reader.onerror = function(ev: FileReaderProgressEvent) {
                                 // set a null object
                                 next(file, null, ev);
                             };
-                            reader.onprogress = function(data) {
-                                if (data.lengthComputable) {
-                                    const progress = ((data.loaded / data.total) * 100);
+                            reader.onprogress = function(ev: FileReaderProgressEvent) {
+                                if (ev.lengthComputable) {
+                                    const progress = ((ev.loaded / ev.total) * 100);
                                     scope.onUploading(true, progress, 100 / fileCounter * n);
                                 }
                             };
@@ -374,7 +375,9 @@ export default [
 
                             fileMessages.push(fileMessageData);
                         });
-                        scope.submit('file', fileMessages);
+                        scope
+                            .submit('file', fileMessages)
+                            .catch((msg) => $log.error('Could not send file:', msg));
                         scope.onUploading(false);
 
                     }).catch((ev: ErrorEvent) => {
@@ -415,7 +418,7 @@ export default [
 
                         // Convert blob to arraybuffer
                         const reader = new FileReader();
-                        reader.onload = function() {
+                        reader.onload = function(progressEvent: FileReaderProgressEvent) {
                             const buffer: ArrayBuffer = this.result;
 
                             // Construct file name
@@ -437,7 +440,9 @@ export default [
                                 size: blob.size,
                                 data: buffer,
                             };
-                            scope.submit('file', [fileMessageData]);
+                            scope
+                                .submit('file', [fileMessageData])
+                                .catch((msg) => $log.error('Could not send file:', msg));
                         };
                         reader.readAsArrayBuffer(blob);
 

+ 18 - 15
src/directives/contact_badge.ts

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

+ 4 - 4
src/directives/distribution_list_badge.ts

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

+ 7 - 7
src/directives/drag_file.ts

@@ -47,7 +47,7 @@ export default [
                 function fetchFileListContents(fileList: FileList): Promise<Map<File, ArrayBuffer>> {
                     return new Promise((resolve) => {
                         const buffers = new Map<File, ArrayBuffer>();
-                        const next = (file: File, res: ArrayBuffer | null, error?: ErrorEvent) => {
+                        const next = (file: File, res: ArrayBuffer | null, error?: FileReaderProgressEvent) => {
                             buffers.set(file, res);
                             if (buffers.size >= fileList.length) {
                                 resolve(buffers);
@@ -60,16 +60,16 @@ export default [
                         for (let n = 0; n < fileList.length; n++) {
                             const reader = new FileReader();
                             const file = fileList.item(n);
-                            reader.onload = (ev: Event) => {
-                                next(file, (ev.target as FileReader).result);
+                            reader.onload = function(ev: FileReaderProgressEvent) {
+                                next(file, ev.target.result);
                             };
-                            reader.onerror = (ev: ErrorEvent) => {
+                            reader.onerror = function(ev: FileReaderProgressEvent) {
                                 // set a null object
                                 next(file, null, ev);
                             };
-                            reader.onprogress = function(data) {
-                                if (data.lengthComputable) {
-                                    const progress = ((data.loaded / data.total) * 100);
+                            reader.onprogress = function(ev: FileReaderProgressEvent) {
+                                if (ev.lengthComputable) {
+                                    const progress = ((ev.loaded / ev.total) * 100);
                                     scope.onUploading(true, progress, 100 / fileList.length * n);
                                 }
                             };

+ 19 - 20
src/directives/group_badge.ts

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

+ 6 - 2
src/directives/latest_message.html

@@ -1,4 +1,8 @@
-<div class="latest-message" ng-class="{'show-draft': ctrl.showDraft(), 'is-typing': ctrl.isTyping(), 'is-hidden': ctrl.isHidden()}">
+<div class="latest-message" ng-class="{
+    'show-draft': ctrl.showDraft(),
+    'is-typing': ctrl.isTyping(),
+    'is-hidden': ctrl.isHidden(),
+}">
 
     <!-- Typing indicator -->
     <div class="left typing">
@@ -43,7 +47,7 @@
                   class="message-date" eee-message="ctrl.message"></span>
 
             <span class="message-state" ng-show="ctrl.statusIcon">
-                 <i class="material-icons md-dark md-14 {{ctrl.message.state}}">
+                 <i class="material-icons md-medium-dark md-14 {{ctrl.message.state}}">
                      {{ ctrl.statusIcon }}
                  </i>
             </span>

+ 57 - 63
src/directives/latest_message.ts

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

+ 6 - 7
src/directives/location.ts

@@ -26,17 +26,16 @@ export default [
                 location: '=',
             },
             controllerAs: 'ctrl',
-            controller: [function() {
-                this.label = this.location.address ? this.location.address
-                        : this.location.lat  + ', ' + this.location.lon;
-            }],
+            controller: [function() { /* nothing */ }],
             template: `
-                <div class="file-message">
+                <div class="location-message">
                     <div class="circle">
                         <i class="material-icons md-24">location_on</i>
                     </div>
-                    <div class="info">
-                            {{ctrl.label}}
+                    <div class="location-details">
+                        <div class="description">{{ ctrl.location.description }}</div>
+                        <div class="details" ng-if="ctrl.location.address">{{ ctrl.location.address }}</div>
+                        <div class="details" ng-if="!ctrl.location.address">{{ ctrl.location.lat }}, {{ ctrl.location.lon }}</div>
                     </div>
                 </div>
             `,

+ 3 - 2
src/directives/mediabox.ts

@@ -53,10 +53,11 @@ export default [
                 };
 
                 // Listen to Mediabox service events
-                const filter = $filter('bufferToUrl') as (buffer: ArrayBuffer, mimeType: string) => string;
+                const bufferToUrl = $filter('bufferToUrl') as
+                    (buffer: ArrayBuffer, mimeType: string, trust: boolean) => string;
                 mediaboxService.evtMediaChanged.attach((dataAvailable: boolean) => {
                     $rootScope.$apply(() => {
-                        this.imageDataUrl = filter(mediaboxService.data, 'image/jpeg');
+                        this.imageDataUrl = bufferToUrl(mediaboxService.data, mediaboxService.mimetype, true);
                         this.caption = mediaboxService.caption || mediaboxService.filename;
                     });
                 });

+ 12 - 9
src/directives/member_list_editor.ts

@@ -15,11 +15,14 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {hasFeature} from '../helpers';
 import {WebClientService} from '../services/webclient';
 
+const AUTOCOMPLETE_MAX_RESULTS = 20;
+
 export default [
-    'WebClientService',
-    function(webClientService: WebClientService) {
+    '$log', 'WebClientService',
+    function($log: ng.ILogService, webClientService: WebClientService) {
         return {
             restrict: 'EA',
             scope: {},
@@ -30,14 +33,14 @@ export default [
             },
             controllerAs: 'ctrl',
             controller: [function() {
-                const AUTOCOMPLETE_MAX_RESULTS = 20;
-
-                // cache all feature level >= 1 contacts
+                // Cache all contacts with group chat support
                 this.allContacts = Array
                     .from(webClientService.contacts.values())
-                    .filter((contactReceiver: threema.ContactReceiver) => {
-                        return contactReceiver.featureLevel >= 0;
-                    }) as threema.ContactReceiver[];
+                    .filter((contactReceiver: threema.ContactReceiver) => hasFeature(
+                        contactReceiver,
+                        threema.ContactReceiverFeature.GROUP_CHAT,
+                        $log,
+                    )) as threema.ContactReceiver[];
 
                 this.selectedItemChange = (contactReceiver: threema.ContactReceiver) => {
                     if (contactReceiver !== undefined) {
@@ -73,7 +76,7 @@ export default [
                 };
 
                 this.onRemoveMember = (contact: threema.ContactReceiver): boolean => {
-                    if (contact.id === webClientService.getMyIdentity().identity) {
+                    if (contact.id === webClientService.me.id) {
                         return false;
                     }
 

+ 1 - 2
src/directives/message.html

@@ -6,13 +6,12 @@
     <eee-avatar
             class="message-avatar"
             ng-if="ctrl.showAvatar"
-            eee-type="'contact'"
             eee-receiver="ctrl.contact"
             eee-resolution="ctrl.resolution"
             ui-sref="messenger.home.conversation({ type: 'contact', id: ctrl.contact.id, initParams: null })"></eee-avatar>
 
     <div class="bubble-triangle"></div>
-    <section class="message-body" ng-class="ctrl.message.type + '-message-body'">
+    <section class="message-body {{ ctrl.message.type + '-message-body' }}">
         <eee-message-contact
             ng-if="ctrl.showName"
             class="message-name"

+ 160 - 84
src/directives/message.ts

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

+ 1 - 1
src/directives/message_date.ts

@@ -23,7 +23,7 @@ export default [
                 message: '=eeeMessage',
             },
             template: `
-                <span>{{ message.date }}</span>
+                <span title="{{ message.date | unixToTimestring:true }}">{{ message.date | unixToTimestring }}</span>
             `,
         };
     },

+ 6 - 2
src/directives/message_icon.ts

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

+ 8 - 8
src/directives/message_media.html

@@ -27,9 +27,9 @@
 
         <!-- Thumbnails -->
         <span class="in-view-indicator" ng-if="ctrl.type !== 'location'" in-view="ctrl.thumbnailInView($inview)"></span>
-        <img ng-if="ctrl.thumbnail !== null" ng-src="{{ctrl.thumbnail}}">
-        <div ng-if="ctrl.message.thumbnail != undefined" class="thumbnail-loader">
-            <img ng-src="{{ ctrl.message.thumbnail.preview | bufferToUrl: 'image/png' }}">
+        <img ng-if="ctrl.thumbnail" ng-src="{{ ctrl.thumbnail }}">
+        <div ng-if="ctrl.message.thumbnail && !ctrl.thumbnail" class="thumbnail-loader">
+            <img ng-src="{{ ctrl.getThumbnailPreviewUri() }}">
         </div>
 
     </div>
@@ -47,7 +47,7 @@
             <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
         </div>
         <!-- Play Indicator -->
-        <div class="circle" ng-click="ctrl.download()" ng-if="ctrl.downloaded">
+        <div class="circle" ng-if="ctrl.downloaded">
             <i class="material-icons md-24">play_arrow</i>
         </div>
         <div class="info" translate>messageTypes.AUDIO_MESSAGE</div>
@@ -55,7 +55,7 @@
 
     <!-- Anim GIF -->
     <div class="animgif" ng-if="ctrl.downloaded && ctrl.isAnimGif">
-        <img ng-src="{{ ctrl.blobBuffer | bufferToUrl: 'image/gif'}}" />
+        <img ng-src="{{ ctrl.blobBufferUrl }}">
     </div>
 
     <!-- Other file messages -->
@@ -65,7 +65,7 @@
         <div class="circle"
              ng-class="{active: !ctrl.isDownloading()}"
              ng-if="!ctrl.downloaded"
-             ng-style="{'background-image': 'url({{ctrl.message.thumbnail.preview | bufferToUrl: 'image/png'}})' }">
+             ng-style="{'background-image': 'url({{ ctrl.getThumbnailPreviewUri() }})' }">
             <i class="material-icons md-24">file_download</i>
             <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
         </div>
@@ -73,11 +73,11 @@
         <!-- File type indicator -->
         <div class="circle"
              ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview !== undefined"
-             ng-style="{'background-image': 'url({{ctrl.message.thumbnail.preview | bufferToUrl: 'image/png'}})' }">
+             ng-style="{'background-image': 'url({{ ctrl.getThumbnailPreviewUri() }})' }">
         </div>
         <div class="circle"
              ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview == undefined">
-            <img ng-src="{{ ctrl.message.file.type | mimeTypeIcon }}"/>
+            <img ng-src="{{ ctrl.message.file.type | mimeTypeIcon }}">
         </div>
 
         <!-- File information -->

+ 221 - 152
src/directives/message_media.ts

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

+ 14 - 2
src/directives/message_menu.html

@@ -1,6 +1,6 @@
 <!-- Non status messages -->
 <md-menu md-position-mode="target-right target" md-offset="0 45">
-    <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdOpenMenu($event)">
+    <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdMenu.open($event)">
         <i class="material-icons md-dark md-24">more_vert</i>
     </md-button>
     <md-menu-content width="1">
@@ -29,11 +29,23 @@
             </md-button>
         </md-menu-item>
         <md-menu-item ng-if="ctrl.access.copy">
-            <md-button ng-click="ctrl.copy($event)">
+            <md-button ng-click="ctrl.copyToClipboard($event)">
                 <md-icon aria-label="Copy" class="material-icons md-24">content_copy</md-icon>
                 <span translate>messenger.COPY</span>
             </md-button>
         </md-menu-item>
+        <md-menu-item ng-if="ctrl.message.events">
+            <md-button ng-click="ctrl.showHistory($event)">
+                <md-icon aria-label="Message history" class="material-icons md-24">history</md-icon>
+                <span translate>messenger.MSG_HISTORY</span>
+            </md-button>
+        </md-menu-item>
+        <md-menu-item ng-if="ctrl.message.type === 'location'">
+            <md-button href="https://www.openstreetmap.org/directions?route=%3B{{ ctrl.message.location.lat }}%2C{{ ctrl.message.location.lon }}#map=14/{{ ctrl.message.location.lat }}/{{ ctrl.message.location.lon }}" target='_blank' rel='noopener noreferrer'>
+                <md-icon aria-label="Navigate" class="material-icons md-24">directions</md-icon>
+                <span translate>messenger.NAVIGATE</span>
+            </md-button>
+        </md-menu-item>
         <md-menu-item ng-if="ctrl.access.delete">
             <md-button ng-click="ctrl.delete($event)">
             <md-icon aria-label="Delete" class="material-icons md-24">delete</md-icon>

+ 14 - 12
src/directives/message_meta.ts

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

+ 1 - 1
src/directives/message_state.ts

@@ -28,7 +28,7 @@ export default [
                 message: '=eeeMessage',
             },
             template: `
-                <i class="material-icons md-dark md-14 {{message.state}}">{{ message | messageStateIcon }}</i>
+                <i class="material-icons md-dark md-14 {{message.state}}" title="{{ message | messageStateTitleText | translate }}">{{ message | messageStateIcon }}</i>
             `,
         };
     },

+ 28 - 27
src/directives/message_text.ts

@@ -17,6 +17,8 @@
 
 // tslint:disable:max-line-length
 
+import {WebClientService} from '../services/webclient';
+
 export default [
     function() {
         return {
@@ -27,38 +29,37 @@ export default [
                 multiLine: '=?eeeMultiLine',
             },
             controllerAs: 'ctrl',
-            controller: [function() {
+            controller: ['WebClientService', function(webClientService: WebClientService) {
                 // Get text depending on type
-                let rawText = null;
-                const message = this.message as threema.Message;
-                switch (message.type) {
-                    case 'text':
-                        rawText = message.body;
-                        break;
-                    case 'location':
-                        rawText = message.location.poi;
-                        break;
-                    case 'file':
-                        // Prefer caption for file messages, if available
-                        if (message.caption && message.caption.length > 0) {
-                            rawText = message.caption;
-                        } else {
-                            rawText = message.file.name;
-                        }
-                        break;
-                    default:
-                        rawText = message.caption;
-                        break;
-                }
-                // Escaping will be done in the HTML using filters
-                this.text = rawText;
-                if (this.multiLine === undefined) {
-                    this.multiLine = true;
+                function getText(message: threema.Message) {
+                    switch (message.type) {
+                        case 'text':
+                            return message.body;
+                        case 'location':
+                            return message.location.description;
+                        case 'file':
+                            // Prefer caption for file messages, if available
+                            if (message.caption && message.caption.length > 0) {
+                                return message.caption;
+                            }
+                            return message.file.name;
+                    }
+                    return message.caption;
                 }
+
+                this.enlargeSingleEmoji = webClientService.appConfig.largeSingleEmoji;
+
+                this.$onInit = function() {
+                    // Escaping will be done in the HTML using filters
+                    this.text = getText(this.message);
+                    if (this.multiLine === undefined) {
+                        this.multiLine = true;
+                    }
+                };
             }],
             template: `
                 <span click-action
-                    ng-bind-html="ctrl.text | escapeHtml | markify | emojify | mentionify | linkify | nlToBr: ctrl.multiLine">
+                    ng-bind-html="ctrl.text | escapeHtml | markify | emojify | enlargeSingleEmoji:ctrl.enlargeSingleEmoji | mentionify | linkify | nlToBr:ctrl.multiLine">
                 </span>
             `,
         };

+ 7 - 5
src/directives/message_voip_status.ts

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

+ 0 - 65
src/directives/my_identity.ts

@@ -1,65 +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/>.
- */
-
-import {u8aToHex} from '../helpers';
-
-export default [
-    '$mdDialog',
-    function($mdDialog) {
-        return {
-            restrict: 'EA',
-            scope: {},
-            bindToController: {
-                identity: '=eeeIdentity',
-            },
-            controllerAs: 'ctrl',
-            controller: [function() {
-                this.showQRCode = () => {
-                    const identity: threema.Identity = this.identity;
-                    $mdDialog.show({
-                        controllerAs: 'ctrl',
-                        controller: [function() {
-                           this.cancel = () =>  {
-                               $mdDialog.cancel();
-                           };
-                           this.identity = identity;
-                           this.qrCode = {
-                                errorCorrectionLevel: 'L',
-                                size: '400px',
-                                data: '3mid:'
-                                + identity.identity
-                                + ','
-                                + u8aToHex(new Uint8Array(identity.publicKey)),
-                            };
-                        }],
-                        templateUrl: 'partials/dialog.myidentity.html',
-                        parent: angular.element(document.body),
-                        clickOutsideToClose: true,
-                        fullscreen: true,
-                    });
-                };
-            }],
-            template: `
-                <div class="my-threema-information" ng-click="ctrl.showQRCode()">
-                    <div class="nickname" ng-cloak
-                        ng-bind-html="ctrl.identity.publicNickname | emojify | emptyToPlaceholder: '-'">
-                    </div>
-                </div>
-            `,
-        };
-    },
-];

+ 34 - 31
src/directives/verification_level.ts

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

+ 172 - 10
src/filters.ts

@@ -15,8 +15,9 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import {escapeRegExp, filter} from './helpers';
+import {bufferToUrl, escapeRegExp, filter, logAdapter} from './helpers';
 import {MimeService} from './services/mime';
+import {NotificationService} from './services/notification';
 import {WebClientService} from './services/webclient';
 
 angular.module('3ema.filters', [])
@@ -83,8 +84,8 @@ angular.module('3ema.filters', [])
         email: true,
         // Don't link phone numbers (doesn't work reliably)
         phone: false,
-        // Don't link twitter handles
-        twitter: false,
+        // Don't link mentions
+        mention: false,
         // Don't link hashtags
         hashtag: false,
     });
@@ -111,6 +112,27 @@ angular.module('3ema.filters', [])
     };
 })
 
+/**
+ * Enlarge 1-3 emoji.
+ */
+.filter('enlargeSingleEmoji', function() {
+    const pattern = /<span class="e1 ([^>]*>[^<]*)<\/span>/g;
+    const singleEmojiThreshold = 3;
+    const singleEmojiClassName = 'large-emoji';
+    return function(text, enlarge = false) {
+        if (!enlarge) {
+            return text;
+        }
+        const matches = text.match(pattern);
+        if (matches != null && matches.length >= 1 && matches.length <= singleEmojiThreshold) {
+            if (text.replace(pattern, '').length === 0) {
+                text = text.replace(pattern, '<span class="e1 ' + singleEmojiClassName + ' $1</span>');
+            }
+        }
+        return text;
+    };
+})
+
 /**
  * Convert markdown elements to html elements
  */
@@ -223,6 +245,12 @@ angular.module('3ema.filters', [])
         return padLeft + left + ':' + padRight + right;
     };
 })
+
+/**
+ * Convert an ArrayBuffer to a data URL.
+ *
+ * Warning: Make sure that this is not called repeatedly on big data, or performance will decrease.
+ */
 .filter('bufferToUrl', ['$sce', '$log', function($sce, $log) {
     const logTag = '[filters.bufferToUrl]';
     return function(buffer: ArrayBuffer, mimeType: string, trust: boolean = true) {
@@ -230,13 +258,7 @@ angular.module('3ema.filters', [])
             $log.error(logTag, 'Could not apply bufferToUrl filter: buffer is', buffer);
             return '';
         }
-        let binary = '';
-        const bytes = new Uint8Array(buffer);
-        const len = bytes.byteLength;
-        for (let i = 0; i < len; i++) {
-            binary += String.fromCharCode(bytes[i]);
-        }
-        const uri = 'data:' + mimeType + ';base64,' +  btoa(binary);
+        const uri = bufferToUrl(buffer, mimeType, logAdapter($log.warn, logTag));
         if (trust) {
             return $sce.trustAsResourceUrl(uri);
         } else {
@@ -244,6 +266,7 @@ angular.module('3ema.filters', [])
         }
     };
 }])
+
 .filter('mapLink', function() {
     return function(location: threema.LocationInfo) {
         return 'https://www.openstreetmap.org/?mlat='
@@ -251,6 +274,7 @@ angular.module('3ema.filters', [])
             + location.lon;
     };
 })
+
 /**
  * Convert message state to material icon class.
  */
@@ -286,11 +310,58 @@ angular.module('3ema.filters', [])
                 return 'thumb_up';
             case 'user-dec':
                 return 'thumb_down';
+            case 'timeout':
+                return 'sync_problem';
             default:
                 return '';
         }
     };
 })
+
+/**
+ * Convert message state to title text.
+ */
+.filter('messageStateTitleText', ['$translate', function($translate: ng.translate.ITranslateService) {
+    return (message: threema.Message) => {
+        if (!message) {
+            return null;
+        }
+
+        if (!message.isOutbox) {
+            switch (message.state) {
+                case 'user-ack':
+                    return 'messageStates.WE_ACK';
+                case 'user-dec':
+                    return 'messageStates.WE_DEC';
+                default:
+                    return 'messageStates.UNKNOWN';
+            }
+        }
+        switch (message.state) {
+            case 'pending':
+                return 'messageStates.PENDING';
+            case 'sending':
+                return 'messageStates.SENDING';
+            case 'sent':
+                return 'messageStates.SENT';
+            case 'delivered':
+                return 'messageStates.DELIVERED';
+            case 'read':
+                return 'messageStates.READ';
+            case 'send-failed':
+                return 'messageStates.FAILED';
+            case 'user-ack':
+                return 'messageStates.USER_ACK';
+            case 'user-dec':
+                return 'messageStates.USER_DEC';
+            case 'timeout':
+                return 'messageStates.TIMEOUT';
+            default:
+                return 'messageStates.UNKNOWN';
+        }
+    };
+}])
+
 .filter('fileSize', function() {
     return (size: number) => {
         if (!size) {
@@ -330,4 +401,95 @@ angular.module('3ema.filters', [])
     };
 }])
 
+/**
+ * Format a unix timestamp as a date.
+ */
+.filter('unixToTimestring', ['$translate', function($translate) {
+    function formatTime(date) {
+        return ('00' + date.getHours()).slice(-2) + ':' +
+               ('00' + date.getMinutes()).slice(-2);
+    }
+
+    function formatMonth(num) {
+        switch (num) {
+            case 0x0:
+                return 'date.month_short.JAN';
+            case 0x1:
+                return 'date.month_short.FEB';
+            case 0x2:
+                return 'date.month_short.MAR';
+            case 0x3:
+                return 'date.month_short.APR';
+            case 0x4:
+                return 'date.month_short.MAY';
+            case 0x5:
+                return 'date.month_short.JUN';
+            case 0x6:
+                return 'date.month_short.JUL';
+            case 0x7:
+                return 'date.month_short.AUG';
+            case 0x8:
+                return 'date.month_short.SEP';
+            case 0x9:
+                return 'date.month_short.OCT';
+            case 0xa:
+                return 'date.month_short.NOV';
+            case 0xb:
+                return 'date.month_short.DEC';
+        }
+    }
+
+    function isSameDay(date1, date2) {
+        return date1.getFullYear() === date2.getFullYear()
+            && date1.getMonth() === date2.getMonth()
+            && date1.getDate() === date2.getDate();
+    }
+
+    return (timestamp: number, forceFull: boolean = false) => {
+        const date = new Date(timestamp * 1000);
+
+        const now = new Date();
+        if (!forceFull && isSameDay(date, now)) {
+            return formatTime(date);
+        }
+
+        const yesterday = new Date(now.getTime() - 1000 * 60 * 60 * 24);
+        if (!forceFull && isSameDay(date, yesterday)) {
+            return $translate.instant('date.YESTERDAY') + ', ' + formatTime(date);
+        }
+
+        let year = '';
+        if (forceFull || date.getFullYear() !== now.getFullYear()) {
+            year = ' ' + date.getFullYear();
+        }
+        return date.getDate() + '. '
+             + $translate.instant(formatMonth(date.getMonth()))
+             + year + ', '
+             + formatTime(date);
+    };
+}])
+
+/**
+ * Return a simplified DND mode.
+ *
+ * This will return either 'on', 'off' or 'mention'.
+ * The 'until' mode will be processed depending on the expiration timestamp.
+ */
+.filter('dndModeSimplified', ['NotificationService', function(notificationService: NotificationService) {
+    return (conversation: threema.Conversation) => {
+        const simplified = notificationService.getAppNotificationSettings(conversation);
+        if (simplified.dnd.enabled) {
+            return simplified.dnd.mentionOnly ? 'mention' : 'on';
+        }
+        return 'off';
+    };
+}])
+
+/**
+ * Mark data as trusted.
+ */
+.filter('unsafeResUrl', ['$sce', function($sce: ng.ISCEService) {
+    return $sce.trustAsResourceUrl;
+}])
+
 ;

+ 86 - 0
src/helpers.ts

@@ -252,3 +252,89 @@ export function supportsPassive(): boolean {
 export function escapeRegExp(str: string) {
     return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
 }
+
+/**
+ * Generate a link to the msgpack visualizer from an Uint8Array containing
+ * msgpack encoded data.
+ */
+export function msgpackVisualizer(bytes: Uint8Array): string {
+    return 'https://msgpack.dbrgn.ch#base64=' + encodeURIComponent(btoa(bytes as any));
+}
+
+/**
+ * Check the featureMask of a contactReceiver
+ */
+export function hasFeature(contactReceiver: threema.ContactReceiver,
+                           feature: threema.ContactReceiverFeature,
+                           $log: ng.ILogService): boolean {
+    const logTag = '[helpers.hasFeature]';
+    if (contactReceiver !== undefined) {
+        if (contactReceiver.featureMask === 0) {
+            $log.warn(logTag, contactReceiver.id, 'featureMask', contactReceiver.featureMask);
+            return false;
+        }
+        // tslint:disable:no-bitwise
+        return (contactReceiver.featureMask & feature) !== 0;
+        // tslint:enable:no-bitwise
+    }
+    $log.warn(logTag, 'Cannot check featureMask of a undefined contactReceiver');
+    return false;
+}
+
+/**
+ * Convert an ArrayBuffer to a data URL.
+ */
+export function bufferToUrl(buffer: ArrayBuffer, mimeType: string, logWarning: (msg: string) => void) {
+    if (buffer === null || buffer === undefined) {
+        throw new Error('Called bufferToUrl on null or undefined');
+    }
+    let binary = '';
+    const bytes = new Uint8Array(buffer);
+    const len = bytes.byteLength;
+    for (let i = 0; i < len; i++) {
+        binary += String.fromCharCode(bytes[i]);
+    }
+    switch (mimeType) {
+        case 'image/jpg':
+        case 'image/jpeg':
+        case 'image/png':
+        case 'image/webp':
+        case 'image/gif':
+        case 'audio/mp4':
+        case 'audio/aac':
+        case 'audio/ogg':
+        case 'audio/webm':
+            // OK
+            break;
+        default:
+            logWarning('bufferToUrl: Unknown mimeType: ' + mimeType);
+            mimeType = 'image/jpeg';
+            break;
+    }
+    return 'data:' + mimeType + ';base64,' + btoa(binary);
+}
+
+/**
+ * Adapter for creating a logging function.
+ *
+ * Example usage:
+ *
+ * const logWarning = logAdapter($log.warn, '[AvatarService]');
+ */
+export function logAdapter(logFunc: (...msg: string[]) => void, logTag: string): ((msg: string) => void) {
+    return (msg: string) => logFunc(logTag, msg);
+}
+
+/**
+ * Return whether a value is not null and not undefined.
+ */
+export function hasValue(val: any): boolean {
+    return val !== null && val !== undefined;
+}
+
+/**
+ * Awaitable timeout function.
+ */
+export function sleep(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+}

+ 36 - 0
src/helpers/crypto.ts

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

+ 28 - 0
src/helpers/messages.ts

@@ -0,0 +1,28 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+// This file contains helper functions related to messages.
+// Try to keep all functions pure!
+
+/**
+ * Return the sending identity of a message.
+ */
+export function getSenderIdentity(message: threema.Message, myId: string): string | null {
+    if (message.isOutbox) { return myId; }
+    if (message.partnerId != null) { return message.partnerId; }
+    return null;
+}

+ 8 - 5
src/types/enums.ts → src/message_helpers.ts

@@ -15,9 +15,12 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-export enum ControllerModelMode {
-    NEW = 1,
-    VIEW = 2,
-    EDIT = 3,
-    CHAT = 4,
+// This file contains helper functions related to messages.
+// Try to keep all functions pure!
+
+/**
+ * Return wether a message is a "first unread" status message.
+ */
+export function isFirstUnreadStatusMessage(message: threema.Message) {
+    return message.type === 'status' && message.statusType === 'firstUnreadMessage';
 }

+ 2 - 2
src/partials/dialog.about.html

@@ -29,7 +29,7 @@
                 <h2 translate>about.CHANGELOG</h2>
                 <p>
                     <span translate>about.CHANGELOG_LINK_BEFORE</span>
-                    <a href="https://github.com/threema-ch/threema-web/blob/master/CHANGELOG.md"
+                    <a href="https://github.com/threema-ch/threema-web/blob/{{ ctrl.config.GIT_BRANCH }}/CHANGELOG.md"
                        target="_blank" rel="noopener noreferrer" translate>about.CHANGELOG_LINK_TEXT</a><span translate>about.CHANGELOG_LINK_AFTER</span>
                 </p>
 
@@ -38,7 +38,7 @@
                     <li translate>about.EMOJI_ART</li>
                     <li>
                         <span translate>about.LICENSE_LINK_BEFORE</span>
-                        <a href="https://github.com/threema-ch/threema-web/blob/master/LICENSE-3RD-PARTY.txt"
+                        <a href="https://github.com/threema-ch/threema-web/blob/{{ ctrl.config.GIT_BRANCH }}/LICENSE-3RD-PARTY.txt"
                            target="_blank" rel="noopener noreferrer" translate>about.LICENSE_LINK_TEXT</a>
                         <span translate>about.LICENSE_LINK_AFTER</span>
                     </li>

+ 3 - 9
src/partials/dialog.myidentity.html → src/partials/dialog.qr.html

@@ -2,21 +2,15 @@
     <form ng-cloak>
         <md-toolbar>
             <div class="md-toolbar-tools">
-                <h2 translate>messenger.MY_THREEMA_ID</h2>
+                <h2>{{ ctrl.profile.identity }}</h2>
                 <span flex></span>
-                <md-button class="md-icon-button" ng-click="ctrl.cancel()" translate-attr="{'aria-label': 'common.OK'}">
+                <md-button class="md-icon-button" ng-click="ctrl.cancel()" translate-attr="{'aria-label': 'common.CANCEL'}">
                     <md-icon aria-label="Close dialog" class="material-icons md-24">close</md-icon>
                 </md-button>
             </div>
         </md-toolbar>
         <md-dialog-content>
             <div class="md-dialog-content center">
-                <dl class="key-values">
-                    <dt>Threema ID</dt>
-                    <dd>{{ctrl.identity.identity}}</dd>
-                    <dt translate>messenger.KEY_FINGERPRINT</dt>
-                    <dd>{{ctrl.identity.fingerprint}}</dd>
-                </dl>
                 <div class="qrcode">
                     <qrcode version="{{ ctrl.qrCode.version }}"
                     error-correction-level="{{ ctrl.qrCode.errorCorrectionLevel }}"
@@ -27,7 +21,7 @@
         <md-dialog-actions layout="row">
             <span flex></span>
             <md-button ng-click="ctrl.cancel()">
-                <span translate>common.OK</span>
+                <span translate>common.CLOSE</span>
             </md-button>
         </md-dialog-actions>
     </form>

+ 34 - 0
src/partials/dialog.version.html

@@ -0,0 +1,34 @@
+<md-dialog aria-label="About">
+    <form ng-cloak>
+        <md-toolbar>
+            <div class="md-toolbar-tools">
+                <h2 translate>Version {{ ctrl.fullVersion }}</h2>
+                <span flex></span>
+                <md-button class="md-icon-button" ng-click="ctrl.cancel()">
+                    <md-icon aria-label="Close dialog" class="material-icons md-24">close</md-icon>
+                </md-button>
+            </div>
+        </md-toolbar>
+        <md-dialog-content>
+            <div class="md-dialog-content">
+                <h2 translate>welcome.VERSION</h2>
+                <p>{{ ctrl.version }}</p>
+
+                <h2 translate>welcome.BACKGROUND_IMAGE</h2>
+                <p><a ng-href="{{ ctrl.config.VERSION_MOUNTAIN_URL }}" target="_blank" rel="noopener noreferrer">{{ ctrl.config.VERSION_MOUNTAIN }} ({{ ctrl.config.VERSION_MOUNTAIN_HEIGHT }}m)</a> / <a ng-href="{{ ctrl.config.VERSION_MOUNTAIN_IMAGE_URL }}" target="_blank" rel="noopener noreferrer">Source Image</a></p>
+
+                <h2 translate>about.CHANGELOG</h2>
+                <p>
+                    <span translate>about.CHANGELOG_LINK_BEFORE</span>
+                    <a href="https://github.com/threema-ch/threema-web/blob/{{ ctrl.config.GIT_BRANCH }}/CHANGELOG.md"
+                       target="_blank" rel="noopener noreferrer" translate>about.CHANGELOG_LINK_TEXT</a><span translate>about.CHANGELOG_LINK_AFTER</span>
+                </p>
+            </div>
+        </md-dialog-content>
+        <md-dialog-actions layout="row">
+            <span flex></span>
+            <md-button ng-click="ctrl.cancel()"><span translate>common.CLOSE</span></md-button>
+        </md-dialog-actions>
+    </form>
+</md-dialog>
+

+ 4 - 5
src/partials/messenger.conversation.html

@@ -7,7 +7,7 @@
     <div id="conversation-header" class="detail-header">
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
         <div class="header-avatar" ng-click="ctrl.showReceiver()">
-            <eee-avatar eee-type="ctrl.type" eee-receiver="ctrl.receiver"
+            <eee-avatar eee-receiver="ctrl.receiver"
                         eee-resolution="'low'"></eee-avatar>
         </div>
         <div class="header-details" ng-click="ctrl.showReceiver()">
@@ -36,7 +36,7 @@
         </md-card>
     </div>
 
-    <div id="conversation-chat" scroll-glue in-view-container ng-show="!ctrl.locked">
+    <div id="conversation-chat" scroll-glue="!ctrl.showScrollJump" in-view-container ng-show="!ctrl.locked">
         <ul class="chat">
             <li in-view="$inview && !ctrl.locked && ctrl.topOfChat()" class="load-more">
                 <div ng-if="ctrl.hasMoreMessages()" class="loading">
@@ -45,7 +45,7 @@
             </li>
             <li ng-repeat="message in ctrl.messages" id="message-{{message.id}}">
                 <eee-message eee-receiver="ctrl.receiver" eee-type="ctrl.type" eee-message="message"
-                             in-view="$inview  && !ctrl.locked && ctrl.msgRead(message.id)"
+                             in-view="$inview  && !ctrl.locked && ctrl.msgRead(message)"
                              in-view-options="{ considerPageVisibility: true }"></eee-message>
             </li>
             <li ng-if="ctrl.isTyping()" class="typing-indicator">
@@ -85,8 +85,7 @@
                     ng-class="{selected: ctrl.selectedMention == $index}">
                     <div class="contact-badge receiver-badge" ng-if="mention.isAll">
                         <section class="avatar-box">
-                            <eee-avatar eee-type="'group'"
-                                        eee-receiver="ctrl.receiver"
+                            <eee-avatar eee-receiver="ctrl.receiver"
                                         eee-resolution="'low'"></eee-avatar>
                         </section>
                         <div translate>messenger.ALL</div>

+ 23 - 15
src/partials/messenger.navigation.html

@@ -1,12 +1,15 @@
-<!-- My identity -->
-<div id="my-identity" ng-if="ctrl.showMyIdentity()">
-    <div class="my-identity-content" eee-my-identity
-            eee-identity="ctrl.getMyIdentity()"></div>
+<!-- Top header (nickname + menu) -->
+<div id="navigation-topheader">
+    <div class="my-identity">
+        <span ng-click="ctrl.showProfile()" ng-cloak translate-attr="{'title': 'messenger.MY_PUBLIC_NICKNAME'}">
+            {{ ctrl.getMe().publicNickname || ctrl.getMe().id }}
+        </span>
+    </div>
 
     <battery-status></battery-status>
 
     <md-menu md-position-mode="target-right target" md-offset="0 45">
-        <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdOpenMenu($event)">
+        <md-button aria-label="Open menu" class="md-icon-button" ng-click="$mdMenu.open($event)">
             <i class="material-icons md-light md-24">more_vert</i>
         </md-button>
         <md-menu-content width="4">
@@ -42,10 +45,10 @@
 <div id="navigation-header">
     <div class="main">
         <md-nav-bar md-no-ink md-selected-nav-item="ctrl.activeTab" nav-bar-aria-label="navigation links">
-            <md-nav-item md-nav-click="" name="conversations">
+            <md-nav-item md-nav-click="1" name="conversations">
                 <i class="material-icons md-dark md-24" translate translate-attr-title="messenger.CONVERSATIONS">speaker_notes</i>
             </md-nav-item>
-            <md-nav-item md-nav-click="" name="contacts">
+            <md-nav-item md-nav-click="1" name="contacts">
                 <i class="material-icons md-dark md-24" translate translate-attr-title="messenger.CONTACTS">person</i>
             </md-nav-item>
         </md-nav-bar>
@@ -73,8 +76,7 @@
                             'active': ctrl.isActive(conversation)}">
 
                 <section class="avatar-box">
-                    <eee-avatar eee-type="conversation.type"
-                                eee-receiver="conversation.receiver"
+                    <eee-avatar eee-receiver="conversation.receiver"
                                 eee-resolution="'low'"></eee-avatar>
                 </section>
 
@@ -82,8 +84,14 @@
                     <section class="receiver-box">
                         <span class="title" ng-class="{'disabled': conversation.receiver.disabled === true}" ng-bind-html="conversation.receiver.displayName | escapeHtml | emojify">
                         </span>
-                        <span class="muted" ng-show="conversation.isMuted">
-                            <i class="material-icons md-dark" translate translate-attr-title="messenger.MUTED">do_not_disturb_on</i>
+                        <span class="notification-settings" ng-show="(conversation | dndModeSimplified) === 'on'">
+                            <img height="16" width="16" src="img/ic_dnd_total_silence.svg" translate translate-attr-title="messenger.MUTED_NONE">
+                        </span>
+                        <span class="notification-settings" ng-show="(conversation | dndModeSimplified) === 'mention'">
+                            <img height="16" width="16" src="img/ic_dnd_mention.svg" translate translate-attr-title="messenger.MUTED_MENTION_ONLY">
+                        </span>
+                        <span class="notification-settings" ng-show="(conversation | dndModeSimplified) === 'off' && conversation.notifications && conversation.notifications.sound.mode === 'muted'">
+                            <img height="16" width="16" src="img/ic_notifications_off.svg" translate translate-attr-title="messenger.MUTED_SILENT">
                         </span>
                         <span class="badge unread-count" ng-show="conversation.unreadCount > 0">
                             {{ conversation.unreadCount }}
@@ -92,7 +100,7 @@
 
                     <section class="message-box">
                         <eee-latest-message
-                            ng-show="!conversation.receiver.isTyping() && conversation.latestMessage"
+                            ng-if="!conversation.receiver.isTyping() && conversation.latestMessage"
                             ng-class="latest-message-text"
                             eee-type="conversation.type"
                             eee-receiver="conversation.receiver"
@@ -109,15 +117,14 @@
 <!-- Contacts -->
 <div id="navigation-contacts" class="tab-content" ng-if="ctrl.activeTab == 'contacts'" in-view-container>
     <p class="empty" ng-if="ctrl.contacts().length === 0" translate>messenger.NO_CONTACTS_FOUND</p>
-    <ul>
+    <ul ng-class="{'hide-inactive': ctrl.hideInactiveContacts()}">
         <li ng-repeat="contact in ctrl.contacts() | isNotMe | filter:ctrl.searchContact"
             ui-sref="messenger.home.conversation({ type: 'contact', id: contact.id, initParams: null })"
             class="contact"
             ng-class="{'inactive': contact.state == 'INACTIVE'}">
 
             <section class="avatar-box">
-                <eee-avatar eee-type="'contact'"
-                            eee-receiver="contact"
+                <eee-avatar eee-receiver="contact"
                             eee-resolution="'low'"></eee-avatar>
             </section>
 
@@ -174,6 +181,7 @@
                 <md-icon class="material-icons md-24">group_add</md-icon>
             </md-button>
             <md-button
+                    ng-if="ctrl.showCreateDistributionListButton()"
                     ng-click="ctrl.createDistributionList()"
                     translate-attr="{'aria-label': 'messenger.CREATE_DISTRIBUTION_LIST'}"
                     aria-label="Create Distribution list"

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

@@ -2,15 +2,19 @@
     <div id="receiver-detail-header" class="detail-header">
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
         <div class="header-details">
-            <h2 ng-bind-html="ctrl.receiver.displayName | escapeHtml | emojify"></h2>
+            <h2 ng-bind-html="ctrl.controllerModel.subject | escapeHtml | emojify"></h2>
         </div>
 
         <div class="header-buttons">
+            <md-button aria-label="chat" class="md-icon-button" ng-show="ctrl.controllerModel.canShowQr()" ng-click="ctrl.showQr()">
+                <md-icon><img src="img/ic_qr.svg" alt="QR"></md-icon>
+            </md-button>
+
             <md-button aria-label="chat" class="md-icon-button" ng-show="ctrl.controllerModel.canEdit()" ng-click="ctrl.edit()">
                 <md-icon class="material-icons md-24">mode_edit</md-icon>
             </md-button>
 
-            <md-button aria-label="edit" class="md-icon-button" ng-click="ctrl.chat()">
+            <md-button aria-label="edit" class="md-icon-button" ng-show="ctrl.controllerModel.canChat()" ng-click="ctrl.chat()">
                 <md-icon class="material-icons md-24">message</md-icon>
             </md-button>
         </div>

+ 3 - 4
src/partials/messenger.receiver/contact.html

@@ -2,8 +2,7 @@
 	<!-- information list card -->
 	<md-card class="two-row">
 		<div class="avatar">
-			<eee-avatar eee-type="'contact'"
-						eee-receiver="ctrl.receiver"
+			<eee-avatar eee-receiver="ctrl.receiver"
 						eee-resolution="'high'"></eee-avatar>
 		</div>
 		<md-card-content>
@@ -26,7 +25,7 @@
 				</dd>
 
 				<dt><span translate>messenger.KEY_FINGERPRINT</span></dt>
-				<dd>{{ ctrl.fingerPrint }}</dd>
+				<dd>{{ ctrl.fingerPrint.value || "Loading..." }}</dd>
 
 				<dt><span translate>messenger.NICKNAME</span></dt>
 				<dd ng-if="ctrl.receiver.publicNickname" ng-bind-html="ctrl.receiver.publicNickname | emojify"></dd>
@@ -102,7 +101,7 @@
 		<md-card-content>
 			<ul class="group-list">
 				<li ng-repeat="distributionListReceiver in ctrl.inDistributionLists">
-					<eee-distribution-list-badge eee-distribution-list-receiver="distributionListReceiver" eee-contact-receiver="ctrl.receiver"/>
+					<eee-distribution-list-badge eee-distribution-list-receiver="distributionListReceiver"/>
 				</li>
 			</ul>
 		</md-card-content>

+ 2 - 4
src/partials/messenger.receiver/distributionList.html

@@ -8,9 +8,7 @@
 		<md-card-content>
 			<ul class="member-list">
 				<li ng-repeat="memberIdentity in ctrl.receiver.members">
-					<eee-contact-badge
-							eee-identity="memberIdentity"
-							eee-linked="true"></eee-contact-badge>
+					<eee-contact-badge eee-identity="memberIdentity"></eee-contact-badge>
 				</li>
 			</ul>
 
@@ -19,10 +17,10 @@
 
 	<md-card>
 		<md-card-content>
+			<section layout="row" layout-sm="column" layout-align="center center" layout-wrap>
 				<md-button ng-disabled="!ctrl.controllerModel.canClean()" class="md-raised" ng-click="ctrl.controllerModel.clean($event)">
 					<span translate>messenger.DELETE_THREAD</span>
 				</md-button>
-			<section layout="row" layout-sm="column" layout-align="center center" layout-wrap>
 				<md-button ng-disabled="!ctrl.receiver.access.canDelete" class="md-raised md-warn" ng-click="ctrl.controllerModel.delete()">
 					<span translate>messenger.DISTRIBUTION_LIST_DELETE</span>
 				</md-button>

+ 2 - 3
src/partials/messenger.receiver/group.html

@@ -1,8 +1,7 @@
 <div class="form-content">
 	<md-card class="two-row">
 		<div class="avatar">
-			<eee-avatar eee-type="'group'"
-						eee-receiver="ctrl.receiver"
+			<eee-avatar eee-receiver="ctrl.receiver"
 						eee-resolution="'high'"></eee-avatar>
 		</div>
 		<div>
@@ -11,7 +10,7 @@
 				<md-card-title-text>
 					<span class="md-headline">
 						<span translate>messenger.GROUP_CREATOR</span>
-						<span ng-if="ctrl.receiver.createdAt"> ({{ctrl.receiver.createdAt}})</span>
+						<span ng-if="ctrl.receiver.createdAt"> ({{ ctrl.receiver.createdAt | unixToTimestring }})</span>
 					</span>
 				</md-card-title-text>
 			</md-card-title>

+ 18 - 0
src/partials/messenger.receiver/me.edit.html

@@ -0,0 +1,18 @@
+<div layout="column" layout-wrap layout-margin layout-align="center center">
+    <h3 class="md-headline" translate>messenger.RECEIVER_AVATAR</h3>
+    <avatar-area
+        load-avatar="ctrl.controllerModel.avatarController.loadAvatar"
+        on-change="ctrl.controllerModel.avatarController.onChangeAvatar"
+        color="ctrl.controllerModel.me.color"
+        enable-clear="true">
+    </avatar-area>
+</div>
+
+<md-card>
+    <md-card-content>
+        <md-input-container class="md-block">
+            <label translate>messenger.MY_PUBLIC_NICKNAME</label>
+            <input ng-disabled="ctrl.isSaving()" ng-model="ctrl.controllerModel.nickname" ng-keypress="ctrl.keypress($event)">
+        </md-input-container>
+    </md-card-content>
+</md-card>

+ 24 - 0
src/partials/messenger.receiver/me.html

@@ -0,0 +1,24 @@
+<div class="form-content">
+	<!-- information list card -->
+	<md-card class="two-row">
+		<div class="avatar">
+			<eee-avatar eee-receiver="ctrl.receiver"
+						eee-resolution="'high'"></eee-avatar>
+		</div>
+		<md-card-content>
+			<dl class="key-values">
+				<dt>Threema ID</dt>
+				<dd>{{ ctrl.receiver.id }}
+					<eee-verification-level
+							contact="ctrl.receiver">
+					</eee-verification-level></dd>
+
+				<dt><span translate>messenger.KEY_FINGERPRINT</span></dt>
+				<dd>{{ ctrl.fingerPrint.value || "Loading..." }}</dd>
+
+				<dt><span translate>messenger.MY_PUBLIC_NICKNAME</span></dt>
+				<dd>{{ ctrl.controllerModel.nickname || "-" }}</dd>
+			</dl>
+		</md-card-content>
+	</md-card>
+</div>

+ 268 - 119
src/partials/messenger.ts

@@ -15,8 +15,16 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {
+    StateParams as UiStateParams,
+    StateProvider as UiStateProvider,
+    StateService as UiStateService,
+    Transition as UiTransition,
+    TransitionService as UiTransitionService,
+} from '@uirouter/angularjs';
+
 import {ContactControllerModel} from '../controller_model/contact';
-import {supportsPassive, throttle} from '../helpers';
+import {bufferToUrl, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
 import {ContactService} from '../services/contact';
 import {ControllerService} from '../services/controller';
 import {ControllerModelService} from '../services/controller_model';
@@ -30,17 +38,21 @@ import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
-import {ControllerModelMode} from '../types/enums';
+import {isContactReceiver} from '../typeguards';
 
-class DialogController {
-    public static $inject = ['$mdDialog'];
+// Type aliases
+import ControllerModelMode = threema.ControllerModelMode;
 
+class DialogController {
     public $mdDialog: ng.material.IDialogService;
     public activeElement: HTMLElement | null;
+    public config: threema.Config;
 
-    constructor($mdDialog: ng.material.IDialogService) {
+    public static $inject = ['$mdDialog', 'CONFIG'];
+    constructor($mdDialog: ng.material.IDialogService, CONFIG: threema.Config) {
         this.$mdDialog = $mdDialog;
         this.activeElement = document.activeElement as HTMLElement;
+        this.config = CONFIG;
     }
 
     public cancel(): void {
@@ -73,15 +85,27 @@ class DialogController {
  * Handle sending of files.
  */
 class SendFileController extends DialogController {
-    public static $inject = ['$mdDialog', 'preview'];
+    public static $inject = ['$mdDialog', '$log', 'CONFIG', 'preview'];
+    private logTag: string = '[SendFileController]';
 
     public caption: string;
     public sendAsFile: boolean = false;
-    public preview: threema.FileMessageData | null = null;
+    private preview: threema.FileMessageData | null = null;
+    public previewDataUrl: string | null = null;
 
-    constructor($mdDialog: ng.material.IDialogService, preview: threema.FileMessageData) {
-        super($mdDialog);
+    constructor($mdDialog: ng.material.IDialogService,
+                $log: ng.ILogService,
+                CONFIG: threema.Config,
+                preview: threema.FileMessageData) {
+        super($mdDialog, CONFIG);
         this.preview = preview;
+        if (preview !== null) {
+            this.previewDataUrl = bufferToUrl(
+                this.preview.data,
+                this.preview.fileType,
+                logAdapter($log.warn, this.logTag),
+            );
+        }
     }
 
     public send(): void {
@@ -98,7 +122,7 @@ class SendFileController extends DialogController {
     }
 
     public hasPreview(): boolean {
-        return this.preview !== null && this.preview !== undefined;
+        return this.previewDataUrl !== null && this.previewDataUrl !== undefined;
     }
 }
 
@@ -168,6 +192,12 @@ class SettingsController {
 
 }
 
+interface ConversationStateParams extends UiStateParams {
+    type: threema.ReceiverType;
+    id: string;
+    initParams: null | {text: string | null};
+}
+
 class ConversationController {
     public name = 'navigation';
     private logTag: string = '[ConversationController]';
@@ -175,11 +205,12 @@ class ConversationController {
     // Angular services
     private $stateParams;
     private $timeout: ng.ITimeoutService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
     private $log: ng.ILogService;
     private $scope: ng.IScope;
     private $rootScope: ng.IRootScopeService;
     private $filter: ng.IFilterService;
+    private $translate: ng.translate.ITranslateService;
 
     // Own services
     private webClientService: WebClientService;
@@ -192,7 +223,7 @@ class ConversationController {
     private $mdToast: ng.material.IToastService;
 
     // Controller model
-    private controllerModel: threema.ControllerModel;
+    private controllerModel: threema.ControllerModel<threema.Receiver>;
 
     // DOM Elements
     private domChatElement: HTMLElement;
@@ -200,29 +231,36 @@ class ConversationController {
     // Scrolling
     public showScrollJump: boolean = false;
 
+    // The conversation receiver
     public receiver: threema.Receiver;
     public type: threema.ReceiverType;
+
+    // The conversation messages
+    private messages: threema.Message[];
+
+    // This will be set to true as soon as the initial messages have been loaded
+    private initialized = false;
+
+    // Mentions
+    public allMentions: threema.Mention[] = [];
+    public currentMentions: threema.Mention[] = [];
+    public currentMentionFilterWord = null;
+    public selectedMention: number = null;
+
     public message: string = '';
-    public lastReadMsgId: number = 0;
+    public lastReadMsg: threema.Message | null = null;
     public msgReadReportPending = false;
     private hasMore = true;
-    private latestRefMsgId: number = null;
+    private latestRefMsgId: string | null = null;
     private allText: string;
-    private messages: threema.Message[];
     public initialData: threema.InitialConversationData = {
         draft: '',
         initialText: '',
     };
-    private $translate: ng.translate.ITranslateService;
     private locked = false;
     public maxTextLength: number;
     public isTyping = (): boolean => false;
 
-    public allMentions: threema.Mention[] = [];
-    public currentMentions: threema.Mention[] = [];
-    public currentMentionFilterWord = null;
-    public selectedMention: number = null;
-
     private uploading = {
         enabled: false,
         value1: 0,
@@ -230,22 +268,23 @@ class ConversationController {
     };
 
     public static $inject = [
-        '$stateParams', '$state', '$timeout', '$log', '$scope', '$rootScope',
-        '$mdDialog', '$mdToast', '$location', '$translate', '$filter',
+        '$stateParams', '$timeout', '$log', '$scope', '$rootScope',
+        '$mdDialog', '$mdToast', '$translate', '$filter',
+        '$state', '$transitions',
         'WebClientService', 'StateService', 'ReceiverService', 'MimeService', 'VersionService',
         'ControllerModelService',
     ];
-    constructor($stateParams: threema.ConversationStateParams,
-                $state: ng.ui.IStateService,
+    constructor($stateParams: ConversationStateParams,
                 $timeout: ng.ITimeoutService,
                 $log: ng.ILogService,
                 $scope: ng.IScope,
                 $rootScope: ng.IRootScopeService,
                 $mdDialog: ng.material.IDialogService,
                 $mdToast: ng.material.IToastService,
-                $location,
                 $translate: ng.translate.ITranslateService,
                 $filter: ng.IFilterService,
+                $state: UiStateService,
+                $transitions: UiTransitionService,
                 webClientService: WebClientService,
                 stateService: StateService,
                 receiverService: ReceiverService,
@@ -275,10 +314,11 @@ class ConversationController {
         this.maxTextLength = this.webClientService.getMaxTextLength();
         this.allText = this.$translate.instant('messenger.ALL');
 
-        // On every navigation event, close all dialogs.
-        // Note: Deprecated. When migrating ui-router ($state),
-        // replace with transition hooks.
-        $rootScope.$on('$stateChangeStart', () => this.$mdDialog.cancel());
+        // On every navigation event, close all dialogs using ui-router transition hooks.
+        $transitions.onStart({}, function(trans: UiTransition) {
+            const $mdDialogInner: ng.material.IDialogService = trans.injector().get('$mdDialog');
+            $mdDialogInner.cancel();
+        });
 
         // Check for version updates
         versionService.checkForUpdate();
@@ -315,9 +355,9 @@ class ConversationController {
             const mode = ControllerModelMode.CHAT;
             switch (this.receiver.type) {
                 case 'me':
-                    $log.warn(this.logTag, 'Cannot chat with own contact');
-                    $state.go('messenger.home');
-                    return;
+                    this.controllerModel = controllerModelService.me(
+                        this.receiver as threema.MeReceiver, mode);
+                    break;
                 case 'contact':
                     this.controllerModel = controllerModelService.contact(
                         this.receiver as threema.ContactReceiver, mode);
@@ -337,9 +377,9 @@ class ConversationController {
                     return;
             }
 
-            // Check if this receiver may be viewed
-            if (this.controllerModel.canView() === false) {
-                $log.warn(this.logTag, 'Cannot view this receiver, redirecting to home');
+            // Check if this receiver may be chatted with
+            if (this.controllerModel.canChat() === false) {
+                $log.warn(this.logTag, 'Cannot chat with this receiver, redirecting to home');
                 $state.go('messenger.home');
                 return;
             }
@@ -351,24 +391,38 @@ class ConversationController {
 
             if (!this.receiver.locked) {
                 let latestHeight = 0;
-                // update unread count
-                this.webClientService.messages.updateFirstUnreadMessage(this.receiver);
+
+                // Subscribe to messages
                 this.messages = this.webClientService.messages.register(
                     this.receiver,
                     this.$scope,
                     (e, allMessages: threema.Message[], hasMore: boolean) => {
+                        // This function is called every time there are new or removed messages.
+
+                        // Update data
                         this.messages = allMessages;
+                        const wasInitialized = this.initialized;
+                        this.initialized = true;
                         this.hasMore = hasMore;
+
+                        // Update "first unread" divider
+                        if (!wasInitialized) {
+                            this.webClientService.messages.updateFirstUnreadMessage(this.receiver);
+                        }
+
+                        // Autoscroll
                         if (this.latestRefMsgId !== null) {
                             // scroll to div..
-                            this.domChatElement.scrollTop =
-                                this.domChatElement.scrollHeight - latestHeight;
+                            this.domChatElement.scrollTop = this.domChatElement.scrollHeight - latestHeight;
                             this.latestRefMsgId = null;
                         }
                         latestHeight = this.domChatElement.scrollHeight;
                     },
                 );
 
+                // Update "first unread" divider
+                this.webClientService.messages.updateFirstUnreadMessage(this.receiver);
+
                 // Enable mentions only in group chats
                 if (this.type === 'group') {
                     this.allMentions.push({
@@ -393,11 +447,10 @@ class ConversationController {
                     initialText: $stateParams.initParams ? $stateParams.initParams.text : '',
                 };
 
-                if (this.receiver.type === 'contact') {
+                if (isContactReceiver(this.receiver)) {
                     this.isTyping = () => this.webClientService.isTyping(this.receiver as threema.ContactReceiver);
                 }
             }
-
         } catch (error) {
             $log.error('Could not set receiver and type');
             $log.debug(error.stack);
@@ -436,7 +489,6 @@ class ConversationController {
         if (errorMessage === undefined || errorMessage.length === 0) {
             errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
         }
-
         this.$mdToast.show(
             this.$mdToast.simple()
                 .textContent(errorMessage)
@@ -449,7 +501,7 @@ class ConversationController {
     public submit = (type: threema.MessageContentType, contents: threema.MessageData[]): Promise<any> => {
         // Validate whether a connection is available
         return new Promise((resolve, reject) => {
-            if (this.stateService.state !== 'ok') {
+            if (!this.stateService.readyToSubmit(this.webClientService.chosenTask)) {
                 // Invalid connection, show toast and abort
                 this.showError(this.$translate.instant('error.NO_CONNECTION'));
                 return reject();
@@ -460,7 +512,7 @@ class ConversationController {
                     if (success) {
                         resolve();
                     } else {
-                        reject();
+                        reject('Message sending unsuccessful');
                     }
                 }
             };
@@ -511,7 +563,7 @@ class ConversationController {
                             <md-dialog class="send-file-dialog">
                                 <md-dialog-content class="md-dialog-content">
                                     <h2 class="md-title">${title}</h2>
-                                    <img class="preview" ng-if="ctrl.hasPreview()" ng-src="{{ ctrl.preview.data | bufferToUrl: ctrl.preview.fileType }}" />
+                                    <img class="preview" ng-if="ctrl.hasPreview()" ng-src="{{ ctrl.previewDataUrl }}">
                                     <md-input-container md-no-float class="input-caption md-prompt-input-container" ng-show="!${showSendAsFileCheckbox} || ctrl.sendAsFile || ${captionSupported}">
                                         <input maxlength="1000" md-autofocus ng-keypress="ctrl.keypress($event)" ng-model="ctrl.caption" placeholder="${placeholder}" aria-label="${placeholder}">
                                     </md-input-container>
@@ -731,18 +783,37 @@ class ConversationController {
      * A message has been seen. Report it to the app, with a small delay to
      * avoid sending too many messages at once.
      */
-    public msgRead(msgId: number): void {
-        if (msgId > this.lastReadMsgId) {
-            this.lastReadMsgId = msgId;
+    public msgRead(message: threema.Message): void {
+        // Ignore status messages
+        if (message.type === 'status') {
+            return;
+        }
+
+        // Update lastReadMsg
+        if (this.lastReadMsg === null || message.sortKey > this.lastReadMsg.sortKey) {
+            this.lastReadMsg = message;
         }
+
         if (!this.msgReadReportPending) {
+            // Don't send a read message for messages that are already read.
+            // (Note: Ignore own messages since those are always read.)
+            if (!message.isOutbox && !message.unread) {
+                return;
+            }
+
+            // Don't send a read message for conversations that have no unread messages.
+            const conversation = this.webClientService.conversations.find(this.receiver);
+            if (conversation !== null && conversation.unreadCount === 0) {
+                return;
+            }
+
             this.msgReadReportPending = true;
             const receiver = angular.copy(this.receiver);
             receiver.type = this.type;
             this.$timeout(() => {
-                this.webClientService.requestRead(receiver, this.lastReadMsgId);
+                this.webClientService.requestRead(receiver, this.lastReadMsg);
                 this.msgReadReportPending = false;
-            }, 500);
+            }, 300);
         }
     }
 
@@ -784,14 +855,14 @@ class NavigationController {
 
     private $mdDialog;
     private $translate: ng.translate.ITranslateService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
 
     public static $inject = [
         '$log', '$state', '$mdDialog', '$translate',
         'WebClientService', 'StateService', 'ReceiverService', 'TrustedKeyStore',
     ];
 
-    constructor($log: ng.ILogService, $state: ng.ui.IStateService,
+    constructor($log: ng.ILogService, $state: UiStateService,
                 $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, stateService: StateService,
                 receiverService: ReceiverService,
@@ -851,6 +922,7 @@ class NavigationController {
     public isVisible(conversation: threema.Conversation) {
         return conversation.receiver.visible;
     }
+
     public conversations(): threema.Conversation[] {
         return this.webClientService.conversations.get();
     }
@@ -859,6 +931,13 @@ class NavigationController {
         return this.receiverService.isConversationActive(value);
     }
 
+    /**
+     * Return true if the app wants to hide inactive contacts.
+     */
+    public hideInactiveContacts(): boolean {
+        return !this.webClientService.appConfig.showInactiveIDs;
+    }
+
     /**
      * Show dialog.
      */
@@ -896,6 +975,14 @@ class NavigationController {
         });
     }
 
+    /**
+     * Show profile.
+     */
+    public showProfile(ev): void {
+        this.receiverService.setActive(undefined);
+        this.$state.go('messenger.home.detail', this.webClientService.me);
+    }
+
     /**
      * Return whether a trusted key is available.
      */
@@ -914,10 +1001,9 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const deleteStoredData = false;
             const resetPush = true;
             const redirect = true;
-            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
+            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
             this.receiverService.setActive(undefined);
         }, () => {
             // do nothing
@@ -935,10 +1021,9 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            const deleteStoredData = true;
             const resetPush = true;
             const redirect = true;
-            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
+            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
             this.receiverService.setActive(undefined);
         }, () => {
             // do nothing
@@ -963,6 +1048,7 @@ class NavigationController {
             type: 'distributionList',
         });
     }
+
     /**
      * Toggle search bar.
      */
@@ -970,16 +1056,25 @@ class NavigationController {
         this.searchVisible = !this.searchVisible;
     }
 
-    public getMyIdentity(): threema.Identity {
-        return this.webClientService.getMyIdentity();
+    /**
+     * Return the user profile.
+     */
+    public getMe(): threema.MeReceiver {
+        return this.webClientService.me;
     }
 
-    public showMyIdentity(): boolean {
-        return this.getMyIdentity() !== undefined;
+    /**
+     * Only show the "create distribution list" button if the app supports it.
+     */
+    public showCreateDistributionListButton(): boolean {
+        return this.webClientService.appCapabilities.distributionLists;
     }
+
 }
 
 class MessengerController {
+    private logTag: string = '[MessengerController]';
+
     public name = 'messenger';
     private receiverService: ReceiverService;
     private $state;
@@ -995,7 +1090,7 @@ class MessengerController {
                 webClientService: WebClientService, controllerService: ControllerService) {
         // Redirect to welcome if necessary
         if (stateService.state === 'error') {
-            $log.debug('MessengerController: WebClient not yet running, redirecting to welcome screen');
+            $log.debug(this.logTag, 'MessengerController: WebClient not yet running, redirecting to welcome screen');
             $state.go('welcome');
             return;
         }
@@ -1023,7 +1118,7 @@ class MessengerController {
         }, true);
 
         this.webClientService.setReceiverListener({
-            onRemoved(receiver: threema.Receiver) {
+            onConversationRemoved(receiver: threema.Receiver) {
                 switch ($state.current.name) {
                     case 'messenger.home.conversation':
                     case 'messenger.home.detail':
@@ -1034,12 +1129,12 @@ class MessengerController {
                             if ($state.params.type === receiver.type
                                 && $state.params.id === receiver.id) {
                                 // conversation or sub form is open, redirect to home!
-                                $state.go('messenger.home', null, {location: 'replace'});
+                                $state.go('messenger.home');
                             }
                         }
                         break;
                     default:
-                        $log.warn('Ignored onRemoved event for state', $state.current.name);
+                        $log.debug(this.logTag, 'Ignored onRemoved event for state', $state.current.name);
                 }
             },
         });
@@ -1053,14 +1148,20 @@ class MessengerController {
 class ReceiverDetailController {
     private logTag: string = '[ReceiverDetailController]';
 
-    public $mdDialog: any;
-    public $state: ng.ui.IStateService;
+    // Angular services
+    private $mdDialog: any;
+    private $scope: ng.IScope;
+    private $state: UiStateService;
+
+    // Own services
+    private fingerPrintService: FingerPrintService;
+    private contactService: ContactService;
+    private webClientService: WebClientService;
+
     public receiver: threema.Receiver;
     public me: threema.MeReceiver;
     public title: string;
-    public fingerPrint?: string;
-    private fingerPrintService: FingerPrintService;
-    private contactService: ContactService;
+    public fingerPrint = { value: null };  // Object, so that data binding works
     private showGroups = false;
     private showDistributionLists = false;
     private inGroups: threema.GroupReceiver[] = [];
@@ -1070,27 +1171,30 @@ class ReceiverDetailController {
     private isWorkReceiver = false;
     private showBlocked = () => false;
 
-    private controllerModel: threema.ControllerModel;
+    private controllerModel: threema.ControllerModel<threema.Receiver>;
 
     public static $inject = [
-        '$log', '$stateParams', '$state', '$mdDialog',
+        '$scope', '$log', '$stateParams', '$state', '$mdDialog', '$translate',
         'WebClientService', 'FingerPrintService', 'ContactService', 'ControllerModelService',
     ];
-    constructor($log: ng.ILogService, $stateParams, $state: ng.ui.IStateService, $mdDialog: ng.material.IDialogService,
+    constructor($scope: ng.IScope, $log: ng.ILogService, $stateParams, $state: UiStateService,
+                $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, fingerPrintService: FingerPrintService,
                 contactService: ContactService, controllerModelService: ControllerModelService) {
 
         this.$mdDialog = $mdDialog;
+        this.$scope = $scope;
         this.$state = $state;
         this.fingerPrintService = fingerPrintService;
         this.contactService = contactService;
+        this.webClientService = webClientService;
 
         this.receiver = webClientService.receivers.getData($stateParams);
         this.me = webClientService.me;
 
-        // Append members
-        if (this.receiver.type === 'contact') {
-            const contactReceiver = this.receiver as threema.ContactReceiver;
+        // Append group membership
+        if (isContactReceiver(this.receiver)) {
+            const contactReceiver = this.receiver;
 
             this.contactService.requiredDetails(contactReceiver)
                 .then(() => {
@@ -1102,7 +1206,11 @@ class ReceiverDetailController {
                 });
 
             this.isWorkReceiver = contactReceiver.identityType === threema.IdentityType.Work;
-            this.fingerPrint = this.fingerPrintService.generate(contactReceiver.publicKey);
+
+            this.fingerPrintService
+                .generate(contactReceiver.publicKey)
+                .then(this.setFingerPrint.bind(this));
+
             webClientService.groups.forEach((groupReceiver: threema.GroupReceiver) => {
                 // check if my identity is a member
                 if (groupReceiver.members.indexOf(contactReceiver.id) !== -1) {
@@ -1126,12 +1234,19 @@ class ReceiverDetailController {
 
         switch (this.receiver.type) {
             case 'me':
-                $log.warn(this.logTag, 'Cannot view own contact');
-                $state.go('messenger.home');
-                return;
+                const meReceiver = this.receiver as threema.MeReceiver;
+                this.fingerPrintService
+                    .generate(meReceiver.publicKey)
+                    .then(this.setFingerPrint.bind(this));
+                this.controllerModel = controllerModelService.me(meReceiver, ControllerModelMode.VIEW);
+                break;
             case 'contact':
+                const contactReceiver = this.receiver as threema.ContactReceiver;
+                this.fingerPrintService
+                    .generate(contactReceiver.publicKey)
+                    .then(this.setFingerPrint.bind(this));
                 this.controllerModel = controllerModelService
-                    .contact(this.receiver as threema.ContactReceiver, ControllerModelMode.VIEW);
+                    .contact(contactReceiver, ControllerModelMode.VIEW);
                 break;
             case 'group':
                 this.controllerModel = controllerModelService
@@ -1148,14 +1263,7 @@ class ReceiverDetailController {
                 return;
         }
 
-        // If this receiver may not be viewed, navigate to "home" view
-        if (this.controllerModel.canView() === false) {
-            $log.warn(this.logTag, 'Cannot view this receiver, redirecting to home');
-            this.$state.go('messenger.home');
-            return;
-        }
-
-        // If this receiver is removed, navigate to "home" view
+        // If this receiver was removed, navigate to "home" view
         this.controllerModel.setOnRemoved((receiverId: string) => {
             $log.warn(this.logTag, 'Receiver removed, redirecting to home');
             this.$state.go('messenger.home');
@@ -1163,6 +1271,17 @@ class ReceiverDetailController {
 
     }
 
+    /**
+     * Set the fingerprint value and run $digest.
+     *
+     * This may only be called from outside the $digest loop
+     * (e.g. from a plain promise callback).
+     */
+    private setFingerPrint(fingerPrint: string): void {
+        this.fingerPrint.value = fingerPrint;
+        this.$scope.$digest();
+    }
+
     public chat(): void {
         this.$state.go('messenger.home.conversation', {
             type: this.receiver.type,
@@ -1182,6 +1301,35 @@ class ReceiverDetailController {
         });
     }
 
+    /**
+     * Show the QR code of the public key.
+     */
+    public showQr(): void {
+        const profile = this.webClientService.me;
+        const $mdDialog = this.$mdDialog;
+        $mdDialog.show({
+            controllerAs: 'ctrl',
+            controller: [function() {
+               this.cancel = () =>  {
+                   $mdDialog.cancel();
+               };
+               this.profile = profile;
+               this.qrCode = {
+                    errorCorrectionLevel: 'L',
+                    size: '400px',
+                    data: '3mid:'
+                    + profile.id
+                    + ','
+                    + u8aToHex(new Uint8Array(profile.publicKey)),
+                };
+            }],
+            templateUrl: 'partials/dialog.qr.html',
+            parent: angular.element(document.body),
+            clickOutsideToClose: true,
+            fullscreen: true,
+        });
+    }
+
     public goBack(): void {
         window.history.back();
     }
@@ -1196,7 +1344,7 @@ class ReceiverEditController {
     private logTag: string = '[ReceiverEditController]';
 
     public $mdDialog: any;
-    public $state: ng.ui.IStateService;
+    public $state: UiStateService;
     private $translate: ng.translate.ITranslateService;
 
     public title: string;
@@ -1204,14 +1352,14 @@ class ReceiverEditController {
     private execute: ExecuteService;
     public loading = false;
 
-    private controllerModel: threema.ControllerModel;
+    private controllerModel: threema.ControllerModel<threema.Receiver>;
     public type: string;
 
     public static $inject = [
         '$log', '$stateParams', '$state', '$mdDialog',
         '$timeout', '$translate', 'WebClientService', 'ControllerModelService',
     ];
-    constructor($log: ng.ILogService, $stateParams, $state: ng.ui.IStateService,
+    constructor($log: ng.ILogService, $stateParams, $state: UiStateService,
                 $mdDialog, $timeout: ng.ITimeoutService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, controllerModelService: ControllerModelService) {
 
@@ -1223,9 +1371,11 @@ class ReceiverEditController {
         const receiver = webClientService.receivers.getData($stateParams);
         switch (receiver.type) {
             case 'me':
-                $log.warn(this.logTag, 'Cannot edit own contact');
-                $state.go('messenger.home');
-                return;
+                this.controllerModel = controllerModelService.me(
+                    receiver as threema.MeReceiver,
+                    ControllerModelMode.EDIT,
+                );
+                break;
             case 'contact':
                 this.controllerModel = controllerModelService.contact(
                     receiver as threema.ContactReceiver,
@@ -1252,13 +1402,6 @@ class ReceiverEditController {
         }
         this.type = receiver.type;
 
-        // If this receiver may not be viewed, navigate to "home" view
-        if (this.controllerModel.canView() === false) {
-            $log.warn(this.logTag, 'Cannot view this receiver, redirecting to home');
-            this.$state.go('messenger.home');
-            return;
-        }
-
         this.execute = new ExecuteService($log, $timeout, 1000);
     }
 
@@ -1269,7 +1412,6 @@ class ReceiverEditController {
     }
 
     public save(): void {
-
         // show loading
         this.loading = true;
 
@@ -1279,7 +1421,7 @@ class ReceiverEditController {
                 this.goBack();
             })
             .catch((errorCode) => {
-                this.showError(errorCode);
+                this.showEditError(errorCode);
             });
     }
 
@@ -1288,13 +1430,17 @@ class ReceiverEditController {
             && this.execute.isRunning();
     }
 
-    public showError(errorCode): void {
+    private showEditError(errorCode: string): void {
+        if (errorCode === undefined) {
+            errorCode = 'unknown';
+        }
         this.$mdDialog.show(
             this.$mdDialog.alert()
                 .clickOutsideToClose(true)
                 .title(this.controllerModel.subject)
-                .textContent(this.$translate.instant('validationError.editReceiver.' + errorCode))
-                .ok(this.$translate.instant('common.OK')));
+                .textContent(this.$translate.instant('validationError.modifyReceiver.' + errorCode))
+                .ok(this.$translate.instant('common.OK')),
+        );
     }
 
     public goBack(): void {
@@ -1302,6 +1448,11 @@ class ReceiverEditController {
     }
 }
 
+interface CreateReceiverStateParams extends UiStateParams {
+    type: threema.ReceiverType;
+    initParams: null | {identity: string | null};
+}
+
 /**
  * Control creating a group or adding contact
  * fields, validate and save routines are implemented in the specific ControllerModel
@@ -1313,19 +1464,19 @@ class ReceiverCreateController {
     private loading = false;
     private $timeout: ng.ITimeoutService;
     private $log: ng.ILogService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
     private $mdToast: any;
     public identity = '';
     private $translate: any;
     public type: string;
     private execute: ExecuteService;
 
-    public controllerModel: threema.ControllerModel;
+    public controllerModel: threema.ControllerModel<threema.Receiver>;
 
     public static $inject = ['$stateParams', '$mdDialog', '$mdToast', '$translate',
         '$timeout', '$state', '$log', 'ControllerModelService'];
-    constructor($stateParams: threema.CreateReceiverStateParams, $mdDialog, $mdToast, $translate,
-                $timeout: ng.ITimeoutService, $state: ng.ui.IStateService, $log: ng.ILogService,
+    constructor($stateParams: CreateReceiverStateParams, $mdDialog, $mdToast, $translate,
+                $timeout: ng.ITimeoutService, $state: UiStateService, $log: ng.ILogService,
                 controllerModelService: ControllerModelService) {
         this.$mdDialog = $mdDialog;
         this.$timeout = $timeout;
@@ -1371,13 +1522,13 @@ class ReceiverCreateController {
 
     private showAddError(errorCode: string): void {
         if (errorCode === undefined) {
-            errorCode = 'invalid_entry';
+            errorCode = 'unknown';
         }
         this.$mdDialog.show(
             this.$mdDialog.alert()
                 .clickOutsideToClose(true)
                 .title(this.controllerModel.subject)
-                .textContent(this.$translate.instant('validationError.createReceiver.' + errorCode))
+                .textContent(this.$translate.instant('validationError.modifyReceiver.' + errorCode))
                 .ok(this.$translate.instant('common.OK')),
         );
     }
@@ -1389,23 +1540,21 @@ class ReceiverCreateController {
     }
 
     public create(): void {
-        // show loading
+        // Show loading indicator
         this.loading = true;
 
-        // validate first
+        // Save, then go to receiver detail page
         this.execute.execute(this.controllerModel.save())
             .then((receiver: threema.Receiver) => {
                 this.$state.go('messenger.home.detail', receiver, {location: 'replace'});
             })
-            .catch((errorCode) => {
-                this.showAddError(errorCode);
-            });
+            .catch(this.showAddError.bind(this));
     }
 }
 
 angular.module('3ema.messenger', ['ngMaterial'])
 
-.config(['$stateProvider', function($stateProvider: ng.ui.IStateProvider) {
+.config(['$stateProvider', function($stateProvider: UiStateProvider) {
 
     $stateProvider
 

+ 17 - 0
src/partials/welcome.html

@@ -1,6 +1,18 @@
 <div id="welcome">
     <div class="center-block text-center welcome">
         <div ng-if="(ctrl.state === 'connecting' || ctrl.state === 'waiting') && ctrl.mode === 'scan'" class="scan">
+            <div class="ios-only-warning" ng-if="ctrl.browser.safari">
+                <img src="img/safari.svg" alt="Safari">
+                <p><strong translate>common.NOTE</strong> <span translate>welcome.SAFARI</span></p>
+            </div>
+
+            <div class="notification">
+                <p translate>welcome.NOTIFICATION_NEW_RELEASE</p>
+            </div>
+            <div class="notification">
+                <p translate>welcome.NOTIFICATION_IOS_BETA</p>
+            </div>
+
             <p class="instructions" translate>welcome.PLEASE_SCAN</p>
             <qrcode version="{{ ctrl.qrCode.version }}"
                     id="connecting-code"
@@ -23,6 +35,10 @@
         </div>
 
         <div ng-if="ctrl.state === 'connecting' && ctrl.mode === 'unlock'" class="unlock">
+            <div class="notification">
+                <p translate>welcome.NOTIFICATION_NEW_RELEASE</p>
+            </div>
+
             <h2 class="instructions" translate>welcome.PLEASE_UNLOCK</h2>
             <div class="password-entry">
                 <label>
@@ -31,6 +47,7 @@
                         <md-input-container md-no-float class="md-block">
                             <input type="password"
                                    ng-model="ctrl.password"
+                                   ng-disabled="ctrl.formLocked"
                                    autofocus
                                    aria-label="Password"
                                    translate-attr="{'placeholder': 'welcome.PASSWORD', 'aria-label': 'welcome.PASSWORD'}"

+ 86 - 31
src/partials/welcome.ts

@@ -19,14 +19,23 @@
 
 /// <reference path="../types/broadcastchannel.d.ts" />
 
+import {
+    StateParams as UiStateParams,
+    StateProvider as UiStateProvider,
+    StateService as UiStateService,
+} from '@uirouter/angularjs';
+
 import {BrowserService} from '../services/browser';
 import {ControllerService} from '../services/controller';
 import {TrustedKeyStoreService} from '../services/keystore';
 import {PushService} from '../services/push';
+import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 
+import GlobalConnectionState = threema.GlobalConnectionState;
+
 class DialogController {
     // TODO: This is also used in partials/messenger.ts. We could somehow
     // extract it into a separate file.
@@ -43,6 +52,10 @@ class DialogController {
     }
 }
 
+interface WelcomeStateParams extends UiStateParams {
+    initParams: null | {keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array};
+}
+
 class WelcomeController {
 
     private static REDIRECT_DELAY = 500;
@@ -51,11 +64,11 @@ class WelcomeController {
 
     // Angular services
     private $scope: ng.IScope;
-    private $state: ng.ui.IStateService;
     private $timeout: ng.ITimeoutService;
     private $interval: ng.IIntervalService;
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
+    private $state: UiStateService;
 
     // Material design services
     private $mdDialog: ng.material.IDialogService;
@@ -63,9 +76,10 @@ class WelcomeController {
 
     // Custom services
     private webClientService: WebClientService;
-    private TrustedKeyStore: TrustedKeyStoreService;
+    private trustedKeyStore: TrustedKeyStoreService;
     private pushService: PushService;
     private stateService: StateService;
+    private settingsService: SettingsService;
     private config: threema.Config;
 
     // Other
@@ -73,24 +87,29 @@ class WelcomeController {
     private mode: 'scan' | 'unlock';
     private qrCode;
     private password: string = '';
+    private formLocked: boolean = false;
     private pleaseUpdateAppMsg: string = null;
+    private browser: threema.BrowserInfo;
+    private browserWarningShown: boolean = false;
 
     public static $inject = [
         '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
-        'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService', 'VersionService',
-        'BROWSER_MIN_VERSIONS', 'CONFIG', 'ControllerService',
+        'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
+        'VersionService', 'SettingsService', 'ControllerService',
+        'BROWSER_MIN_VERSIONS', 'CONFIG',
     ];
-    constructor($scope: ng.IScope, $state: ng.ui.IStateService, $stateParams: threema.WelcomeStateParams,
+    constructor($scope: ng.IScope, $state: UiStateService, $stateParams: WelcomeStateParams,
                 $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
-                webClientService: WebClientService, TrustedKeyStore: TrustedKeyStoreService,
+                webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
                 stateService: StateService, pushService: PushService,
                 browserService: BrowserService,
                 versionService: VersionService,
+                settingsService: SettingsService,
+                controllerService: ControllerService,
                 minVersions: threema.BrowserMinVersions,
-                config: threema.Config,
-                controllerService: ControllerService) {
+                config: threema.Config) {
         controllerService.setControllerName('welcome');
         // Angular services
         this.$scope = $scope;
@@ -104,40 +123,54 @@ class WelcomeController {
 
         // Own services
         this.webClientService = webClientService;
-        this.TrustedKeyStore = TrustedKeyStore;
+        this.trustedKeyStore = trustedKeyStore;
         this.stateService = stateService;
         this.pushService = pushService;
+        this.settingsService = settingsService;
         this.config = config;
 
         // Determine whether browser warning should be shown
-        const browser = browserService.getBrowser();
-        const version = parseFloat(browser.version);
-        $log.debug('Detected browser:', browser.textInfo);
-        if (isNaN(version)) {
+        this.browser = browserService.getBrowser();
+        const version = this.browser.version;
+        $log.debug('Detected browser:', this.browser.textInfo);
+        if (version === undefined) {
             $log.warn('Could not determine browser version');
             this.showBrowserWarning();
-        } else if (browser.chrome === true) {
+        } else if (this.browser.chrome === true) {
             if (version < minVersions.CHROME) {
                 $log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')');
                 this.showBrowserWarning();
             }
-        } else if (browser.firefox === true) {
+        } else if (this.browser.firefox === true) {
             if (version < minVersions.FF) {
                 $log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')');
                 this.showBrowserWarning();
             }
-        } else if (browser.opera === true) {
+        } else if (this.browser.opera === true) {
             if (version < minVersions.OPERA) {
                 $log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')');
                 this.showBrowserWarning();
             }
+        } else if (this.browser.safari === true) {
+            if (version < minVersions.SAFARI) {
+                $log.warn('Safari is too old (' + version + ' < ' + minVersions.SAFARI + ')');
+                this.showBrowserWarning();
+            }
         } else {
             $log.warn('Non-supported browser, please use Chrome, Firefox or Opera');
             this.showBrowserWarning();
         }
 
+        // Show a "new version info" dialog the first time.
+        if (!this.browserWarningShown) {
+            // The browser warning dialog interferes with the new version dialog, so don't trigger both.
+            if (this.settingsService.retrieveUntrustedKeyValuePair('v2infoShown', false) !== 'yes') {
+                this.showNewVersionInfos();
+            }
+        }
+
         // Determine whether local storage is available
-        if (this.TrustedKeyStore.blocked === true) {
+        if (this.trustedKeyStore.blocked === true) {
             $log.error('Cannot access local storage. Is it being blocked by a browser add-on?');
             this.showLocalStorageWarning();
         }
@@ -161,7 +194,7 @@ class WelcomeController {
         // Determine whether trusted key is available
         let hasTrustedKey = null;
         try {
-            hasTrustedKey = this.TrustedKeyStore.hasTrustedKey();
+            hasTrustedKey = this.trustedKeyStore.hasTrustedKey();
         } catch (e) {
             $log.error('Exception while accessing local storage:', e);
             this.showLocalStorageException(e);
@@ -257,8 +290,12 @@ class WelcomeController {
      * Decrypt the keys and initiate the session.
      */
     private unlockConfirm(): void {
-        const decrypted: threema.TrustedKeyStoreData = this.TrustedKeyStore.retrieveTrustedKey(this.password);
+        // Lock form to prevent further input
+        this.formLocked = true;
+
+        const decrypted: threema.TrustedKeyStoreData = this.trustedKeyStore.retrieveTrustedKey(this.password);
         if (decrypted === null) {
+            this.formLocked = false;
             return this.showDecryptionFailed();
         }
 
@@ -269,9 +306,9 @@ class WelcomeController {
         this.setupBroadcastChannel(keyStore.publicKeyHex);
 
         // Initialize push service
-        if (decrypted.pushToken !== null) {
-            this.pushService.init(decrypted.pushToken);
-            this.$log.debug(this.logTag, '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
@@ -328,7 +365,7 @@ class WelcomeController {
                         this.$log.error(this.logTag, 'Session already connected in another tab or window');
                         this.$timeout(() => {
                             this.stateService.updateConnectionBuildupState('already_connected');
-                            this.stateService.state = 'error';
+                            this.stateService.state = GlobalConnectionState.Error;
                         }, 500);
                     }
                     break;
@@ -358,6 +395,7 @@ class WelcomeController {
      * Show a browser warning dialog.
      */
     private showBrowserWarning(): void {
+        this.browserWarningShown = true;
         this.$translate.onReady().then(() => {
             const confirm = this.$mdDialog.confirm()
                 .title(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED'))
@@ -368,7 +406,7 @@ class WelcomeController {
                 // do nothing
             }, () => {
                 // Redirect to Threema website
-                window.location.replace('https://threema.ch/');
+                window.location.replace('https://threema.ch/threema-web');
             });
         });
     }
@@ -421,13 +459,30 @@ class WelcomeController {
     private showAlreadyConnected(): void {
         this.$translate.onReady().then(() => {
             const confirm = this.$mdDialog.alert()
-            .title(this.$translate.instant('welcome.ALREADY_CONNECTED'))
-            .htmlContent(this.$translate.instant('welcome.ALREADY_CONNECTED_DETAILS'))
-            .ok(this.$translate.instant('common.OK'));
+                .title(this.$translate.instant('welcome.ALREADY_CONNECTED'))
+                .htmlContent(this.$translate.instant('welcome.ALREADY_CONNECTED_DETAILS'))
+                .ok(this.$translate.instant('common.OK'));
             this.$mdDialog.show(confirm);
         });
     }
 
+    /**
+     * Show version 2 release information.
+     * TODO: Remove this in next version!
+     */
+    private showNewVersionInfos(): void {
+        this.$translate.onReady().then(() => {
+            const confirm = this.$mdDialog.alert()
+                .title(this.$translate.instant('welcome.NEW_VERSION'))
+                .htmlContent(this.$translate.instant('welcome.NEW_VERSION_DETAILS'))
+                .ok(this.$translate.instant('common.UNDERSTOOD'));
+            this.$mdDialog.show(confirm).then(() => {
+                // Remember that dialog was dismissed
+                this.settingsService.storeUntrustedKeyValuePair('v2infoShown', 'yes');
+            });
+        });
+    }
+
     /**
      * Forget trusted keys.
      */
@@ -441,10 +496,9 @@ class WelcomeController {
 
         this.$mdDialog.show(confirm).then(() =>  {
             // Force-stop the webclient
-            const deleteStoredData = true;
             const resetPush = true;
             const redirect = false;
-            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
+            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
 
             // Reset state
             this.stateService.updateConnectionBuildupState('new');
@@ -452,6 +506,7 @@ class WelcomeController {
             // Go back to scan mode
             this.mode = 'scan';
             this.password = '';
+            this.formLocked = false;
 
             // Initiate scan
             this.scan();
@@ -515,6 +570,7 @@ class WelcomeController {
 
                 // Clear local password variable
                 this.password = '';
+                this.formLocked = false;
 
                 // Redirect to home
                 this.$timeout(() => this.$state.go('messenger.home'), WelcomeController.REDIRECT_DELAY);
@@ -544,10 +600,9 @@ class WelcomeController {
 
 angular.module('3ema.welcome', [])
 
-.config(['$stateProvider', ($stateProvider: ng.ui.IStateProvider) => {
+.config(['$stateProvider', ($stateProvider: UiStateProvider) => {
 
     $stateProvider
-
         .state('welcome', {
             url: '/welcome',
             templateUrl: 'partials/welcome.html',

+ 33 - 0
src/receiver_helpers.ts

@@ -0,0 +1,33 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+// This file contains helper functions related to receivers.
+// Try to keep all functions pure!
+
+/**
+ * Return wether a contact is a Threema Gateway contact.
+ */
+export function isGatewayContact(receiver: threema.ContactReceiver) {
+    return receiver.id.startsWith('*');
+}
+
+/**
+ * Return wether a contact is the ECHOECHO test contact.
+ */
+export function isEchoContact(receiver: threema.ContactReceiver) {
+    return receiver.id === 'ECHOECHO';
+}

+ 0 - 1
src/sass/app.scss

@@ -57,7 +57,6 @@
 @import "sections/compose_area";
 @import "sections/footer";
 @import "sections/status_bar";
-@import "sections/my_identity";
 @import "sections/noscript";
 
 // Vendors: Third party code.

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików