ソースを参照

Merge pull request #475 from threema-ch/ios

Threema Web v2.0.0
Danilo Bargen 7 年 前
コミット
0a38dbab87
100 ファイル変更3167 行追加1421 行削除
  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" }}
           - v1-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ arch }}-{{ checksum "package.json" }}
     - run: npm install
     - run: npm install
     - run: npm run build
     - run: npm run build
+    - run: npm run build:tests
     - run: npm test
     - run: npm test
     - save_cache:
     - save_cache:
         key: v1-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ arch }}-{{ checksum "package.json" }}
         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
 This changelog lists the most important changes for each released version. For
 the full log, please refer to the git commit history.
 the full log, please refer to the git commit history.
 
 
+### [v2.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)
 ### [v1.8.2][v1.8.2] (2018-02-21)
 
 
 Changes:
 Changes:
@@ -285,12 +384,14 @@ Contributors:
 
 
 First public release.
 First public release.
 
 
+[i4]: https://github.com/threema-ch/threema-web/issues/4
 [i6]: https://github.com/threema-ch/threema-web/issues/6
 [i6]: https://github.com/threema-ch/threema-web/issues/6
 [i8]: https://github.com/threema-ch/threema-web/issues/8
 [i8]: https://github.com/threema-ch/threema-web/issues/8
 [i11]: https://github.com/threema-ch/threema-web/issues/11
 [i11]: https://github.com/threema-ch/threema-web/issues/11
 [i17]: https://github.com/threema-ch/threema-web/issues/17
 [i17]: https://github.com/threema-ch/threema-web/issues/17
 [i20]: https://github.com/threema-ch/threema-web/issues/20
 [i20]: https://github.com/threema-ch/threema-web/issues/20
 [i29]: https://github.com/threema-ch/threema-web/issues/29
 [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
 [i38]: https://github.com/threema-ch/threema-web/issues/38
 [i39]: https://github.com/threema-ch/threema-web/issues/39
 [i39]: https://github.com/threema-ch/threema-web/issues/39
 [i41]: https://github.com/threema-ch/threema-web/issues/41
 [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
 [i50]: https://github.com/threema-ch/threema-web/issues/50
 [i54]: https://github.com/threema-ch/threema-web/issues/54
 [i54]: https://github.com/threema-ch/threema-web/issues/54
 [i57]: https://github.com/threema-ch/threema-web/issues/57
 [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
 [i59]: https://github.com/threema-ch/threema-web/issues/59
 [i61]: https://github.com/threema-ch/threema-web/issues/61
 [i61]: https://github.com/threema-ch/threema-web/issues/61
 [i64]: https://github.com/threema-ch/threema-web/issues/64
 [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
 [i83]: https://github.com/threema-ch/threema-web/issues/83
 [i86]: https://github.com/threema-ch/threema-web/issues/86
 [i86]: https://github.com/threema-ch/threema-web/issues/86
 [i90]: https://github.com/threema-ch/threema-web/issues/90
 [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
 [i93]: https://github.com/threema-ch/threema-web/issues/93
 [i102]: https://github.com/threema-ch/threema-web/issues/102
 [i102]: https://github.com/threema-ch/threema-web/issues/102
 [i108]: https://github.com/threema-ch/threema-web/issues/108
 [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
 [i148]: https://github.com/threema-ch/threema-web/issues/148
 [i150]: https://github.com/threema-ch/threema-web/issues/150
 [i150]: https://github.com/threema-ch/threema-web/issues/150
 [i153]: https://github.com/threema-ch/threema-web/issues/153
 [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
 [i156]: https://github.com/threema-ch/threema-web/issues/156
 [i161]: https://github.com/threema-ch/threema-web/issues/161
 [i161]: https://github.com/threema-ch/threema-web/issues/161
 [i167]: https://github.com/threema-ch/threema-web/issues/167
 [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
 [i205]: https://github.com/threema-ch/threema-web/issues/205
 [i211]: https://github.com/threema-ch/threema-web/issues/211
 [i211]: https://github.com/threema-ch/threema-web/issues/211
 [i215]: https://github.com/threema-ch/threema-web/issues/215
 [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
 [i231]: https://github.com/threema-ch/threema-web/issues/231
 [i235]: https://github.com/threema-ch/threema-web/issues/235
 [i235]: https://github.com/threema-ch/threema-web/issues/235
 [i237]: https://github.com/threema-ch/threema-web/issues/237
 [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
 [i289]: https://github.com/threema-ch/threema-web/issues/289
 [i291]: https://github.com/threema-ch/threema-web/issues/291
 [i291]: https://github.com/threema-ch/threema-web/issues/291
 [i296]: https://github.com/threema-ch/threema-web/issues/296
 [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
 [i310]: https://github.com/threema-ch/threema-web/issues/310
 [i311]: https://github.com/threema-ch/threema-web/issues/311
 [i311]: https://github.com/threema-ch/threema-web/issues/311
 [i315]: https://github.com/threema-ch/threema-web/issues/315
 [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
 [i382]: https://github.com/threema-ch/threema-web/issues/382
 [i385]: https://github.com/threema-ch/threema-web/issues/385
 [i385]: https://github.com/threema-ch/threema-web/issues/385
 [i390]: https://github.com/threema-ch/threema-web/issues/390
 [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
 [i396]: https://github.com/threema-ch/threema-web/issues/396
 [i401]: https://github.com/threema-ch/threema-web/issues/401
 [i401]: https://github.com/threema-ch/threema-web/issues/401
 [i402]: https://github.com/threema-ch/threema-web/issues/402
 [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
 [i435]: https://github.com/threema-ch/threema-web/issues/435
 [i439]: https://github.com/threema-ch/threema-web/issues/439
 [i439]: https://github.com/threema-ch/threema-web/issues/439
 [i441]: https://github.com/threema-ch/threema-web/issues/441
 [i441]: https://github.com/threema-ch/threema-web/issues/441
-
+[i445]: https://github.com/threema-ch/threema-web/issues/445
+[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.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.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
 [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/
 [@bluec0re]: https://github.com/bluec0re/
 [@Octoate]: https://github.com/Octoate/
 [@Octoate]: https://github.com/Octoate/
 [@joelfischerr]: https://github.com/joelfischerr/
 [@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
 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
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
 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
 License for angular-qrcode
 ----------
 ----------
@@ -270,7 +241,7 @@ License for angular-translate
 
 
 The MIT License (MIT)
 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
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
 of this software and associated documentation files (the "Software"), to deal
@@ -299,7 +270,7 @@ License for angular-ui-router
 
 
 The MIT License
 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
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
 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
 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:
 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
 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
 Permission is hereby granted, free of charge, to any person obtaining
 a copy of this software and associated documentation files (the
 a copy of this software and associated documentation files (the
@@ -743,7 +705,7 @@ License for saltyrtc-client
 
 
 The MIT License (MIT)
 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
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
 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:
 Run the dev server:
 
 
-    npm run serve:live
+    npm run devserver
 
 
 Then open the URL in your browser:
 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
 *(Note that this setup should not be used in production. To run Threema
 Web on a server, please follow the instructions at
 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:
 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:
 To run linting checks:
 
 

+ 6 - 6
dist/package.sh

@@ -56,13 +56,14 @@ targets=(
     msgpack-lite/dist/msgpack.min.js
     msgpack-lite/dist/msgpack.min.js
     tweetnacl/nacl-fast.min.js
     tweetnacl/nacl-fast.min.js
     file-saver/FileSaver.min.js
     file-saver/FileSaver.min.js
-    js-sha256/build/sha256.min.js
     @saltyrtc/chunked-dc/dist/chunked-dc.es5.js
     @saltyrtc/chunked-dc/dist/chunked-dc.es5.js
     @saltyrtc/client/dist/saltyrtc-client.es5.js
     @saltyrtc/client/dist/saltyrtc-client.es5.js
     @saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js
     @saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js
+    @saltyrtc/task-relayed-data/dist/saltyrtc-task-relayed-data.es5.js
     webrtc-adapter/out/adapter_no_edge.js
     webrtc-adapter/out/adapter_no_edge.js
     webrtc-adapter/out/adapter.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
     angular-qrcode/angular-qrcode.js
     angularjs-scroll-glue/src/scrollglue.js
     angularjs-scroll-glue/src/scrollglue.js
     angular-material/angular-material.min.js
     angular-material/angular-material.min.js
@@ -70,12 +71,11 @@ targets=(
     croppie/croppie.min.js
     croppie/croppie.min.js
     croppie/croppie.css
     croppie/croppie.css
     autolinker/dist/Autolinker.min.js
     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
     messageformat/messageformat.min.js
     angular-translate/dist/angular-translate.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-loader-static-files/angular-translate-loader-static-files.min.js
     angular-translate/dist/angular-translate-interpolation-messageformat/angular-translate-interpolation-messageformat.min.js
     angular-translate/dist/angular-translate-interpolation-messageformat/angular-translate-interpolation-messageformat.min.js
-    angular-messages/angular-messages.min.js
     sdp/sdp.js
     sdp/sdp.js
 )
 )
 
 
@@ -90,8 +90,8 @@ done
 echo "+ Update version number..."
 echo "+ Update version number..."
 # Note: The `-i.bak` notation is requires so that the sed command works both on Linux
 # 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
 # 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..."
 echo "+ Update permissions..."
 find $DIR/ -type f -exec chmod 644 {} \;
 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
 - STUN / TURN Server
 
 
 The push relay server could in theory be self-hosted too, but it won't help as
 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
 If you have questions, please [open an
 issue](https://github.com/threema-ch/threema-web/issues) on Github.
 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
 ## Push Relay
 
 
 While you could in theory host your own version of the push server, it won't
 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
 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-aria' 'node_modules/angular-aria/LICENSE.md'
     'angular-inview' 'public/libs/angular-inview/LICENSE'
     'angular-inview' 'public/libs/angular-inview/LICENSE'
     'angular-material' 'node_modules/angular-material/LICENSE'
     'angular-material' 'node_modules/angular-material/LICENSE'
-    'angular-messages' 'node_modules/angular-messages/LICENSE.md'
     'angular-qrcode' '.licenses/angular-qrcode'
     'angular-qrcode' '.licenses/angular-qrcode'
     'angular-route' 'node_modules/angular-route/LICENSE.md'
     'angular-route' 'node_modules/angular-route/LICENSE.md'
     'angular-sanitize' 'node_modules/angular-sanitize/LICENSE.md'
     'angular-sanitize' 'node_modules/angular-sanitize/LICENSE.md'
     'angular-translate' 'node_modules/angular-translate/LICENSE'
     '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'
     'angularjs-scroll-glue' '.licenses/angularjs-scroll-glue'
     'autolinker' 'node_modules/autolinker/LICENSE'
     'autolinker' 'node_modules/autolinker/LICENSE'
     'babel-es6-polyfill' '.licenses/babel-es6-polyfill'
     'babel-es6-polyfill' '.licenses/babel-es6-polyfill'
@@ -25,7 +24,6 @@ LICENSE_FILES=(
     'EmojiOne JS' '.licenses/emojione-js'
     'EmojiOne JS' '.licenses/emojione-js'
     'EmojiOne Artwork' '.licenses/emojione-artwork'
     'EmojiOne Artwork' '.licenses/emojione-artwork'
     'file-saver' 'node_modules/file-saver/LICENSE.md'
     'file-saver' 'node_modules/file-saver/LICENSE.md'
-    'js-sha256' 'node_modules/js-sha256/LICENSE.txt'
     'messageformat' 'node_modules/messageformat/LICENSE'
     'messageformat' 'node_modules/messageformat/LICENSE'
     'msgpack-lite' 'node_modules/msgpack-lite/LICENSE'
     'msgpack-lite' 'node_modules/msgpack-lite/LICENSE'
     'node-sass' 'node_modules/node-sass/LICENSE'
     'node-sass' 'node_modules/node-sass/LICENSE'

+ 25 - 17
index.html

@@ -27,14 +27,18 @@
 
 
     <title>Threema Web</title>
     <title>Threema Web</title>
     <meta name="description" translate translate-attr-content="meta.DESCRIPTION"
     <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 -->
     <!-- Angular -->
     <link rel="stylesheet" href="node_modules/angular/angular-csp.css?v=[[VERSION]]">
     <link rel="stylesheet" href="node_modules/angular/angular-csp.css?v=[[VERSION]]">
@@ -53,10 +57,10 @@
 </head>
 </head>
 
 
 <body ng-controller="StatusController as ctrl" class="{{ ctrl.statusClass }}" ng-class="{expanded: ctrl.expandStatusBar}">
 <body ng-controller="StatusController as ctrl" class="{{ ctrl.statusClass }}" ng-class="{expanded: ctrl.expandStatusBar}">
-    <img src="img/bg1.jpg?v=1" id="background-image" draggable="false"/>
+    <img src="img/bg.jpg?v=1" id="background-image" draggable="false">
 
 
     <noscript>
     <noscript>
-        <img id="logo-noscript" src="img/logo.svg?v=[[VERSION]]"></img>
+        <img id="logo-noscript" src="img/logo.svg?v=[[VERSION]]"/>
         <div>
         <div>
             <h2>Error: JavaScript not supported</h2>
             <h2>Error: JavaScript not supported</h2>
             <p>Please enable JavaScript in your browser to be able to use Threema Web.</p>
             <p>Please enable JavaScript in your browser to be able to use Threema Web.</p>
@@ -79,12 +83,15 @@
             </div>
             </div>
             <div id="main-content" ui-view></div>
             <div id="main-content" ui-view></div>
         </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>
         </div>
-        <footer>
+        <footer ng-controller="FooterController as ctrl">
             <ul>
             <ul>
-                <li>Version [[VERSION]]</li>
+                <li><a ng-click="ctrl.showVersionInfo('[[VERSION]]')">Version [[VERSION]] {{ ctrl.config.VERSION_MOUNTAIN }}</a></li>
                 <li><a
                 <li><a
                         href="https://threema.ch/threema-web"
                         href="https://threema.ch/threema-web"
                         target="_blank"
                         target="_blank"
@@ -106,10 +113,11 @@
     <script src="node_modules/babel-es6-polyfill/browser-polyfill.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/babel-es6-polyfill/browser-polyfill.min.js?v=[[VERSION]]"></script>
 
 
     <!-- Various libraries -->
     <!-- 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-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-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="libs/emojione/emojione.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/angularjs-scroll-glue/src/scrollglue.js?v=[[VERSION]]"></script>
     <script src="node_modules/angularjs-scroll-glue/src/scrollglue.js?v=[[VERSION]]"></script>
     <script src="libs/angular-inview/angular-inview.js?v=[[VERSION]]"></script>
     <script src="libs/angular-inview/angular-inview.js?v=[[VERSION]]"></script>
@@ -124,10 +132,10 @@
     <script src="node_modules/msgpack-lite/dist/msgpack.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/msgpack-lite/dist/msgpack.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/tweetnacl/nacl-fast.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/tweetnacl/nacl-fast.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/file-saver/FileSaver.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/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/chunked-dc/dist/chunked-dc.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/client/dist/saltyrtc-client.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/client/dist/saltyrtc-client.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js?v=[[VERSION]]"></script>
     <script src="node_modules/@saltyrtc/task-webrtc/dist/saltyrtc-task-webrtc.es5.js?v=[[VERSION]]"></script>
+    <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/croppie/croppie.min.js?v=[[VERSION]]"></script>
     <script src="node_modules/autolinker/dist/Autolinker.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-aria/angular-aria.min.js',
             'node_modules/angular-animate/angular-animate.min.js',
             'node_modules/angular-animate/angular-animate.min.js',
             'node_modules/angular-material/angular-material.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/app.js',
             'dist/ts-tests.js',
             'dist/ts-tests.js',
             'tests/filters.js',
             'tests/filters.js',
@@ -17,6 +20,10 @@ module.exports = function(config) {
             'tests/service/qrcode.js',
             'tests/service/qrcode.js',
             'tests/service/uri.js',
             'tests/service/uri.js',
             'tests/service/webclient.js',
             'tests/service/webclient.js',
+            'tests/service/string.js',
+            'tests/service/browser.js',
+            'tests/service/keystore.js',
+            'tests/service/notification.js',
             'tests/helpers.js',
             'tests/helpers.js',
         ],
         ],
         customLaunchers: {
         customLaunchers: {

ファイルの差分が大きいため隠しています
+ 361 - 245
package-lock.json


+ 40 - 41
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "threema-web",
   "name": "threema-web",
-  "version": "1.8.2",
+  "version": "2.0.0-beta.8",
   "description": "Threema Webclient",
   "description": "Threema Webclient",
   "scripts": {
   "scripts": {
     "build": "npm run build:js && npm run build:css",
     "build": "npm run build:js && npm run build:css",
@@ -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: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",
     "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",
     "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",
     "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\"",
     "lint": "tslint -c tslint.json --project tsconfig.json --exclude \"**/src/config.ts\"",
     "clean": "rm -rf js/ build/ dist/app*"
     "clean": "rm -rf js/ build/ dist/app*"
@@ -26,58 +26,57 @@
   "private": true,
   "private": true,
   "homepage": "https://threema.ch/",
   "homepage": "https://threema.ch/",
   "dependencies": {
   "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-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/filesaver": "~0.0.30",
-    "@types/jquery": "^2.0.48",
+    "@types/jquery": "^3.3.4",
     "@types/msgpack-lite": "^0.1.6",
     "@types/msgpack-lite": "^0.1.6",
     "@types/webrtc": "0.0.23",
     "@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",
     "angularjs-scroll-glue": "~2.1.0",
-    "autolinker": "~0.27.0",
+    "autolinker": "~1.6.2",
     "babel-es6-polyfill": "~1.1.0",
     "babel-es6-polyfill": "~1.1.0",
     "babel-preset-es2015": "~6.14.0",
     "babel-preset-es2015": "~6.14.0",
     "babelify": "~7.3.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",
     "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",
     "ts-events": "^3.1.5",
-    "tsify": "~2.0.1",
+    "tsify": "~4.0.0",
     "tweetnacl": "^1.0.0",
     "tweetnacl": "^1.0.0",
-    "typescript": "~2.6",
-    "webrtc-adapter": "~3.4.3"
+    "typescript": "^2.9.2",
+    "webrtc-adapter": "^6.3.0"
   },
   },
   "devDependencies": {
   "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",
     "concurrently": "~3.3.0",
     "jasmine": "^3.1.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); }
 .material-icons.md-light.md-inactive { color: rgba(255, 255, 255, 0.3); }
 
 
 /* specific color masks */
 /* specific color masks */
-.material-icons.user-dec {
+.material-icons.md-dark.user-dec,
+.material-icons.md-medium-dark.user-dec {
     color: #ff9800;
     color: #ff9800;
 }
 }
-.material-icons.user-ack {
+.material-icons.md-dark.user-ack,
+.material-icons.md-medium-dark.user-ack {
     color: #4caf50;
     color: #4caf50;
 }
 }
-.material-icons.send-failed {
+.material-icons.md-dark.send-failed,
+.material-icons.md-medium-dark.send-failed {
     color: #d50000;
     color: #d50000;
 }
 }

+ 95 - 15
public/i18n/de.json

@@ -4,7 +4,6 @@
     },
     },
     "welcome": {
     "welcome": {
         "ABORT": "Abbrechen",
         "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",
         "CONTINUE_ANYWAY": "Trotzdem fortfahren",
         "PLEASE_SCAN": "Scannen Sie den QR-Code mit Ihrer Threema-App",
         "PLEASE_SCAN": "Scannen Sie den QR-Code mit Ihrer Threema-App",
         "PLEASE_UNLOCK": "Verbindung wiederaufbauen",
         "PLEASE_UNLOCK": "Verbindung wiederaufbauen",
@@ -12,17 +11,20 @@
         "PLEASE_RELOAD": "Bitte laden Sie die Seite neu.",
         "PLEASE_RELOAD": "Bitte laden Sie die Seite neu.",
         "RELOAD": "Seite neu laden",
         "RELOAD": "Seite neu laden",
         "PASSWORD": "Passwort",
         "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.",
         "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.",
         "ENTER_PASSWORD": "Um die letzte Sitzung wiederherzustellen, <br> geben Sie bitte das entsprechende Passwort ein.",
         "UNLOCK_FAILED_TITLE": "Verbindung aufbauen fehlgeschlagen",
         "UNLOCK_FAILED_TITLE": "Verbindung aufbauen fehlgeschlagen",
         "ALTERNATIVELY": "Alternativ:",
         "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",
         "IF_YOU_WANT": "Wenn Sie möchten, können Sie die",
         "FORGET_SESSION": "gespeicherte Sitzung löschen",
         "FORGET_SESSION": "gespeicherte Sitzung löschen",
         "BTN_RECONNECT": "Verbindung aufbauen",
         "BTN_RECONNECT": "Verbindung aufbauen",
         "BROWSER_NOT_SUPPORTED": "Dieser Browser wird nicht unterst\u00fctzt",
         "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",
         "CONNECTING": "Verbindung wird aufgebaut",
         "WAITING_FOR_PUSH": "Threema-App wird<br>aufgeweckt …",
         "WAITING_FOR_PUSH": "Threema-App wird<br>aufgeweckt …",
         "CONNECTING_TO_APP": "Verbindung zu App<br>wird aufgebaut …",
         "CONNECTING_TO_APP": "Verbindung zu App<br>wird aufgebaut …",
@@ -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_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.",
         "LOCAL_STORAGE_EXCEPTION_DETAILS": "Kritischer Fehler beim Zugriff auf LocalStorage: {errorMsg}.<br>Bitte starten Sie Ihren Browser neu.",
         "ALREADY_CONNECTED": "Bereits verbunden",
         "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": {
     "connecting": {
         "CONNECTION_PROBLEMS": "Verbindungsprobleme",
         "CONNECTION_PROBLEMS": "Verbindungsprobleme",
@@ -79,7 +87,9 @@
         "ARE_YOU_SURE": "Sind Sie sicher?",
         "ARE_YOU_SURE": "Sind Sie sicher?",
         "SAVE": "Speichern",
         "SAVE": "Speichern",
         "DONE": "Fertig",
         "DONE": "Fertig",
-        "MODIFY": "Ändern"
+        "MODIFY": "Ändern",
+        "NOTE": "Hinweis:",
+        "UNDERSTOOD": "Verstanden"
     },
     },
     "messenger": {
     "messenger": {
         "VERIFICATION_LEVEL": "Vertrauensstufe",
         "VERIFICATION_LEVEL": "Vertrauensstufe",
@@ -115,8 +125,18 @@
         "GROUP_ROLE_NORMAL": "Mitglied",
         "GROUP_ROLE_NORMAL": "Mitglied",
         "GROUP_ROLE_CREATOR": "Ersteller",
         "GROUP_ROLE_CREATOR": "Ersteller",
         "DOWNLOAD": "Herunterladen",
         "DOWNLOAD": "Herunterladen",
-        "COPY": "Kopieren",
         "DOWNLOADING": "Laden …",
         "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_SEND": "Senden an «{senderName}»?",
         "CONFIRM_FILE_CAPTION": "Optionale Beschriftung",
         "CONFIRM_FILE_CAPTION": "Optionale Beschriftung",
         "CONFIRM_SEND_AS_FILE": "Als Datei senden",
         "CONFIRM_SEND_AS_FILE": "Als Datei senden",
@@ -130,7 +150,7 @@
         "SYSTEM_CONTACT": "Systemkontakt",
         "SYSTEM_CONTACT": "Systemkontakt",
         "EMAIL_ADDRESSES": "E-Mail-Adressen",
         "EMAIL_ADDRESSES": "E-Mail-Adressen",
         "PHONE_NUMBERS": "Telefonnummern",
         "PHONE_NUMBERS": "Telefonnummern",
-        "EDIT_RECEIVER": "{receiverName} bearbeiten",
+        "EDIT_RECEIVER": "Profil bearbeiten",
         "CREATE_GROUP": "Neue Gruppe",
         "CREATE_GROUP": "Neue Gruppe",
         "GROUP_SELECT_CONTACTS": "Mitglieder wählen",
         "GROUP_SELECT_CONTACTS": "Mitglieder wählen",
         "GROUP_DELETE": "Gruppe löschen",
         "GROUP_DELETE": "Gruppe löschen",
@@ -154,16 +174,34 @@
         "PRIVATE_CHAT_DESCRIPTION": "Private Unterhaltungen werden in Threema Web nicht unterstützt.",
         "PRIVATE_CHAT_DESCRIPTION": "Private Unterhaltungen werden in Threema Web nicht unterstützt.",
         "MESSAGE_TOO_LONG_SPLIT_SUBJECT": "Nachricht aufteilen.",
         "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?",
         "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",
         "UNKNOWN_MESSAGE_TYPE": "Unbekannter Nachrichtentyp",
         "NICKNAME": "Nickname",
         "NICKNAME": "Nickname",
         "THREEMA_WORK_CONTACT": "Threema Work Nutzer",
         "THREEMA_WORK_CONTACT": "Threema Work Nutzer",
+        "THREEMA_HOME_CONTACT": "Privater Threema-Kontakt",
         "THREEMA_BLOCKED_RECEIVER": "blockiert",
         "THREEMA_BLOCKED_RECEIVER": "blockiert",
         "DELETE_THREAD": "Chat löschen",
         "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.}}",
         "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": "Keine Benachrichtigungen",
+        "MUTED_NONE": "Keine Benachrichtigungen",
+        "MUTED_MENTION_ONLY": "Nur bei Erwähnung benachrichtigen",
+        "MUTED_SILENT": "Stumme Benachrichtigungen",
         "ALL": "Alle"
         "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": {
     "messageTypes": {
         "AUDIO_MESSAGE": "Sprachnachricht",
         "AUDIO_MESSAGE": "Sprachnachricht",
         "FILE_MESSAGE": "Datei",
         "FILE_MESSAGE": "Datei",
@@ -176,8 +214,22 @@
         "gif": "GIF"
         "gif": "GIF"
     },
     },
     "validationError": {
     "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": {
     "error": {
@@ -187,11 +239,15 @@
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» kann noch keine Dateien erhalten.",
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» kann noch keine Dateien erhalten.",
         "CONTACT_BLOCKED":  "Sie können keine Nachrichten an blockierte Kontakte senden.",
         "CONTACT_BLOCKED":  "Sie können keine Nachrichten an blockierte Kontakte senden.",
         "ERROR_OCCURRED": "Es ist ein Fehler aufgetreten.",
         "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).",
         "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": "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_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": {
     "mimeTypes": {
         "apk": "Android-Paket",
         "apk": "Android-Paket",
@@ -217,7 +273,7 @@
         "SOURCE_CODE": "Der Quellcode und die Lizenzbedingungen finden sich auf GitHub:",
         "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>",
         "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",
         "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_TEXT": "im Quellcode",
         "LICENSE_LINK_AFTER": "gefunden werden",
         "LICENSE_LINK_AFTER": "gefunden werden",
         "CHANGELOG": "Änderungsprotokoll",
         "CHANGELOG": "Änderungsprotokoll",
@@ -236,7 +292,7 @@
     },
     },
     "version": {
     "version": {
         "NEW_VERSION": "Neue Version Verfügbar",
         "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": {
     "voip": {
         "CALL_MISSED": "Verpasster Anruf",
         "CALL_MISSED": "Verpasster Anruf",
@@ -251,5 +307,29 @@
         "ALERT": "Entladen: {percent}%",
         "ALERT": "Entladen: {percent}%",
         "LEVEL_LOW": "Der Akkustand Ihres Gerätes ist niedrig ({percent}%).",
         "LEVEL_LOW": "Der Akkustand Ihres Gerätes ist niedrig ({percent}%).",
         "LEVEL_CRITICAL": "Der Akkustand Ihres Gerätes ist kritisch ({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": {
     "welcome": {
         "ABORT": "Abort",
         "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",
         "CONTINUE_ANYWAY": "Continue anyway",
         "PLEASE_SCAN": "Scan this QR code with your Threema app",
         "PLEASE_SCAN": "Scan this QR code with your Threema app",
         "PLEASE_UNLOCK": "Reconnecting session",
         "PLEASE_UNLOCK": "Reconnecting session",
@@ -12,7 +11,7 @@
         "PLEASE_RELOAD": "Please reload the page to try again.",
         "PLEASE_RELOAD": "Please reload the page to try again.",
         "RELOAD": "Reload page",
         "RELOAD": "Reload page",
         "PASSWORD": "Password",
         "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.",
         "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:",
         "ENTER_PASSWORD": "To reconnect to your previous session,<br>please enter the password:",
         "UNLOCK_FAILED_TITLE": "Unlocking failed",
         "UNLOCK_FAILED_TITLE": "Unlocking failed",
@@ -22,7 +21,10 @@
         "FORGET_SESSION": "forget this session",
         "FORGET_SESSION": "forget this session",
         "BTN_RECONNECT": "Reconnect",
         "BTN_RECONNECT": "Reconnect",
         "BROWSER_NOT_SUPPORTED": "This browser is not supported",
         "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",
         "CONNECTING": "Connecting",
         "WAITING_FOR_PUSH": "Waiting for<br>app wakeup …",
         "WAITING_FOR_PUSH": "Waiting for<br>app wakeup …",
         "CONNECTING_TO_APP": "Connection to app is<br>being established …",
         "CONNECTING_TO_APP": "Connection to app is<br>being established …",
@@ -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_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.",
         "LOCAL_STORAGE_EXCEPTION_DETAILS": "Critical error when accessing LocalStorage: {errorMsg}.<br>Try restarting your browser.",
         "ALREADY_CONNECTED": "Already connected",
         "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": {
     "connecting": {
         "CONNECTION_PROBLEMS": "Connection problems",
         "CONNECTION_PROBLEMS": "Connection problems",
@@ -75,11 +83,13 @@
         "NO": "No",
         "NO": "No",
         "OK": "Ok",
         "OK": "Ok",
         "DELETE": "Delete",
         "DELETE": "Delete",
-        "EDIT": "Bearbeiten",
+        "EDIT": "Edit",
         "ARE_YOU_SURE": "Are you sure?",
         "ARE_YOU_SURE": "Are you sure?",
         "SAVE": "Save",
         "SAVE": "Save",
         "DONE": "Done",
         "DONE": "Done",
-        "MODIFY": "Modify"
+        "MODIFY": "Modify",
+        "NOTE": "Note:",
+        "UNDERSTOOD": "Understood"
     },
     },
     "messenger": {
     "messenger": {
         "VERIFICATION_LEVEL": "Verification level",
         "VERIFICATION_LEVEL": "Verification level",
@@ -115,8 +125,18 @@
         "GROUP_ROLE_NORMAL": "Member",
         "GROUP_ROLE_NORMAL": "Member",
         "GROUP_ROLE_CREATOR": "Creator",
         "GROUP_ROLE_CREATOR": "Creator",
         "DOWNLOAD": "Download",
         "DOWNLOAD": "Download",
-        "COPY": "Copy",
         "DOWNLOADING": "Downloading …",
         "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_SEND": "Send to «{senderName}»?",
         "CONFIRM_FILE_CAPTION": "Optional caption",
         "CONFIRM_FILE_CAPTION": "Optional caption",
         "CONFIRM_SEND_AS_FILE": "Send as file message",
         "CONFIRM_SEND_AS_FILE": "Send as file message",
@@ -130,7 +150,7 @@
         "SYSTEM_CONTACT": "System contact",
         "SYSTEM_CONTACT": "System contact",
         "EMAIL_ADDRESSES": "Emails",
         "EMAIL_ADDRESSES": "Emails",
         "PHONE_NUMBERS": "Phone numbers",
         "PHONE_NUMBERS": "Phone numbers",
-        "EDIT_RECEIVER": "Edit {receiverName}",
+        "EDIT_RECEIVER": "Edit profile",
         "CREATE_GROUP": "New group",
         "CREATE_GROUP": "New group",
         "GROUP_SELECT_CONTACTS": "Select members",
         "GROUP_SELECT_CONTACTS": "Select members",
         "GROUP_DELETE": "Delete group",
         "GROUP_DELETE": "Delete group",
@@ -158,12 +178,29 @@
         "UNKNOWN_MESSAGE_TYPE": "Unknown message type",
         "UNKNOWN_MESSAGE_TYPE": "Unknown message type",
         "NICKNAME": "Nickname",
         "NICKNAME": "Nickname",
         "THREEMA_WORK_CONTACT": "Threema Work user",
         "THREEMA_WORK_CONTACT": "Threema Work user",
+        "THREEMA_HOME_CONTACT": "Private Threema contact",
         "THREEMA_BLOCKED_RECEIVER": "blocked",
         "THREEMA_BLOCKED_RECEIVER": "blocked",
         "DELETE_THREAD": "Delete chat",
         "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.}}",
         "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"
         "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": {
     "messageTypes": {
         "AUDIO_MESSAGE": "Audio Message",
         "AUDIO_MESSAGE": "Audio Message",
         "FILE_MESSAGE": "File Message",
         "FILE_MESSAGE": "File Message",
@@ -176,8 +213,22 @@
         "gif": "GIF"
         "gif": "GIF"
     },
     },
     "validationError": {
     "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": {
     "error": {
@@ -187,11 +238,15 @@
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» cannot receive files.",
         "FILE_MESSAGES_NOT_SUPPORTED": "«{receiverName}» cannot receive files.",
         "CONTACT_BLOCKED":  "You cannot send messages to a blocked contact.",
         "CONTACT_BLOCKED":  "You cannot send messages to a blocked contact.",
         "ERROR_OCCURRED": "An error occurred.",
         "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).",
         "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": "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_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": {
     "mimeTypes": {
         "apk": "Android package",
         "apk": "Android package",
@@ -251,5 +306,29 @@
         "ALERT": "Discharging: {percent}%",
         "ALERT": "Discharging: {percent}%",
         "LEVEL_LOW": "Your device battery level is low ({percent}%).",
         "LEVEL_LOW": "Your device battery level is low ({percent}%).",
         "LEVEL_CRITICAL": "Your device battery level is critical ({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>

ファイルの差分が大きいため隠しています
+ 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.",
   "description": "Chat from your desktop with Threema Web and have full access to all chats, contacts and media files.",
   "display": "standalone",
   "display": "standalone",
   "icons": [
   "icons": [
     {
     {
-      "src": "img/favicon.ico",
+      "src": "img/favicon/favicon.ico?v=[[VERSION]]",
       "sizes": "64x64",
       "sizes": "64x64",
       "type": "image/x-icon"
       "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"
       "type": "image/png"
     }
     }
   ],
   ],
   "name": "Threema Web",
   "name": "Threema Web",
   "short_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
 // Set versions
-.value('VERSION', '0.0.1')
-.value('PROTOCOL_VERSION', 1)
+.value('VERSION', '[[VERSION]]')
+.value('PROTOCOL_VERSION', 2)
 
 
 // Configuration object
 // Configuration object
 .constant('CONFIG', config)
 .constant('CONFIG', config)
@@ -73,6 +73,7 @@ angular.module('3ema', [
     FF: 50,
     FF: 50,
     CHROME: 45,
     CHROME: 45,
     OPERA: 32,
     OPERA: 32,
+    SAFARI: 11,
 })
 })
 
 
 // Set default route
 // Set default route

+ 10 - 3
src/config.ts

@@ -7,10 +7,15 @@ export default {
 
 
     // General
     // General
     SELF_HOSTED: false,
     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
-    SALTYRTC_HOST: null,
+    SALTYRTC_HOST: 'saltyrtc-beta.threema.ch',
     SALTYRTC_HOST_PREFIX: 'saltyrtc-',
     SALTYRTC_HOST_PREFIX: 'saltyrtc-',
     SALTYRTC_HOST_SUFFIX: '.threema.ch',
     SALTYRTC_HOST_SUFFIX: '.threema.ch',
     SALTYRTC_PORT: 443,
     SALTYRTC_PORT: 443,
@@ -31,7 +36,9 @@ export default {
     PUSH_URL: 'https://push-web.threema.ch/push',
     PUSH_URL: 'https://push-web.threema.ch/push',
 
 
     // Debugging options
     // 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,
     ICE_DEBUGGING: false,
 
 
 } as threema.Config;
 } as threema.Config;

+ 25 - 5
src/controller_model/avatar.ts

@@ -18,10 +18,13 @@
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
 export class AvatarControllerModel {
 export class AvatarControllerModel {
+    private logTag: string = '[AvatarControllerModel]';
+
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private avatar: ArrayBuffer = null;
     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,
     constructor($log: ng.ILogService,
                 webClientService: WebClientService,
                 webClientService: WebClientService,
@@ -29,14 +32,16 @@ export class AvatarControllerModel {
         this.$log = $log;
         this.$log = $log;
         this.loadAvatar = new Promise((resolve, reject) => {
         this.loadAvatar = new Promise((resolve, reject) => {
             if (receiver === null) {
             if (receiver === null) {
+                $log.debug(this.logTag, 'loadAvatar: No receiver defined, no avatar');
                 resolve(null);
                 resolve(null);
                 return;
                 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)
                 webClientService.requestAvatar(receiver, true)
-                    .then((image: string) => resolve(image))
+                    .then((data: ArrayBuffer) => resolve(data))
                     .catch(() => reject());
                     .catch(() => reject());
             } else {
             } else {
+                $log.debug(this.logTag, 'loadAvatar: Returning cached version');
                 resolve(receiver.avatar.high);
                 resolve(receiver.avatar.high);
             }
             }
         });
         });
@@ -44,10 +49,25 @@ export class AvatarControllerModel {
         // bind to the editor
         // bind to the editor
         this.onChangeAvatar = (image: ArrayBuffer) => {
         this.onChangeAvatar = (image: ArrayBuffer) => {
             this.avatar = image;
             this.avatar = image;
+            this._avatarChanged = true;
         };
         };
     }
     }
 
 
+    /**
+     * Return the avatar bytes (or null if no avatar is defined).
+     */
     public getAvatar(): ArrayBuffer | null {
     public getAvatar(): ArrayBuffer | null {
         return this.avatar;
         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 {WebClientService} from '../services/webclient';
-import {ControllerModelMode} from '../types/enums';
 import {AvatarControllerModel} from './avatar';
 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
     // Angular services
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
     private $translate: ng.translate.ITranslateService;
     private $mdDialog: ng.material.IDialogService;
     private $mdDialog: ng.material.IDialogService;
 
 
-    private onRemovedCallback: any;
+    private onRemovedCallback: threema.OnRemovedCallback;
     public firstName: string;
     public firstName: string;
     public lastName: string;
     public lastName: string;
     public identity: string;
     public identity: string;
@@ -53,9 +55,7 @@ export class ContactControllerModel implements threema.ControllerModel {
 
 
         switch (this.getMode()) {
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
             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.firstName = this.contact.firstName;
                 this.lastName = this.contact.lastName;
                 this.lastName = this.contact.lastName;
                 this.avatarController = new AvatarControllerModel(
                 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;
         this.onRemovedCallback = callback;
     }
     }
 
 
@@ -99,7 +99,7 @@ export class ContactControllerModel implements threema.ControllerModel {
         return this.identity !== undefined && this.identity.length === 8;
         return this.identity !== undefined && this.identity.length === 8;
     }
     }
 
 
-    public canView(): boolean {
+    public canChat(): boolean {
         return this.contact.id !== this.webClientService.me.id;
         return this.contact.id !== this.webClientService.me.id;
     }
     }
 
 
@@ -112,7 +112,7 @@ export class ContactControllerModel implements threema.ControllerModel {
     }
     }
 
 
     public canClean(): boolean {
     public canClean(): boolean {
-        return this.canView();
+        return this.canChat();
     }
     }
 
 
     public clean(ev: any): any {
     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> {
     public save(): Promise<threema.ContactReceiver> {
         switch (this.getMode()) {
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
             case ControllerModelMode.EDIT:
@@ -153,7 +157,7 @@ export class ContactControllerModel implements threema.ControllerModel {
                     this.contact.id,
                     this.contact.id,
                     this.firstName,
                     this.firstName,
                     this.lastName,
                     this.lastName,
-                    this.avatarController.getAvatar(),
+                    this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
                 );
                 );
             case ControllerModelMode.NEW:
             case ControllerModelMode.NEW:
                 return this.webClientService.addContact(this.identity);
                 return this.webClientService.addContact(this.identity);
@@ -164,7 +168,7 @@ export class ContactControllerModel implements threema.ControllerModel {
     }
     }
 
 
     public onChangeMembers(identities: string[]): void {
     public onChangeMembers(identities: string[]): void {
-        return null;
+        // Do nothing
     }
     }
 
 
     public getMembers(): string[] {
     public getMembers(): string[] {

+ 14 - 10
src/controller_model/distributionList.ts

@@ -16,9 +16,11 @@
  */
  */
 
 
 import {WebClientService} from '../services/webclient';
 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 $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
     private $translate: ng.translate.ITranslateService;
@@ -32,7 +34,7 @@ export class DistributionListControllerModel implements threema.ControllerModel
     private distributionList: threema.DistributionListReceiver;
     private distributionList: threema.DistributionListReceiver;
     private webClientService: WebClientService;
     private webClientService: WebClientService;
     private mode: ControllerModelMode;
     private mode: ControllerModelMode;
-    private onRemovedCallback: any;
+    private onRemovedCallback: threema.OnRemovedCallback;
 
 
     constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
     constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
                 webClientService: WebClientService,
                 webClientService: WebClientService,
@@ -49,9 +51,7 @@ export class DistributionListControllerModel implements threema.ControllerModel
 
 
         switch (this.getMode()) {
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
             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.name = this.distributionList.displayName;
                 this.members = this.distributionList.members;
                 this.members = this.distributionList.members;
                 break;
                 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;
         this.onRemovedCallback = callback;
     }
     }
 
 
@@ -82,11 +82,11 @@ export class DistributionListControllerModel implements threema.ControllerModel
 
 
     public isValid(): boolean {
     public isValid(): boolean {
         return this.members.filter((identity: string) => {
         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;
         return true;
     }
     }
 
 
@@ -130,6 +130,10 @@ export class DistributionListControllerModel implements threema.ControllerModel
             });
             });
     }
     }
 
 
+    public canShowQr(): boolean {
+        return false;
+    }
+
     public delete(ev): void {
     public delete(ev): void {
 
 
         const confirm = this.$mdDialog.confirm()
         const confirm = this.$mdDialog.confirm()

+ 38 - 17
src/controller_model/group.ts

@@ -16,10 +16,12 @@
  */
  */
 
 
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
-import {ControllerModelMode} from '../types/enums';
 import {AvatarControllerModel} from './avatar';
 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 $log: ng.ILogService;
     private $translate: ng.translate.ITranslateService;
     private $translate: ng.translate.ITranslateService;
@@ -27,14 +29,14 @@ export class GroupControllerModel implements threema.ControllerModel {
     public members: string[];
     public members: string[];
     public name: string;
     public name: string;
     public subject: string;
     public subject: string;
-    public isLoading = false;
+    public isLoading = false; // TODO: Show loading indicator
 
 
     private addContactPlaceholder: string;
     private addContactPlaceholder: string;
     private group: threema.GroupReceiver;
     private group: threema.GroupReceiver;
     private webClientService: WebClientService;
     private webClientService: WebClientService;
     private avatarController: AvatarControllerModel;
     private avatarController: AvatarControllerModel;
     private mode: ControllerModelMode;
     private mode: ControllerModelMode;
-    private onRemovedCallback: any;
+    private onRemovedCallback: threema.OnRemovedCallback;
 
 
     constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
     constructor($log: ng.ILogService, $translate: ng.translate.ITranslateService, $mdDialog: ng.material.IDialogService,
                 webClientService: WebClientService,
                 webClientService: WebClientService,
@@ -51,9 +53,7 @@ export class GroupControllerModel implements threema.ControllerModel {
 
 
         switch (this.getMode()) {
         switch (this.getMode()) {
             case ControllerModelMode.EDIT:
             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.name = this.group.displayName;
                 this.members = this.group.members;
                 this.members = this.group.members;
                 this.avatarController = new AvatarControllerModel(
                 this.avatarController = new AvatarControllerModel(
@@ -84,7 +84,7 @@ export class GroupControllerModel implements threema.ControllerModel {
         return this.webClientService.getMaxGroupMemberSize();
         return this.webClientService.getMaxGroupMemberSize();
     }
     }
 
 
-    public setOnRemoved(callback: any): void {
+    public setOnRemoved(callback: threema.OnRemovedCallback): void {
         this.onRemovedCallback = callback;
         this.onRemovedCallback = callback;
     }
     }
 
 
@@ -94,11 +94,11 @@ export class GroupControllerModel implements threema.ControllerModel {
 
 
     public isValid(): boolean {
     public isValid(): boolean {
         return this.members.filter((identity: string) => {
         return this.members.filter((identity: string) => {
-                return identity !== this.webClientService.getMyIdentity().identity;
+                return identity !== this.webClientService.me.id;
             }).length > 0;
             }).length > 0;
     }
     }
 
 
-    public canView(): boolean {
+    public canChat(): boolean {
         return true;
         return true;
     }
     }
 
 
@@ -111,7 +111,7 @@ export class GroupControllerModel implements threema.ControllerModel {
     }
     }
 
 
     public canClean(): boolean {
     public canClean(): boolean {
-        return this.canView();
+        return this.canChat();
     }
     }
 
 
     public clean(ev: any): any {
     public clean(ev: any): any {
@@ -145,11 +145,15 @@ export class GroupControllerModel implements threema.ControllerModel {
             });
             });
     }
     }
 
 
+    public canShowQr(): boolean {
+        return false;
+    }
+
     public leave(ev): void {
     public leave(ev): void {
         const confirm = this.$mdDialog.confirm()
         const confirm = this.$mdDialog.confirm()
             .title(this.$translate.instant('messenger.GROUP_LEAVE'))
             .title(this.$translate.instant('messenger.GROUP_LEAVE'))
             .textContent(this.$translate.instant(
             .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_ADMIN'
                     : 'messenger.GROUP_REALLY_LEAVE'))
                     : 'messenger.GROUP_REALLY_LEAVE'))
             .targetEvent(ev)
             .targetEvent(ev)
@@ -224,8 +228,9 @@ export class GroupControllerModel implements threema.ControllerModel {
             .then(() => {
             .then(() => {
                 this.isLoading = false;
                 this.isLoading = false;
             })
             })
-            .catch(() => {
+            .catch((errorCode) => {
                 this.isLoading = false;
                 this.isLoading = false;
+                this.showError(errorCode);
             });
             });
     }
     }
 
 
@@ -236,14 +241,14 @@ export class GroupControllerModel implements threema.ControllerModel {
                     this.group.id,
                     this.group.id,
                     this.members,
                     this.members,
                     this.name,
                     this.name,
-                    this.avatarController.getAvatar(),
+                    this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
                 );
                 );
             case ControllerModelMode.NEW:
             case ControllerModelMode.NEW:
-
                 return this.webClientService.createGroup(
                 return this.webClientService.createGroup(
                     this.members,
                     this.members,
-                    this.name,
-                    this.avatarController.getAvatar());
+                    (this.name && this.name.length > 0) ? this.name : undefined,
+                    this.avatarController.avatarChanged ? this.avatarController.getAvatar() : undefined,
+                );
             default:
             default:
                 this.$log.error('not allowed to save group');
                 this.$log.error('not allowed to save group');
 
 
@@ -257,4 +262,20 @@ export class GroupControllerModel implements threema.ControllerModel {
     public getMembers(): string[] {
     public getMembers(): string[] {
         return this.members;
         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/>.
  * 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';
 import {StatusController} from './controllers/status';
 
 
 angular.module('3ema.controllers', ['3ema.services'])
 angular.module('3ema.controllers', ['3ema.services'])
 
 
-.controller('AndroidOnlyController', AndroidOnlyController)
+.controller('AndroidIosOnlyController', AndroidIosOnlyController)
+.controller('FooterController', FooterController)
 .controller('StatusController', StatusController)
 .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/>.
  * 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 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/>.
  * 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 {ControllerService} from '../services/controller';
 import {StateService} from '../services/state';
 import {StateService} from '../services/state';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
+import GlobalConnectionState = threema.GlobalConnectionState;
+
 /**
 /**
  * This controller handles state changes globally.
  * This controller handles state changes globally.
  *
  *
@@ -31,7 +35,7 @@ export class StatusController {
     private logTag: string = '[StatusController]';
     private logTag: string = '[StatusController]';
 
 
     // State variable
     // State variable
-    private state: threema.GlobalConnectionState = 'error';
+    private state = GlobalConnectionState.Error;
 
 
     // Expanded status bar
     // Expanded status bar
     public expandStatusBar = false;
     public expandStatusBar = false;
@@ -43,8 +47,8 @@ export class StatusController {
 
 
     // Angular services
     // Angular services
     private $timeout: ng.ITimeoutService;
     private $timeout: ng.ITimeoutService;
-    private $state: ng.ui.IStateService;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
+    private $state: UiStateService;
 
 
     // Custom services
     // Custom services
     private stateService: StateService;
     private stateService: StateService;
@@ -53,7 +57,7 @@ export class StatusController {
 
 
     public static $inject = ['$scope', '$timeout', '$log', '$state', 'StateService',
     public static $inject = ['$scope', '$timeout', '$log', '$state', 'StateService',
         'WebClientService', 'ControllerService'];
         '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,
                 stateService: StateService, webClientService: WebClientService,
                 controllerService: ControllerService) {
                 controllerService: ControllerService) {
 
 
@@ -67,13 +71,10 @@ export class StatusController {
         this.webClientService = webClientService;
         this.webClientService = webClientService;
         this.controllerService = controllerService;
         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.
      * Return the prefixed status.
      */
      */
     public get statusClass(): string {
     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,
     private onStateChange(newValue: threema.GlobalConnectionState,
                           oldValue: threema.GlobalConnectionState): void {
                           oldValue: threema.GlobalConnectionState): void {
+        this.$log.debug(this.logTag, 'State change:', oldValue, '->', newValue);
         if (newValue === oldValue) {
         if (newValue === oldValue) {
             return;
             return;
         }
         }
         this.state = newValue;
         this.state = newValue;
+
+        const isWebrtc = this.webClientService.chosenTask === threema.ChosenTask.WebRTC;
+        const isRelayedData = this.webClientService.chosenTask === threema.ChosenTask.RelayedData;
+
         switch (newValue) {
         switch (newValue) {
             case 'ok':
             case 'ok':
                 this.collapseStatusBar();
                 this.collapseStatusBar();
                 break;
                 break;
             case 'warning':
             case 'warning':
-                if (oldValue === 'ok') {
+                if (oldValue === 'ok' && isWebrtc) {
                     this.scheduleStatusBar();
                     this.scheduleStatusBar();
                 }
                 }
+                if (this.stateService.wasConnected) {
+                    this.webClientService.clearIsTypingFlags();
+                }
+                if (this.stateService.wasConnected && isRelayedData) {
+                    this.reconnectIos();
+                }
                 break;
                 break;
             case 'error':
             case 'error':
-                if (this.stateService.wasConnected) {
+                if (this.stateService.wasConnected && isWebrtc) {
                     if (oldValue === 'ok') {
                     if (oldValue === 'ok') {
                         this.scheduleStatusBar();
                         this.scheduleStatusBar();
                     }
                     }
-                    this.reconnect();
+                    this.reconnectAndroid();
                 }
                 }
                 break;
                 break;
             default:
             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
         // Get original keys
         const originalKeyStore = this.webClientService.salty.keyStore;
         const originalKeyStore = this.webClientService.salty.keyStore;
@@ -185,10 +197,9 @@ export class StatusController {
 
 
         // Function to soft-reconnect. Does not reset the loaded data.
         // Function to soft-reconnect. Does not reset the loaded data.
         const doSoftReconnect = () => {
         const doSoftReconnect = () => {
-            const deleteStoredData = false;
             const resetPush = false;
             const resetPush = false;
             const redirect = 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.init(originalKeyStore, originalPeerPermanentKeyBytes, false);
             this.webClientService.start().then(
             this.webClientService.start().then(
                 () => {
                 () => {
@@ -229,6 +240,53 @@ export class StatusController {
         // TODO: Handle server closing state
         // 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 {
     public wide(): boolean {
         return this.controllerService.getControllerName() !== undefined
         return this.controllerService.getControllerName() !== undefined
             && this.controllerService.getControllerName() === 'messenger';
             && 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 messageState from './directives/message_state';
 import messageText from './directives/message_text';
 import messageText from './directives/message_text';
 import messageVoipStatus from './directives/message_voip_status';
 import messageVoipStatus from './directives/message_voip_status';
-import myIdentity from './directives/my_identity';
 import searchbox from './directives/searchbox';
 import searchbox from './directives/searchbox';
 import statusBar from './directives/status_bar';
 import statusBar from './directives/status_bar';
 import verificationLevel from './directives/verification_level';
 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('batteryStatus', batteryStatus);
 angular.module('3ema.directives').directive('clickAction', clickAction);
 angular.module('3ema.directives').directive('clickAction', clickAction);
 angular.module('3ema.directives').directive('composeArea', composeArea);
 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('eeeAvatar', avatar);
 angular.module('3ema.directives').directive('eeeContactBadge', contactBadge);
 angular.module('3ema.directives').directive('eeeContactBadge', contactBadge);
 angular.module('3ema.directives').directive('eeeDistributionListBadge', distributionListBadge);
 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('eeeMessageState', messageState);
 angular.module('3ema.directives').directive('eeeMessageText', messageText);
 angular.module('3ema.directives').directive('eeeMessageText', messageText);
 angular.module('3ema.directives').directive('eeeMessageVoipStatus', messageVoipStatus);
 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('eeeVerificationLevel', verificationLevel);
 angular.module('3ema.directives').directive('includeReplace', includeReplace);
 angular.module('3ema.directives').directive('includeReplace', includeReplace);
 angular.module('3ema.directives').directive('location', location);
 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('mediabox', mediabox);
+angular.module('3ema.directives').directive('memberListEditor', memberListEditor);
 angular.module('3ema.directives').directive('searchbox', searchbox);
 angular.module('3ema.directives').directive('searchbox', searchbox);
 angular.module('3ema.directives').directive('statusBar', statusBar);
 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/>.
  * 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 {WebClientService} from '../services/webclient';
+import {isContactReceiver} from '../typeguards';
 
 
 export default [
 export default [
     '$rootScope',
     '$rootScope',
     '$timeout',
     '$timeout',
+    '$log',
     'WebClientService',
     'WebClientService',
     function($rootScope: ng.IRootScopeService,
     function($rootScope: ng.IRootScopeService,
              $timeout: ng.ITimeoutService,
              $timeout: ng.ITimeoutService,
+             $log: ng.ILogService,
              webClientService: WebClientService) {
              webClientService: WebClientService) {
         return {
         return {
             restrict: 'E',
             restrict: 'E',
             scope: {},
             scope: {},
             bindToController: {
             bindToController: {
-                type: '=eeeType',
                 receiver: '=eeeReceiver',
                 receiver: '=eeeReceiver',
                 resolution: '=eeeResolution',
                 resolution: '=eeeResolution',
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             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;
                 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: `
             template: `
@@ -118,16 +201,21 @@ export default [
                             'title': 'messenger.THREEMA_WORK_CONTACT'}">
                             'title': 'messenger.THREEMA_WORK_CONTACT'}">
                         <img src="img/ic_work_round.svg" alt="Threema Work user">
                         <img src="img/ic_work_round.svg" alt="Threema Work user">
                     </div>
                     </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()"
                     <div class="blocked-indicator"  ng-if="ctrl.showBlocked()"
                         translate-attr="{'aria-label': 'messenger.THREEMA_BLOCKED_RECEIVER',
                         translate-attr="{'aria-label': 'messenger.THREEMA_BLOCKED_RECEIVER',
                             'title': '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>
                     </div>
                     <img
                     <img
                          ng-class="ctrl.avatarClass()"
                          ng-class="ctrl.avatarClass()"
                          ng-style="{ 'background-color': ctrl.backgroundColor }"
                          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>
                </div>
             `,
             `,
         };
         };

+ 87 - 95
src/directives/avatar_area.ts

@@ -17,24 +17,21 @@
 
 
 // tslint:disable:max-line-length
 // tslint:disable:max-line-length
 
 
+import {bufferToUrl, logAdapter} from '../helpers';
+import {WebClientService} from '../services/webclient';
+
 /**
 /**
  * Support uploading and resizing avatar
  * Support uploading and resizing avatar
  */
  */
 export default [
 export default [
     '$rootScope',
     '$rootScope',
     '$log',
     '$log',
-    '$window',
-    '$timeout',
-    '$translate',
-    '$filter',
     '$mdDialog',
     '$mdDialog',
+    'WebClientService',
     function($rootScope: ng.IRootScopeService,
     function($rootScope: ng.IRootScopeService,
              $log: ng.ILogService,
              $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 {
         return {
             restrict: 'EA',
             restrict: 'EA',
             scope: true,
             scope: true,
@@ -46,100 +43,95 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             controller: [function() {
+                const logTag = '[AvatarAreaDirective]';
+
                 this.isLoading = false;
                 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: `
             template: `
                 <div class="avatar-area overview">
                 <div class="avatar-area overview">
@@ -150,7 +142,7 @@ export default [
                                     md-diameter="96"></md-progress-circular>
                                     md-diameter="96"></md-progress-circular>
 
 
                         </div>
                         </div>
-                        <img ng-src="{{ctrl.avatar}}" ng-show="ctrl.avatar !== null" />
+                        <img ng-src="{{ ctrl.avatar }}" ng-if="ctrl.avatar !== null">
                     </div>
                     </div>
                     <div class="avatar-area-navigation"  layout="row" layout-wrap layout-margin layout-align="center">
                     <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
 // tslint:disable:max-line-length
 
 
+import {bufferToUrl, logAdapter} from '../helpers';
+
 /**
 /**
  * Support uploading and resizing avatar
  * Support uploading and resizing avatar
  */
  */
@@ -24,10 +26,9 @@ export default [
     '$window',
     '$window',
     '$timeout',
     '$timeout',
     '$translate',
     '$translate',
-    '$filter',
     '$log',
     '$log',
     '$mdDialog',
     '$mdDialog',
-    function($window, $timeout: ng.ITimeoutService, $translate, $filter: any, $log: ng.ILogService, $mdDialog) {
+    function($window, $timeout: ng.ITimeoutService, $translate, $log: ng.ILogService, $mdDialog) {
         return {
         return {
             restrict: 'EA',
             restrict: 'EA',
             scope: {
             scope: {
@@ -53,7 +54,7 @@ export default [
                     if (croppieInstance !== null) {
                     if (croppieInstance !== null) {
                         return croppieInstance;
                         return croppieInstance;
                     }
                     }
-                    croppieInstance = new Croppie(element[0].querySelector('.croppie-container'), {
+                    croppieInstance = new Croppie(element[0].querySelector('.croppie-target'), {
                         viewport: {
                         viewport: {
                             type: 'square',
                             type: 'square',
                             width: VIEWPORT_SIZE,
                             width: VIEWPORT_SIZE,
@@ -71,7 +72,8 @@ export default [
                                     type: 'blob',
                                     type: 'blob',
                                     // max allowed size on device
                                     // max allowed size on device
                                     size: [512, 512],
                                     size: [512, 512],
-                                    circle: 'false',
+                                    circle: false,
+                                    format: 'png',
                                 })
                                 })
                                     .then((blob: Blob) => {
                                     .then((blob: Blob) => {
                                         const fileReader = new FileReader();
                                         const fileReader = new FileReader();
@@ -102,15 +104,15 @@ export default [
                 function fetchFileContent(file: File): Promise<ArrayBuffer> {
                 function fetchFileContent(file: File): Promise<ArrayBuffer> {
                     return new Promise((resolve, reject) => {
                     return new Promise((resolve, reject) => {
                         const reader = new FileReader();
                         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
                             // set a null object
                             reject(ev);
                             reject(ev);
                         };
                         };
-                        reader.onprogress = function(data) {
-                            if (data.lengthComputable) {
+                        reader.onprogress = function(ev: FileReaderProgressEvent) {
+                            if (ev.lengthComputable) {
                                 // TODO implement progress?
                                 // TODO implement progress?
                                 // let progress = ((data.loaded / data.total) * 100);
                                 // let progress = ((data.loaded / data.total) * 100);
                             }
                             }
@@ -125,7 +127,7 @@ export default [
                     }
                     }
                     // get first
                     // get first
                     fetchFileContent(fileList[0]).then((data: ArrayBuffer) => {
                     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);
                         setImage(image);
                     }).catch((ev: ErrorEvent) => {
                     }).catch((ev: ErrorEvent) => {
                         $log.error(logTag, 'Could not load file:', ev.message);
                         $log.error(logTag, 'Could not load file:', ev.message);
@@ -254,9 +256,9 @@ export default [
             },
             },
             template: `
             template: `
                 <div class="avatar-editor">
                 <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">
                           <md-button type="submit" class="file-trigger md-raised">
                             <span translate>messenger.UPLOAD_AVATAR</span>
                             <span translate>messenger.UPLOAD_AVATAR</span>
                            </md-button>
                            </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
  * 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/>.
  * 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 {UriService} from '../services/uri';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
@@ -22,7 +25,7 @@ export default [
     '$state',
     '$state',
     'UriService',
     'UriService',
     'WebClientService',
     'WebClientService',
-    function($timeout, $state: ng.ui.IStateService, uriService: UriService, webClientService: WebClientService) {
+    function($timeout, $state: UiStateService, uriService: UriService, webClientService: WebClientService) {
 
 
         const validateThreemaId = (id: string): boolean => {
         const validateThreemaId = (id: string): boolean => {
             return id !== undefined && id !== null && /^[0-9A-Z]{8}/.test(id);
             return id !== undefined && id !== null && /^[0-9A-Z]{8}/.test(id);

+ 17 - 12
src/directives/compose_area.ts

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

+ 18 - 15
src/directives/contact_badge.ts

@@ -17,6 +17,8 @@
 
 
 // tslint:disable:max-line-length
 // tslint:disable:max-line-length
 
 
+import {StateService as UiStateService} from '@uirouter/angularjs';
+
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
 /**
 /**
@@ -25,7 +27,7 @@ import {WebClientService} from '../services/webclient';
 export default [
 export default [
     'WebClientService',
     'WebClientService',
     '$state',
     '$state',
-    function(webClientService: WebClientService, $state: ng.ui.IStateService) {
+    function(webClientService: WebClientService, $state: UiStateService) {
         return {
         return {
             restrict: 'EA',
             restrict: 'EA',
             scope: {},
             scope: {},
@@ -37,26 +39,27 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             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: `
             template: `
                 <div class="contact-badge receiver-badge" ng-click="ctrl.click()">
                 <div class="contact-badge receiver-badge" ng-click="ctrl.click()">
                     <section class="avatar-box">
                     <section class="avatar-box">
-                        <eee-avatar eee-type="'contact'"
-                                    eee-receiver="ctrl.contactReceiver"
+                        <eee-avatar eee-receiver="ctrl.contactReceiver"
                                     eee-resolution="'low'"></eee-avatar>
                                     eee-resolution="'low'"></eee-avatar>
                     </section>
                     </section>
                     <div class="receiver-badge-name"
                     <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/>.
  * 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
  * Show a distribution list receiver with small avatar, name and verification level
  */
  */
 export default [
 export default [
     '$state',
     '$state',
-    function($state: ng.ui.IStateService) {
+    function($state: UiStateService) {
         return {
         return {
             restrict: 'EA',
             restrict: 'EA',
             scope: {},
             scope: {},
             bindToController: {
             bindToController: {
                 distributionListReceiver: '=eeeDistributionListReceiver',
                 distributionListReceiver: '=eeeDistributionListReceiver',
-                contactReceiver: '=?eeeContactReceiver',
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             controller: [function() {
@@ -41,8 +42,7 @@ export default [
             template: `
             template: `
                 <div class="distribution-list-badge receiver-badge" ng-click="ctrl.click()">
                 <div class="distribution-list-badge receiver-badge" ng-click="ctrl.click()">
                     <section class="avatar-box">
                     <section class="avatar-box">
-                        <eee-avatar eee-type="'distributionList'"
-                                    eee-receiver="ctrl.distributionListReceiver"
+                        <eee-avatar eee-receiver="ctrl.distributionListReceiver"
                                     eee-resolution="'low'"></eee-avatar>
                                     eee-resolution="'low'"></eee-avatar>
                     </section>
                     </section>
                     <div class="receiver-badge-name"
                     <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>> {
                 function fetchFileListContents(fileList: FileList): Promise<Map<File, ArrayBuffer>> {
                     return new Promise((resolve) => {
                     return new Promise((resolve) => {
                         const buffers = new Map<File, ArrayBuffer>();
                         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);
                             buffers.set(file, res);
                             if (buffers.size >= fileList.length) {
                             if (buffers.size >= fileList.length) {
                                 resolve(buffers);
                                 resolve(buffers);
@@ -60,16 +60,16 @@ export default [
                         for (let n = 0; n < fileList.length; n++) {
                         for (let n = 0; n < fileList.length; n++) {
                             const reader = new FileReader();
                             const reader = new FileReader();
                             const file = fileList.item(n);
                             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
                                 // set a null object
                                 next(file, null, ev);
                                 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);
                                     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/>.
  * 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
  * Show a contact receiver with small avatar, name and verification level
  */
  */
 export default [
 export default [
     '$translate',
     '$translate',
     '$state',
     '$state',
-    function($translate, $state: ng.ui.IStateService) {
+    function($translate, $state: UiStateService) {
         return {
         return {
             restrict: 'EA',
             restrict: 'EA',
             scope: {},
             scope: {},
@@ -31,23 +33,6 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             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 = () => {
                 this.click = () => {
                     $state.go('messenger.home.conversation', {
                     $state.go('messenger.home.conversation', {
                         type: 'group',
                         type: 'group',
@@ -55,12 +40,26 @@ export default [
                         initParams: null,
                         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: `
             template: `
                 <div class="group-badge receiver-badge" ng-click="ctrl.click()">
                 <div class="group-badge receiver-badge" ng-click="ctrl.click()">
                     <section class="avatar-box">
                     <section class="avatar-box">
-                        <eee-avatar eee-type="'group'"
-                                    eee-receiver="ctrl.groupReceiver"
+                        <eee-avatar eee-receiver="ctrl.groupReceiver"
                                     eee-resolution="'low'"></eee-avatar>
                                     eee-resolution="'low'"></eee-avatar>
                     </section>
                     </section>
                     <div class="receiver-badge-name"
                     <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 -->
     <!-- Typing indicator -->
     <div class="left typing">
     <div class="left typing">
@@ -43,7 +47,7 @@
                   class="message-date" eee-message="ctrl.message"></span>
                   class="message-date" eee-message="ctrl.message"></span>
 
 
             <span class="message-state" ng-show="ctrl.statusIcon">
             <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 }}
                      {{ ctrl.statusIcon }}
                  </i>
                  </i>
             </span>
             </span>

+ 57 - 63
src/directives/latest_message.ts

@@ -15,6 +15,7 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
  */
 
 
+import {getSenderIdentity} from '../helpers/messages';
 import {MessageService} from '../services/message';
 import {MessageService} from '../services/message';
 import {ReceiverService} from '../services/receiver';
 import {ReceiverService} from '../services/receiver';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
@@ -33,80 +34,73 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             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',
             templateUrl: 'directives/latest_message.html',
         };
         };

+ 6 - 7
src/directives/location.ts

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

+ 3 - 2
src/directives/mediabox.ts

@@ -53,10 +53,11 @@ export default [
                 };
                 };
 
 
                 // Listen to Mediabox service events
                 // 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) => {
                 mediaboxService.evtMediaChanged.attach((dataAvailable: boolean) => {
                     $rootScope.$apply(() => {
                     $rootScope.$apply(() => {
-                        this.imageDataUrl = filter(mediaboxService.data, 'image/jpeg');
+                        this.imageDataUrl = bufferToUrl(mediaboxService.data, mediaboxService.mimetype, true);
                         this.caption = mediaboxService.caption || mediaboxService.filename;
                         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/>.
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
  */
 
 
+import {hasFeature} from '../helpers';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
+const AUTOCOMPLETE_MAX_RESULTS = 20;
+
 export default [
 export default [
-    'WebClientService',
-    function(webClientService: WebClientService) {
+    '$log', 'WebClientService',
+    function($log: ng.ILogService, webClientService: WebClientService) {
         return {
         return {
             restrict: 'EA',
             restrict: 'EA',
             scope: {},
             scope: {},
@@ -30,14 +33,14 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             controller: [function() {
-                const AUTOCOMPLETE_MAX_RESULTS = 20;
-
-                // cache all feature level >= 1 contacts
+                // Cache all contacts with group chat support
                 this.allContacts = Array
                 this.allContacts = Array
                     .from(webClientService.contacts.values())
                     .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) => {
                 this.selectedItemChange = (contactReceiver: threema.ContactReceiver) => {
                     if (contactReceiver !== undefined) {
                     if (contactReceiver !== undefined) {
@@ -73,7 +76,7 @@ export default [
                 };
                 };
 
 
                 this.onRemoveMember = (contact: threema.ContactReceiver): boolean => {
                 this.onRemoveMember = (contact: threema.ContactReceiver): boolean => {
-                    if (contact.id === webClientService.getMyIdentity().identity) {
+                    if (contact.id === webClientService.me.id) {
                         return false;
                         return false;
                     }
                     }
 
 

+ 1 - 2
src/directives/message.html

@@ -6,13 +6,12 @@
     <eee-avatar
     <eee-avatar
             class="message-avatar"
             class="message-avatar"
             ng-if="ctrl.showAvatar"
             ng-if="ctrl.showAvatar"
-            eee-type="'contact'"
             eee-receiver="ctrl.contact"
             eee-receiver="ctrl.contact"
             eee-resolution="ctrl.resolution"
             eee-resolution="ctrl.resolution"
             ui-sref="messenger.home.conversation({ type: 'contact', id: ctrl.contact.id, initParams: null })"></eee-avatar>
             ui-sref="messenger.home.conversation({ type: 'contact', id: ctrl.contact.id, initParams: null })"></eee-avatar>
 
 
     <div class="bubble-triangle"></div>
     <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
         <eee-message-contact
             ng-if="ctrl.showName"
             ng-if="ctrl.showName"
             class="message-name"
             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/>.
  * 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 {MessageService} from '../services/message';
 import {ReceiverService} from '../services/receiver';
 import {ReceiverService} from '../services/receiver';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
@@ -24,13 +27,18 @@ export default [
     'MessageService',
     'MessageService',
     'ReceiverService',
     'ReceiverService',
     '$mdDialog',
     '$mdDialog',
+    '$mdToast',
     '$translate',
     '$translate',
     '$rootScope',
     '$rootScope',
     '$log',
     '$log',
-    function(webClientService: WebClientService, messageService: MessageService,
+    function(webClientService: WebClientService,
+             messageService: MessageService,
              receiverService: ReceiverService,
              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 {
         return {
             restrict: 'E',
             restrict: 'E',
@@ -43,97 +51,165 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             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.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) {
             link: function(scope: any, element: ng.IAugmentedJQuery, attrs) {

+ 1 - 1
src/directives/message_date.ts

@@ -23,7 +23,7 @@ export default [
                 message: '=eeeMessage',
                 message: '=eeeMessage',
             },
             },
             template: `
             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;
                             return null;
                     }
                     }
                 };
                 };
-                this.icon = getIcon(this.message.type);
+
+                this.$onInit = function() {
+                    this.icon = getIcon(this.message.type);
+                    this.altText = this.message.type + ' icon';
+                };
             }],
             }],
             template: `
             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 -->
         <!-- Thumbnails -->
         <span class="in-view-indicator" ng-if="ctrl.type !== 'location'" in-view="ctrl.thumbnailInView($inview)"></span>
         <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>
 
 
     </div>
     </div>
@@ -47,7 +47,7 @@
             <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
             <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
         </div>
         </div>
         <!-- Play Indicator -->
         <!-- 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>
             <i class="material-icons md-24">play_arrow</i>
         </div>
         </div>
         <div class="info" translate>messageTypes.AUDIO_MESSAGE</div>
         <div class="info" translate>messageTypes.AUDIO_MESSAGE</div>
@@ -55,7 +55,7 @@
 
 
     <!-- Anim GIF -->
     <!-- Anim GIF -->
     <div class="animgif" ng-if="ctrl.downloaded && ctrl.isAnimGif">
     <div class="animgif" ng-if="ctrl.downloaded && ctrl.isAnimGif">
-        <img ng-src="{{ ctrl.blobBuffer | bufferToUrl: 'image/gif'}}" />
+        <img ng-src="{{ ctrl.blobBufferUrl }}">
     </div>
     </div>
 
 
     <!-- Other file messages -->
     <!-- Other file messages -->
@@ -65,7 +65,7 @@
         <div class="circle"
         <div class="circle"
              ng-class="{active: !ctrl.isDownloading()}"
              ng-class="{active: !ctrl.isDownloading()}"
              ng-if="!ctrl.downloaded"
              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>
             <i class="material-icons md-24">file_download</i>
             <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
             <div class="loading" ng-class="{active: ctrl.isDownloading()}"></div>
         </div>
         </div>
@@ -73,11 +73,11 @@
         <!-- File type indicator -->
         <!-- File type indicator -->
         <div class="circle"
         <div class="circle"
              ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview !== undefined"
              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>
         <div class="circle"
         <div class="circle"
              ng-if="ctrl.downloaded && ctrl.message.thumbnail.preview == undefined">
              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>
         </div>
 
 
         <!-- File information -->
         <!-- 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/>.
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
  */
 
 
+import {bufferToUrl, hasValue, logAdapter} from '../helpers';
 import {MediaboxService} from '../services/mediabox';
 import {MediaboxService} from '../services/mediabox';
 import {MessageService} from '../services/message';
 import {MessageService} from '../services/message';
 import {WebClientService} from '../services/webclient';
 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 [
 export default [
     'WebClientService',
     'WebClientService',
     'MediaboxService',
     'MediaboxService',
@@ -26,6 +65,7 @@ export default [
     '$rootScope',
     '$rootScope',
     '$mdDialog',
     '$mdDialog',
     '$timeout',
     '$timeout',
+    '$translate',
     '$log',
     '$log',
     '$filter',
     '$filter',
     '$window',
     '$window',
@@ -35,6 +75,7 @@ export default [
              $rootScope: ng.IRootScopeService,
              $rootScope: ng.IRootScopeService,
              $mdDialog: ng.material.IDialogService,
              $mdDialog: ng.material.IDialogService,
              $timeout: ng.ITimeoutService,
              $timeout: ng.ITimeoutService,
+             $translate: ng.translate.ITranslateService,
              $log: ng.ILogService,
              $log: ng.ILogService,
              $filter: ng.IFilterService,
              $filter: ng.IFilterService,
              $window: ng.IWindowService) {
              $window: ng.IWindowService) {
@@ -48,168 +89,196 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             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',
             templateUrl: 'directives/message_media.html',

+ 14 - 2
src/directives/message_menu.html

@@ -1,6 +1,6 @@
 <!-- Non status messages -->
 <!-- Non status messages -->
 <md-menu md-position-mode="target-right target" md-offset="0 45">
 <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>
         <i class="material-icons md-dark md-24">more_vert</i>
     </md-button>
     </md-button>
     <md-menu-content width="1">
     <md-menu-content width="1">
@@ -29,11 +29,23 @@
             </md-button>
             </md-button>
         </md-menu-item>
         </md-menu-item>
         <md-menu-item ng-if="ctrl.access.copy">
         <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>
                 <md-icon aria-label="Copy" class="material-icons md-24">content_copy</md-icon>
                 <span translate>messenger.COPY</span>
                 <span translate>messenger.COPY</span>
             </md-button>
             </md-button>
         </md-menu-item>
         </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-menu-item ng-if="ctrl.access.delete">
             <md-button ng-click="ctrl.delete($event)">
             <md-button ng-click="ctrl.delete($event)">
             <md-icon aria-label="Delete" class="material-icons md-24">delete</md-icon>
             <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',
             controllerAs: 'ctrl',
             controller: [function() {
             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: `
             template: `
                 <span ng-if="ctrl.isGif" class="message-meta-item">GIF</span>
                 <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',
                 message: '=eeeMessage',
             },
             },
             template: `
             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
 // tslint:disable:max-line-length
 
 
+import {WebClientService} from '../services/webclient';
+
 export default [
 export default [
     function() {
     function() {
         return {
         return {
@@ -27,38 +29,37 @@ export default [
                 multiLine: '=?eeeMultiLine',
                 multiLine: '=?eeeMultiLine',
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
-            controller: [function() {
+            controller: ['WebClientService', function(webClientService: WebClientService) {
                 // Get text depending on type
                 // 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: `
             template: `
                 <span click-action
                 <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>
                 </span>
             `,
             `,
         };
         };

+ 7 - 5
src/directives/message_voip_status.ts

@@ -27,11 +27,13 @@ export default [
             },
             },
             controllerAs: 'ctrl',
             controllerAs: 'ctrl',
             controller: [function() {
             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: `
             template: `
                 <p ng-if="ctrl.status === 1">
                 <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',
             controllerAs: 'ctrl',
             controller: [function() {
             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: `
             template: `
                 <span class="verification-dots {{ctrl.cls}}" title="{{ctrl.description}}">
                 <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/>.
  * 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 {MimeService} from './services/mime';
+import {NotificationService} from './services/notification';
 import {WebClientService} from './services/webclient';
 import {WebClientService} from './services/webclient';
 
 
 angular.module('3ema.filters', [])
 angular.module('3ema.filters', [])
@@ -83,8 +84,8 @@ angular.module('3ema.filters', [])
         email: true,
         email: true,
         // Don't link phone numbers (doesn't work reliably)
         // Don't link phone numbers (doesn't work reliably)
         phone: false,
         phone: false,
-        // Don't link twitter handles
-        twitter: false,
+        // Don't link mentions
+        mention: false,
         // Don't link hashtags
         // Don't link hashtags
         hashtag: false,
         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
  * Convert markdown elements to html elements
  */
  */
@@ -223,6 +245,12 @@ angular.module('3ema.filters', [])
         return padLeft + left + ':' + padRight + right;
         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) {
 .filter('bufferToUrl', ['$sce', '$log', function($sce, $log) {
     const logTag = '[filters.bufferToUrl]';
     const logTag = '[filters.bufferToUrl]';
     return function(buffer: ArrayBuffer, mimeType: string, trust: boolean = true) {
     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);
             $log.error(logTag, 'Could not apply bufferToUrl filter: buffer is', buffer);
             return '';
             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) {
         if (trust) {
             return $sce.trustAsResourceUrl(uri);
             return $sce.trustAsResourceUrl(uri);
         } else {
         } else {
@@ -244,6 +266,7 @@ angular.module('3ema.filters', [])
         }
         }
     };
     };
 }])
 }])
+
 .filter('mapLink', function() {
 .filter('mapLink', function() {
     return function(location: threema.LocationInfo) {
     return function(location: threema.LocationInfo) {
         return 'https://www.openstreetmap.org/?mlat='
         return 'https://www.openstreetmap.org/?mlat='
@@ -251,6 +274,7 @@ angular.module('3ema.filters', [])
             + location.lon;
             + location.lon;
     };
     };
 })
 })
+
 /**
 /**
  * Convert message state to material icon class.
  * Convert message state to material icon class.
  */
  */
@@ -286,11 +310,58 @@ angular.module('3ema.filters', [])
                 return 'thumb_up';
                 return 'thumb_up';
             case 'user-dec':
             case 'user-dec':
                 return 'thumb_down';
                 return 'thumb_down';
+            case 'timeout':
+                return 'sync_problem';
             default:
             default:
                 return '';
                 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() {
 .filter('fileSize', function() {
     return (size: number) => {
     return (size: number) => {
         if (!size) {
         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) {
 export function escapeRegExp(str: string) {
     return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched 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/>.
  * 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>
                 <h2 translate>about.CHANGELOG</h2>
                 <p>
                 <p>
                     <span translate>about.CHANGELOG_LINK_BEFORE</span>
                     <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>
                        target="_blank" rel="noopener noreferrer" translate>about.CHANGELOG_LINK_TEXT</a><span translate>about.CHANGELOG_LINK_AFTER</span>
                 </p>
                 </p>
 
 
@@ -38,7 +38,7 @@
                     <li translate>about.EMOJI_ART</li>
                     <li translate>about.EMOJI_ART</li>
                     <li>
                     <li>
                         <span translate>about.LICENSE_LINK_BEFORE</span>
                         <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>
                            target="_blank" rel="noopener noreferrer" translate>about.LICENSE_LINK_TEXT</a>
                         <span translate>about.LICENSE_LINK_AFTER</span>
                         <span translate>about.LICENSE_LINK_AFTER</span>
                     </li>
                     </li>

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

@@ -2,21 +2,15 @@
     <form ng-cloak>
     <form ng-cloak>
         <md-toolbar>
         <md-toolbar>
             <div class="md-toolbar-tools">
             <div class="md-toolbar-tools">
-                <h2 translate>messenger.MY_THREEMA_ID</h2>
+                <h2>{{ ctrl.profile.identity }}</h2>
                 <span flex></span>
                 <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-icon aria-label="Close dialog" class="material-icons md-24">close</md-icon>
                 </md-button>
                 </md-button>
             </div>
             </div>
         </md-toolbar>
         </md-toolbar>
         <md-dialog-content>
         <md-dialog-content>
             <div class="md-dialog-content center">
             <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">
                 <div class="qrcode">
                     <qrcode version="{{ ctrl.qrCode.version }}"
                     <qrcode version="{{ ctrl.qrCode.version }}"
                     error-correction-level="{{ ctrl.qrCode.errorCorrectionLevel }}"
                     error-correction-level="{{ ctrl.qrCode.errorCorrectionLevel }}"
@@ -27,7 +21,7 @@
         <md-dialog-actions layout="row">
         <md-dialog-actions layout="row">
             <span flex></span>
             <span flex></span>
             <md-button ng-click="ctrl.cancel()">
             <md-button ng-click="ctrl.cancel()">
-                <span translate>common.OK</span>
+                <span translate>common.CLOSE</span>
             </md-button>
             </md-button>
         </md-dialog-actions>
         </md-dialog-actions>
     </form>
     </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">
     <div id="conversation-header" class="detail-header">
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
         <div class="header-avatar" ng-click="ctrl.showReceiver()">
         <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>
                         eee-resolution="'low'"></eee-avatar>
         </div>
         </div>
         <div class="header-details" ng-click="ctrl.showReceiver()">
         <div class="header-details" ng-click="ctrl.showReceiver()">
@@ -36,7 +36,7 @@
         </md-card>
         </md-card>
     </div>
     </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">
         <ul class="chat">
             <li in-view="$inview && !ctrl.locked && ctrl.topOfChat()" class="load-more">
             <li in-view="$inview && !ctrl.locked && ctrl.topOfChat()" class="load-more">
                 <div ng-if="ctrl.hasMoreMessages()" class="loading">
                 <div ng-if="ctrl.hasMoreMessages()" class="loading">
@@ -45,7 +45,7 @@
             </li>
             </li>
             <li ng-repeat="message in ctrl.messages" id="message-{{message.id}}">
             <li ng-repeat="message in ctrl.messages" id="message-{{message.id}}">
                 <eee-message eee-receiver="ctrl.receiver" eee-type="ctrl.type" eee-message="message"
                 <eee-message eee-receiver="ctrl.receiver" eee-type="ctrl.type" eee-message="message"
-                             in-view="$inview  && !ctrl.locked && ctrl.msgRead(message.id)"
+                             in-view="$inview  && !ctrl.locked && ctrl.msgRead(message)"
                              in-view-options="{ considerPageVisibility: true }"></eee-message>
                              in-view-options="{ considerPageVisibility: true }"></eee-message>
             </li>
             </li>
             <li ng-if="ctrl.isTyping()" class="typing-indicator">
             <li ng-if="ctrl.isTyping()" class="typing-indicator">
@@ -85,8 +85,7 @@
                     ng-class="{selected: ctrl.selectedMention == $index}">
                     ng-class="{selected: ctrl.selectedMention == $index}">
                     <div class="contact-badge receiver-badge" ng-if="mention.isAll">
                     <div class="contact-badge receiver-badge" ng-if="mention.isAll">
                         <section class="avatar-box">
                         <section class="avatar-box">
-                            <eee-avatar eee-type="'group'"
-                                        eee-receiver="ctrl.receiver"
+                            <eee-avatar eee-receiver="ctrl.receiver"
                                         eee-resolution="'low'"></eee-avatar>
                                         eee-resolution="'low'"></eee-avatar>
                         </section>
                         </section>
                         <div translate>messenger.ALL</div>
                         <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>
     <battery-status></battery-status>
 
 
     <md-menu md-position-mode="target-right target" md-offset="0 45">
     <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>
             <i class="material-icons md-light md-24">more_vert</i>
         </md-button>
         </md-button>
         <md-menu-content width="4">
         <md-menu-content width="4">
@@ -42,10 +45,10 @@
 <div id="navigation-header">
 <div id="navigation-header">
     <div class="main">
     <div class="main">
         <md-nav-bar md-no-ink md-selected-nav-item="ctrl.activeTab" nav-bar-aria-label="navigation links">
         <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>
                 <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-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>
                 <i class="material-icons md-dark md-24" translate translate-attr-title="messenger.CONTACTS">person</i>
             </md-nav-item>
             </md-nav-item>
         </md-nav-bar>
         </md-nav-bar>
@@ -73,8 +76,7 @@
                             'active': ctrl.isActive(conversation)}">
                             'active': ctrl.isActive(conversation)}">
 
 
                 <section class="avatar-box">
                 <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>
                                 eee-resolution="'low'"></eee-avatar>
                 </section>
                 </section>
 
 
@@ -82,8 +84,14 @@
                     <section class="receiver-box">
                     <section class="receiver-box">
                         <span class="title" ng-class="{'disabled': conversation.receiver.disabled === true}" ng-bind-html="conversation.receiver.displayName | escapeHtml | emojify">
                         <span class="title" ng-class="{'disabled': conversation.receiver.disabled === true}" ng-bind-html="conversation.receiver.displayName | escapeHtml | emojify">
                         </span>
                         </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>
                         <span class="badge unread-count" ng-show="conversation.unreadCount > 0">
                         <span class="badge unread-count" ng-show="conversation.unreadCount > 0">
                             {{ conversation.unreadCount }}
                             {{ conversation.unreadCount }}
@@ -92,7 +100,7 @@
 
 
                     <section class="message-box">
                     <section class="message-box">
                         <eee-latest-message
                         <eee-latest-message
-                            ng-show="!conversation.receiver.isTyping() && conversation.latestMessage"
+                            ng-if="!conversation.receiver.isTyping() && conversation.latestMessage"
                             ng-class="latest-message-text"
                             ng-class="latest-message-text"
                             eee-type="conversation.type"
                             eee-type="conversation.type"
                             eee-receiver="conversation.receiver"
                             eee-receiver="conversation.receiver"
@@ -109,15 +117,14 @@
 <!-- Contacts -->
 <!-- Contacts -->
 <div id="navigation-contacts" class="tab-content" ng-if="ctrl.activeTab == 'contacts'" in-view-container>
 <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>
     <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"
         <li ng-repeat="contact in ctrl.contacts() | isNotMe | filter:ctrl.searchContact"
             ui-sref="messenger.home.conversation({ type: 'contact', id: contact.id, initParams: null })"
             ui-sref="messenger.home.conversation({ type: 'contact', id: contact.id, initParams: null })"
             class="contact"
             class="contact"
             ng-class="{'inactive': contact.state == 'INACTIVE'}">
             ng-class="{'inactive': contact.state == 'INACTIVE'}">
 
 
             <section class="avatar-box">
             <section class="avatar-box">
-                <eee-avatar eee-type="'contact'"
-                            eee-receiver="contact"
+                <eee-avatar eee-receiver="contact"
                             eee-resolution="'low'"></eee-avatar>
                             eee-resolution="'low'"></eee-avatar>
             </section>
             </section>
 
 
@@ -174,6 +181,7 @@
                 <md-icon class="material-icons md-24">group_add</md-icon>
                 <md-icon class="material-icons md-24">group_add</md-icon>
             </md-button>
             </md-button>
             <md-button
             <md-button
+                    ng-if="ctrl.showCreateDistributionListButton()"
                     ng-click="ctrl.createDistributionList()"
                     ng-click="ctrl.createDistributionList()"
                     translate-attr="{'aria-label': 'messenger.CREATE_DISTRIBUTION_LIST'}"
                     translate-attr="{'aria-label': 'messenger.CREATE_DISTRIBUTION_LIST'}"
                     aria-label="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">
     <div id="receiver-detail-header" class="detail-header">
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
         <ng-include src="'partials/messenger.backbutton.html'"></ng-include>
         <div class="header-details">
         <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>
 
 
         <div class="header-buttons">
         <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-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-icon class="material-icons md-24">mode_edit</md-icon>
             </md-button>
             </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-icon class="material-icons md-24">message</md-icon>
             </md-button>
             </md-button>
         </div>
         </div>

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

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

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

@@ -8,9 +8,7 @@
 		<md-card-content>
 		<md-card-content>
 			<ul class="member-list">
 			<ul class="member-list">
 				<li ng-repeat="memberIdentity in ctrl.receiver.members">
 				<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>
 				</li>
 			</ul>
 			</ul>
 
 
@@ -19,10 +17,10 @@
 
 
 	<md-card>
 	<md-card>
 		<md-card-content>
 		<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)">
 				<md-button ng-disabled="!ctrl.controllerModel.canClean()" class="md-raised" ng-click="ctrl.controllerModel.clean($event)">
 					<span translate>messenger.DELETE_THREAD</span>
 					<span translate>messenger.DELETE_THREAD</span>
 				</md-button>
 				</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()">
 				<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>
 					<span translate>messenger.DISTRIBUTION_LIST_DELETE</span>
 				</md-button>
 				</md-button>

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

@@ -1,8 +1,7 @@
 <div class="form-content">
 <div class="form-content">
 	<md-card class="two-row">
 	<md-card class="two-row">
 		<div class="avatar">
 		<div class="avatar">
-			<eee-avatar eee-type="'group'"
-						eee-receiver="ctrl.receiver"
+			<eee-avatar eee-receiver="ctrl.receiver"
 						eee-resolution="'high'"></eee-avatar>
 						eee-resolution="'high'"></eee-avatar>
 		</div>
 		</div>
 		<div>
 		<div>
@@ -11,7 +10,7 @@
 				<md-card-title-text>
 				<md-card-title-text>
 					<span class="md-headline">
 					<span class="md-headline">
 						<span translate>messenger.GROUP_CREATOR</span>
 						<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>
 					</span>
 				</md-card-title-text>
 				</md-card-title-text>
 			</md-card-title>
 			</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/>.
  * 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 {ContactControllerModel} from '../controller_model/contact';
-import {supportsPassive, throttle} from '../helpers';
+import {bufferToUrl, logAdapter, supportsPassive, throttle, u8aToHex} from '../helpers';
 import {ContactService} from '../services/contact';
 import {ContactService} from '../services/contact';
 import {ControllerService} from '../services/controller';
 import {ControllerService} from '../services/controller';
 import {ControllerModelService} from '../services/controller_model';
 import {ControllerModelService} from '../services/controller_model';
@@ -30,17 +38,21 @@ import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
 import {StateService} from '../services/state';
 import {VersionService} from '../services/version';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 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 $mdDialog: ng.material.IDialogService;
     public activeElement: HTMLElement | null;
     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.$mdDialog = $mdDialog;
         this.activeElement = document.activeElement as HTMLElement;
         this.activeElement = document.activeElement as HTMLElement;
+        this.config = CONFIG;
     }
     }
 
 
     public cancel(): void {
     public cancel(): void {
@@ -73,15 +85,27 @@ class DialogController {
  * Handle sending of files.
  * Handle sending of files.
  */
  */
 class SendFileController extends DialogController {
 class SendFileController extends DialogController {
-    public static $inject = ['$mdDialog', 'preview'];
+    public static $inject = ['$mdDialog', '$log', 'CONFIG', 'preview'];
+    private logTag: string = '[SendFileController]';
 
 
     public caption: string;
     public caption: string;
     public sendAsFile: boolean = false;
     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;
         this.preview = preview;
+        if (preview !== null) {
+            this.previewDataUrl = bufferToUrl(
+                this.preview.data,
+                this.preview.fileType,
+                logAdapter($log.warn, this.logTag),
+            );
+        }
     }
     }
 
 
     public send(): void {
     public send(): void {
@@ -98,7 +122,7 @@ class SendFileController extends DialogController {
     }
     }
 
 
     public hasPreview(): boolean {
     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 {
 class ConversationController {
     public name = 'navigation';
     public name = 'navigation';
     private logTag: string = '[ConversationController]';
     private logTag: string = '[ConversationController]';
@@ -175,11 +205,12 @@ class ConversationController {
     // Angular services
     // Angular services
     private $stateParams;
     private $stateParams;
     private $timeout: ng.ITimeoutService;
     private $timeout: ng.ITimeoutService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private $scope: ng.IScope;
     private $scope: ng.IScope;
     private $rootScope: ng.IRootScopeService;
     private $rootScope: ng.IRootScopeService;
     private $filter: ng.IFilterService;
     private $filter: ng.IFilterService;
+    private $translate: ng.translate.ITranslateService;
 
 
     // Own services
     // Own services
     private webClientService: WebClientService;
     private webClientService: WebClientService;
@@ -192,7 +223,7 @@ class ConversationController {
     private $mdToast: ng.material.IToastService;
     private $mdToast: ng.material.IToastService;
 
 
     // Controller model
     // Controller model
-    private controllerModel: threema.ControllerModel;
+    private controllerModel: threema.ControllerModel<threema.Receiver>;
 
 
     // DOM Elements
     // DOM Elements
     private domChatElement: HTMLElement;
     private domChatElement: HTMLElement;
@@ -200,29 +231,36 @@ class ConversationController {
     // Scrolling
     // Scrolling
     public showScrollJump: boolean = false;
     public showScrollJump: boolean = false;
 
 
+    // The conversation receiver
     public receiver: threema.Receiver;
     public receiver: threema.Receiver;
     public type: threema.ReceiverType;
     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 message: string = '';
-    public lastReadMsgId: number = 0;
+    public lastReadMsg: threema.Message | null = null;
     public msgReadReportPending = false;
     public msgReadReportPending = false;
     private hasMore = true;
     private hasMore = true;
-    private latestRefMsgId: number = null;
+    private latestRefMsgId: string | null = null;
     private allText: string;
     private allText: string;
-    private messages: threema.Message[];
     public initialData: threema.InitialConversationData = {
     public initialData: threema.InitialConversationData = {
         draft: '',
         draft: '',
         initialText: '',
         initialText: '',
     };
     };
-    private $translate: ng.translate.ITranslateService;
     private locked = false;
     private locked = false;
     public maxTextLength: number;
     public maxTextLength: number;
     public isTyping = (): boolean => false;
     public isTyping = (): boolean => false;
 
 
-    public allMentions: threema.Mention[] = [];
-    public currentMentions: threema.Mention[] = [];
-    public currentMentionFilterWord = null;
-    public selectedMention: number = null;
-
     private uploading = {
     private uploading = {
         enabled: false,
         enabled: false,
         value1: 0,
         value1: 0,
@@ -230,22 +268,23 @@ class ConversationController {
     };
     };
 
 
     public static $inject = [
     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',
         'WebClientService', 'StateService', 'ReceiverService', 'MimeService', 'VersionService',
         'ControllerModelService',
         'ControllerModelService',
     ];
     ];
-    constructor($stateParams: threema.ConversationStateParams,
-                $state: ng.ui.IStateService,
+    constructor($stateParams: ConversationStateParams,
                 $timeout: ng.ITimeoutService,
                 $timeout: ng.ITimeoutService,
                 $log: ng.ILogService,
                 $log: ng.ILogService,
                 $scope: ng.IScope,
                 $scope: ng.IScope,
                 $rootScope: ng.IRootScopeService,
                 $rootScope: ng.IRootScopeService,
                 $mdDialog: ng.material.IDialogService,
                 $mdDialog: ng.material.IDialogService,
                 $mdToast: ng.material.IToastService,
                 $mdToast: ng.material.IToastService,
-                $location,
                 $translate: ng.translate.ITranslateService,
                 $translate: ng.translate.ITranslateService,
                 $filter: ng.IFilterService,
                 $filter: ng.IFilterService,
+                $state: UiStateService,
+                $transitions: UiTransitionService,
                 webClientService: WebClientService,
                 webClientService: WebClientService,
                 stateService: StateService,
                 stateService: StateService,
                 receiverService: ReceiverService,
                 receiverService: ReceiverService,
@@ -275,10 +314,11 @@ class ConversationController {
         this.maxTextLength = this.webClientService.getMaxTextLength();
         this.maxTextLength = this.webClientService.getMaxTextLength();
         this.allText = this.$translate.instant('messenger.ALL');
         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
         // Check for version updates
         versionService.checkForUpdate();
         versionService.checkForUpdate();
@@ -315,9 +355,9 @@ class ConversationController {
             const mode = ControllerModelMode.CHAT;
             const mode = ControllerModelMode.CHAT;
             switch (this.receiver.type) {
             switch (this.receiver.type) {
                 case 'me':
                 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':
                 case 'contact':
                     this.controllerModel = controllerModelService.contact(
                     this.controllerModel = controllerModelService.contact(
                         this.receiver as threema.ContactReceiver, mode);
                         this.receiver as threema.ContactReceiver, mode);
@@ -337,9 +377,9 @@ class ConversationController {
                     return;
                     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');
                 $state.go('messenger.home');
                 return;
                 return;
             }
             }
@@ -351,24 +391,38 @@ class ConversationController {
 
 
             if (!this.receiver.locked) {
             if (!this.receiver.locked) {
                 let latestHeight = 0;
                 let latestHeight = 0;
-                // update unread count
-                this.webClientService.messages.updateFirstUnreadMessage(this.receiver);
+
+                // Subscribe to messages
                 this.messages = this.webClientService.messages.register(
                 this.messages = this.webClientService.messages.register(
                     this.receiver,
                     this.receiver,
                     this.$scope,
                     this.$scope,
                     (e, allMessages: threema.Message[], hasMore: boolean) => {
                     (e, allMessages: threema.Message[], hasMore: boolean) => {
+                        // This function is called every time there are new or removed messages.
+
+                        // Update data
                         this.messages = allMessages;
                         this.messages = allMessages;
+                        const wasInitialized = this.initialized;
+                        this.initialized = true;
                         this.hasMore = hasMore;
                         this.hasMore = hasMore;
+
+                        // Update "first unread" divider
+                        if (!wasInitialized) {
+                            this.webClientService.messages.updateFirstUnreadMessage(this.receiver);
+                        }
+
+                        // Autoscroll
                         if (this.latestRefMsgId !== null) {
                         if (this.latestRefMsgId !== null) {
                             // scroll to div..
                             // scroll to div..
-                            this.domChatElement.scrollTop =
-                                this.domChatElement.scrollHeight - latestHeight;
+                            this.domChatElement.scrollTop = this.domChatElement.scrollHeight - latestHeight;
                             this.latestRefMsgId = null;
                             this.latestRefMsgId = null;
                         }
                         }
                         latestHeight = this.domChatElement.scrollHeight;
                         latestHeight = this.domChatElement.scrollHeight;
                     },
                     },
                 );
                 );
 
 
+                // Update "first unread" divider
+                this.webClientService.messages.updateFirstUnreadMessage(this.receiver);
+
                 // Enable mentions only in group chats
                 // Enable mentions only in group chats
                 if (this.type === 'group') {
                 if (this.type === 'group') {
                     this.allMentions.push({
                     this.allMentions.push({
@@ -393,11 +447,10 @@ class ConversationController {
                     initialText: $stateParams.initParams ? $stateParams.initParams.text : '',
                     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);
                     this.isTyping = () => this.webClientService.isTyping(this.receiver as threema.ContactReceiver);
                 }
                 }
             }
             }
-
         } catch (error) {
         } catch (error) {
             $log.error('Could not set receiver and type');
             $log.error('Could not set receiver and type');
             $log.debug(error.stack);
             $log.debug(error.stack);
@@ -436,7 +489,6 @@ class ConversationController {
         if (errorMessage === undefined || errorMessage.length === 0) {
         if (errorMessage === undefined || errorMessage.length === 0) {
             errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
             errorMessage = this.$translate.instant('error.ERROR_OCCURRED');
         }
         }
-
         this.$mdToast.show(
         this.$mdToast.show(
             this.$mdToast.simple()
             this.$mdToast.simple()
                 .textContent(errorMessage)
                 .textContent(errorMessage)
@@ -449,7 +501,7 @@ class ConversationController {
     public submit = (type: threema.MessageContentType, contents: threema.MessageData[]): Promise<any> => {
     public submit = (type: threema.MessageContentType, contents: threema.MessageData[]): Promise<any> => {
         // Validate whether a connection is available
         // Validate whether a connection is available
         return new Promise((resolve, reject) => {
         return new Promise((resolve, reject) => {
-            if (this.stateService.state !== 'ok') {
+            if (!this.stateService.readyToSubmit(this.webClientService.chosenTask)) {
                 // Invalid connection, show toast and abort
                 // Invalid connection, show toast and abort
                 this.showError(this.$translate.instant('error.NO_CONNECTION'));
                 this.showError(this.$translate.instant('error.NO_CONNECTION'));
                 return reject();
                 return reject();
@@ -460,7 +512,7 @@ class ConversationController {
                     if (success) {
                     if (success) {
                         resolve();
                         resolve();
                     } else {
                     } else {
-                        reject();
+                        reject('Message sending unsuccessful');
                     }
                     }
                 }
                 }
             };
             };
@@ -511,7 +563,7 @@ class ConversationController {
                             <md-dialog class="send-file-dialog">
                             <md-dialog class="send-file-dialog">
                                 <md-dialog-content class="md-dialog-content">
                                 <md-dialog-content class="md-dialog-content">
                                     <h2 class="md-title">${title}</h2>
                                     <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}">
                                     <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}">
                                         <input maxlength="1000" md-autofocus ng-keypress="ctrl.keypress($event)" ng-model="ctrl.caption" placeholder="${placeholder}" aria-label="${placeholder}">
                                     </md-input-container>
                                     </md-input-container>
@@ -731,18 +783,37 @@ class ConversationController {
      * A message has been seen. Report it to the app, with a small delay to
      * A message has been seen. Report it to the app, with a small delay to
      * avoid sending too many messages at once.
      * 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) {
         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;
             this.msgReadReportPending = true;
             const receiver = angular.copy(this.receiver);
             const receiver = angular.copy(this.receiver);
             receiver.type = this.type;
             receiver.type = this.type;
             this.$timeout(() => {
             this.$timeout(() => {
-                this.webClientService.requestRead(receiver, this.lastReadMsgId);
+                this.webClientService.requestRead(receiver, this.lastReadMsg);
                 this.msgReadReportPending = false;
                 this.msgReadReportPending = false;
-            }, 500);
+            }, 300);
         }
         }
     }
     }
 
 
@@ -784,14 +855,14 @@ class NavigationController {
 
 
     private $mdDialog;
     private $mdDialog;
     private $translate: ng.translate.ITranslateService;
     private $translate: ng.translate.ITranslateService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
 
 
     public static $inject = [
     public static $inject = [
         '$log', '$state', '$mdDialog', '$translate',
         '$log', '$state', '$mdDialog', '$translate',
         'WebClientService', 'StateService', 'ReceiverService', 'TrustedKeyStore',
         '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,
                 $mdDialog: ng.material.IDialogService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, stateService: StateService,
                 webClientService: WebClientService, stateService: StateService,
                 receiverService: ReceiverService,
                 receiverService: ReceiverService,
@@ -851,6 +922,7 @@ class NavigationController {
     public isVisible(conversation: threema.Conversation) {
     public isVisible(conversation: threema.Conversation) {
         return conversation.receiver.visible;
         return conversation.receiver.visible;
     }
     }
+
     public conversations(): threema.Conversation[] {
     public conversations(): threema.Conversation[] {
         return this.webClientService.conversations.get();
         return this.webClientService.conversations.get();
     }
     }
@@ -859,6 +931,13 @@ class NavigationController {
         return this.receiverService.isConversationActive(value);
         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.
      * 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.
      * Return whether a trusted key is available.
      */
      */
@@ -914,10 +1001,9 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
         this.$mdDialog.show(confirm).then(() => {
-            const deleteStoredData = false;
             const resetPush = true;
             const resetPush = true;
             const redirect = true;
             const redirect = true;
-            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
+            this.webClientService.stop(true, threema.DisconnectReason.SessionStopped, resetPush, redirect);
             this.receiverService.setActive(undefined);
             this.receiverService.setActive(undefined);
         }, () => {
         }, () => {
             // do nothing
             // do nothing
@@ -935,10 +1021,9 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
         this.$mdDialog.show(confirm).then(() => {
-            const deleteStoredData = true;
             const resetPush = true;
             const resetPush = true;
             const redirect = true;
             const redirect = true;
-            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
+            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
             this.receiverService.setActive(undefined);
             this.receiverService.setActive(undefined);
         }, () => {
         }, () => {
             // do nothing
             // do nothing
@@ -963,6 +1048,7 @@ class NavigationController {
             type: 'distributionList',
             type: 'distributionList',
         });
         });
     }
     }
+
     /**
     /**
      * Toggle search bar.
      * Toggle search bar.
      */
      */
@@ -970,16 +1056,25 @@ class NavigationController {
         this.searchVisible = !this.searchVisible;
         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 {
 class MessengerController {
+    private logTag: string = '[MessengerController]';
+
     public name = 'messenger';
     public name = 'messenger';
     private receiverService: ReceiverService;
     private receiverService: ReceiverService;
     private $state;
     private $state;
@@ -995,7 +1090,7 @@ class MessengerController {
                 webClientService: WebClientService, controllerService: ControllerService) {
                 webClientService: WebClientService, controllerService: ControllerService) {
         // Redirect to welcome if necessary
         // Redirect to welcome if necessary
         if (stateService.state === 'error') {
         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');
             $state.go('welcome');
             return;
             return;
         }
         }
@@ -1023,7 +1118,7 @@ class MessengerController {
         }, true);
         }, true);
 
 
         this.webClientService.setReceiverListener({
         this.webClientService.setReceiverListener({
-            onRemoved(receiver: threema.Receiver) {
+            onConversationRemoved(receiver: threema.Receiver) {
                 switch ($state.current.name) {
                 switch ($state.current.name) {
                     case 'messenger.home.conversation':
                     case 'messenger.home.conversation':
                     case 'messenger.home.detail':
                     case 'messenger.home.detail':
@@ -1034,12 +1129,12 @@ class MessengerController {
                             if ($state.params.type === receiver.type
                             if ($state.params.type === receiver.type
                                 && $state.params.id === receiver.id) {
                                 && $state.params.id === receiver.id) {
                                 // conversation or sub form is open, redirect to home!
                                 // conversation or sub form is open, redirect to home!
-                                $state.go('messenger.home', null, {location: 'replace'});
+                                $state.go('messenger.home');
                             }
                             }
                         }
                         }
                         break;
                         break;
                     default:
                     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 {
 class ReceiverDetailController {
     private logTag: string = '[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 receiver: threema.Receiver;
     public me: threema.MeReceiver;
     public me: threema.MeReceiver;
     public title: string;
     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 showGroups = false;
     private showDistributionLists = false;
     private showDistributionLists = false;
     private inGroups: threema.GroupReceiver[] = [];
     private inGroups: threema.GroupReceiver[] = [];
@@ -1070,27 +1171,30 @@ class ReceiverDetailController {
     private isWorkReceiver = false;
     private isWorkReceiver = false;
     private showBlocked = () => false;
     private showBlocked = () => false;
 
 
-    private controllerModel: threema.ControllerModel;
+    private controllerModel: threema.ControllerModel<threema.Receiver>;
 
 
     public static $inject = [
     public static $inject = [
-        '$log', '$stateParams', '$state', '$mdDialog',
+        '$scope', '$log', '$stateParams', '$state', '$mdDialog', '$translate',
         'WebClientService', 'FingerPrintService', 'ContactService', 'ControllerModelService',
         '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,
                 webClientService: WebClientService, fingerPrintService: FingerPrintService,
                 contactService: ContactService, controllerModelService: ControllerModelService) {
                 contactService: ContactService, controllerModelService: ControllerModelService) {
 
 
         this.$mdDialog = $mdDialog;
         this.$mdDialog = $mdDialog;
+        this.$scope = $scope;
         this.$state = $state;
         this.$state = $state;
         this.fingerPrintService = fingerPrintService;
         this.fingerPrintService = fingerPrintService;
         this.contactService = contactService;
         this.contactService = contactService;
+        this.webClientService = webClientService;
 
 
         this.receiver = webClientService.receivers.getData($stateParams);
         this.receiver = webClientService.receivers.getData($stateParams);
         this.me = webClientService.me;
         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)
             this.contactService.requiredDetails(contactReceiver)
                 .then(() => {
                 .then(() => {
@@ -1102,7 +1206,11 @@ class ReceiverDetailController {
                 });
                 });
 
 
             this.isWorkReceiver = contactReceiver.identityType === threema.IdentityType.Work;
             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) => {
             webClientService.groups.forEach((groupReceiver: threema.GroupReceiver) => {
                 // check if my identity is a member
                 // check if my identity is a member
                 if (groupReceiver.members.indexOf(contactReceiver.id) !== -1) {
                 if (groupReceiver.members.indexOf(contactReceiver.id) !== -1) {
@@ -1126,12 +1234,19 @@ class ReceiverDetailController {
 
 
         switch (this.receiver.type) {
         switch (this.receiver.type) {
             case 'me':
             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':
             case 'contact':
+                const contactReceiver = this.receiver as threema.ContactReceiver;
+                this.fingerPrintService
+                    .generate(contactReceiver.publicKey)
+                    .then(this.setFingerPrint.bind(this));
                 this.controllerModel = controllerModelService
                 this.controllerModel = controllerModelService
-                    .contact(this.receiver as threema.ContactReceiver, ControllerModelMode.VIEW);
+                    .contact(contactReceiver, ControllerModelMode.VIEW);
                 break;
                 break;
             case 'group':
             case 'group':
                 this.controllerModel = controllerModelService
                 this.controllerModel = controllerModelService
@@ -1148,14 +1263,7 @@ class ReceiverDetailController {
                 return;
                 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) => {
         this.controllerModel.setOnRemoved((receiverId: string) => {
             $log.warn(this.logTag, 'Receiver removed, redirecting to home');
             $log.warn(this.logTag, 'Receiver removed, redirecting to home');
             this.$state.go('messenger.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 {
     public chat(): void {
         this.$state.go('messenger.home.conversation', {
         this.$state.go('messenger.home.conversation', {
             type: this.receiver.type,
             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 {
     public goBack(): void {
         window.history.back();
         window.history.back();
     }
     }
@@ -1196,7 +1344,7 @@ class ReceiverEditController {
     private logTag: string = '[ReceiverEditController]';
     private logTag: string = '[ReceiverEditController]';
 
 
     public $mdDialog: any;
     public $mdDialog: any;
-    public $state: ng.ui.IStateService;
+    public $state: UiStateService;
     private $translate: ng.translate.ITranslateService;
     private $translate: ng.translate.ITranslateService;
 
 
     public title: string;
     public title: string;
@@ -1204,14 +1352,14 @@ class ReceiverEditController {
     private execute: ExecuteService;
     private execute: ExecuteService;
     public loading = false;
     public loading = false;
 
 
-    private controllerModel: threema.ControllerModel;
+    private controllerModel: threema.ControllerModel<threema.Receiver>;
     public type: string;
     public type: string;
 
 
     public static $inject = [
     public static $inject = [
         '$log', '$stateParams', '$state', '$mdDialog',
         '$log', '$stateParams', '$state', '$mdDialog',
         '$timeout', '$translate', 'WebClientService', 'ControllerModelService',
         '$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,
                 $mdDialog, $timeout: ng.ITimeoutService, $translate: ng.translate.ITranslateService,
                 webClientService: WebClientService, controllerModelService: ControllerModelService) {
                 webClientService: WebClientService, controllerModelService: ControllerModelService) {
 
 
@@ -1223,9 +1371,11 @@ class ReceiverEditController {
         const receiver = webClientService.receivers.getData($stateParams);
         const receiver = webClientService.receivers.getData($stateParams);
         switch (receiver.type) {
         switch (receiver.type) {
             case 'me':
             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':
             case 'contact':
                 this.controllerModel = controllerModelService.contact(
                 this.controllerModel = controllerModelService.contact(
                     receiver as threema.ContactReceiver,
                     receiver as threema.ContactReceiver,
@@ -1252,13 +1402,6 @@ class ReceiverEditController {
         }
         }
         this.type = receiver.type;
         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);
         this.execute = new ExecuteService($log, $timeout, 1000);
     }
     }
 
 
@@ -1269,7 +1412,6 @@ class ReceiverEditController {
     }
     }
 
 
     public save(): void {
     public save(): void {
-
         // show loading
         // show loading
         this.loading = true;
         this.loading = true;
 
 
@@ -1279,7 +1421,7 @@ class ReceiverEditController {
                 this.goBack();
                 this.goBack();
             })
             })
             .catch((errorCode) => {
             .catch((errorCode) => {
-                this.showError(errorCode);
+                this.showEditError(errorCode);
             });
             });
     }
     }
 
 
@@ -1288,13 +1430,17 @@ class ReceiverEditController {
             && this.execute.isRunning();
             && this.execute.isRunning();
     }
     }
 
 
-    public showError(errorCode): void {
+    private showEditError(errorCode: string): void {
+        if (errorCode === undefined) {
+            errorCode = 'unknown';
+        }
         this.$mdDialog.show(
         this.$mdDialog.show(
             this.$mdDialog.alert()
             this.$mdDialog.alert()
                 .clickOutsideToClose(true)
                 .clickOutsideToClose(true)
                 .title(this.controllerModel.subject)
                 .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 {
     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
  * Control creating a group or adding contact
  * fields, validate and save routines are implemented in the specific ControllerModel
  * fields, validate and save routines are implemented in the specific ControllerModel
@@ -1313,19 +1464,19 @@ class ReceiverCreateController {
     private loading = false;
     private loading = false;
     private $timeout: ng.ITimeoutService;
     private $timeout: ng.ITimeoutService;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
-    private $state: ng.ui.IStateService;
+    private $state: UiStateService;
     private $mdToast: any;
     private $mdToast: any;
     public identity = '';
     public identity = '';
     private $translate: any;
     private $translate: any;
     public type: string;
     public type: string;
     private execute: ExecuteService;
     private execute: ExecuteService;
 
 
-    public controllerModel: threema.ControllerModel;
+    public controllerModel: threema.ControllerModel<threema.Receiver>;
 
 
     public static $inject = ['$stateParams', '$mdDialog', '$mdToast', '$translate',
     public static $inject = ['$stateParams', '$mdDialog', '$mdToast', '$translate',
         '$timeout', '$state', '$log', 'ControllerModelService'];
         '$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) {
                 controllerModelService: ControllerModelService) {
         this.$mdDialog = $mdDialog;
         this.$mdDialog = $mdDialog;
         this.$timeout = $timeout;
         this.$timeout = $timeout;
@@ -1371,13 +1522,13 @@ class ReceiverCreateController {
 
 
     private showAddError(errorCode: string): void {
     private showAddError(errorCode: string): void {
         if (errorCode === undefined) {
         if (errorCode === undefined) {
-            errorCode = 'invalid_entry';
+            errorCode = 'unknown';
         }
         }
         this.$mdDialog.show(
         this.$mdDialog.show(
             this.$mdDialog.alert()
             this.$mdDialog.alert()
                 .clickOutsideToClose(true)
                 .clickOutsideToClose(true)
                 .title(this.controllerModel.subject)
                 .title(this.controllerModel.subject)
-                .textContent(this.$translate.instant('validationError.createReceiver.' + errorCode))
+                .textContent(this.$translate.instant('validationError.modifyReceiver.' + errorCode))
                 .ok(this.$translate.instant('common.OK')),
                 .ok(this.$translate.instant('common.OK')),
         );
         );
     }
     }
@@ -1389,23 +1540,21 @@ class ReceiverCreateController {
     }
     }
 
 
     public create(): void {
     public create(): void {
-        // show loading
+        // Show loading indicator
         this.loading = true;
         this.loading = true;
 
 
-        // validate first
+        // Save, then go to receiver detail page
         this.execute.execute(this.controllerModel.save())
         this.execute.execute(this.controllerModel.save())
             .then((receiver: threema.Receiver) => {
             .then((receiver: threema.Receiver) => {
                 this.$state.go('messenger.home.detail', receiver, {location: 'replace'});
                 this.$state.go('messenger.home.detail', receiver, {location: 'replace'});
             })
             })
-            .catch((errorCode) => {
-                this.showAddError(errorCode);
-            });
+            .catch(this.showAddError.bind(this));
     }
     }
 }
 }
 
 
 angular.module('3ema.messenger', ['ngMaterial'])
 angular.module('3ema.messenger', ['ngMaterial'])
 
 
-.config(['$stateProvider', function($stateProvider: ng.ui.IStateProvider) {
+.config(['$stateProvider', function($stateProvider: UiStateProvider) {
 
 
     $stateProvider
     $stateProvider
 
 

+ 17 - 0
src/partials/welcome.html

@@ -1,6 +1,18 @@
 <div id="welcome">
 <div id="welcome">
     <div class="center-block text-center welcome">
     <div class="center-block text-center welcome">
         <div ng-if="(ctrl.state === 'connecting' || ctrl.state === 'waiting') && ctrl.mode === 'scan'" class="scan">
         <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>
             <p class="instructions" translate>welcome.PLEASE_SCAN</p>
             <qrcode version="{{ ctrl.qrCode.version }}"
             <qrcode version="{{ ctrl.qrCode.version }}"
                     id="connecting-code"
                     id="connecting-code"
@@ -23,6 +35,10 @@
         </div>
         </div>
 
 
         <div ng-if="ctrl.state === 'connecting' && ctrl.mode === 'unlock'" class="unlock">
         <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>
             <h2 class="instructions" translate>welcome.PLEASE_UNLOCK</h2>
             <div class="password-entry">
             <div class="password-entry">
                 <label>
                 <label>
@@ -31,6 +47,7 @@
                         <md-input-container md-no-float class="md-block">
                         <md-input-container md-no-float class="md-block">
                             <input type="password"
                             <input type="password"
                                    ng-model="ctrl.password"
                                    ng-model="ctrl.password"
+                                   ng-disabled="ctrl.formLocked"
                                    autofocus
                                    autofocus
                                    aria-label="Password"
                                    aria-label="Password"
                                    translate-attr="{'placeholder': 'welcome.PASSWORD', 'aria-label': 'welcome.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" />
 /// <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 {BrowserService} from '../services/browser';
 import {ControllerService} from '../services/controller';
 import {ControllerService} from '../services/controller';
 import {TrustedKeyStoreService} from '../services/keystore';
 import {TrustedKeyStoreService} from '../services/keystore';
 import {PushService} from '../services/push';
 import {PushService} from '../services/push';
+import {SettingsService} from '../services/settings';
 import {StateService} from '../services/state';
 import {StateService} from '../services/state';
 import {VersionService} from '../services/version';
 import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 import {WebClientService} from '../services/webclient';
 
 
+import GlobalConnectionState = threema.GlobalConnectionState;
+
 class DialogController {
 class DialogController {
     // TODO: This is also used in partials/messenger.ts. We could somehow
     // TODO: This is also used in partials/messenger.ts. We could somehow
     // extract it into a separate file.
     // 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 {
 class WelcomeController {
 
 
     private static REDIRECT_DELAY = 500;
     private static REDIRECT_DELAY = 500;
@@ -51,11 +64,11 @@ class WelcomeController {
 
 
     // Angular services
     // Angular services
     private $scope: ng.IScope;
     private $scope: ng.IScope;
-    private $state: ng.ui.IStateService;
     private $timeout: ng.ITimeoutService;
     private $timeout: ng.ITimeoutService;
     private $interval: ng.IIntervalService;
     private $interval: ng.IIntervalService;
     private $log: ng.ILogService;
     private $log: ng.ILogService;
     private $window: ng.IWindowService;
     private $window: ng.IWindowService;
+    private $state: UiStateService;
 
 
     // Material design services
     // Material design services
     private $mdDialog: ng.material.IDialogService;
     private $mdDialog: ng.material.IDialogService;
@@ -63,9 +76,10 @@ class WelcomeController {
 
 
     // Custom services
     // Custom services
     private webClientService: WebClientService;
     private webClientService: WebClientService;
-    private TrustedKeyStore: TrustedKeyStoreService;
+    private trustedKeyStore: TrustedKeyStoreService;
     private pushService: PushService;
     private pushService: PushService;
     private stateService: StateService;
     private stateService: StateService;
+    private settingsService: SettingsService;
     private config: threema.Config;
     private config: threema.Config;
 
 
     // Other
     // Other
@@ -73,24 +87,29 @@ class WelcomeController {
     private mode: 'scan' | 'unlock';
     private mode: 'scan' | 'unlock';
     private qrCode;
     private qrCode;
     private password: string = '';
     private password: string = '';
+    private formLocked: boolean = false;
     private pleaseUpdateAppMsg: string = null;
     private pleaseUpdateAppMsg: string = null;
+    private browser: threema.BrowserInfo;
+    private browserWarningShown: boolean = false;
 
 
     public static $inject = [
     public static $inject = [
         '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
         '$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,
                 $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
                 $translate: ng.translate.ITranslateService,
-                webClientService: WebClientService, TrustedKeyStore: TrustedKeyStoreService,
+                webClientService: WebClientService, trustedKeyStore: TrustedKeyStoreService,
                 stateService: StateService, pushService: PushService,
                 stateService: StateService, pushService: PushService,
                 browserService: BrowserService,
                 browserService: BrowserService,
                 versionService: VersionService,
                 versionService: VersionService,
+                settingsService: SettingsService,
+                controllerService: ControllerService,
                 minVersions: threema.BrowserMinVersions,
                 minVersions: threema.BrowserMinVersions,
-                config: threema.Config,
-                controllerService: ControllerService) {
+                config: threema.Config) {
         controllerService.setControllerName('welcome');
         controllerService.setControllerName('welcome');
         // Angular services
         // Angular services
         this.$scope = $scope;
         this.$scope = $scope;
@@ -104,40 +123,54 @@ class WelcomeController {
 
 
         // Own services
         // Own services
         this.webClientService = webClientService;
         this.webClientService = webClientService;
-        this.TrustedKeyStore = TrustedKeyStore;
+        this.trustedKeyStore = trustedKeyStore;
         this.stateService = stateService;
         this.stateService = stateService;
         this.pushService = pushService;
         this.pushService = pushService;
+        this.settingsService = settingsService;
         this.config = config;
         this.config = config;
 
 
         // Determine whether browser warning should be shown
         // 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');
             $log.warn('Could not determine browser version');
             this.showBrowserWarning();
             this.showBrowserWarning();
-        } else if (browser.chrome === true) {
+        } else if (this.browser.chrome === true) {
             if (version < minVersions.CHROME) {
             if (version < minVersions.CHROME) {
                 $log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')');
                 $log.warn('Chrome is too old (' + version + ' < ' + minVersions.CHROME + ')');
                 this.showBrowserWarning();
                 this.showBrowserWarning();
             }
             }
-        } else if (browser.firefox === true) {
+        } else if (this.browser.firefox === true) {
             if (version < minVersions.FF) {
             if (version < minVersions.FF) {
                 $log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')');
                 $log.warn('Firefox is too old (' + version + ' < ' + minVersions.FF + ')');
                 this.showBrowserWarning();
                 this.showBrowserWarning();
             }
             }
-        } else if (browser.opera === true) {
+        } else if (this.browser.opera === true) {
             if (version < minVersions.OPERA) {
             if (version < minVersions.OPERA) {
                 $log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')');
                 $log.warn('Opera is too old (' + version + ' < ' + minVersions.OPERA + ')');
                 this.showBrowserWarning();
                 this.showBrowserWarning();
             }
             }
+        } else if (this.browser.safari === true) {
+            if (version < minVersions.SAFARI) {
+                $log.warn('Safari is too old (' + version + ' < ' + minVersions.SAFARI + ')');
+                this.showBrowserWarning();
+            }
         } else {
         } else {
             $log.warn('Non-supported browser, please use Chrome, Firefox or Opera');
             $log.warn('Non-supported browser, please use Chrome, Firefox or Opera');
             this.showBrowserWarning();
             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
         // 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?');
             $log.error('Cannot access local storage. Is it being blocked by a browser add-on?');
             this.showLocalStorageWarning();
             this.showLocalStorageWarning();
         }
         }
@@ -161,7 +194,7 @@ class WelcomeController {
         // Determine whether trusted key is available
         // Determine whether trusted key is available
         let hasTrustedKey = null;
         let hasTrustedKey = null;
         try {
         try {
-            hasTrustedKey = this.TrustedKeyStore.hasTrustedKey();
+            hasTrustedKey = this.trustedKeyStore.hasTrustedKey();
         } catch (e) {
         } catch (e) {
             $log.error('Exception while accessing local storage:', e);
             $log.error('Exception while accessing local storage:', e);
             this.showLocalStorageException(e);
             this.showLocalStorageException(e);
@@ -257,8 +290,12 @@ class WelcomeController {
      * Decrypt the keys and initiate the session.
      * Decrypt the keys and initiate the session.
      */
      */
     private unlockConfirm(): void {
     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) {
         if (decrypted === null) {
+            this.formLocked = false;
             return this.showDecryptionFailed();
             return this.showDecryptionFailed();
         }
         }
 
 
@@ -269,9 +306,9 @@ class WelcomeController {
         this.setupBroadcastChannel(keyStore.publicKeyHex);
         this.setupBroadcastChannel(keyStore.publicKeyHex);
 
 
         // Initialize push service
         // 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
         // Reconnect
@@ -328,7 +365,7 @@ class WelcomeController {
                         this.$log.error(this.logTag, 'Session already connected in another tab or window');
                         this.$log.error(this.logTag, 'Session already connected in another tab or window');
                         this.$timeout(() => {
                         this.$timeout(() => {
                             this.stateService.updateConnectionBuildupState('already_connected');
                             this.stateService.updateConnectionBuildupState('already_connected');
-                            this.stateService.state = 'error';
+                            this.stateService.state = GlobalConnectionState.Error;
                         }, 500);
                         }, 500);
                     }
                     }
                     break;
                     break;
@@ -358,6 +395,7 @@ class WelcomeController {
      * Show a browser warning dialog.
      * Show a browser warning dialog.
      */
      */
     private showBrowserWarning(): void {
     private showBrowserWarning(): void {
+        this.browserWarningShown = true;
         this.$translate.onReady().then(() => {
         this.$translate.onReady().then(() => {
             const confirm = this.$mdDialog.confirm()
             const confirm = this.$mdDialog.confirm()
                 .title(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED'))
                 .title(this.$translate.instant('welcome.BROWSER_NOT_SUPPORTED'))
@@ -368,7 +406,7 @@ class WelcomeController {
                 // do nothing
                 // do nothing
             }, () => {
             }, () => {
                 // Redirect to Threema website
                 // 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 {
     private showAlreadyConnected(): void {
         this.$translate.onReady().then(() => {
         this.$translate.onReady().then(() => {
             const confirm = this.$mdDialog.alert()
             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);
             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.
      * Forget trusted keys.
      */
      */
@@ -441,10 +496,9 @@ class WelcomeController {
 
 
         this.$mdDialog.show(confirm).then(() =>  {
         this.$mdDialog.show(confirm).then(() =>  {
             // Force-stop the webclient
             // Force-stop the webclient
-            const deleteStoredData = true;
             const resetPush = true;
             const resetPush = true;
             const redirect = false;
             const redirect = false;
-            this.webClientService.stop(true, deleteStoredData, resetPush, redirect);
+            this.webClientService.stop(true, threema.DisconnectReason.SessionDeleted, resetPush, redirect);
 
 
             // Reset state
             // Reset state
             this.stateService.updateConnectionBuildupState('new');
             this.stateService.updateConnectionBuildupState('new');
@@ -452,6 +506,7 @@ class WelcomeController {
             // Go back to scan mode
             // Go back to scan mode
             this.mode = 'scan';
             this.mode = 'scan';
             this.password = '';
             this.password = '';
+            this.formLocked = false;
 
 
             // Initiate scan
             // Initiate scan
             this.scan();
             this.scan();
@@ -515,6 +570,7 @@ class WelcomeController {
 
 
                 // Clear local password variable
                 // Clear local password variable
                 this.password = '';
                 this.password = '';
+                this.formLocked = false;
 
 
                 // Redirect to home
                 // Redirect to home
                 this.$timeout(() => this.$state.go('messenger.home'), WelcomeController.REDIRECT_DELAY);
                 this.$timeout(() => this.$state.go('messenger.home'), WelcomeController.REDIRECT_DELAY);
@@ -544,10 +600,9 @@ class WelcomeController {
 
 
 angular.module('3ema.welcome', [])
 angular.module('3ema.welcome', [])
 
 
-.config(['$stateProvider', ($stateProvider: ng.ui.IStateProvider) => {
+.config(['$stateProvider', ($stateProvider: UiStateProvider) => {
 
 
     $stateProvider
     $stateProvider
-
         .state('welcome', {
         .state('welcome', {
             url: '/welcome',
             url: '/welcome',
             templateUrl: 'partials/welcome.html',
             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/compose_area";
 @import "sections/footer";
 @import "sections/footer";
 @import "sections/status_bar";
 @import "sections/status_bar";
-@import "sections/my_identity";
 @import "sections/noscript";
 @import "sections/noscript";
 
 
 // Vendors: Third party code.
 // Vendors: Third party code.

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません