diff --git a/.bowerrc b/.bowerrc
new file mode 100644
index 00000000000..cf4ec5b9cd1
--- /dev/null
+++ b/.bowerrc
@@ -0,0 +1,3 @@
+{
+ "directory": "temp/bower_components"
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000000..9d08a1a828a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000000..34d849c83db
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,15 @@
+# Reference: https://help.github.com/articles/dealing-with-line-endings/
+
+# enforce LF line endings
+* text eol=lf
+
+# Denote all files that are truly binary and should not be modified.
+*.eot binary
+*.jar binary
+*.jpg binary
+*.otf binary
+*.png binary
+*.svg binary
+*.ttf binary
+*.woff binary
+*.woff2 binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000000..cf3bfaf164d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+*.pyc
+.DS_Store
+.grunt
+.Icon
+/_SpecRunner.html
+/app/ext
+/aws/s3
+/aws/static
+/aws/templates/**/*.html
+/aws/templates/auth
+/aws/version
+/deploy
+/dist
+/docs
+/nbproject
+/node_modules
+/temp
+/test/_SpecRunner.html
+/test/coverage
+/test/js/*
+/Web.config
+aws-keys.json
+Gruntfile.js
+npm-debug.log
+strings-ar.coffee
+strings-fr.coffee
+strings-it.coffee
+strings-pt.coffee
+strings-ru.coffee
+
+# Automatically created files
+/doc
+
+# IntelliJ
+# http://devnet.jetbrains.com/docs/DOC-1186
+/.idea/**/*.xml
+!/.idea/codeStyleSettings.xml
+!/.idea/modules.xml
diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml
new file mode 100644
index 00000000000..fbfe254af26
--- /dev/null
+++ b/.idea/codeStyleSettings.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000000..468998ad51a
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/wire-webapp.iml b/.idea/wire-webapp.iml
new file mode 100644
index 00000000000..b0452cd8fbe
--- /dev/null
+++ b/.idea/wire-webapp.iml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 00000000000..07daeb53d95
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,42 @@
+# http://docs.travis-ci.com/user/workers/container-based-infrastructure/
+sudo: required
+dist: trusty
+
+# https://docs.travis-ci.com/user/installing-dependencies/#Installing-Packages-with-the-APT-Addon
+addons:
+ apt:
+ sources:
+ - google-chrome
+ packages:
+ - google-chrome-stable
+
+# http://docs.travis-ci.com/user/languages/javascript-with-nodejs/
+language: node_js
+node_js:
+ - node
+
+# https://blog.travis-ci.com/2013-12-05-speed-up-your-builds-cache-your-dependencies
+cache:
+ directories:
+ - bower_components
+ - node_modules
+
+env:
+ global:
+ # RAYGUN_USERNAME
+ - secure: "xMiUg9p7HCDUarPgsb5CVuTTnYWAZfJf1a8AJA0WKLg1XY3hN/CayYE4jxor+hW2GZVhAeUFPHJXo0OpF9yhXdspVG4i6p/ZAq97N2rlGIHXngTGp6J3pCRtC1wIJqG9g45lST84IfrP0yqnMcY2rnQkE0ZbY9Qn4gIvCEjXJpzA8aI4Lqr/K/LI3cYdnlgPu0s9E2I/Kl0ZenDqD9W0TFyufWNXzH8HqyrEw7UjRM9d8oosYydM5bNmW4oCrMrLMDcqbwC8PPvJUrJKt8tubDuqDDNVECiSE2Rz8FPA3r017heQOOGinzAiUkMVHlJEdcPxBViPhGE6BN10QC1vR6uxaM/9UBIUodLfculzhZ5xcHYgEVHC1w4M5+zoa4F8iecUMMwT1+qC6Na/apTpUNy+OLguK4Se7dTKtfd4+FTbWZANI8xT5duVNnJ//rH6U06OpXDKNvWlYyGl2zZr0gcTXGqxKU+7kVjNZa1k6ltOgy86pTm4VAOLBGfqUbSwRVhW3aqJKS/yjsAkT9oLKuB3T0osVPcsSxS4MmODUmUazr7UMZr8t2S0L0v0NE/Q1I+gykqEzV8ecd9s6RXaWQiwZXgSjMe3UR3qrFO+r1fbwXIo45biPyd1rDFR1Y/dPHxDCPOV6fXjWXp68r5d9tcZTc6xjU1vgoplJfxAyhA="
+ # RAYGUN_PASSWORD
+ - secure: "29BtoWU0D68HVLqJ5iG3QNs+B2ytaZsTRsgW5T0WBCWS8b4cOOFLjwQHh5f+Qr0oPjU+cwQ0Sequ9wzGK882lhoT5SyddURhnj2CxWko0zmeNst2YpjEEdrgvtqQMGG+SXizjG9gcPcf52mF0du7LdICh1pN5HZMVckPD02t4+quZn0SpwdvQSCdWtivSNSenguhdJg9z6ulKKJUV3nqvdJsSAC48q9awEfc59dvo8nf7v44bTlIcqGkiCMpR2pxPXjwOvogWQcUZvd2onSZvtOJWOega5iAc0PvSs9p2eLpY82lQSlvsLxaytSiQw50rDLYi1p4ouUn//DUH0Ir8A3g57MRzXkMLs1JDXOJyTbPgLkOdB/TmlryQ2iT0XnHp4ncjWrWjmgQPDKizk7HFeSvMPKjo9V2F46FwXClq1b/U1jP5PVOQHwtujVVmp9CXvBedz1SLaY2GIe0uLexFis1P5UZSCmtP/GpnO47JIguYU43Xkre2hdn1pnpCHiDv4xWISfinBZa+gncjAEodKkApysx48cK6nbZ8HLPnMGDk04oZO+zFz2IprkkefdoKJ32J6BnBtQqmJAXwPaC+AiIEcHWTbHRtZM0NvaxsQFB/MPCy+A2H1amC63wE/CJZbGf2sNQZCNUwsFq8awRxHt8frSS/9Rw+Wg6jDMZp+w="
+
+# http://docs.travis-ci.com/user/gui-and-headless-browsers
+before_install:
+ - export CHROME_BIN=/usr/bin/google-chrome
+ - export DISPLAY=:99.0
+ - sh -e /etc/init.d/xvfb start
+
+# http://docs.travis-ci.com/user/pull-requests/
+script:
+ - npm test
+
+notifications:
+ email: false
diff --git a/Gruntfile.coffee b/Gruntfile.coffee
new file mode 100755
index 00000000000..7e618d91d49
--- /dev/null
+++ b/Gruntfile.coffee
@@ -0,0 +1,146 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+# @formatter:off
+module.exports = (grunt) ->
+ require('load-grunt-tasks') grunt
+
+ path = require 'path'
+
+ config =
+ aws:
+ port: 5000
+ server:
+ port: 8888
+
+ dir =
+ app_: 'app'
+ app:
+ demo: 'app/demo'
+ ext: 'app/ext'
+ page: 'app/page'
+ script: 'app/script'
+ style: 'app/style'
+ template_dist: 'app/page/template/_dist'
+ aws_: 'aws'
+ aws:
+ s3: 'aws/s3'
+ static: 'aws/static'
+ templates: 'aws/templates'
+ deploy: 'deploy'
+ dist: 'dist'
+ docs:
+ api: 'docs/api'
+ coverage: 'docs/coverage'
+ temp: 'temp'
+ test_: 'test'
+ test:
+ api: 'test/api'
+ coffee: 'test/coffee'
+ coverage: 'test/coverage'
+ js: 'test/js'
+ lib: 'test/lib'
+ unit_tests: 'test/unit_tests'
+
+ grunt.initConfig
+ pkg: grunt.file.readJSON 'package.json'
+ config: config
+ dir: dir
+ aws_s3: require "./grunt/config/aws_s3"
+ bower: require "./grunt/config/bower"
+ clean: require "./grunt/config/clean"
+ codo: require './grunt/config/codo'
+ coffee: require "./grunt/config/coffee"
+ coffeelint: require "./grunt/config/coffeelint"
+ compress: require "./grunt/config/compress"
+ connect: require "./grunt/config/connect"
+ copy: require "./grunt/config/copy"
+ includereplace: require "./grunt/config/includereplace"
+ karma: require "./grunt/config/karma"
+ less: require "./grunt/config/less"
+ open: require "./grunt/config/open"
+ path: require "path"
+ shell: require "./grunt/config/shell"
+ todo: require "./grunt/config/todo"
+ uglify: require "./grunt/config/uglify"
+ watch: require "./grunt/config/watch"
+ postcss: require "./grunt/config/postcss"
+
+###############################################################################
+# Tasks
+###############################################################################
+ grunt.loadTasks 'grunt/tasks'
+ grunt.registerTask 'default', ['prepare_dist', 'host']
+ grunt.registerTask 'init', ['clean:ext', 'clean:temp', 'bower', 'scripts']
+
+###############################################################################
+# Deploy to different environments
+###############################################################################
+ grunt.registerTask 'app_deploy', ['gitinfo', 'aws_deploy']
+ grunt.registerTask 'app_deploy_edge', ['gitinfo', 'set_version:edge', 'aws_deploy']
+ grunt.registerTask 'app_deploy_staging', ['gitinfo', 'set_version:staging', 'aws_deploy']
+ grunt.registerTask 'app_deploy_prod', ['gitinfo', 'set_version:prod', 'aws_deploy']
+ grunt.registerTask 'app_deploy_taco', ['gitinfo', 'set_version:taco', 'aws_deploy']
+
+ grunt.registerTask 'app_deploy_travis', (target) ->
+ if target in ['prod', 'staging']
+ grunt.task.run "set_version:#{target}", 'init', "prepare_#{target}", 'aws_prepare'
+ else if target is 'dev'
+ grunt.task.run "set_version:staging", 'init', "prepare_staging", 'aws_prepare'
+ else
+ grunt.fail.warn 'Invalid target specified. Valid targets are: "prod" & "staging"'
+
+###############################################################################
+# Test Related
+###############################################################################
+ grunt.registerTask 'test', ->
+ grunt.task.run ['clean:docs_coverage', 'scripts', 'test_init', 'test_prepare', 'karma:test']
+
+ grunt.registerTask 'test_prepare', (test_name) ->
+ scripts = grunt.config 'scripts'
+ # Little hack because of a configuration bug in "grunt-karma":
+ # @see https://github.com/karma-runner/grunt-karma/issues/21#issuecomment-27518692
+ prepare_file_names = (file_name_array) =>
+ return (file_name.replace 'deploy/', '' for file_name in file_name_array)
+
+ helper_files = grunt.config.get 'karma.options.files'
+ app_files = prepare_file_names scripts.app
+ component_files = prepare_file_names scripts.component
+ vendor_files = prepare_file_names scripts.vendor
+ test_files = if test_name then ["../test/js/#{test_name}Spec.js"] else ['../test/**/*Spec.js']
+
+ files = [].concat helper_files, vendor_files, component_files, app_files, test_files
+ grunt.config 'karma.options.files', files
+
+ grunt.registerTask 'test_init', ['prepare_dist', 'prepare_test']
+
+ grunt.registerTask 'test_run', (test_name) ->
+ grunt.config 'karma.options.reporters', ['progress']
+ grunt.task.run ['scripts', 'newer:coffee:dist', 'newer:coffee:test', "test_prepare:#{test_name}", 'karma:test']
+
+###############################################################################
+# Documentation
+###############################################################################
+ grunt.registerTask 'generate_docs', (command) ->
+ switch command
+ when 'all'
+ grunt.task.run ['clean:docs', 'codo:app', 'test:coverage']
+ else
+ grunt.task.run ['clean:docs', 'codo:app']
+
+# @formatter:on
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000000..9cecc1d4669
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ {one line to give the program's name and a brief idea of what it does.}
+ Copyright (C) {year} {name of author}
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ {project} Copyright (C) {year} {fullname}
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 00000000000..a7d059a7f9e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,48 @@
+# Wire™
+
+![Wire logo](https://github.com/wireapp/wire/blob/master/assets/logo.png?raw=true)
+
+This repository is part of the source code of Wire. You can find more information at [wire.com](https://wire.com) or by contacting opensource@wire.com.
+
+You can find the published source code at [github.com/wireapp/wire](https://github.com/wireapp/wire).
+
+For licensing information, see the attached LICENSE file and the list of third-party licenses at [wire.com/legal/licenses/](https://wire.com/legal/licenses/).
+
+If you compile the open source software that we make available from time to time to develop your own mobile, desktop or web application, and cause that application to connect to our servers for any purposes, we refer to that resulting application as an “Open Source App”. All Open Source Apps are subject to, and may only be used and/or commercialized in accordance with, the Terms of Use applicable to the Wire Application, which can be found at https://wire.com/legal/#terms. Additionally, if you choose to build an Open Source App, certain restrictions apply, as follows:
+
+a. You agree not to change the way the Open Source App connects and interacts with our servers; b. You agree not to weaken any of the security features of the Open Source App; c. You agree not to use our servers to store data for purposes other than the intended and original functionality of the Open Source App; d. You acknowledge that you are solely responsible for any and all updates to your Open Source App.
+
+For clarity, if you compile the open source software that we make available from time to time to develop your own mobile, desktop or web application, and do not cause that application to connect to our servers for any purposes, then that application will not be deemed an Open Source App and the foregoing will not apply to that application.
+
+No license is granted to the Wire trademark and its associated logos, all of which will continue to be owned exclusively by Wire Swiss GmbH. Any use of the Wire trademark and/or its associated logos is expressly prohibited without the express prior written consent of Wire Swiss GmbH.
+
+# How to build the open source client
+
+### Requirements
+
+- Install [Node.js](https://nodejs.org/)
+- Install [Grunt](http://gruntjs.com/): `npm install -g grunt-cli`
+
+### Run Wire for Web locally
+
+The first time you run the project you should install the dependencies by
+executing the following from your terminal:
+
+```bash
+npm install
+```
+
+To run the actual server:
+
+```bash
+grunt
+```
+
+If everything went well the app will be available on
+[`localhost:8888`](http://localhost:8888).
+
+### Generate code coverage
+
+```bash
+grunt test:coverage
+```
diff --git a/app/audio/buzzer/applause.mp3 b/app/audio/buzzer/applause.mp3
new file mode 100644
index 00000000000..3348bee3af2
Binary files /dev/null and b/app/audio/buzzer/applause.mp3 differ
diff --git a/app/audio/buzzer/baby-cry.mp3 b/app/audio/buzzer/baby-cry.mp3
new file mode 100644
index 00000000000..669dd1220b9
Binary files /dev/null and b/app/audio/buzzer/baby-cry.mp3 differ
diff --git a/app/audio/buzzer/crowd-negative.mp3 b/app/audio/buzzer/crowd-negative.mp3
new file mode 100644
index 00000000000..e36c964fcc3
Binary files /dev/null and b/app/audio/buzzer/crowd-negative.mp3 differ
diff --git a/app/audio/buzzer/dogs.mp3 b/app/audio/buzzer/dogs.mp3
new file mode 100644
index 00000000000..f9dc1954dde
Binary files /dev/null and b/app/audio/buzzer/dogs.mp3 differ
diff --git a/app/audio/buzzer/gun-shot.mp3 b/app/audio/buzzer/gun-shot.mp3
new file mode 100644
index 00000000000..2f6a5bd7535
Binary files /dev/null and b/app/audio/buzzer/gun-shot.mp3 differ
diff --git a/app/audio/buzzer/wrong-answer.mp3 b/app/audio/buzzer/wrong-answer.mp3
new file mode 100644
index 00000000000..275aa8909df
Binary files /dev/null and b/app/audio/buzzer/wrong-answer.mp3 differ
diff --git a/app/audio/digits/0.mp3 b/app/audio/digits/0.mp3
new file mode 100644
index 00000000000..d118ff54a3c
Binary files /dev/null and b/app/audio/digits/0.mp3 differ
diff --git a/app/audio/digits/1.mp3 b/app/audio/digits/1.mp3
new file mode 100644
index 00000000000..39ae4c8569c
Binary files /dev/null and b/app/audio/digits/1.mp3 differ
diff --git a/app/audio/digits/2.mp3 b/app/audio/digits/2.mp3
new file mode 100644
index 00000000000..0a5b58e84b3
Binary files /dev/null and b/app/audio/digits/2.mp3 differ
diff --git a/app/audio/digits/3.mp3 b/app/audio/digits/3.mp3
new file mode 100644
index 00000000000..a5209749dbd
Binary files /dev/null and b/app/audio/digits/3.mp3 differ
diff --git a/app/audio/digits/4.mp3 b/app/audio/digits/4.mp3
new file mode 100644
index 00000000000..bf834c33def
Binary files /dev/null and b/app/audio/digits/4.mp3 differ
diff --git a/app/audio/digits/5.mp3 b/app/audio/digits/5.mp3
new file mode 100644
index 00000000000..079c52b36dd
Binary files /dev/null and b/app/audio/digits/5.mp3 differ
diff --git a/app/audio/digits/6.mp3 b/app/audio/digits/6.mp3
new file mode 100644
index 00000000000..3c1801ba49c
Binary files /dev/null and b/app/audio/digits/6.mp3 differ
diff --git a/app/audio/digits/7.mp3 b/app/audio/digits/7.mp3
new file mode 100644
index 00000000000..b8af6e16f32
Binary files /dev/null and b/app/audio/digits/7.mp3 differ
diff --git a/app/audio/digits/8.mp3 b/app/audio/digits/8.mp3
new file mode 100644
index 00000000000..c1a9b54c4ab
Binary files /dev/null and b/app/audio/digits/8.mp3 differ
diff --git a/app/audio/digits/9.mp3 b/app/audio/digits/9.mp3
new file mode 100644
index 00000000000..d16b9e86e0d
Binary files /dev/null and b/app/audio/digits/9.mp3 differ
diff --git a/app/demo/demo.html b/app/demo/demo.html
new file mode 100644
index 00000000000..4e252543938
--- /dev/null
+++ b/app/demo/demo.html
@@ -0,0 +1,189 @@
+
+
+
+
+
+ Wire
+ #include('../../page/template/#dest/style.htm')
+
+
+
+
+
+ #include('microbar.htm')
+
diff --git a/app/demo/template/buttons.htm b/app/demo/template/buttons.htm
new file mode 100644
index 00000000000..6c952c53155
--- /dev/null
+++ b/app/demo/template/buttons.htm
@@ -0,0 +1,100 @@
+
Normal
+
Button
+
Inverted
+
Disabled
+
White
+
Outline
+
Small
+
Button
+
Inverted
+
Disabled
+
White
+
Outline
+
Icon
+
+ Import
+
+
+
+
+ Import OSX Contacts
+
+
Round
+
button-round-xl
+
+
+
+
+
+
+
+
+
+
+
+
button-round-text
+
+
+
gif
+
+
+
gif
+
+
+
gif
+
+
+
button-round-dark
+
+
+
+
+
+
+
+
+
+
+
+
button-round-theme
+
+
+
+
+
+
+
+
+
+
+
+
button-round-theme-green
+
+
+
+
+
+
+
+
+
+
+
+
button-round-theme-light
+
+
+
+
+
+
+
+
+
+
+
+
Icon
+
+
+
Import
+
+
diff --git a/app/demo/template/checkbox.htm b/app/demo/template/checkbox.htm
new file mode 100644
index 00000000000..598911df6b2
--- /dev/null
+++ b/app/demo/template/checkbox.htm
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/demo/template/colors.htm b/app/demo/template/colors.htm
new file mode 100644
index 00000000000..ee8d98b3885
--- /dev/null
+++ b/app/demo/template/colors.htm
@@ -0,0 +1,11 @@
+
+
blue
+
green
+
yellow
+
red
+
orange
+
pink
+
purple
+
graphite-dark
+
graphite
+
diff --git a/app/demo/template/dots.htm b/app/demo/template/dots.htm
new file mode 100644
index 00000000000..a9390db1e21
--- /dev/null
+++ b/app/demo/template/dots.htm
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/demo/template/grid.htm b/app/demo/template/grid.htm
new file mode 100644
index 00000000000..34c35c88c34
--- /dev/null
+++ b/app/demo/template/grid.htm
@@ -0,0 +1,5 @@
+
+
col-12
+
col-6
+
col-6
+
diff --git a/app/demo/template/icons.htm b/app/demo/template/icons.htm
new file mode 100644
index 00000000000..6715c7f188a
--- /dev/null
+++ b/app/demo/template/icons.htm
@@ -0,0 +1 @@
+
diff --git a/app/demo/template/media-controls.htm b/app/demo/template/media-controls.htm
new file mode 100644
index 00000000000..b14d3b520a2
--- /dev/null
+++ b/app/demo/template/media-controls.htm
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/demo/template/microbar.htm b/app/demo/template/microbar.htm
new file mode 100644
index 00000000000..b6c725db037
--- /dev/null
+++ b/app/demo/template/microbar.htm
@@ -0,0 +1,9 @@
+
+
blue
+
green
+
yellow
+
red
+
orange
+
pink
+
purple
+
diff --git a/app/demo/template/modal.htm b/app/demo/template/modal.htm
new file mode 100644
index 00000000000..0d37e524a5c
--- /dev/null
+++ b/app/demo/template/modal.htm
@@ -0,0 +1,15 @@
+
+
+
+
+
+
Dummy Title
+
+
Here is some dummy text. Like it?
+
+
+
+
diff --git a/app/demo/template/popovers.htm b/app/demo/template/popovers.htm
new file mode 100644
index 00000000000..76cca4932fc
--- /dev/null
+++ b/app/demo/template/popovers.htm
@@ -0,0 +1,20 @@
+
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+
+
+
+
+
Item
+
Item
+
Item
+
Item
+
+
+
diff --git a/app/font/Wire.ttf b/app/font/Wire.ttf
new file mode 100755
index 00000000000..447b8456352
Binary files /dev/null and b/app/font/Wire.ttf differ
diff --git a/app/image/debug/ping-fireworks.png b/app/image/debug/ping-fireworks.png
new file mode 100644
index 00000000000..1db5a695f01
Binary files /dev/null and b/app/image/debug/ping-fireworks.png differ
diff --git a/app/image/debug/wooden-background.jpg b/app/image/debug/wooden-background.jpg
new file mode 100644
index 00000000000..6abac9e9700
Binary files /dev/null and b/app/image/debug/wooden-background.jpg differ
diff --git a/app/image/favicon.ico b/app/image/favicon.ico
new file mode 100644
index 00000000000..c528ac2b004
Binary files /dev/null and b/app/image/favicon.ico differ
diff --git a/app/image/icon/wire-icon-128.png b/app/image/icon/wire-icon-128.png
new file mode 100755
index 00000000000..3282867af4d
Binary files /dev/null and b/app/image/icon/wire-icon-128.png differ
diff --git a/app/image/icon/wire-icon-256.png b/app/image/icon/wire-icon-256.png
new file mode 100755
index 00000000000..0da89a070be
Binary files /dev/null and b/app/image/icon/wire-icon-256.png differ
diff --git a/app/image/icon/wire-icon-64.png b/app/image/icon/wire-icon-64.png
new file mode 100755
index 00000000000..801028a6639
Binary files /dev/null and b/app/image/icon/wire-icon-64.png differ
diff --git a/app/image/logo/notification.png b/app/image/logo/notification.png
new file mode 100644
index 00000000000..4f17cdef741
Binary files /dev/null and b/app/image/logo/notification.png differ
diff --git a/app/image/logo/wire-logo-120.png b/app/image/logo/wire-logo-120.png
new file mode 100644
index 00000000000..504b73e725b
Binary files /dev/null and b/app/image/logo/wire-logo-120.png differ
diff --git a/app/page/auth.html b/app/page/auth.html
new file mode 100644
index 00000000000..233813bf43c
--- /dev/null
+++ b/app/page/auth.html
@@ -0,0 +1,360 @@
+
+
+
+
+
+ #include('meta.htm')
+ #include('graph.htm')
+ Wire
+
+
+
+
+
diff --git a/app/script/announce/AnnounceRepository.coffee b/app/script/announce/AnnounceRepository.coffee
new file mode 100644
index 00000000000..13d9d9ef396
--- /dev/null
+++ b/app/script/announce/AnnounceRepository.coffee
@@ -0,0 +1,75 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.announce ?= {}
+
+CHECK_TIMEOUT = 5 * 60 * 1000
+CHECK_INTERVAL = 3 * 60 * 60 * 1000
+
+class z.announce.AnnounceRepository
+ PRIMARY_KEY_CURRENT_announce: 'local_identity'
+ constructor: (@announce_service) ->
+ @logger = new z.util.Logger 'z.announce.AnnounceRepository', z.config.LOGGER.OPTIONS
+ return @
+
+ init: ->
+ window.setTimeout =>
+ @fetch()
+ @schedule_check()
+ , CHECK_TIMEOUT
+
+ fetch: =>
+ @announce_service.fetch @process_announce_list
+
+ schedule_check: =>
+ window.setInterval @fetch, CHECK_INTERVAL
+
+ process_announce_list: (announce_list) =>
+ if announce_list
+ for announce in announce_list
+ if not z.util.Environment.frontend.is_localhost()
+ continue if announce.version_max and z.util.Environment.version(false) > announce.version_max
+ continue if announce.version_min and z.util.Environment.version(false) < announce.version_min
+ key = "#{z.storage.StorageKey.ANNOUNCE.ANNOUNCE_KEY}@#{announce.key}"
+ if not z.storage.get_value key
+ z.storage.set_value key, 'read'
+ return if window.Notification.permission is z.util.BrowserPermissionType.DENIED
+
+ if not (z.localization.Localizer.locale is 'en')
+ announce.title = announce["title_#{z.localization.Localizer.locale}"] or announce.title
+ announce.message = announce["message_#{z.localization.Localizer.locale}"] or announce.message
+
+ notification = new window.Notification announce.title,
+ body: announce.message
+ icon: if z.util.Environment.electron and z.util.Environment.os.mac then '' else window.notification_icon or '/image/logo/notification.png'
+ sticky: true
+ requireInteraction: true
+
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ANNOUNCE.SENT, campaign: announce.campaign
+ @logger.log @logger.levels.INFO, "Announcement Shown '#{announce.title}'"
+
+ notification.onclick = (event) =>
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.ANNOUNCE.CLICKED, campaign: announce.campaign
+ @logger.log @logger.levels.INFO, "Announcement Clicked '#{announce.title}'"
+ if announce.link
+ window.open announce.link, '_blank'
+ if announce.refresh
+ window.location.reload true
+ notification.close()
+ break
diff --git a/app/script/announce/AnnounceService.coffee b/app/script/announce/AnnounceService.coffee
new file mode 100644
index 00000000000..7f8ca1dbb70
--- /dev/null
+++ b/app/script/announce/AnnounceService.coffee
@@ -0,0 +1,35 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.announce ?= {}
+
+class z.announce.AnnounceService
+ constructor: ->
+ @logger = new z.util.Logger 'z.announce.AnnounceService', z.config.LOGGER.OPTIONS
+ @url = "#{z.config.ANNOUNCE_URL}?order=created&active=true"
+ @url += '&production=true' if z.util.Environment.frontend.is_production()
+ return @
+
+ fetch: (callback) ->
+ $.get @url
+ .done (data, textStatus, jqXHR) ->
+ callback? data['result']
+ .fail (jqXHR, textStatus, errorThrown) =>
+ @logger.log @logger.levels.ERROR, 'Failed to fetch announcements', errorThrown
+ callback?()
diff --git a/app/script/assets/Asset.coffee b/app/script/assets/Asset.coffee
new file mode 100644
index 00000000000..1d80fe30e15
--- /dev/null
+++ b/app/script/assets/Asset.coffee
@@ -0,0 +1,64 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+# Asset entity for the asset service.
+class z.assets.Asset
+ ###
+ Construct a new asset for the asset service.
+
+ @param config [Object] Asset configuration
+ ###
+ constructor: (config) ->
+ @correlation_id = config.correlation_id or z.util.create_random_uuid()
+ @content_type = config.content_type
+ @array_buffer = config.array_buffer
+ @payload =
+ conv_id: config.conversation_id
+ correlation_id: @correlation_id
+ public: config.public or false
+ tag: config.tag or 'medium'
+ inline: config.inline or false
+ nonce: @correlation_id
+ md5: config.md5
+ width: config.width
+ height: config.height
+ original_width: config.original_width
+ original_height: config.original_height
+ native_push: config.native_push or false
+
+ # Create the content disposition header for the asset.
+ get_content_disposition: ->
+ payload = ['zasset']
+ for key, value of @payload
+ payload.push "#{key}=#{value}"
+ return payload.join ';'
+
+ ###
+ Sets the image payload of the asset.
+
+ @param image [Object] Image object to be set on the asset entity
+ ###
+ set_image: (image) ->
+ @content_type = z.util.get_content_type_from_data_url image.src
+ @array_buffer = z.util.base64_to_array image.src
+ @payload.width = image.width
+ @payload.height = image.height
+ @payload.md5 = z.util.encode_base64_md5_array_buffer_view @array_buffer
diff --git a/app/script/assets/AssetCrypto.coffee b/app/script/assets/AssetCrypto.coffee
new file mode 100644
index 00000000000..3952aa39be9
--- /dev/null
+++ b/app/script/assets/AssetCrypto.coffee
@@ -0,0 +1,84 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+z.assets.AssetCrypto =
+ ###
+ @param plaintext [ArrayBuffer]
+
+ @return key_bytes [ArrayBuffer] AES key used for encryption
+ @return computed_sha256 [ArrayBuffer] SHA-256 checksum of the ciphertext
+ @return ciphertext [ArrayBuffer] Encrypted plaintext
+ ###
+ encrypt_aes_asset: (plaintext) ->
+ key = null
+ iv_ciphertext = null
+ computed_sha256 = null
+ iv = new Uint8Array 16
+
+ window.crypto.getRandomValues iv
+
+ return new Promise (resolve, reject) ->
+ window.crypto.subtle.generateKey {name: 'AES-CBC', length: 256}, true, ['encrypt']
+ .then (ckey) ->
+ key = ckey
+
+ window.crypto.subtle.encrypt {name: 'AES-CBC', iv: iv.buffer}, key, plaintext
+ .then (ciphertext) ->
+ iv_ciphertext = new Uint8Array(ciphertext.byteLength + iv.byteLength)
+ iv_ciphertext.set iv, 0
+ iv_ciphertext.set new Uint8Array(ciphertext), iv.byteLength
+
+ window.crypto.subtle.digest 'SHA-256', iv_ciphertext
+ .then (digest) ->
+ computed_sha256 = digest
+
+ window.crypto.subtle.exportKey 'raw', key
+ .then (key_bytes) ->
+ resolve [key_bytes, computed_sha256, iv_ciphertext.buffer]
+ .catch (error) ->
+ reject error
+
+ ###
+ @param key_bytes [ArrayBuffer] AES key used for encryption
+ @param computed_sha256 [ArrayBuffer] SHA-256 checksum of the ciphertext
+ @param ciphertext [ArrayBuffer] Encrypted plaintext
+
+ @param [ArrayBuffer]
+ ###
+ decrypt_aes_asset: (ciphertext, key_bytes, reference_sha256) ->
+ return new Promise (resolve, reject) ->
+ window.crypto.subtle.digest 'SHA-256', ciphertext
+ .then (computed_sha256) ->
+ a = new Uint32Array reference_sha256
+ b = new Uint32Array computed_sha256
+
+ if not a.every((x, i) -> x is b[i])
+ throw new Error 'Encrypted asset does not match its SHA-256 hash'
+
+ window.crypto.subtle.importKey 'raw', key_bytes, 'AES-CBC', false, ['decrypt']
+ .then (key) ->
+ iv = ciphertext.slice 0, 16
+ img_ciphertext = ciphertext.slice 16
+ window.crypto.subtle.decrypt {name: 'AES-CBC', iv: iv}, key, img_ciphertext
+ .then (img_plaintext) ->
+ resolve img_plaintext
+ .catch (error) ->
+ reject error
diff --git a/app/script/assets/AssetRemoteData.coffee b/app/script/assets/AssetRemoteData.coffee
new file mode 100644
index 00000000000..5c1c7042f2e
--- /dev/null
+++ b/app/script/assets/AssetRemoteData.coffee
@@ -0,0 +1,80 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+class z.assets.AssetRemoteData
+
+ ###
+ Use either z.assets.AssetRemoteData.v2 or z.assets.AssetRemoteData.v3
+ to initialize.
+
+ @param otr_key [Uint8Array]
+ @param sha256 [Uint8Array]
+ ###
+ constructor: (@otr_key, @sha256) ->
+ @download_progress = ko.observable()
+ @cancel_download = undefined
+ @generate_url = undefined
+
+ ###
+ Static initializer for v3 assets
+
+ @param asset_key [String]
+ @param otr_key [Uint8Array]
+ @param sha256 [Uint8Array]
+ @param asset_token [String] token is optional
+ ###
+ @v3: (asset_key, otr_key, sha256, asset_token) ->
+ remote_data = new z.assets.AssetRemoteData otr_key, sha256
+ remote_data.generate_url = -> wire.app.service.asset.generate_asset_url_v3 asset_key, asset_token
+ return remote_data
+
+ ###
+ Static initializer for v2 assets
+
+ @param conversation_id [String]
+ @param asset_id [String]
+ @param otr_key [Uint8Array]
+ @param sha256 [Uint8Array]
+ ###
+ @v2: (conversation_id, asset_id, otr_key, sha256) ->
+ remote_data = new z.assets.AssetRemoteData otr_key, sha256
+ remote_data.generate_url = -> wire.app.service.asset.generate_asset_url_v2 asset_id, conversation_id
+ return remote_data
+
+ ###
+ Loads and decrypts stored asset
+
+ @returns [Blob]
+ ###
+ load: =>
+ type = undefined
+
+ @_load_buffer()
+ .then (data) =>
+ [buffer, type] = data
+ return z.assets.AssetCrypto.decrypt_aes_asset buffer, @otr_key.buffer, @sha256.buffer
+ .then (buffer) ->
+ return new Blob [new Uint8Array buffer], type: type
+
+ _load_buffer: =>
+ z.util.load_url_buffer @generate_url(), (xhr) =>
+ xhr.onprogress = (event) => @download_progress Math.round event.loaded / event.total * 100
+ @cancel_download = -> xhr.abort.call xhr
diff --git a/app/script/assets/AssetRetentionPolicy.coffee b/app/script/assets/AssetRetentionPolicy.coffee
new file mode 100644
index 00000000000..dd90f4bb382
--- /dev/null
+++ b/app/script/assets/AssetRetentionPolicy.coffee
@@ -0,0 +1,25 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+z.assets.AssetRetentionPolicy =
+ ETERNAL: 'eternal'
+ PERSISTENT: 'persistent'
+ VOLATILE: 'volatile'
diff --git a/app/script/assets/AssetService.coffee b/app/script/assets/AssetService.coffee
new file mode 100644
index 00000000000..d63fbaa5d83
--- /dev/null
+++ b/app/script/assets/AssetService.coffee
@@ -0,0 +1,418 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+# AssetService for all asset handling and the calls to the backend REST API.
+class z.assets.AssetService
+ ###
+ Construct a new Asset Service.
+
+ @param client [z.service.Client] Client for the API calls
+ ###
+ constructor: (@client) ->
+ @logger = new z.util.Logger 'z.assets.AssetService', z.config.LOGGER.OPTIONS
+ @rotator = new zeta.webapp.module.image.rotation.ImageFileRotator()
+ @compressor = new zeta.webapp.module.image.ImageCompressor()
+
+ @BOUNDARY = 'frontier'
+
+ @PREVIEW_CONFIG =
+ squared: false
+ max_image_size: 30
+ max_byte_size: 1024
+ lossy_scaling: true
+ compression: 0
+
+ @SMALL_PROFILE_CONFIG =
+ squared: true
+ max_image_size: 280
+ max_byte_size: 1024 * 1024
+ lossy_scaling: true
+ compression: 0.7
+
+ @pending_uploads = {}
+
+ ###############################################################################
+ # REST API calls
+ ###############################################################################
+
+ ###
+ Upload any asset to the backend using asset api v1.
+
+ @deprecated
+ @param config [Object] Configuration object containing the jQuery call settings
+ @option config [String] url
+ @option config [Object] data
+ @option config [String] contentType
+ @option config [String] contentDisposition
+ @option config [Function] callback
+ ###
+ post_asset: (config) ->
+ @client.send_request
+ type: 'POST'
+ url: config.url
+ data: config.data
+ processData: false # otherwise jquery will convert it to a query string
+ contentType: config.contentType
+ headers:
+ 'Content-Disposition': config.contentDisposition
+ callback: config.callback
+
+ ###
+ Upload any asset pair to the backend using asset api v1.
+
+ @deprecated
+ @param small [z.assets.Asset] Small asset for upload
+ @param medium [z.assets.Asset] Medium asset for upload
+ ###
+ post_asset_pair: (small, medium) ->
+ Promise.all [
+ @post_asset
+ contentType: small.content_type
+ url: @client.create_url '/assets'
+ contentDisposition: small.get_content_disposition()
+ data: small.array_buffer
+ @post_asset
+ contentType: medium.content_type
+ url: @client.create_url '/assets'
+ contentDisposition: medium.get_content_disposition()
+ data: medium.array_buffer
+ ]
+
+ ###############################################################################
+ # Asset service interactions
+ ###############################################################################
+
+ ###
+ Update the user profile image by first making it usable, transforming it and then uploading the asset pair.
+
+ @deprecated
+ @param conversation_id [String] ID of self conversation
+ @param file [File, Blob] Image
+ ###
+ upload_profile_image: (conversation_id, file, callback) ->
+ @_convert_image file, (image) =>
+ @_upload_profile_assets conversation_id, image, callback
+
+ ###
+ Upload arbitrary binary data using the new asset api v3.
+ The data is AES encrypted before uploading.
+
+ @param bytes [Uint8Array] asset binary data
+ @param options [Object]
+ @option public [Boolean]
+ @option retention [z.assets.AssetRetentionPolicy]
+ ###
+ _upload_asset: (bytes, options) ->
+ key_bytes = null
+ sha256 = null
+
+ z.assets.AssetCrypto.encrypt_aes_asset bytes
+ .then (data) =>
+ [key_bytes, sha256, ciphertext] = data
+ return @post_asset_v3 ciphertext, options
+ .then (data) ->
+ {key, token} = data
+ return [key_bytes, sha256, key, token]
+
+ ###
+ Upload image the new asset api v3. Promise will resolve with z.proto.Asset instance.
+ In case of an successful upload the uploaded property is set. Otherwise it will be marked as not
+ uploaded.
+
+ @param file [File, Blob] Image
+ @param options [Object]
+ @option public [Boolean]
+ @option retention [z.assets.AssetRetentionPolicy]
+ ###
+ upload_image_asset: (file, options) ->
+ compressed_image = null
+ image_bytes = null
+
+ @compress_image file
+ .then (data) ->
+ [original_image, compressed_image] = data
+ return z.util.base64_to_array compressed_image.src
+ .then (bytes) =>
+ image_bytes = bytes
+ @_upload_asset image_bytes, options
+ .then (data) ->
+ [key_bytes, sha256, key, token] = data
+ image_meta_data = new z.proto.Asset.ImageMetaData compressed_image.width, compressed_image.height
+ asset = new z.proto.Asset()
+ asset.set 'original', new z.proto.Asset.Original file.type, image_bytes.length, null, image_meta_data
+ asset.set 'uploaded', new z.proto.Asset.RemoteData key_bytes, sha256, key, token
+ return asset
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, error
+ asset = new z.proto.Asset()
+ asset.set 'not_uploaded', z.proto.Asset.NotUploaded.FAILED
+ return asset
+
+ ###
+ Generates the URL an asset can be downloaded from.
+
+ @param asset_id [String] ID of the asset
+ @param conversation_id [String] ID of the conversation the asset belongs to
+ @return [String] Asset URL
+ ###
+ generate_asset_url: (asset_id, conversation_id) ->
+ url = @client.create_url "/assets/#{asset_id}"
+ asset_url = "#{url}?access_token=#{@client.access_token}&conv_id=#{conversation_id}"
+ return asset_url
+
+ ###
+ Generates the URL for asset api v2.
+
+ @param asset_id [String] ID of the asset
+ @param conversation_id [String] ID of the conversation the asset belongs to
+ @return [String] Asset URL
+ ###
+ generate_asset_url_v2: (asset_id, conversation_id) ->
+ url = @client.create_url "/conversations/#{conversation_id}/otr/assets/#{asset_id}"
+ asset_url = "#{url}?access_token=#{@client.access_token}"
+ return asset_url
+
+ ###
+ Generates the URL for asset api v3.
+
+ @param asset_key [String]
+ @param asset_token [String]
+ @return [String] Asset URL
+ ###
+ generate_asset_url_v3: (asset_key, asset_token) ->
+ url = @client.create_url "/assets/v3/#{asset_key}/"
+ asset_url = "#{url}?access_token=#{@client.access_token}"
+ asset_url = "#{asset_url}&asset_token=#{asset_token}" if asset_token
+ return asset_url
+
+ ###############################################################################
+ # Private
+ ###############################################################################
+
+ ###
+ Compress image before uploading.
+
+ @param file [File, Blob] Image
+ ###
+ compress_image: (file) ->
+ return new Promise (resolve) =>
+ @_convert_image file, (image) =>
+ @compressor.transform_image image, (compressed_image) ->
+ resolve [image, compressed_image]
+
+ ###
+ Convert an image before uploading it.
+
+ @param file [File, Blob] Image
+ @param callback [Function] Function to be called on return
+ ###
+ _convert_image: (file, callback) ->
+ @_rotate_image file, (rotated_file) ->
+ z.util.read_deferred(rotated_file, 'url').done (url) ->
+ image = new Image()
+ image.onload = -> callback image
+ image.onerror = (e) => @logger.log "Loading image failed #{e}"
+ image.src = url
+ ###
+ Rotate an image file unless it is a gif.
+
+ @private
+
+ @param file [Object] Image file to be rotated
+ @param callback [Function] Function to be called on return
+ ###
+ _rotate_image: (file, callback) ->
+ return callback file if file.type is 'image/gif'
+ @rotator.rotate file, callback
+
+ ###
+ Update the profile image of the user.
+
+ @note We need to upload it in sizes 'smallProfile', and 'medium'.
+ @private
+
+ @param conversation_id [String] ID of the self conversation
+ @param image [Object] Image to be used as new profile picture
+ @param callback [Function] Function to be called on server return
+ ###
+ _upload_profile_assets: (conversation_id, image, callback) ->
+ # Finished compressing medium image
+ medium_image_compressed = (medium_image) =>
+ medium_asset = new z.assets.Asset
+ conversation_id: conversation_id
+ original_width: medium_image.width
+ original_height: medium_image.height
+ public: true
+ medium_asset.set_image medium_image
+
+ # Finished compressing small image
+ small_profile_image_compressed = (small_image) =>
+ small_profile_asset = $.extend true, {}, medium_asset
+ small_profile_asset.payload.tag = z.assets.ImageSizeType.SMALL_PROFILE
+ small_profile_asset.set_image small_image
+
+ @post_asset_pair small_profile_asset, medium_asset
+ .then (value) ->
+ [small_response, medium_response] = value
+ callback [small_response.data, medium_response.data]
+ .catch (error) ->
+ callback [], error
+
+ # Compress small image
+ @compressor.transform_image medium_image, small_profile_image_compressed, @SMALL_PROFILE_CONFIG
+
+ # Compress medium image
+ @compressor.transform_image image, medium_image_compressed # default config #
+
+ ###
+ Create request data for asset upload.
+
+ @param asset_data [UInt8Array|ArrayBuffer] Asset data
+ @param metadata [Object] image meta data
+ ###
+ _create_asset_multipart_body: (asset_data, metadata) ->
+ metadata = JSON.stringify metadata
+ asset_data_md5 = z.util.encode_base64_md5_array_buffer_view asset_data
+
+ body = ''
+ body += '--' + @BOUNDARY + '\r\n'
+ body += 'Content-Type: application/json; charset=utf-8\r\n'
+ body += "Content-length: #{metadata.length}\r\n"
+ body += '\r\n'
+ body += metadata + '\r\n'
+ body += '--' + @BOUNDARY + '\r\n'
+ body += 'Content-Type: application/octet-stream\r\n'
+ body += "Content-length: #{asset_data.length}\r\n"
+ body += "Content-MD5: #{asset_data_md5}\r\n"
+ body += '\r\n'
+
+ footer = '\r\n--' + @BOUNDARY + '--\r\n'
+
+ return new Blob [body, asset_data, footer]
+
+ ###
+ Post assets to a conversation.
+
+ @deprecated
+ @param conversation_id [String] ID of the self conversation
+ @param json_payload [Object] First part of the multipart message
+ @param body_payload [Object] Image to be used as new profile picture
+ @param force_sending [Boolean] Force sending
+ @param upload_id [String] Identifies the upload request
+ ###
+ post_asset_v2: (conversation_id, json_payload, body_payload, force_sending, upload_id) ->
+ return new Promise (resolve, reject) =>
+ url = @client.create_url "/conversations/#{conversation_id}/otr/assets"
+ url = "#{url}?ignore_missing=true" if force_sending
+
+ data = @_create_asset_multipart_body body_payload, json_payload
+ pending_uploads = @pending_uploads
+
+ xhr = new XMLHttpRequest()
+ xhr.open 'POST', url
+ xhr.setRequestHeader 'Content-Type', 'multipart/mixed; boundary=' + @BOUNDARY
+ xhr.setRequestHeader 'Authorization', "#{@client.access_token_type} #{@client.access_token}"
+ xhr.onload = (event) ->
+ if @status is 201
+ resolve [JSON.parse(@response), @getResponseHeader 'Location']
+ else if @status is 412
+ reject JSON.parse @response
+ else
+ reject event
+ delete pending_uploads[upload_id]
+ xhr.onerror = (error) ->
+ reject error
+ delete pending_uploads[upload_id]
+ xhr.upload.onprogress = (event) ->
+ if upload_id
+ # we use amplify due to the fact that Promise API lacks progress support
+ percentage_progress = Math.round(event.loaded / event.total * 100)
+ amplify.publish 'upload' + upload_id, percentage_progress
+ xhr.send data
+
+ pending_uploads[upload_id] = xhr
+
+ ###
+ Post assets using asset api v3.
+
+ @param asset_data [Uint8Array|ArrayBuffer]
+ @param metadata [Object]
+ @option public [Boolean] Default is false
+ @option retention [z.assets.AssetRetentionPolicy] Default is z.assets.AssetRetentionPolicy.PERSISTENT
+ @param xhr_accessor_function [Function] Function will get a reference to the underlying XMLHTTPRequest
+ ###
+ post_asset_v3: (asset_data, metadata, xhr_accessor_function) ->
+ return new Promise (resolve, reject) =>
+ metadata = $.extend
+ public: false
+ retention: z.assets.AssetRetentionPolicy.PERSISTENT
+ , metadata
+
+ xhr = new XMLHttpRequest()
+ xhr.open 'POST', @client.create_url "/assets/v3"
+ xhr.setRequestHeader 'Content-Type', 'multipart/mixed; boundary=' + @BOUNDARY
+ xhr.setRequestHeader 'Authorization', "#{@client.access_token_type} #{@client.access_token}"
+ xhr.onload = (event) -> if @status is 201 then resolve JSON.parse(@response) else reject event
+ xhr.onerror = reject
+ xhr_accessor_function? xhr
+ xhr.send @_create_asset_multipart_body new Uint8Array(asset_data), metadata
+
+ ###
+ Cancel an asset upload.
+
+ @param upload_id [String] Identifies the upload request
+ ###
+ cancel_asset_upload: (upload_id) =>
+ xhr = @pending_uploads[upload_id]
+ if xhr?
+ xhr.abort()
+ delete @pending_uploads[upload_id]
+
+ ###
+ Post an OTR asset to a conversation.
+
+ @param file [File, Blob] Image
+ @param image [Object] Image to be used as new profile picture
+ ###
+ create_asset_proto: (file) ->
+ original_image = null
+ compressed_image = null
+ image_bytes = null
+
+ @compress_image file
+ .then (data) ->
+ [original_image, compressed_image] = data
+ return z.util.base64_to_array compressed_image.src
+ .then (data) ->
+ image_bytes = data
+ z.assets.AssetCrypto.encrypt_aes_asset image_bytes
+ .then ([key_bytes, sha256, ciphertext]) ->
+ image_asset = new z.proto.ImageAsset()
+ image_asset.set_tag z.assets.ImageSizeType.MEDIUM
+ image_asset.set_width compressed_image.width
+ image_asset.set_height compressed_image.height
+ image_asset.set_original_width original_image.width
+ image_asset.set_original_height original_image.height
+ image_asset.set_mime_type file.type
+ image_asset.set_size image_bytes.length
+ image_asset.set_otr_key key_bytes
+ image_asset.set_sha256 sha256
+ return [image_asset, new Uint8Array ciphertext]
diff --git a/app/script/assets/AssetTransferState.coffee b/app/script/assets/AssetTransferState.coffee
new file mode 100644
index 00000000000..ed90b5bd2fb
--- /dev/null
+++ b/app/script/assets/AssetTransferState.coffee
@@ -0,0 +1,28 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+# Enum of different asset upload status.
+z.assets.AssetTransferState =
+ UPLOADING: 'uploading'
+ UPLOADED: 'uploaded'
+ UPLOAD_FAILED: 'upload-failed'
+ UPLOAD_CANCELED: 'upload-canceled'
+ DOWNLOADING: 'downloading'
diff --git a/app/script/assets/AssetType.coffee b/app/script/assets/AssetType.coffee
new file mode 100644
index 00000000000..827d0b7ef42
--- /dev/null
+++ b/app/script/assets/AssetType.coffee
@@ -0,0 +1,28 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+# Enum of different asset types.
+z.assets.AssetType =
+ FILE: 'File'
+ LOCATION: 'Location'
+ MEDIUM_IMAGE: 'MediumImage'
+ PREVIEW_IMAGE: 'PreviewImage'
+ TEXT: 'Text'
diff --git a/app/script/assets/AssetUploadFailedReason.coffee b/app/script/assets/AssetUploadFailedReason.coffee
new file mode 100644
index 00000000000..61e514bcf1c
--- /dev/null
+++ b/app/script/assets/AssetUploadFailedReason.coffee
@@ -0,0 +1,25 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+# Enum of different asset upload status.
+z.assets.AssetUploadFailedReason =
+ FAILED: 1
+ CANCELLED: 0
diff --git a/app/script/assets/ImageSizeType.coffee b/app/script/assets/ImageSizeType.coffee
new file mode 100644
index 00000000000..119f335cbc8
--- /dev/null
+++ b/app/script/assets/ImageSizeType.coffee
@@ -0,0 +1,26 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.assets ?= {}
+
+# Enum of different image size types.
+z.assets.ImageSizeType =
+ MEDIUM: 'medium'
+ PREVIEW: 'preview'
+ SMALL_PROFILE: 'smallProfile'
diff --git a/app/script/audio/AudioPlayingType.coffee b/app/script/audio/AudioPlayingType.coffee
new file mode 100644
index 00000000000..09699e589bf
--- /dev/null
+++ b/app/script/audio/AudioPlayingType.coffee
@@ -0,0 +1,40 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.audio ?= {}
+
+# Enum of sounds playing for different sound settings.
+z.audio.AudioPlayingType =
+ NONE: [
+ z.audio.AudioType.CALL_DROP
+ z.audio.AudioType.NETWORK_INTERRUPTION
+ z.audio.AudioType.OUTGOING_CALL
+ z.audio.AudioType.READY_TO_TALK
+ z.audio.AudioType.TALK_LATER
+ ]
+ SOME: [
+ z.audio.AudioType.CALL_DROP
+ z.audio.AudioType.INCOMING_CALL
+ z.audio.AudioType.INCOMING_PING
+ z.audio.AudioType.NETWORK_INTERRUPTION
+ z.audio.AudioType.OUTGOING_CALL
+ z.audio.AudioType.OUTGOING_PING
+ z.audio.AudioType.READY_TO_TALK
+ z.audio.AudioType.TALK_LATER
+ ]
diff --git a/app/script/audio/AudioRepository.coffee b/app/script/audio/AudioRepository.coffee
new file mode 100644
index 00000000000..5f96bd9a049
--- /dev/null
+++ b/app/script/audio/AudioRepository.coffee
@@ -0,0 +1,160 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.audio ?= {}
+
+# Enum of audio settings.
+z.audio.AudioSetting =
+ ALL: 'all'
+ NONE: 'none'
+ SOME: 'some'
+
+AUDIO_PATH = '/audio'
+
+# Audio repository for all audio interactions.
+class z.audio.AudioRepository
+ # Construct a new Audio Repository.
+ constructor: ->
+ @logger = new z.util.Logger 'z.audio.AudioRepository', z.config.LOGGER.OPTIONS
+ @audio_context = undefined
+ @in_loop = {}
+ @_init_sound_manager()
+ @_subscribe_to_audio_properties()
+
+ # Closing the AudioContext.
+ close_audio_context: =>
+ if @audio_context
+ @audio_context.close()
+ @audio_context = undefined
+ @logger.log @logger.levels.INFO, 'Closed existing AudioContext'
+
+ # Initialize the AudioContext.
+ get_audio_context: =>
+ if @audio_context
+ @logger.log @logger.levels.INFO, 'Reusing existing AudioContext', @audio_context
+ return @audio_context
+ else if window.AudioContext
+ @audio_context = new window.AudioContext()
+ @logger.log @logger.levels.INFO, 'Initialized a new AudioContext', @audio_context
+ return @audio_context
+ else
+ @logger.log @logger.levels.ERROR, 'The flow audio cannot use the Web Audio API as it is unavailable.'
+ return undefined
+
+ ###
+ Initialize a sound.
+
+ @private
+ @param id [z.audio.AudioType] ID of the sound
+ @param url [String] URL of sound file
+ @return [Function] Function to set up the sound in SoundManager
+ ###
+ _init_sound: (id, url) ->
+ return soundManager.createSound id: id, url: url
+
+ # Initialize all sounds.
+ _init_sounds: ->
+ @alert = @_init_sound z.audio.AudioType.ALERT, "#{AUDIO_PATH}/alert.mp3"
+ @call_drop = @_init_sound z.audio.AudioType.CALL_DROP, "#{AUDIO_PATH}/call_drop.mp3"
+ @network_interruption = @_init_sound z.audio.AudioType.NETWORK_INTERRUPTION, "#{AUDIO_PATH}/nw_interruption.mp3"
+ @new_message = @_init_sound z.audio.AudioType.NEW_MESSAGE, "#{AUDIO_PATH}/new_message.mp3"
+ @ping_from_me = @_init_sound z.audio.AudioType.OUTGOING_PING, "#{AUDIO_PATH}/ping_from_me.mp3"
+ @ping_from_them = @_init_sound z.audio.AudioType.INCOMING_PING, "#{AUDIO_PATH}/ping_from_them.mp3"
+ @ready_to_talk = @_init_sound z.audio.AudioType.READY_TO_TALK, "#{AUDIO_PATH}/ready_to_talk.mp3"
+ @ringing_from_me = @_init_sound z.audio.AudioType.OUTGOING_CALL, "#{AUDIO_PATH}/ringing_from_me.mp3"
+ @ringing_from_them = @_init_sound z.audio.AudioType.INCOMING_CALL, "#{AUDIO_PATH}/ringing_from_them.mp3"
+ @talk_later = @_init_sound z.audio.AudioType.TALK_LATER, "#{AUDIO_PATH}/talk_later.mp3"
+
+ # Use Amplify to subscribe to all audio playback related events.
+ _subscribe_to_audio_events: ->
+ amplify.subscribe z.event.WebApp.AUDIO.PLAY, @, @_play
+ amplify.subscribe z.event.WebApp.AUDIO.PLAY_IN_LOOP, @, @_play_in_loop
+ amplify.subscribe z.event.WebApp.AUDIO.STOP, @, @_stop
+
+ # Use Amplify to subscribe to all audio properties related events.
+ _subscribe_to_audio_properties: ->
+ @sound_setting = ko.observable z.audio.AudioSetting.ALL
+ @sound_setting.subscribe (sound_setting) =>
+ @_stop_all() if sound_setting is z.audio.AudioSetting.NONE
+
+ amplify.subscribe z.event.WebApp.PROPERTIES.UPDATED, (properties) =>
+ @sound_setting properties.settings.sound.alerts
+
+ amplify.subscribe z.event.WebApp.PROPERTIES.UPDATE.SOUND_ALERTS, (value) =>
+ @sound_setting value
+
+ # Initialize the SoundManager.
+ _init_sound_manager: ->
+ soundManager.setup
+ debugMode: false
+ useConsole: false
+ onready: =>
+ @_init_sounds()
+ @_subscribe_to_audio_events()
+
+ ###
+ Start playback of a sound
+ @param audio_id [String] Sound that should be played
+ ###
+ _play: (audio_id) ->
+ audio = soundManager.getSoundById audio_id
+
+ return if @sound_setting() is z.audio.AudioSetting.NONE and audio_id not in z.audio.AudioPlayingType.NONE
+ return if @sound_setting() is z.audio.AudioSetting.SOME and audio_id not in z.audio.AudioPlayingType.SOME
+
+ @logger.log "Playing sound: #{audio_id}", audio
+ audio.play()
+
+ ###
+ Start playback of a sound in a loop.
+
+ @note Prevent playing multiples instances of looping sounds
+ @param audio [Object] SoundManager sound object
+ @param is_first_time [Boolean] Is this the initial call or an on finish loop
+ ###
+ _play_in_loop: (audio_id, is_first_time = true) ->
+ audio = soundManager.getSoundById audio_id
+
+ return if @sound_setting() is z.audio.AudioSetting.NONE and audio_id not in z.audio.AudioPlayingType.NONE
+ return if @sound_setting() is z.audio.AudioSetting.SOME and audio_id not in z.audio.AudioPlayingType.SOME
+
+ if not @in_loop[audio_id]
+ @logger.log "Looping sound: #{audio_id}", audio
+ @in_loop[audio_id] = audio_id
+ else
+ return if is_first_time
+
+ audio.play onfinish: =>
+ @_play_in_loop audio.id, false
+
+ ###
+ Stop playback of a sound.
+ @param audio [Object] SoundManager sound object
+ ###
+ _stop: (audio_id) ->
+ audio = soundManager.getSoundById audio_id
+
+ @logger.log "Stopping sound: #{audio_id}", audio
+ audio.stop()
+
+ delete @in_loop[audio_id] if @in_loop[audio_id]
+
+ # Stop all sounds playing in loop.
+ _stop_all: ->
+ @_stop sound for sound of @in_loop
diff --git a/app/script/audio/AudioType.coffee b/app/script/audio/AudioType.coffee
new file mode 100644
index 00000000000..1596ff4e7e8
--- /dev/null
+++ b/app/script/audio/AudioType.coffee
@@ -0,0 +1,33 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.audio ?= {}
+
+# Enum of different supported sounds.
+z.audio.AudioType =
+ ALERT: 'alert'
+ CALL_DROP: 'call-drop'
+ INCOMING_CALL: 'ringing-from-them'
+ INCOMING_PING: 'ping-from-them'
+ NETWORK_INTERRUPTION: 'network-interruption'
+ NEW_MESSAGE: 'new-message'
+ OUTGOING_CALL: 'ringing-from-me'
+ OUTGOING_PING: 'ping-from-me'
+ READY_TO_TALK: 'ready-to-talk'
+ TALK_LATER: 'talk-later'
diff --git a/app/script/auth/AccessTokenError.coffee b/app/script/auth/AccessTokenError.coffee
new file mode 100644
index 00000000000..72c5332af6c
--- /dev/null
+++ b/app/script/auth/AccessTokenError.coffee
@@ -0,0 +1,36 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.auth ?= {}
+
+class z.auth.AccessTokenError
+ constructor: (message, type) ->
+ @name = @constructor.name
+ @message = message
+ @type = type
+ @stack = (new Error()).stack
+
+ @:: = new Error()
+ @::constructor = @
+ @::TYPE = {
+ NOT_FOUND_IN_CACHE: 'z.auth.AccessTokenError::TYPE.NOT_FOUND_IN_CACHE'
+ REFRESH_IN_PROGRESS: 'z.auth.AccessTokenError::TYPE.REFRESH_IN_PROGRESS'
+ REQUEST_FAILED: 'z.auth.AccessTokenError::TYPE.REQUEST_FAILED'
+ REQUEST_FORBIDDEN: 'z.auth.AccessTokenError::TYPE.REQUEST_FORBIDDEN'
+ }
diff --git a/app/script/auth/AuthRepository.coffee b/app/script/auth/AuthRepository.coffee
new file mode 100644
index 00000000000..afac30f2506
--- /dev/null
+++ b/app/script/auth/AuthRepository.coffee
@@ -0,0 +1,255 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.auth ?= {}
+
+# Authentication Repository for all authentication and registration interactions with the authentication service.
+class z.auth.AuthRepository
+ ###
+ Construct a new Authentication Repository.
+ @param auth_service [z.auth.AuthService] Backend REST API service implementation
+ ###
+ constructor: (@auth_service) ->
+ @logger = new z.util.Logger 'z.auth.AuthRepository', z.config.LOGGER.OPTIONS
+
+ @access_token_refresh = undefined
+
+ amplify.subscribe z.event.WebApp.CONNECTION.ACCESS_TOKEN.RENEW, @renew_access_token
+
+ # Print all cookies for a user in the console.
+ list_cookies: ->
+ @auth_service.get_cookies
+ .then (cookies) =>
+ @logger.force_log 'Backend cookies:'
+ for cookie, index in cookies
+ expiration = z.util.format_timestamp cookie.time
+ log = "Label: #{cookie.label} | Type: #{cookie.type} | Expiration: #{expiration}"
+ @logger.force_log "Cookie No. #{index + 1} | #{log}"
+ .catch (error) =>
+ @logger.force_log 'Could not list user cookies', error
+
+ ###
+ Login (with email or phone) in order to obtain an access-token and cookie.
+
+ @param login [Object] Containing sign in information
+ @option login [String] email The email address for a password login
+ @option login [String] phone The phone number for a password or SMS login
+ @option login [String] password The password for a password login
+ @option login [String] code The login code for an SMS login
+ @param persist [Boolean] Request a persistent cookie instead of a session cookie
+ @return [Promise] Promise that resolves with the received access token
+ ###
+ login: (login, persist) =>
+ return new Promise (resolve, reject) =>
+ @auth_service.post_login login, persist
+ .then (response) =>
+ @save_access_token response
+ z.storage.set_value z.storage.StorageKey.AUTH.PERSIST, persist
+ z.storage.set_value z.storage.StorageKey.AUTH.SHOW_LOGIN, true
+ resolve response
+ .catch (error) -> reject error
+
+ ###
+ Logout the user on the backend.
+ @return [Promise] Promise that will always resolve
+ ###
+ logout: =>
+ return new Promise (resolve) =>
+ @auth_service.post_logout()
+ .then =>
+ @logger.log @logger.levels.INFO, 'Log out on backend successful'
+ resolve()
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "Log out on backend failed: #{error.message}", error
+ resolve()
+
+ ###
+ Register a new user (with email).
+
+ @param new_user [Object] Containing the email, username and password needed for account creation
+ @option new_user [String] name
+ @option new_user [String] email
+ @option new_user [String] password
+ @option new_user [String] label Cookie label
+ @return [Promise] Promise that will resolve on success
+ ###
+ register: (new_user) =>
+ return new Promise (resolve, reject) =>
+ @auth_service.post_register new_user
+ .then (response) =>
+ z.storage.set_value z.storage.StorageKey.AUTH.PERSIST, true
+ z.storage.set_value z.storage.StorageKey.AUTH.SHOW_LOGIN, true
+ z.storage.set_value new_user.label_key, new_user.label
+ @logger.log @logger.levels.INFO,
+ "COOKIE::'#{new_user.label}' Saved cookie label with key '#{new_user.label_key}' in Local Storage", {
+ key: new_user.label_key,
+ value: new_user.label
+ }
+ resolve response
+ .catch (error) -> reject error
+
+ ###
+ Resend an email or phone activation code.
+
+ @param send_activation_code [Object] Containing the email or phone number needed to resend activation email
+ @option send_activation_code [String] email
+ @return [Promise] Promise that resolves on success
+ ###
+ resend_activation: (send_activation_code) =>
+ @auth_service.post_activate_send send_activation_code
+
+ ###
+ Retrieve personal invite information.
+ @param invite [String] Invite code
+ @return [Promise] Promise that resolves with the invite data
+ ###
+ retrieve_invite: (invite) =>
+ @auth_service.get_invitations_info invite
+
+ ###
+ Request SMS validation code.
+ @param request_code [Object] Containing the phone number in E.164 format and whether a code should be forced
+ @return [Promise] Promise that resolve on success
+ ###
+ request_login_code: (request_code) =>
+ @auth_service.post_login_send request_code
+
+ ###
+ Renew access-token provided a valid cookie.
+ ###
+ renew_access_token: =>
+ @get_access_token()
+ .then =>
+ @logger.log @logger.levels.INFO, 'Refreshed Access Token successfully.'
+ amplify.publish z.event.WebApp.CONNECTION.ACCESS_TOKEN.RENEWED
+ .catch (error) =>
+ if error.type is z.auth.AccessTokenError::TYPE.REQUEST_FORBIDDEN
+ @logger.log @logger.levels.WARN, "Session expired on access token refresh: #{error.message}", error
+ Raygun.send error
+ amplify.publish z.event.WebApp.SIGN_OUT, 'session_expired', false, true
+ else if error.type isnt z.auth.AccessTokenError::TYPE.REFRESH_IN_PROGRESS
+ @logger.log @logger.levels.ERROR, "Refreshing access token failed: '#{error.type}'", error
+ # @todo What do we do in this case?
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.CONNECTIVITY_RECONNECT
+
+ # Get the cached access token from the Amplify store.
+ get_cached_access_token: ->
+ return new Promise (resolve, reject) =>
+ access_token = z.storage.get_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.VALUE
+ access_token_type = z.storage.get_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.TYPE
+
+ if access_token
+ @logger.log @logger.levels.INFO, 'Cached access token found in Local Storage', access_token
+ @auth_service.save_access_token_in_client access_token_type, access_token
+ @_schedule_token_refresh z.storage.get_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.EXPIRATION
+ resolve()
+ else
+ error_message = 'No cached access token found in Local Storage'
+ reject new z.auth.AccessTokenError error_message, z.auth.AccessTokenError::TYPE.NOT_FOUND_IN_CACHE
+
+ ###
+ Initially get access-token provided a valid cookie.
+ @return [Promise] Returns a Promise that resolve with the access token data
+ ###
+ get_access_token: =>
+ return new Promise (resolve, reject) =>
+ if @auth_service.client.is_requesting_access_token()
+ error_message = 'Access Token request already in progress'
+ @logger.log @logger.levels.WARN, error_message
+ reject new z.auth.AccessTokenError error_message, z.auth.AccessTokenError::TYPE.REFRESH_IN_PROGRESS
+ else
+ @auth_service.post_access()
+ .then (access_token) =>
+ @save_access_token access_token
+ resolve access_token
+ .catch (error) ->
+ reject error
+
+ ###
+ Store the access token using Amplify.
+
+ @example Access Token data we expect:
+ access_token: Lt-IRHxkY9JLA5UuBR3Exxj5lCUf...
+ access_token_expiration: 1424951321067 => Thu, 26 Feb 2015 11:48:41 GMT
+ access_token_type: Bearer
+ access_token_ttl: 900000 => 900s/15min
+
+ @param access_token_data [Object, String] Access Token
+ @option access_token_data [String] access_token
+ @option access_token_data [String] expires_in
+ @option access_token_data [String] type
+ ###
+ save_access_token: (access_token_data) =>
+ expires_in_millis = 1000 * access_token_data.expires_in
+ expiration_timestamp = Date.now() + expires_in_millis
+
+ z.storage.set_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.VALUE, access_token_data.access_token, access_token_data.expires_in
+ z.storage.set_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.EXPIRATION, expiration_timestamp, access_token_data.expires_in
+ z.storage.set_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.TTL, expires_in_millis, access_token_data.expires_in
+ z.storage.set_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.TYPE, access_token_data.token_type, access_token_data.expires_in
+
+ @logger.log @logger.levels.LEVEL_1, 'Saved access token.', access_token_data
+ @_log_access_token_expiration expiration_timestamp
+ @_schedule_token_refresh expiration_timestamp
+
+ @auth_service.save_access_token_in_client access_token_data.token_type, access_token_data.access_token
+
+ # Deletes all access token data stored on the client.
+ delete_access_token: ->
+ z.storage.reset_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.VALUE
+ z.storage.reset_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.EXPIRATION
+ z.storage.reset_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.TTL
+ z.storage.reset_value z.storage.StorageKey.AUTH.ACCESS_TOKEN.TYPE
+
+ ###
+ Logs the expiration time of the access token.
+ @private
+ @param expiration_timestamp [Integer] Timestamp when access token expires
+ ###
+ _log_access_token_expiration: (expiration_timestamp) =>
+ expiration_log = z.util.format_timestamp expiration_timestamp
+ @logger.log @logger.levels.INFO, "Your access token will expire on: #{expiration_log}"
+
+ ###
+ Refreshes the access token in time before it expires.
+
+ @note Access token will be refreshed 1 minute (60000ms) before it expires
+ @private
+ @param expiration_timestamp [Integer] The expiration date (and time) as timestamp
+ ###
+ _schedule_token_refresh: (expiration_timestamp) =>
+ window.clearTimeout @access_token_refresh if @access_token_refresh
+ callback_timestamp = expiration_timestamp - 60000
+
+ if callback_timestamp < Date.now()
+ @logger.log @logger.levels.INFO, 'Immediately executing access token refresh'
+ @renew_access_token()
+ else
+ time = z.util.format_timestamp callback_timestamp
+ @logger.log @logger.levels.INFO, "Scheduling next access token refresh for '#{time}'"
+
+ @access_token_refresh = window.setTimeout =>
+ if callback_timestamp > (Date.now() + 15000)
+ @logger.log @logger.levels.INFO, "Access token refresh scheduled for '#{time}' skipped because it was executed late"
+ else if navigator.onLine
+ @logger.log @logger.levels.INFO, "Access token refresh scheduled for '#{time}' executed"
+ @renew_access_token()
+ else
+ @logger.log @logger.levels.INFO, "Access token refresh scheduled for '#{time}' skipped because we are offline"
+ , callback_timestamp - Date.now()
diff --git a/app/script/auth/AuthService.coffee b/app/script/auth/AuthService.coffee
new file mode 100644
index 00000000000..4ce015678ee
--- /dev/null
+++ b/app/script/auth/AuthService.coffee
@@ -0,0 +1,303 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.auth ?= {}
+
+# Authentication Service for all authentication and registration calls to the backend REST API.
+class z.auth.AuthService
+ URL_ACCESS: '/access'
+ URL_ACTIVATE: '/activate'
+ URL_COOKIES: '/cookies'
+ URL_INVITATIONS: '/invitations'
+ URL_LOGIN: '/login'
+ URL_REGISTER: '/register'
+
+ ###
+ Construct a new Authentication Service.
+ @param client [z.service.Client] Client for the API calls
+ ###
+ constructor: (@client) ->
+ @logger = new z.util.Logger 'z.auth.AuthService', z.config.LOGGER.OPTIONS
+
+
+ ###############################################################################
+ # Authentication
+ ###############################################################################
+
+ ###
+ Get all cookies for a user.
+ @return [Promise] Promise that resolves with an array of cookies
+ ###
+ get_cookies: ->
+ return new Promise (resolve, reject) =>
+ $.ajax
+ crossDomain: true
+ headers:
+ Authorization: "Bearer #{window.decodeURIComponent(@client.access_token)}"
+ type: 'GET'
+ url: @client.create_url "#{@URL_COOKIES}"
+ .done (data) ->
+ resolve data.cookies
+ .fail (jqXHR, textStatus, errorThrown) ->
+ reject jqXHR.responseJSON or errorThrown
+
+ ###
+ Get invite information.
+ @param code [String] Invite code
+ @return [Promise] Promise that resolves with invitations information
+ ###
+ get_invitations_info: (code) ->
+ return new Promise (resolve, reject) =>
+ $.ajax
+ crossDomain: true
+ type: 'GET'
+ url: @client.create_url "#{@URL_INVITATIONS}/info"
+ data: "code=#{code}"
+ .done (data) ->
+ resolve data
+ .fail (jqXHR, textStatus, errorThrown) ->
+ reject jqXHR.responseJSON or errorThrown
+
+ ###
+ Get access-token if a valid cookie is provided.
+
+ @note Don't use our client wrapper here, because to query "/access" we need to set "withCredentials" to "true" in order to send the cookie.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/auth/authenticate
+
+ @param options [Object]
+ @option options [Boolean] should_retry Should we retry on error
+ @option options [Boolean] retry_limit Should we retry on error
+ ###
+ post_access: (options) ->
+ return new Promise (resolve, reject) =>
+ @client.is_requesting_access_token true
+
+ options = $.extend
+ should_retry: true
+ retries: 0
+ retry_limit: 10
+ , options
+
+ settings =
+ crossDomain: true
+ type: 'POST'
+ url: @client.create_url "#{@URL_ACCESS}"
+ xhrFields:
+ withCredentials: true
+ success: (data) =>
+ @logger.log @logger.levels.INFO,
+ "Requesting access token successful after #{options.retries + 1} attempt(s)", data
+ @save_access_token_in_client data.token_type, data.access_token
+ resolve data
+ error: (jqXHR, textStatus, errorThrown) =>
+ options.retries++
+ should_retry = options.should_retry and options.retries < options.retry_limit
+ if not navigator.onLine
+ @logger.log @logger.levels.WARN, 'Access token refresh paused due to lack of internet connectivity'
+ $(window).on 'online', =>
+ @logger.log @logger.levels.INFO, 'Internet connectivity regained. Continuing access token refresh.'
+ $.ajax settings
+ else if should_retry and jqXHR.status isnt z.service.BackendClientError::STATUS_CODE.FORBIDDEN
+ window.setTimeout =>
+ @logger.log @logger.levels.INFO,
+ "Trying to get a new access token - attempt '#{options.retries}'"
+ $.ajax settings
+ , 500
+ else
+ error_description = "Requesting access token failed after '#{options.retries}' attempt(s): #{errorThrown}"
+ if jqXHR.responseJSON or jqXHR.responseText?.startsWith '{'
+ error = jqXHR.responseJSON or JSON.parse jqXHR.responseText
+ if error.code is z.service.BackendClientError::STATUS_CODE.FORBIDDEN
+ error_message = "#{error_description} (#{error.label} - #{error.message})"
+ error = new z.auth.AccessTokenError error_message, z.auth.AccessTokenError::TYPE.REQUEST_FORBIDDEN
+ else
+ error_message = 'Access token refresh failed'
+ error = new z.auth.AccessTokenError error_message, z.auth.AccessTokenError::TYPE.REQUEST_FAILED
+ Raygun.send error
+
+ @save_access_token_in_client()
+ @logger.log @logger.levels.ERROR, error_description, jqXHR
+ reject error
+
+ if @client.access_token
+ settings.headers =
+ Authorization: "Bearer #{window.decodeURIComponent(@client.access_token)}"
+
+ $.ajax settings
+
+ ###
+ Resend an email or phone activation code.
+
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/sendActivationCode
+
+ @param send_activation_code [Object] Containing the email or phone number needed to resend activation email
+ @option send_activation_code [String] email
+ @return [Promise] Promise that resolves on successful code resend
+ ###
+ post_activate_send: (send_activation_code) ->
+ return new Promise (resolve, reject) =>
+ $.ajax
+ contentType: 'application/json; charset=utf-8'
+ crossDomain: true
+ data: pako.gzip JSON.stringify send_activation_code
+ headers:
+ 'Content-Encoding': 'gzip'
+ processData: false
+ type: 'POST'
+ url: @client.create_url "#{@URL_ACTIVATE}/send"
+ .done ->
+ resolve z.service.BackendClientError::STATUS_CODE.OK
+ .fail (jqXHR, textStatus, errorThrown) ->
+ reject jqXHR.responseJSON or errorThrown
+
+ ###
+ Delete all cookies on the backend.
+
+ @param email [String] The user's e-mail address
+ @param password [String] The user's password
+ @param labels [Array] A list of cookie labels to remove from the system (optional)
+ ###
+ post_cookies_remove: (email, password, labels) ->
+ @client.send_json
+ url: @client.create_url "#{@URL_COOKIES}/remove"
+ type: 'POST'
+ data:
+ email: email
+ password: password
+ labels: labels
+
+ ###
+ Login in order to obtain an access-token and cookie.
+
+ @note Don't use our client wrapper here. On cookie requests we need to use plain jQuery AJAX.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/auth/login
+
+ @param login [Object] Containing sign in information
+ @option login [String] email The email address for a password login
+ @option login [String] phone The phone number for a password or SMS login
+ @option login [String] password The password for a password login
+ @option login [String] code The login code for an SMS login
+ @param persist [Boolean] Request a persistent cookie instead of a session cookie
+ @return [Promise] Promise that resolves with access token
+ ###
+ post_login: (login, persist) ->
+ return new Promise (resolve, reject) =>
+ $.ajax
+ contentType: 'application/json; charset=utf-8'
+ crossDomain: true
+ data: pako.gzip JSON.stringify login
+ headers:
+ 'Content-Encoding': 'gzip'
+ processData: false
+ type: 'POST'
+ url: @client.create_url "#{@URL_LOGIN}?persist=#{persist}"
+ xhrFields:
+ withCredentials: true
+ .done (data) ->
+ resolve data
+ .fail (jqXHR, textStatus, errorThrown) =>
+ if jqXHR.status is z.service.BackendClientError::STATUS_CODE.TOO_MANY_REQUESTS and login.email
+ # Backend blocked our user account from login, so we have to reset our cookies
+ @post_cookies_remove login.email, login.password, undefined
+ .then -> reject jqXHR.responseJSON or errorThrown
+ else
+ reject jqXHR.responseJSON or errorThrown
+
+ ###
+ A login code can be used only once and times out after 10 minutes.
+
+ @note Only one login code may be pending at a time.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/sendLoginCode
+
+ @param request_code [Object] Containing the phone number in E.164 format and whether a code should be forced
+ @return [Promise] Promise that resolves on successful login code request
+ ###
+ post_login_send: (request_code) ->
+ return new Promise (resolve, reject) =>
+ $.ajax
+ contentType: 'application/json; charset=utf-8'
+ data: pako.gzip JSON.stringify request_code
+ headers:
+ 'Content-Encoding': 'gzip'
+ processData: false
+ type: 'POST'
+ url: @client.create_url "#{@URL_LOGIN}/send"
+ .done (data) ->
+ resolve data
+ .fail (jqXHR, textStatus, errorThrown) ->
+ reject jqXHR.responseJSON or errorThrown
+
+ ###
+ Logout on the backend side.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/auth/logout
+ ###
+ post_logout: ->
+ return new Promise (resolve, reject) =>
+ $.ajax
+ crossDomain: true
+ headers:
+ Authorization: "Bearer #{window.decodeURIComponent(@client.access_token)}"
+ type: 'POST'
+ url: @client.create_url "#{@URL_ACCESS}/logout"
+ xhrFields:
+ withCredentials: true
+ .done (data) ->
+ resolve data
+ .fail (jqXHR, textStatus, errorThrown) ->
+ reject [jqXHR.responseJSON or errorThrown]
+
+ ###
+ Register a new user.
+
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/register
+
+ @param new_user [Object] Containing the email, username and password needed for account creation
+ @option new_user [String] name
+ @option new_user [String] email
+ @option new_user [String] password
+ @option new_user [String] locale
+ @return [Promise] Promise that will resolve on success
+ ###
+ post_register: (new_user) ->
+ return new Promise (resolve, reject) =>
+ $.ajax
+ contentType: 'application/json; charset=utf-8'
+ crossDomain: true
+ data: pako.gzip JSON.stringify new_user
+ headers:
+ 'Content-Encoding': 'gzip'
+ processData: false
+ type: 'POST'
+ url: @client.create_url "#{@URL_REGISTER}?challenge_cookie=true"
+ xhrFields:
+ withCredentials: true
+ .done (data) ->
+ resolve data
+ .fail (jqXHR, textStatus, errorThrown) ->
+ reject jqXHR.responseJSON or errorThrown
+
+ ###
+ Save the access token date in the client.
+ @param type [String] Access token type
+ @param value [String] Access token
+ ###
+ save_access_token_in_client: (type = '', value = '') =>
+ @client.access_token_type = type
+ @client.access_token = value
+ @client.is_requesting_access_token false
diff --git a/app/script/auth/AuthView.coffee b/app/script/auth/AuthView.coffee
new file mode 100644
index 00000000000..6694ef37a9e
--- /dev/null
+++ b/app/script/auth/AuthView.coffee
@@ -0,0 +1,62 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.auth ?= {}
+
+z.auth.AuthView =
+ ANIMATION_DIRECTION:
+ HORIZONTAL_LEFT: 'horizontal-left'
+ HORIZONTAL_RIGHT: 'horizontal-right'
+ VERTICAL_BOTTOM: 'vertical-bottom'
+ VERTICAL_TOP: 'vertical-top'
+ MODE:
+ ACCOUNT_EMAIL: 'email'
+ ACCOUNT_LOGIN: 'login'
+ ACCOUNT_REGISTER: 'register'
+ ACCOUNT_PHONE: 'phone'
+ HISTORY: 'history'
+ LIMIT: 'limit'
+ POSTED: 'posted'
+ POSTED_OFFLINE: 'offline'
+ POSTED_PENDING: 'pending'
+ POSTED_RESEND: 'resend'
+ POSTED_RETRY: 'retry'
+ POSTED_VERIFY: 'verify'
+ VERIFY_ADD_EMAIL: 'add_email'
+ VERIFY_CODE: 'code'
+ REGISTRATION_CONTEXT:
+ EMAIL: 'email'
+ GENERIC_INVITE: 'generic_invite'
+ PERSONAL_INVITE: 'personal_invite'
+ SECTION:
+ ACCOUNT: 'account'
+ POSTED: 'posted'
+ VERIFY: 'verify'
+ LIMIT: 'limit'
+ HISTORY: 'history'
+ TYPE:
+ CODE: 'code'
+ EMAIL: 'email'
+ FORM: 'form'
+ MODE: 'mode'
+ NAME: 'name'
+ PASSWORD: 'password'
+ PHONE: 'phone'
+ SECTION: 'section'
+ TERMS: 'terms'
diff --git a/app/script/auth/URLParameter.coffee b/app/script/auth/URLParameter.coffee
new file mode 100644
index 00000000000..3eb9c5ec85a
--- /dev/null
+++ b/app/script/auth/URLParameter.coffee
@@ -0,0 +1,28 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.auth ?= {}
+
+z.auth.URLParameter =
+ CONNECT: 'connect'
+ ENVIRONMENT: 'env'
+ EXPIRED: 'expired'
+ INVITE: 'invite'
+ LOCALE: 'hl'
+ LOCALYTICS: 'localytics'
diff --git a/app/script/auth/ValidationError.coffee b/app/script/auth/ValidationError.coffee
new file mode 100644
index 00000000000..bc905dc2ae8
--- /dev/null
+++ b/app/script/auth/ValidationError.coffee
@@ -0,0 +1,28 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.auth ?= {}
+
+# Authentication error entity.
+class z.auth.ValidationError
+ # Construct a new Authentication error.
+ constructor: (types, string_identifier) ->
+ types = [types] if _.isString types
+ @types = types
+ @message = z.localization.Localizer.get_text string_identifier
diff --git a/app/script/cache/CacheRepository.coffee b/app/script/cache/CacheRepository.coffee
new file mode 100644
index 00000000000..3d45438df6c
--- /dev/null
+++ b/app/script/cache/CacheRepository.coffee
@@ -0,0 +1,56 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.cache ?= {}
+
+###
+Cache repository for local storage interactions using amplify.
+
+@todo We have to come up with a smart solution to handle "amplify.store quota exceeded"
+ This happened when doing "@cache_repository.set_entity user_et"
+
+###
+class z.cache.CacheRepository
+ # Construct a new Cache Repository.
+ constructor: ->
+ @logger = new z.util.Logger 'z.auth.CacheRepository', z.config.LOGGER.OPTIONS
+
+ ###
+ Deletes cached data.
+
+ @param keep_conversation_input [Boolean] Should conversation input be kept
+ @param protected_key_patterns [Array] Keys which should NOT be deleted from the cache
+
+ @return [Array] Keys which have been deleted from the cache
+ ###
+ clear_cache: (keep_conversation_input = false, protected_key_patterns = [z.storage.StorageKey.AUTH.SHOW_LOGIN]) ->
+ protected_key_patterns.push z.storage.StorageKey.CONVERSATION.INPUT if keep_conversation_input
+ deleted_keys = []
+
+ $.each amplify.store(), (stored_key) ->
+ should_be_deleted = true
+
+ for protected_key_pattern in protected_key_patterns
+ should_be_deleted = false if stored_key.startsWith protected_key_pattern
+
+ if should_be_deleted
+ z.storage.reset_value stored_key
+ deleted_keys.push stored_key
+
+ return deleted_keys
diff --git a/app/script/calling/CallCenter.coffee b/app/script/calling/CallCenter.coffee
new file mode 100644
index 00000000000..d30fb2fc616
--- /dev/null
+++ b/app/script/calling/CallCenter.coffee
@@ -0,0 +1,267 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+
+SUPPORTED_EVENTS = [
+ z.event.Backend.CALL.FLOW_ADD
+ z.event.Backend.CALL.REMOTE_CANDIDATES_ADD
+ z.event.Backend.CALL.REMOTE_CANDIDATES_UPDATE
+ z.event.Backend.CALL.REMOTE_SDP
+ z.event.Backend.CALL.STATE
+ z.event.Backend.CONVERSATION.VOICE_CHANNEL_ACTIVATE
+ z.event.Backend.CONVERSATION.VOICE_CHANNEL_DEACTIVATE
+]
+
+# User repository for all call interactions with the call service.
+class z.calling.CallCenter
+ ###
+ Extended check for calling support of browser.
+ @param conversation_id [String] Conversation ID
+ @return [Boolean] True if calling is supported
+ ###
+ @supports_calling: ->
+ return z.util.Environment.browser.supports.calling
+
+ ###
+ Extended check for screen sharing support of browser.
+ @return [Boolean] True if screen sharing is supported
+ ###
+ @supports_screen_sharing: ->
+ return false if z.util.Environment.frontend.is_production()
+ return z.util.Environment.browser.supports.screen_sharing
+
+ ###
+ Construct a new Call Center repository.
+
+ @param call_service [z.calling.CallService] Backend REST API call service implementation
+ @param conversation_repository [z.conversation.ConversationRepository] Repository for conversation interactions
+ @param user_repository [z.user.UserRepository] Repository for all user and connection interactions
+ @param audio_repository [z.audio.AudioRepository] Repository for all audio interactions
+ ###
+ constructor: (@call_service, @conversation_repository, @user_repository, @audio_repository) ->
+ @logger = new z.util.Logger 'z.calling.CallCenter', z.config.LOGGER.OPTIONS
+
+ # Telemetry
+ @telemetry = new z.telemetry.calling.CallTelemetry()
+ @flow_status = undefined
+ @timings = ko.observable()
+
+ # Media Handler
+ @media_devices_handler = new z.calling.handler.MediaDevicesHandler @
+ @media_stream_handler = new z.calling.handler.MediaStreamHandler @
+ @media_element_handler = new z.calling.handler.MediaElementHandler @
+
+ # Call Handler
+ @state_handler = new z.calling.handler.CallStateHandler @
+ @signaling_handler = new z.calling.handler.CallSignalingHandler @
+
+ #Calls
+ @calls = @state_handler.calls
+ @joined_call = @state_handler.joined_call
+
+ @subscribe_to_events()
+
+ # Subscribe to amplify topics.
+ subscribe_to_events: =>
+ amplify.subscribe z.event.WebApp.CALL.EVENT_FROM_BACKEND, @on_event
+ amplify.subscribe z.event.WebApp.CONVERSATION.EVENT_FROM_BACKEND, @on_event
+ amplify.subscribe z.event.WebApp.DEBUG.UPDATE_LAST_CALL_STATUS, @store_flow_status
+ amplify.subscribe z.util.Logger::LOG_ON_DEBUG, @set_logging
+
+ # Un-subscribe from amplify topics.
+ un_subscribe: ->
+ @state_handler.un_subscribe()
+ @signaling_handler.un_subscribe()
+ amplify.unsubscribeAll z.event.WebApp.CALL.EVENT_FROM_BACKEND
+
+
+ ###############################################################################
+ # Events
+ ###############################################################################
+
+ ###
+ Handle incoming backend events.
+ @param event [Object] Event payload
+ ###
+ on_event: (event) =>
+ return if @state_handler.is_handling_notifications() or event.type not in SUPPORTED_EVENTS
+
+ @logger.log @logger.levels.INFO,
+ "»» Event: '#{event.type}'", {event_object: event, event_json: JSON.stringify event}
+ if z.calling.CallCenter.supports_calling()
+ @_on_event_in_supported_browsers event
+ else
+ @_on_event_in_unsupported_browsers event
+
+ ###
+ Backend calling event handling for browsers supporting calling.
+ @private
+ @param event [Object] Event payload
+ ###
+ _on_event_in_supported_browsers: (event) ->
+ @telemetry.trace_event event
+ switch event.type
+ when z.event.Backend.CALL.FLOW_ADD
+ @signaling_handler.on_flow_add_event event
+ when z.event.Backend.CALL.REMOTE_CANDIDATES_ADD, z.event.Backend.CALL.REMOTE_CANDIDATES_UPDATE
+ @signaling_handler.on_remote_ice_candidates event
+ when z.event.Backend.CALL.REMOTE_SDP
+ @signaling_handler.on_remote_sdp event
+ when z.event.Backend.CALL.STATE
+ @state_handler.on_call_state event
+
+ ###
+ Backend calling event handling for browsers not supporting calling.
+ @private
+ @param event [Object] Event payload
+ ###
+ _on_event_in_unsupported_browsers: (event) ->
+ switch event.type
+ when z.event.Backend.CONVERSATION.VOICE_CHANNEL_ACTIVATE
+ @user_repository.get_user_by_id @get_creator_id(event), (creator_et) ->
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.UNSUPPORTED_INCOMING_CALL, {
+ first_name: creator_et.name()
+ call_id: event.conversation
+ }
+ when z.event.Backend.CONVERSATION.VOICE_CHANNEL_DEACTIVATE
+ amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.UNSUPPORTED_INCOMING_CALL
+
+
+ ###############################################################################
+ # Helper functions
+ ###############################################################################
+
+ ###
+ Get a call entity.
+ @param conversation_id [String] Conversation ID of requested call
+ @return [z.calling.Call] Call entity for conversation ID
+ ###
+ get_call_by_id: (conversation_id) ->
+ return Promise.resolve()
+ .then =>
+ if conversation_id
+ for call_et in @calls() when call_et.id is conversation_id
+ return call_et
+ throw new z.calling.CallError 'No call for conversation ID found', z.calling.CallError::TYPE.CALL_NOT_FOUND
+ throw new z.calling.CallError 'No conversation ID given', z.calling.CallError::TYPE.NO_CONVERSATION_ID
+
+ ###
+ Helper to identify the creator of a call or choose the first joined one.
+ @private
+ @param event [Object] Event payload
+ ###
+ get_creator_id: (event) ->
+ if creator_id = event.creator or event.from
+ return creator_id
+ else
+ return user_id for user_id, device_info of event.participants when device_info.state is z.calling.enum.ParticipantState.JOINED
+
+
+ ###############################################################################
+ # Util functions
+ ###############################################################################
+
+ ###
+ Count into the flows of a call.
+ @param conversation_id [String] Conversation ID
+ ###
+ count_flows: (conversation_id) =>
+ @get_call_by_id conversation_id
+ .then (call_et) =>
+ counting = ({flow: flow_et, sound: "/audio/digits/#{i}.mp3"} for flow_et, i in call_et.get_flows())
+ counting.reverse()
+
+ _count_flow = =>
+ act = counting.pop()
+ return if not act
+ user_name = act.flow.remote_user.name()
+ @logger.log @logger.levels.INFO, "Sending audio file '#{act.sound}' to flow '#{act.flow.id}' (#{user_name})"
+ act.flow.inject_audio_file act.sound, _count_flow
+
+ _count_flow()
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "No call for conversation '#{conversation_id}' found to count into flows", error
+
+
+ ###
+ Inject audio into all flows of a call.
+ @param conversation_id [String] Conversation ID
+ @param file_path [String] Path to audio file
+ ###
+ inject_audio: (conversation_id, file_path) =>
+ @get_call_by_id conversation_id
+ .then (call_et) ->
+ flow_et.inject_audio_file file_path for flow_et in call_et.get_flows()
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "No call for conversation '#{conversation_id}' found to inject audio into flows", error
+
+
+ ###############################################################################
+ # Logging
+ ###############################################################################
+
+ # Log call sessions
+ log_sessions: =>
+ @telemetry.log_sessions()
+
+ print_call_states: =>
+ session_id = 'unknown'
+ for call_et in @calls()
+ @logger.force_log "Call state for conversation: #{call_et.id}\n"
+ session_id = call_et.log_state()
+ return "session id is : #{session_id}"
+
+ # Report a call for call analysis
+ report_call: =>
+ send_report = (custom_data) =>
+ Raygun.send new Error('Call failure report'), custom_data
+ @logger.log @logger.levels.INFO,
+ "Reported status of flow id '#{custom_data.meta.flow_id}' for call analysis", custom_data
+
+ call_et = @_find_ongoing_call()
+ if call_et
+ send_report flow_et.report_status() for flow_et in call_et.get_flows()
+ else if @flow_status
+ send_report @flow_status
+ else
+ @logger.log @logger.levels.WARN, 'Could not find flows to report for call analysis'
+
+ # Set logging on adapter.js
+ set_logging: (is_logging_enabled) =>
+ @logger.log @logger.levels.INFO, "Set logging for webRTC Adapter: #{is_logging_enabled}"
+ adapter?.disableLog = not is_logging_enabled
+
+ # Store last flow status
+ store_flow_status: (flow_status) =>
+ @flow_status = flow_status if flow_status
+
+ ###
+ Please solely use this method for logging purposes! It's not intended to do actual work / heavy lifting.
+
+ @private
+ @param conversation_id [String] Conversation ID
+ @return [z.calling.Call] Returns an ongoing call entity
+ ###
+ _find_ongoing_call: (conversation_id) ->
+ @get_call_by_id conversation_id
+ .then (call_et) ->
+ return call_et
+ .catch =>
+ return call_et for call in @calls() when call.state() not in z.calling.enum.CallStateGroups.IS_ENDED
diff --git a/app/script/calling/CallError.coffee b/app/script/calling/CallError.coffee
new file mode 100644
index 00000000000..4505921fe24
--- /dev/null
+++ b/app/script/calling/CallError.coffee
@@ -0,0 +1,42 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+
+class z.calling.CallError
+ constructor: (message, type) ->
+ @name = @constructor.name
+ @message = message
+ @type = type
+ @stack = (new Error()).stack
+
+ @:: = new Error()
+ @::constructor = @
+ @::TYPE = {
+ CALL_NOT_FOUND: 'z.calling.CallError::TYPE.CALL_NOT_FOUND'
+ CONVERSATION_EMPTY: 'z.calling.CallError::TYPE.CONVERSATION_EMPTY'
+ CONVERSATION_TOO_BIG: 'z.calling.CallError::TYPE.CONVERSATION_TOO_BIG'
+ FLOW_NOT_FOUND: 'z.calling.CallError::TYPE.FLOW_NOT_FOUND'
+ NO_CAMERA_FOUND: 'z.calling.CallError::TYPE.NO_CAMERA_FOUND'
+ NO_CONVERSATION_ID: 'z.calling.CallError::TYPE.NO_CONVERSATION_ID'
+ NO_DEVICES_FOUND: 'z.calling.CallError::TYPE.NO_DEVICES_FOUND'
+ NO_MICROPHONE_FOUND: 'z.calling.CallError::TYPE.NO_MICROPHONE_FOUND'
+ NOT_SUPPORTED: 'z.calling.CallError::TYPE.NOT_SUPPORTED'
+ VOICE_CHANNEL_FULL: 'z.calling.CallError::TYPE.VOICE_CHANNEL_FULL'
+ }
diff --git a/app/script/calling/CallService.coffee b/app/script/calling/CallService.coffee
new file mode 100644
index 00000000000..31297038946
--- /dev/null
+++ b/app/script/calling/CallService.coffee
@@ -0,0 +1,148 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+
+class z.calling.CallService
+ constructor: (@client) ->
+ @logger = new z.util.Logger 'z.calling.CallService', z.config.LOGGER.OPTIONS
+
+ ###
+ Deletes a flow on the backend.
+
+ @note If a call connection with a remote device (participant) ends, we need to delete the flow to this device.
+
+ Sometimes calls to a remote device ended already but the backend state still has active flows to these devices.
+ In this case we need to force the deletion of these active flows with the reason "released".
+
+ If we detect on the PeerConnection level, that a remote participant dropped, we can
+ delete the flow to them with reason "timeout".
+
+ Possible errors:
+ - {"code":404,"message":"the requested flow does not exist","label":"not-found"}
+ - {"code":403,"message":"cannot remove active flow","label":"in-use"}
+
+ @param delete_flow_info [Object] Info needed to delete a flow
+ @option delete_flow_info [String] conversation_id
+ @option delete_flow_info [String] flow_id
+ @param callbacks [Array] Callbacks after the request has been made
+ ###
+ delete_flow: (delete_flow_info, callbacks) ->
+ reason = delete_flow_info.reason
+ url = "/conversations/#{delete_flow_info.conversation_id}/call/flows/#{delete_flow_info.flow_id}"
+ url += "?reason=#{reason}" if reason
+
+ @client.send_request
+ type: 'DELETE'
+ api_endpoint: '/conversations/{conversation_id}/call/flows/{flow_id}'
+ url: @client.create_url url
+ callback: callbacks
+
+ ###
+ Lists existing call flows for a specific conversation.
+
+ @param conversation_id [String] Conversation ID
+ @param callbacks [Array] Callbacks after the request has been made
+ ###
+ get_flows: (conversation_id, callbacks) ->
+ @client.send_request
+ type: 'GET'
+ api_endpoint: '/conversations/{conversation_id}/call/flows'
+ url: @client.create_url "/conversations/#{conversation_id}/call/flows"
+ callback: callbacks
+
+ ###
+ Returns the participants and their call states in a specified conversation.
+
+ @param conversation_id [String] Conversation ID
+ @param callbacks [Array] Callbacks after the request has been made
+ ###
+ get_state: (conversation_id, callbacks) ->
+ @client.send_request
+ type: 'GET'
+ api_endpoint: '/conversations/{conversation_id}/call/state'
+ url: @client.create_url "/conversations/#{conversation_id}/call/state"
+ callback: callbacks
+
+ ###
+ Commands the backend to create a flow.
+
+ @param conversation_id [String] Conversation ID
+ @param callbacks [Array] Callbacks after the request has been made
+ ###
+ post_flows: (conversation_id, callbacks) ->
+ @client.send_request
+ type: 'POST'
+ api_endpoint: '/conversations/{conversation_id}/call/flows'
+ url: @client.create_url "/conversations/#{conversation_id}/call/flows"
+ callback: callbacks
+
+ ###
+ Add an ICE candidate.
+
+ @param conversation_id [String] Conversation ID
+ @param flow_id [String] Flow ID
+ @param ice_info [z.calling.payloads.ICECandidateInfo] Signaling info bundled with ICE candidate
+ @param callbacks [Array] Callbacks after the request has been made
+ ###
+ post_local_candidates: (conversation_id, flow_id, ice_info, callbacks) ->
+ @client.send_json
+ type: 'POST'
+ api_endpoint: '/conversations/{conversation_id}/call/flows/{flow_id}/local_candidates'
+ url: @client.create_url "/conversations/#{conversation_id}/call/flows/#{flow_id}/local_candidates"
+ data:
+ candidates: [ice_info]
+ callback: callbacks
+
+ ###
+ Update the SDP of a connection.
+
+ @note Errors can be:
+ - {"code":400,"message":"invalid SDP transition requested","label":"bad-sdp"}
+
+ The "bad-sdp" can happen when you send an offer to an offer or if one flow has been already partially negotiated and we try to negotiate for a second flow.
+
+ @param conversation_id [String] Conversation ID
+ @param flow_id [String] Flow ID
+ @param sdp [z.calling.SDPInfo] Signaling info bundled with SDP
+ @param callbacks [Array] Callbacks after the request has been made
+ ###
+ put_local_sdp: (conversation_id, flow_id, sdp, callbacks) ->
+ @client.send_json
+ type: 'PUT'
+ api_endpoint: '/conversations/{conversation_id}/call/flows/{flow_id}/local_sdp'
+ url: @client.create_url "/conversations/#{conversation_id}/call/flows/#{flow_id}/local_sdp"
+ data: sdp
+ callback: callbacks
+
+ ###
+ Returns the current state of the client and all participants.
+
+ @param conversation_id [String] Conversation ID
+ @param payload [Object] Participant payload to be set
+ @param callbacks [Array] Callbacks after the request has been made
+ ###
+ put_state: (conversation_id, payload, callbacks) ->
+ @client.send_json
+ type: 'PUT'
+ api_endpoint: '/conversations/{conversation_id}/call/state'
+ url: @client.create_url "/conversations/#{conversation_id}/call/state"
+ data:
+ self: payload
+ callback: callbacks
diff --git a/app/script/calling/CallTrackingInfo.coffee b/app/script/calling/CallTrackingInfo.coffee
new file mode 100644
index 00000000000..f3ff8f7c999
--- /dev/null
+++ b/app/script/calling/CallTrackingInfo.coffee
@@ -0,0 +1,34 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+
+class z.calling.CallTrackingInfo
+ constructor: (params) ->
+ @conversation_id = params.conversation_id
+ @session_id = params.session_id
+ @time_started = new Date()
+ @participants_joined = {}
+
+ add_participant: (participant_et) ->
+ @participants_joined[participant_et.user.name()] = true
+
+ to_string: ->
+ participants = Object.keys(@participants_joined).join ', '
+ return "#{@session_id} in #{@conversation_id} | #{@time_started.toUTCString()} | To: #{participants}"
diff --git a/app/script/calling/entities/Call.coffee b/app/script/calling/entities/Call.coffee
new file mode 100644
index 00000000000..cef8c71565d
--- /dev/null
+++ b/app/script/calling/entities/Call.coffee
@@ -0,0 +1,518 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.entities ?= {}
+
+# Call entity.
+class z.calling.entities.Call
+ ###
+ Construct a new call entity.
+ @param conversation_et [z.entity.Conversation] Conversation the call takes place in
+ @param self_user [z.entity.User] Self user entity
+ ###
+ constructor: (@conversation_et, @self_user, @telemetry) ->
+ @logger = new z.util.Logger "z.calling.Call (#{@conversation_et.id})", z.config.LOGGER.OPTIONS
+
+ # IDs and references
+ @id = @conversation_et.id
+ @session_id = ko.observable undefined
+ @event_sequence = 0
+
+ # States
+ @call_timer_interval = undefined
+ @timer_start = undefined
+ @duration_time = ko.observable 0
+ @finished_reason = z.calling.enum.CallFinishedReason.UNKNOWN
+ @remote_media_type = ko.observable z.calling.enum.MediaType.NONE
+
+ @is_connected = ko.observable false
+ @is_group = @conversation_et.is_group
+ @is_remote_screen_shared = ko.pureComputed =>
+ return @remote_media_type() is z.calling.enum.MediaType.SCREEN
+ @is_remote_videod = ko.pureComputed =>
+ return @remote_media_type() in [z.calling.enum.MediaType.SCREEN, z.calling.enum.MediaType.VIDEO]
+
+ @self_client_joined = ko.observable false
+ @self_user_joined = ko.observable false
+ @state = ko.observable z.calling.enum.CallState.UNKNOWN
+ @previous_state = undefined
+ @is_declined_timer = undefined
+
+ # user declined group call
+ @is_declined = ko.observable false
+ @is_declined.subscribe (is_declined) =>
+ @_stop_call_sound true if is_declined
+
+ @self_user_joined.subscribe (is_joined) =>
+ @is_declined false if is_joined
+
+ # User entities
+ @creator = ko.observable undefined
+
+ # @todo Calculate panning on participants update
+ @participants = ko.observableArray []
+ @participants_count = ko.pureComputed =>
+ if @self_user_joined()
+ @get_number_of_participants() + 1
+ else
+ @get_number_of_participants()
+ @max_number_of_participants = 0
+
+ @interrupted_participants = ko.observableArray []
+
+ # Media
+ @local_audio_stream = ko.observable()
+ @local_video_stream = ko.observable()
+
+ # Statistics
+ @_reset_timer()
+
+ # Observable subscriptions
+ @is_connected.subscribe (is_connected) =>
+ if is_connected
+ @telemetry.track_event z.tracking.EventName.CALLING.ESTABLISHED_CALL, @
+ @timer_start = Date.now() - 100
+ @call_timer_interval = window.setInterval =>
+ @update_timer_duration()
+ , 1000
+
+ @participants_count.subscribe (users_in_call) =>
+ @max_number_of_participants = Math.max users_in_call, @max_number_of_participants
+
+ @self_client_joined.subscribe (is_joined) =>
+ if is_joined
+ if @state() isnt z.calling.enum.CallState.OUTGOING
+ amplify.publish z.event.WebApp.CALL.SIGNALING.POST_FLOWS, @id
+ else
+ @is_connected false
+ amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.CALL_DROP
+ @telemetry.track_duration @
+ @_reset_timer()
+ @_reset_flows()
+
+ @is_ongoing_on_another_client = ko.pureComputed =>
+ return @self_user_joined() and not @self_client_joined()
+
+ @network_interruption = ko.pureComputed =>
+ if @is_connected()
+ if @is_group()
+ return @interrupted_participants().length > 0 and @interrupted_participants().length is @participants().length
+ else
+ return @interrupted_participants().length > 0
+ return false
+
+ @network_interruption.subscribe (is_interrupted) ->
+ if is_interrupted
+ amplify.publish z.event.WebApp.AUDIO.PLAY_IN_LOOP, z.audio.AudioType.NETWORK_INTERRUPTION
+ else
+ amplify.publish z.event.WebApp.AUDIO.STOP, z.audio.AudioType.NETWORK_INTERRUPTION
+
+ @state.subscribe (state) =>
+ @logger.log @logger.levels.DEBUG, "Call state changed to '#{state}'"
+
+ @_clear_join_timer() if @is_group()
+
+ switch state
+ when z.calling.enum.CallState.CONNECTING
+ @_on_state_connecting()
+ when z.calling.enum.CallState.INCOMING
+ @_on_state_incoming()
+ when z.calling.enum.CallState.DELETED
+ @_on_state_deleted()
+ when z.calling.enum.CallState.IGNORED
+ @_on_state_ignored()
+ when z.calling.enum.CallState.ONGOING
+ @_on_state_ongoing()
+ when z.calling.enum.CallState.OUTGOING
+ @_on_state_outgoing()
+
+ @previous_state = state
+
+ update_timer_duration: =>
+ @duration_time Math.floor (Date.now() - @timer_start) / 1000
+
+
+ ###############################################################################
+ # Call states
+ ###############################################################################
+
+ _on_state_connecting: =>
+ @_stop_call_sound @previous_state is z.calling.enum.CallState.INCOMING
+ @is_declined false
+
+ _on_state_incoming: =>
+ @_play_call_sound true
+ @_group_call_timeout true if @is_group()
+
+ _on_state_deleted: =>
+ if @previous_state in z.calling.enum.CallStateGroups.IS_RINGING
+ @_stop_call_sound @previous_state is z.calling.enum.CallState.INCOMING
+ @is_declined false
+
+ _on_state_ignored: =>
+ if @previous_state in z.calling.enum.CallStateGroups.IS_RINGING
+ @_stop_call_sound @previous_state is z.calling.enum.CallState.INCOMING
+
+ _on_state_ongoing: =>
+ if @previous_state in z.calling.enum.CallStateGroups.IS_RINGING
+ @_stop_call_sound @previous_state is z.calling.enum.CallState.INCOMING
+
+ _on_state_outgoing: =>
+ @_play_call_sound false
+ @_group_call_timeout false if @is_group()
+
+ _clear_join_timer: =>
+ window.clearTimeout @is_declined_timer
+ @is_declined_timer = undefined
+
+ _group_call_timeout: (is_incoming) =>
+ @is_declined_timer = window.setTimeout =>
+ @_stop_call_sound is_incoming
+ @is_declined true if is_incoming
+ , 30000
+
+ _play_call_sound: (is_incoming) ->
+ if is_incoming
+ amplify.publish z.event.WebApp.AUDIO.PLAY_IN_LOOP, z.audio.AudioType.INCOMING_CALL
+ else
+ amplify.publish z.event.WebApp.AUDIO.PLAY_IN_LOOP, z.audio.AudioType.OUTGOING_CALL
+
+ _stop_call_sound: (is_incoming) ->
+ if is_incoming
+ amplify.publish z.event.WebApp.AUDIO.STOP, z.audio.AudioType.INCOMING_CALL
+ else
+ amplify.publish z.event.WebApp.AUDIO.STOP, z.audio.AudioType.OUTGOING_CALL
+
+
+ ###############################################################################
+ # State handling
+ ###############################################################################
+
+ # Check whether this call is a video call
+ update_remote_state: (participants) ->
+ media_type_updated = false
+ for participant_id, state of participants when participant_id isnt @self_user.id
+ participant_et = @get_participant_by_id participant_id
+ if participant_et
+ participant_et.state.muted state.muted if state.muted?
+ participant_et.state.screen_shared state.screen_shared if state.screen_shared?
+ participant_et.state.videod state.videod if state.videod?
+
+ if state.screen_shared
+ @remote_media_type z.calling.enum.MediaType.SCREEN
+ media_type_updated = true
+ else if state.videod
+ @remote_media_type z.calling.enum.MediaType.VIDEO
+ media_type_updated = true
+
+ @remote_media_type z.calling.enum.MediaType.AUDIO if not media_type_updated
+
+ # Ignore a call.
+ ignore: =>
+ if @is_group()
+ @is_declined true # TODO would be nice if group call could have also an ignored state
+ else
+ @state z.calling.enum.CallState.IGNORED
+
+
+ ###############################################################################
+ # Participants
+ ###############################################################################
+
+ ###
+ Add a participant to the call.
+ @param participant_et [z.calling.Participant] Participant entity to be added to the call
+ @return [Boolean] Has the participant been added
+ ###
+ add_participant: (participant_et) =>
+ if not @get_participant_by_id participant_et.user.id
+ @participants.push participant_et
+ @logger.log @logger.levels.DEBUG, "Participants updated: '#{participant_et.user.name()}' added'"
+ return true
+ return false
+
+ ###
+ Remove a participant from the call.
+
+ @param participant_et [z.calling.Participant] Participant entity to be removed from the call
+ @param delete_on_backend [Boolean] Should the flow with the participant be removed on the backend
+ @return [Boolean] Has the participant been removed
+ ###
+ delete_participant: (participant_et, delete_on_backend = true) =>
+ return false if not @get_participant_by_id participant_et.user.id
+
+ # Delete participant
+ @participants.remove participant_et
+ # Delete flows from participant (if any left)
+ flow_et = participant_et.get_flow()
+ if flow_et
+ @_delete_flow_by_id flow_et, delete_on_backend
+
+ @logger.log @logger.levels.DEBUG, "Participants updated: '#{participant_et.user.name()}' removed"
+ if @get_number_of_participants() is 0
+ amplify.publish z.event.WebApp.CALL.STATE.DELETE, @id
+ return true
+
+ ###
+ Get the number of participants in the call.
+ @return [Number] Number of participants in call excluding the self user
+ ###
+ get_number_of_participants: =>
+ return @participants().length
+
+ ###
+ Get a call participant by his id.
+ @param user_id [String] User ID of participant to be returned
+ @return [z.calling.Participant] Participant that matches given user ID
+ ###
+ get_participant_by_id: (user_id) =>
+ return participant for participant in @participants() when participant.user.id is user_id
+
+ ###
+ Set a user as the creator of the call.
+ @param user_et [z.entity.User] User entity to be set as call creator
+ ###
+ set_creator: (user_et) =>
+ if not @creator()
+ @logger.log @logger.levels.INFO, "Call created by: #{user_et.name()}"
+ @creator user_et
+
+ ###
+ Update the remote participants of a call.
+
+ @param participants_ets [Array] Array joined call participants
+ @param sequential_event [Boolean] Should the update be limited to one change only
+
+ @note Some backend 'call-state' events contain false information
+ If a call event is sequential to the previous one (meaning the sequence number is increased by one) and the
+ 'event.cause' of the call state event is 'requested' (as it was triggered by another client PUTting its state)
+ then the delta in participants can only be one. If we have added a user, we cannot add or remove another one.
+ ###
+ update_participants: (participant_ets = [], sequential_event = false) =>
+ sequential_event = false if @state() in z.calling.enum.CallStateGroups.IS_RINGING
+ if sequential_event
+ @logger.log @logger.levels.INFO, 'Sequential event by request: Only one participant change will be applied'
+
+ # Add active participants
+ for participant_et in participant_ets when @add_participant participant_et
+ participant_joined = true
+ break if sequential_event
+
+ # Find inactive participants
+ if participant_ets.length isnt @get_number_of_participants() and (not sequential_event or not participant_joined)
+ active_participant_ids = (participant_et.user.id for participant_et in participant_ets)
+ delete_participants_ets = (
+ participant_et for participant_et in @participants() when participant_et.user.id not in active_participant_ids
+ )
+
+ # Remove inactive participants
+ if delete_participants_ets?.length > 0
+ for participant_et in delete_participants_ets when @delete_participant participant_et
+ participant_left = true
+ break if sequential_event
+ if participant_left and @self_client_joined()
+ amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.CALL_DROP
+
+ @_sort_participants_by_panning()
+
+
+ ###############################################################################
+ # Flows
+ ###############################################################################
+
+ ###
+ Construct and add a flow to a call participant.
+
+ @param flow_id [String] ID of flow to be constructed
+ @param user_et [z.entity.User] User that the flow is with
+ @param audio_context [AudioContext] Audio context for the flow audio
+ @param call_timings [z.telemetry.calling.CallSetupTimings] Optional object to track duration of call setup
+ ###
+ construct_flow: (flow_id, user_et, audio_context, call_timings) =>
+ participant_et = @get_participant_by_id user_et.id
+
+ create_flow = (flow_id, participant_et) =>
+ if call_timings
+ flow_timings = $.extend new z.telemetry.calling.CallSetupTimings(@id), call_timings.get()
+ flow_timings.time_step z.telemetry.calling.CallSetupSteps.FLOW_RECEIVED
+ flow_timings.flow_id = flow_id
+ flow_et = new z.calling.entities.Flow flow_id, @, participant_et, audio_context, flow_timings
+ participant_et.add_flow flow_et
+ return flow_et
+
+ if participant_et
+ # We have to update the user info
+ if @get_flow_by_id flow_id
+ @logger.log @logger.levels.WARN, "Not adding flow '#{flow_id}' as it already exists"
+ else
+ @logger.log @logger.levels.DEBUG, "Adding flow '#{flow_id}' to participant '#{participant_et.user.name()}'"
+ create_flow flow_id, participant_et
+ else
+ participant_et = new z.calling.entities.Participant user_et
+ @add_participant participant_et
+ @logger.log @logger.levels.DEBUG, "Adding flow '#{flow_id}' to new participant '#{participant_et.user.name()}'"
+ create_flow flow_id, participant_et
+
+ ###
+ Get the flow that matches the given ID.
+ @param flow_id [String] ID of flow to be returned
+ @return [z.calling.Flow] Matching flow entity
+ ###
+ get_flow_by_id: (flow_id) =>
+ return flow_et for flow_et in @get_flows() when flow_et.id is flow_id
+
+ ###
+ Get all flows of the call.
+ @return [Array] Array of flows
+ ###
+ get_flows: =>
+ return (participant_et.get_flow() for participant_et in @participants() when participant_et.get_flow())
+
+ ###
+ Get the flow to a specific user.
+ @param user_id [String] User ID that the flow connects to
+ @return [z.calling.Flow] Flow to given user
+ ###
+ get_flows_by_user_id: (user_id) =>
+ return @get_participant_by_id(user_id).flows()
+
+ ###
+ Get full flow telemetry report of the call.
+ @return [Array] Array of flows
+ ###
+ get_flow_telemetry: =>
+ return (participant.get_flow()?.get_telemetry() for participant in @participants() when participant.get_flow())
+
+ ###
+ Get the number of flows of the call.
+ @return [Number] Number of flows
+ ###
+ get_number_of_flows: =>
+ return @get_flows().length
+
+ ###
+ Get the number of active flows of the call.
+ @return [Number] Number of active flows
+ ###
+ get_number_of_active_flows: =>
+ return (flow_et for flow_et in @get_flows() when flow_et.is_active).length or 0
+
+ ###
+ Delete a flow with a given ID.
+
+ @private
+ @param flow_et [z.calling.Flow] Flow entity to be deleted
+ @param delete_on_backend [Boolean] Should the flow with the participant be removed on the backend
+ ###
+ _delete_flow_by_id: (flow_et, delete_on_backend = true) =>
+ return if not flow_et
+ flow_et.reset_flow()
+
+ return if not delete_on_backend
+ # Delete flow on backend
+ flow_deletion_info = new z.calling.payloads.FlowDeletionInfo @id, flow_et.id
+ amplify.publish z.event.WebApp.CALL.SIGNALING.DELETE_FLOW, flow_deletion_info
+
+ ###############################################################################
+ # Panning
+ ###############################################################################
+
+ ###
+ Calculates the panning (from left to right) to position a user in a group call.
+
+ @note The deal is to calculate Jenkins' one-at-a-time hash (JOAAT) for each participant and then
+ sort all participants in an array by their JOAAT hash. After that the array index of each user
+ is used to allocate the position with the return value of this function.
+
+ @param index [Number] Index of a user in a sorted array
+ @param total [Number] Number of users
+ @return [Number] Panning in the range of -1 to 1 with -1 on the left
+ ###
+ _calculate_panning: (index, total) ->
+ return 0.0 if total is 1
+
+ pos = -(total - 1.0) / (total + 1.0)
+ delta = (-2.0 * pos) / (total - 1.0)
+
+ return pos + delta * index
+
+ # Sort the call participants by their audio panning.
+ _sort_participants_by_panning: ->
+ return if @participants().length < 2
+
+ # Sort by JOOAT Hash and calculate panning
+ @participants.sort (participant_a, participant_b) ->
+ return participant_a.user.joaat_hash - participant_b.user.joaat_hash
+
+ for participant_et, i in @participants()
+ panning = @_calculate_panning i, @participants().length
+ @logger.log @logger.levels.INFO,
+ "Panning for '#{participant_et.user.name()}' recalculated to '#{panning}'"
+ participant_et.panning panning
+
+ panning_info = (participant_et.user.name() for participant_et in @participants()).join ', '
+ @logger.log @logger.levels.INFO, "New panning order: #{panning_info}"
+
+
+ ###############################################################################
+ # Reset
+ ###############################################################################
+
+ ###
+ Reset the call states.
+ @private
+ ###
+ reset_call: =>
+ @self_client_joined false
+ @event_sequence = 0
+ @finished_reason = z.calling.enum.CallFinishedReason.UNKNOWN
+ @is_connected false
+ @session_id undefined
+ @self_user_joined false
+ @is_declined false
+
+ ###
+ Reset the call timers.
+ @private
+ ###
+ _reset_timer: ->
+ window.clearInterval @call_timer_interval if @call_timer_interval
+ @timer_start = undefined
+ @duration_time 0
+
+ ###
+ Reset all flows of the call.
+ @private
+ ###
+ _reset_flows: ->
+ @_delete_flow_by_id flow_et for flow_et in @get_flows()
+
+
+ ###############################################################################
+ # Logging
+ ###############################################################################
+
+ # Log flow status to console.
+ log_status: =>
+ flow_et.log_status() for flow_et in @get_flows()
+
+ # Log flow setup step timings to console.
+ log_timings: =>
+ flow_et.log_timings() for flow_et in @get_flows()
diff --git a/app/script/calling/entities/Flow.coffee b/app/script/calling/entities/Flow.coffee
new file mode 100644
index 00000000000..f68dd9cfee0
--- /dev/null
+++ b/app/script/calling/entities/Flow.coffee
@@ -0,0 +1,946 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.entities ?= {}
+
+# Static array of where to put people in the stereo scape.
+AUDIO_BITRATE = '30'
+AUDIO_PTIME = '60'
+
+# Flow entity.
+class z.calling.entities.Flow
+ ###
+ Construct a new flow entity.
+
+ @param id [String] ID of the flow
+ @param call_et [z.calling.Call] Call entity that the flow belongs to
+ @param participant_et [z.calling.Participant] Participant entity that the flow belongs to
+ @param audio_context [AudioContext] AudioContext to be used with the flow
+ @param timings [z.telemetry.calling.CallSetupTimings] Timing statistics of call setup steps
+ ###
+ constructor: (@id, @call_et, @participant_et, @audio_context, timings) ->
+ @logger = new z.util.Logger "z.calling.Flow (#{@id})", z.config.LOGGER.OPTIONS
+
+ @conversation_id = @call_et.id
+
+ # States
+ @converted_own_sdp_state = ko.observable false
+ @is_active = ko.observable false
+ @is_answer = ko.observable undefined
+ @is_group = @call_et.is_group
+
+ # Audio
+ @audio = new z.calling.entities.FlowAudio @, @audio_context
+
+ # ICE candidates
+ @ice_candidates_cache = []
+
+ # Users
+ @creator_user_id = ko.observable undefined
+ @remote_user = @participant_et.user
+ @remote_user_id = @remote_user.id
+ @self_user_id = @call_et.self_user.id
+
+ # Telemetry
+ @telemetry = new z.telemetry.calling.FlowTelemetry @id, @remote_user_id, @call_et, timings
+
+ @is_answer.subscribe (is_answer) => @telemetry.update_is_answer is_answer
+
+ @creator_user_id.subscribe (user_id) =>
+ if user_id is @self_user_id
+ @logger.log @logger.levels.INFO, "Creator: We are the official '#{z.calling.rtc.SDPType.OFFER}'"
+ @is_answer false
+ else
+ @logger.log @logger.levels.INFO, "Creator: We are the official '#{z.calling.rtc.SDPType.ANSWER}'"
+ @is_answer true
+
+
+ ###############################################################################
+ # PeerConnection
+ ###############################################################################
+
+ @peer_connection = undefined
+ @payload = ko.observable undefined
+ @pc_initialized = ko.observable false
+ @pc_initialized.subscribe (is_initialized) =>
+ @telemetry.set_peer_connection @peer_connection
+ @telemetry.schedule_check 5000 if is_initialized
+
+ @audio_stream = @call_et.local_audio_stream
+ @video_stream = @call_et.local_video_stream
+
+ @has_media_stream = ko.pureComputed => return @video_stream()? or @audio_stream()?
+
+ @connection_state = ko.observable z.calling.rtc.ICEConnectionState.NEW
+ @gathering_state = ko.observable z.calling.rtc.ICEGatheringState.NEW
+ @signaling_state = ko.observable z.calling.rtc.SignalingState.NEW
+
+ @connection_state.subscribe (ice_connection_state) =>
+ switch ice_connection_state
+ when z.calling.rtc.ICEConnectionState.CHECKING
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.ICE_CONNECTION_CHECKING
+
+ when z.calling.rtc.ICEConnectionState.COMPLETED, z.calling.rtc.ICEConnectionState.CONNECTED
+ @telemetry.start_statistics ice_connection_state
+ @call_et.is_connected true
+ @participant_et.is_connected true
+ @call_et.interrupted_participants.remove @participant_et
+ @call_et.state z.calling.enum.CallState.ONGOING
+
+ when z.calling.rtc.ICEConnectionState.DISCONNECTED
+ @participant_et.is_connected false
+ @call_et.interrupted_participants.push @participant_et
+ @is_answer false
+ @negotiation_mode z.calling.enum.SDPNegotiationMode.ICE_RESTART
+
+ when z.calling.rtc.ICEConnectionState.FAILED
+ @participant_et.is_connected false
+ if @is_group()
+ @call_et.interrupted_participants.remove @participant_et
+ @call_et.delete_participant @participant_et if @call_et.self_client_joined()
+ else
+ amplify.publish z.event.WebApp.CALL.STATE.LEAVE, @call_et.id
+
+ when z.calling.rtc.ICEConnectionState.CLOSED
+ @participant_et.is_connected false
+ @call_et.delete_participant @participant_et if @call_et.self_client_joined()
+
+ @signaling_state.subscribe (signaling_state) =>
+ if signaling_state is z.calling.rtc.SignalingState.CLOSED and not @converted_own_sdp_state()
+ @logger.log @logger.levels.DEBUG, "PeerConnection with '#{@remote_user.name()}' was closed"
+ @call_et.delete_participant @participant_et
+ @_remove_media_streams()
+ if not @is_group()
+ @call_et.finished_reason = z.calling.enum.CallFinishedReason.CONNECTION_DROPPED
+
+ @negotiation_mode = ko.observable z.calling.enum.SDPNegotiationMode.DEFAULT
+ @negotiation_needed = ko.observable false
+
+
+ ###############################################################################
+ # Local SDP
+ ###############################################################################
+
+ @local_sdp_type = ko.observable undefined
+ @local_sdp = ko.observable undefined
+ @local_sdp.subscribe (sdp) =>
+ if sdp
+ @local_sdp_type sdp.type
+ if @has_sent_local_sdp()
+ @has_sent_local_sdp false
+ @should_add_local_sdp true
+
+ @has_sent_local_sdp = ko.observable false
+ @should_add_local_sdp = ko.observable true
+
+ @send_sdp_timeout = undefined
+
+ @can_set_local_sdp = ko.pureComputed =>
+ in_connection_progress = @connection_state() is z.calling.rtc.ICEConnectionState.CHECKING
+ progress_gathering_states = [z.calling.rtc.ICEGatheringState.GATHERING, z.calling.rtc.ICEGatheringState.COMPLETE]
+ in_progress = in_connection_progress and @gathering_state() in progress_gathering_states
+
+ in_offer_state = @local_sdp_type() is z.calling.rtc.SDPType.OFFER
+ in_wrong_state = in_offer_state and @signaling_state() is z.calling.rtc.SignalingState.REMOTE_OFFER
+ is_blocked = @signaling_state() is z.calling.rtc.SignalingState.CLOSED
+
+ return @local_sdp() and @should_add_local_sdp() and not is_blocked and not in_progress and not in_wrong_state
+
+ @can_set_local_sdp.subscribe (can_set) =>
+ if can_set
+ @logger.log @logger.levels.DEBUG, "State changed - can_set_local_sdp: #{can_set}"
+ @_set_local_sdp()
+
+
+ ###############################################################################
+ # Remote SDP
+ ###############################################################################
+
+ @remote_sdp_type = ko.observable undefined
+ @remote_sdp = ko.observable undefined
+ @remote_sdp.subscribe (sdp) =>
+ if sdp
+ @remote_sdp_type sdp.type
+ @should_add_remote_sdp true
+
+ @should_add_remote_sdp = ko.observable false
+
+ @can_set_remote_sdp = ko.pureComputed =>
+ is_remote_offer = @remote_sdp_type() is z.calling.rtc.SDPType.OFFER
+ have_local_offer = @signaling_state() is z.calling.rtc.SignalingState.LOCAL_OFFER
+ in_wrong_state = is_remote_offer and have_local_offer
+
+ return @pc_initialized() and @should_add_remote_sdp() and not in_wrong_state
+
+ @can_set_remote_sdp.subscribe (can_set) =>
+ if can_set
+ @logger.log @logger.levels.DEBUG, "State changed - can_set_remote_sdp: '#{can_set}'"
+ @_set_remote_sdp()
+ .then =>
+ if @has_sent_local_sdp() and @remote_sdp().type is z.calling.rtc.SDPType.OFFER
+ @is_answer true
+
+
+ ###############################################################################
+ # Gates
+ ###############################################################################
+
+ @can_create_sdp = ko.pureComputed =>
+ in_state_for_creation = @negotiation_needed() and @signaling_state() isnt z.calling.rtc.SignalingState.CLOSED
+ can_create = @pc_initialized() and in_state_for_creation
+ @logger.log @logger.levels.OFF, "State recalculated - can_create_answer: #{can_create}"
+ return can_create
+
+ @can_create_sdp.subscribe (can_create) =>
+ if can_create
+ @logger.log @logger.levels.DEBUG, "State changed - can_create_sdp: #{can_create}"
+
+ @can_create_answer = ko.pureComputed =>
+ answer_state = @is_answer() and @signaling_state() is z.calling.rtc.SignalingState.REMOTE_OFFER
+ can_create = @can_create_sdp() and answer_state
+ @logger.log @logger.levels.OFF, "State recalculated - can_create_answer: #{can_create}"
+ return can_create
+
+ @can_create_answer.subscribe (can_create) =>
+ if can_create
+ @logger.log @logger.levels.DEBUG, "State changed - can_create_answer: #{can_create}"
+ @negotiation_needed false
+ @_create_answer()
+
+ @can_create_offer = ko.pureComputed =>
+ offer_state = not @is_answer() and @signaling_state() is z.calling.rtc.SignalingState.STABLE
+ can_create = @can_create_sdp() and offer_state
+ @logger.log @logger.levels.OFF, "State recalculated - can_create_offer: #{can_create}"
+ return can_create
+
+ @can_create_offer.subscribe (can_create) =>
+ if can_create
+ @logger.log @logger.levels.DEBUG, "State changed - can_create_offer: #{can_create}"
+ @negotiation_needed false
+ @_create_offer()
+
+ @can_initialize_peer_connection = ko.pureComputed =>
+ can_initialize = @has_media_stream() and @payload() and not @pc_initialized()
+ @logger.log @logger.levels.OFF, "State recalculated - can_initialize_peer_connection: #{can_initialize}"
+ return can_initialize
+
+ @can_initialize_peer_connection.subscribe (can_initialize) =>
+ if can_initialize
+ @logger.log @logger.levels.DEBUG, "State changed - can_initialize_peer_connection: #{can_initialize}"
+ @_initialize_peer_connection()
+
+ @can_set_ice_candidates = ko.pureComputed =>
+ can_set = @local_sdp() and @remote_sdp() and @signaling_state() is z.calling.rtc.SignalingState.STABLE
+ @logger.log @logger.levels.OFF, "State recalculated - can_set_ice_candidates: #{can_set}"
+ return can_set
+
+ @can_set_ice_candidates.subscribe (can_set) =>
+ if can_set
+ @logger.log @logger.levels.DEBUG, "State changed - can_set_ice_candidates: #{can_set}"
+ @_add_cached_ice_candidates()
+
+ @logger.log @logger.levels.INFO, "Flow has an initial panning of '#{@participant_et.panning()}'"
+
+
+ ###############################################################################
+ # Payload handling
+ ###############################################################################
+
+ ###
+ Add the payload to the flow.
+ @note Magic here is that if the remote_user is not the creator then the creator *MUST* be us even if creator is null
+ @param payload [RTCConfiguration] Configuration to be used to set up the PeerConnection
+ ###
+ add_payload: (payload) =>
+ @logger.log @logger.levels.INFO, "Setting payload to be used for flow with '#{@remote_user.name()}'"
+ return @logger.log @logger.levels.WARN, 'Payload already set' if @payload()
+
+ @creator_user_id payload.creator
+ @payload @_rewrite_payload payload
+ if payload.remote_user isnt payload.creator
+ @logger.log @logger.levels.INFO, "We are the creator of flow with user '#{@remote_user.name()}'"
+ @is_answer false
+ else
+ @logger.log @logger.levels.INFO, "We are not the creator of flow with user '#{@remote_user.name()}'"
+ @is_answer true
+
+ @is_active payload.active
+ @audio.hookup payload.active
+
+ ###
+ Rewrite the payload to be standards compliant.
+
+ @private
+ @param payload [RTCConfiguration] Payload to be rewritten
+ @return [RTCConfiguration] Rewritten payload
+ ###
+ _rewrite_payload: (payload) ->
+ for ice_server in payload.ice_servers when not ice_server.urls
+ ice_server.urls = [ice_server.url]
+ return payload
+
+
+ ###############################################################################
+ # PeerConnection handling
+ ###############################################################################
+
+ ###
+ Close the PeerConnection.
+ @private
+ ###
+ _close_peer_connection: ->
+ @logger.log @logger.levels.INFO, "Closing PeerConnection with '#{@remote_user.name()}'"
+ if @peer_connection?
+ @peer_connection.onsignalingstatechange = =>
+ @logger.log @logger.levels.DEBUG, "State change ignored - signaling state: #{@peer_connection.signalingState}"
+ @peer_connection.close()
+ @logger.log @logger.levels.DEBUG, 'Closing PeerConnection successful'
+
+ ###
+ Create the PeerConnection configuration.
+ @private
+ @return [RTCConfiguration] Configuration object to initialize PeerConnection
+ ###
+ _configure_peer_connection: ->
+ return {} =
+ iceServers: @payload().ice_servers
+ bundlePolicy: 'max-bundle'
+ rtcpMuxPolicy: 'require'
+
+ ###
+ Initialize the PeerConnection for the flow.
+ @see https://developer.mozilla.org/en-US/docs/Web/API/RTCConfiguration
+ @private
+ ###
+ _create_peer_connection: ->
+ @peer_connection = new window.RTCPeerConnection @_configure_peer_connection()
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.PEER_CONNECTION_CREATED
+ @signaling_state @peer_connection.signalingState
+ @logger.log @logger.levels.DEBUG, "PeerConnection with '#{@remote_user.name()}' created", @payload().ice_servers
+
+ ###
+ A MediaStream was added to the PeerConnection.
+ @param event [MediaStreamEvent] Event that contains the newly added MediaStream
+ ###
+ @peer_connection.onaddstream = (event) =>
+ @logger.log @logger.levels.DEBUG, 'Remote MediaStream added to PeerConnection',
+ {stream: event.stream, audio_tracks: event.stream.getAudioTracks(), video_tracks: event.stream.getVideoTracks()}
+ media_stream = z.calling.handler.MediaStreamHandler.detect_media_stream_type event.stream
+ if media_stream.type is z.calling.enum.MediaType.AUDIO
+ media_stream = @audio.wrap_speaker_stream event.stream
+ media_stream_info = new z.calling.payloads.MediaStreamInfo z.calling.enum.MediaStreamSource.REMOTE, @id, media_stream, @call_et
+ amplify.publish z.event.WebApp.CALL.MEDIA.ADD_STREAM, media_stream_info
+
+ ###
+ A MediaStreamTrack was added to the PeerConnection.
+ @param event [MediaStreamTrackEvent] Event that contains the newly added MediaStreamTrack
+ ###
+ @peer_connection.onaddtrack = (event) =>
+ @logger.log @logger.levels.DEBUG, 'Remote MediaStreamTrack added to PeerConnection', event
+
+ ###
+ A MediaStream was removed from the PeerConnection.
+ @param event [MediaStreamEvent] Event that a MediaStream has been removed
+ ###
+ @peer_connection.onremovestream = (event) =>
+ @logger.log @logger.levels.DEBUG, 'Remote MediaStream removed from PeerConnection', event
+
+ ###
+ A MediaStreamTrack was removed from the PeerConnection.
+ @param event [MediaStreamTrackEvent] Event that a MediaStreamTrack has been removed
+ ###
+ @peer_connection.onremovetrack = (event) =>
+ @logger.log @logger.levels.DEBUG, 'Remote MediaStreamTrack removed from PeerConnection', event
+
+ ###
+ A local ICE candidates is available.
+ @param event [RTCPeerConnectionIceEvent] Event that contains the generated ICE candidate
+ ###
+ @peer_connection.onicecandidate = (event) =>
+ @logger.log @logger.levels.INFO, 'New ICE candidate generated', event
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.ICE_GATHERING_STARTED
+ if @has_sent_local_sdp()
+ if event.candidate
+ @_send_ice_candidate event.candidate
+ else
+ @logger.log @logger.levels.INFO, 'End of ICE candidates - trickling end candidate'
+ @_send_ice_candidate @_fake_ice_candidate 'a=end-of-candidates'
+ else if not event.candidate
+ @logger.log @logger.levels.INFO, 'End of ICE candidates - sending SDP'
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.ICE_GATHERING_COMPLETED
+ @_send_local_sdp()
+
+ # ICE connection state has changed.
+ @peer_connection.oniceconnectionstatechange = (event) =>
+ @logger.log @logger.levels.DEBUG, 'State changed - ICE connection', event
+ return if not @peer_connection or @call_et.state() is z.calling.enum.CallState.DELETED
+
+ @logger.log @logger.levels.LEVEL_1, "ICE connection state: #{@peer_connection.iceConnectionState}"
+ @logger.log @logger.levels.LEVEL_1, "ICE gathering state: #{@peer_connection.iceGatheringState}"
+
+ @gathering_state @peer_connection.iceGatheringState
+ @connection_state @peer_connection.iceConnectionState
+
+ # SDP negotiation needed.
+ @peer_connection.onnegotiationneeded = (event) =>
+ if not @negotiation_needed()
+ @logger.log @logger.levels.DEBUG, 'State changed - negotiation needed: true', event
+ @negotiation_needed true
+
+ # Signaling state has changed.
+ @peer_connection.onsignalingstatechange = (event) =>
+ @logger.log @logger.levels.DEBUG, "State changed - signaling state: #{@peer_connection.signalingState}", event
+ @signaling_state @peer_connection.signalingState
+
+ # Initialize the PeerConnection.
+ _initialize_peer_connection: ->
+ @_create_peer_connection()
+ @_add_media_streams()
+ @pc_initialized true
+
+
+ ###############################################################################
+ # SDP handling
+ ###############################################################################
+
+ ###
+ Save the remote SDP received from backend within the flow.
+ @param remote_sdp [RTCSessionDescription] Remote Session Description Protocol
+ ###
+ save_remote_sdp: (remote_sdp) =>
+ @logger.log @logger.levels.DEBUG, "Saving remote SDP of type '#{remote_sdp.type}'"
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.REMOTE_SDP_RECEIVED
+ @remote_sdp @_rewrite_sdp remote_sdp, z.calling.enum.SDPSource.REMOTE
+
+ ###
+ Create a local SDP of type 'answer'.
+ @see https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createAnswer
+ @private
+ ###
+ _create_answer: ->
+ @logger.log @logger.levels.INFO, "Creating '#{z.calling.rtc.SDPType.ANSWER}' for flow with '#{@remote_user.name()}'"
+ @peer_connection.createAnswer()
+ .then (sdp_answer) =>
+ @logger.log @logger.levels.DEBUG, "Creating '#{z.calling.rtc.SDPType.ANSWER}' successful", sdp_answer
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.LOCAL_SDP_CREATED
+ @local_sdp @_rewrite_sdp sdp_answer, z.calling.enum.SDPSource.LOCAL
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Creating '#{z.calling.rtc.SDPType.ANSWER}' failed: #{error.name}", error
+ attributes = {cause: error.name, step: 'create_sdp', type: z.calling.rtc.SDPType.ANSWER}
+ @call_et.telemetry.track_event z.tracking.EventName.CALLING.FAILED_RTC, undefined, attributes
+
+ ###
+ Create a local SDP of type 'offer'.
+
+ @see https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer
+ @private
+ @param restart [Boolean] Is ICE restart negotiation
+ ###
+ _create_offer: (restart) ->
+ offer_options =
+ iceRestart: restart
+ offerToReceiveAudio: true
+ offerToReceiveVideo: true
+ voiceActivityDetection: true
+
+ @logger.log @logger.levels.INFO, "Creating '#{z.calling.rtc.SDPType.OFFER}' for flow with '#{@remote_user.name()}'"
+ @peer_connection.createOffer offer_options
+ .then (sdp_offer) =>
+ @logger.log @logger.levels.DEBUG, "Creating '#{z.calling.rtc.SDPType.OFFER}' successful", sdp_offer
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.LOCAL_SDP_CREATED
+ @local_sdp @_rewrite_sdp sdp_offer, z.calling.enum.SDPSource.LOCAL
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Creating '#{z.calling.rtc.SDPType.OFFER}' failed: #{error.name}", error
+ attributes = {cause: error.name, step: 'create_sdp', type: z.calling.rtc.SDPType.OFFER}
+ @call_et.telemetry.track_event z.tracking.EventName.CALLING.FAILED_RTC, undefined, attributes
+ @_solve_colliding_states()
+
+ ###
+ Rewrite the SDP for compatibility reasons.
+
+ @private
+ @param rtc_sdp [RTCSessionDescription] Session Description Protocol to be rewritten
+ @param sdp_source [z.calling.enum.SDPSource] Source of the SDP - local or remote
+ @return [RTCSessionDescription] Rewritten Session Description Protocol
+ ###
+ _rewrite_sdp: (rtc_sdp, sdp_source = z.calling.enum.SDPSource.REMOTE) ->
+ if sdp_source is z.calling.enum.SDPSource.LOCAL
+ rtc_sdp.sdp = rtc_sdp.sdp.replace 'UDP/TLS/', ''
+
+ sdp_lines = rtc_sdp.sdp.split '\r\n'
+ outlines = []
+
+ ice_candidates = []
+
+ for sdp_line in sdp_lines
+ outline = sdp_line
+
+ if sdp_line.startsWith 't='
+ if sdp_source is z.calling.enum.SDPSource.LOCAL and not z.util.Environment.frontend.is_localhost()
+ outlines.push sdp_line
+ browser_string = "#{z.util.Environment.browser.name} #{z.util.Environment.browser.version}"
+ if z.util.Environment.electron
+ outline = "a=tool:electron #{z.util.Environment.version()} #{z.util.Environment.version false} (#{browser_string})"
+ else
+ outline = "a=tool:webapp #{z.util.Environment.version false} (#{browser_string})"
+ @logger.log @logger.levels.INFO, "Added tool version to local SDP: #{outline}"
+
+ else if sdp_line.startsWith 'a=candidate'
+ ice_candidates.push sdp_line
+
+ else if sdp_line.startsWith 'a=group'
+ if @negotiation_mode() is z.calling.enum.SDPNegotiationMode.STREAM_CHANGE and sdp_source is z.calling.enum.SDPSource.LOCAL
+ outlines.push 'a=x-streamchange'
+ @logger.log @logger.levels.INFO, 'Added stream renegotiation flag to local SDP'
+
+ # Code to nail in bit-rate and ptime settings for improved performance and experience
+ else if sdp_line.startsWith 'm=audio'
+ if @negotiation_mode() is z.calling.enum.SDPNegotiationMode.ICE_RESTART or sdp_source is z.calling.enum.SDPSource.LOCAL and @is_group()
+ outlines.push sdp_line
+ outline = "b=AS:#{AUDIO_BITRATE}"
+ @logger.log @logger.levels.INFO, "Limited audio bit-rate in local SDP: #{outline}"
+
+ else if sdp_line.startsWith 'm=video'
+ if sdp_source is z.calling.enum.SDPSource.LOCAL and @_should_rewrite_codecs()
+ outline = sdp_line.replace(' 98', '').replace ' 116', ''
+ @logger.log @logger.levels.WARN, 'Removed video codecs to prevent video freeze due to issue in Chrome 50 and 51' if outline isnt sdp_line
+
+ else if sdp_line.startsWith 'a=fmtp'
+ if sdp_source is z.calling.enum.SDPSource.LOCAL and @_should_rewrite_codecs()
+ if sdp_line.endsWith '98 apt=116'
+ @logger.log @logger.levels.WARN, 'Removed FMTP line to prevent video freeze due to issue in Chrome 50 and 51'
+ outline = undefined
+
+ else if sdp_line.startsWith 'a=rtpmap'
+ if sdp_source is z.calling.enum.SDPSource.LOCAL and @_should_rewrite_codecs()
+ if sdp_line.endsWith('98 rtx/90000') or sdp_line.endsWith '116 red/90000'
+ @logger.log @logger.levels.WARN, 'Removed RTPMAP line to prevent video freeze due to issue in Chrome 50 and 51'
+ outline = undefined
+
+ if @negotiation_mode() is z.calling.enum.SDPNegotiationMode.ICE_RESTART or sdp_source is z.calling.enum.SDPSource.LOCAL and @is_group()
+ if z.util.contains sdp_line, 'opus'
+ outlines.push sdp_line
+ outline = "a=ptime:#{AUDIO_PTIME}"
+ @logger.log @logger.levels.INFO, "Changed audio p-time in local SDP: #{outline}"
+
+ if outline isnt undefined
+ outlines.push outline
+
+ @logger.log @logger.levels.INFO,
+ "'#{ice_candidates.length}' ICE candidate(s) found in '#{sdp_source}' SDP", ice_candidates
+
+ rewritten_sdp = outlines.join '\r\n'
+
+ if rtc_sdp.sdp isnt rewritten_sdp
+ rtc_sdp.sdp = rewritten_sdp
+ @logger.log @logger.levels.INFO, "Rewrote '#{sdp_source}' SDP of type '#{rtc_sdp.type}'", rtc_sdp
+
+ return rtc_sdp
+
+ ###
+ Initiates the sending of the local Session Description Protocol to the backend.
+ @private
+ ###
+ _send_local_sdp: ->
+ @local_sdp @_rewrite_sdp @peer_connection.localDescription, z.calling.enum.SDPSource.LOCAL
+ sdp_info = new z.calling.payloads.SDPInfo {conversation_id: @conversation_id, flow_id: @id, sdp: @local_sdp()}
+
+ on_success = =>
+ window.clearTimeout @send_sdp_timeout
+ @logger.log @logger.levels.INFO, "Sending local SDP of type '#{@local_sdp().type}' successful"
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.LOCAL_SDP_SEND
+ @has_sent_local_sdp true
+
+ on_failure = =>
+ @logger.log @logger.levels.WARN, "Failed to send local SDP of type '#{@local_sdp().type}'"
+
+ @logger.log @logger.levels.INFO, "Sending local SDP for flow with '#{@remote_user.name()}'\n#{@local_sdp().sdp}"
+ amplify.publish z.event.WebApp.CALL.SIGNALING.SEND_LOCAL_SDP_INFO, sdp_info, on_success, on_failure
+
+ ###
+ Sets the local Session Description Protocol on the PeerConnection.
+ @private
+ ###
+ _set_local_sdp: ->
+ @logger.log @logger.levels.INFO, "Setting local SDP of type '#{@local_sdp().type}'", @local_sdp()
+ @peer_connection.setLocalDescription @local_sdp()
+ .then =>
+ @logger.log @logger.levels.DEBUG,
+ "Setting local SDP of type '#{@local_sdp().type}' successful", @peer_connection.localDescription
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.LOCAL_SDP_SET
+ @should_add_local_sdp false
+ @send_sdp_timeout = window.setTimeout =>
+ @_send_local_sdp() if not @has_sent_local_sdp()
+ , 1000
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Setting local SDP of type '#{@local_sdp().type}' failed: #{error.name}", error
+ attributes = {cause: error.name, step: 'set_sdp', location: 'local', type: @local_sdp()?.type}
+ @call_et.telemetry.track_event z.tracking.EventName.CALLING.FAILED_RTC, undefined, attributes
+
+ ###
+ Sets the remote Session Description Protocol on the PeerConnection.
+ @private
+ ###
+ _set_remote_sdp: ->
+ @logger.log @logger.levels.INFO, "Setting remote SDP of type '#{@remote_sdp().type}'\n#{@remote_sdp().sdp}"
+ @peer_connection.setRemoteDescription @remote_sdp()
+ .then =>
+ @logger.log @logger.levels.DEBUG,
+ "Setting remote SDP of type '#{@remote_sdp().type}' successful", @peer_connection.remoteDescription
+ @telemetry.time_step z.telemetry.calling.CallSetupSteps.REMOTE_SDP_SET
+ @should_add_remote_sdp false
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Setting remote SDP of type '#{@remote_sdp().type}' failed: #{error.name}", error
+ attributes = {cause: error.name, step: 'set_sdp', location: 'remote', type: @remote_sdp()?.type}
+ @call_et.telemetry.track_event z.tracking.EventName.CALLING.FAILED_RTC, undefined, attributes
+
+ ###
+ Solve colliding SDP states.
+ @note If we receive a remote offer while we have a local offer, we need to check who needs to switch his SDP type.
+ @private
+ ###
+ _solve_colliding_states: ->
+ we_switched_state = false
+ if @self_user_id < @remote_user_id
+ @logger.log @logger.levels.WARN,
+ "We need to switch state of flow with '#{@remote_user.name()}'. Local SDP needs to be changed."
+ we_switched_state = true
+ @_switch_local_sdp_state()
+ else
+ @logger.log @logger.levels.WARN,
+ "Remote side needs to switch state of flow with '#{@remote_user.name()}'. Waiting for new remote SDP."
+
+ return we_switched_state
+
+
+ ###############################################################################
+ # SDP sate collision handling
+ ###############################################################################
+
+ ###
+ Switch the local SDP state.
+ @note Set converted flag first, because it influences the tear-down of the PeerConnection
+ @private
+ ###
+ _switch_local_sdp_state: ->
+ @logger.log @logger.levels.DEBUG, '_switch_local_sdp_state'
+
+ @logger.log @logger.levels.LEVEL_2,
+ "Switching from local #{@local_sdp_type()} to local '#{z.calling.rtc.SDPType.ANSWER}'"
+
+ @converted_own_sdp_state true
+ @is_answer true
+ remote_sdp_cache = @remote_sdp()
+ @_close_peer_connection()
+ @_reset_signaling_states()
+ @_initialize_peer_connection()
+ @remote_sdp remote_sdp_cache
+ @_set_remote_sdp()
+ @converted_own_sdp_state false
+
+
+ ###############################################################################
+ # ICE candidate handling
+ ###############################################################################
+
+ ###
+ Add or cache remote ICE candidate.
+ @param ice_candidate [RTCIceCandidate] Received remote ICE candidate
+ ###
+ add_remote_ice_candidate: (ice_candidate) =>
+ if z.util.contains ice_candidate.candidate, 'end-of-candidates'
+ @logger.log @logger.levels.INFO, 'Ignoring remote non-candidate'
+ return
+
+ if @can_set_ice_candidates()
+ @_add_ice_candidate ice_candidate
+ @ice_candidates_cache.push ice_candidate
+ else
+ @ice_candidates_cache.push ice_candidate
+ @logger.log @logger.levels.INFO, 'Cached ICE candidate for flow'
+
+ ###
+ Add all cached ICE candidates to the flow.
+ @private
+ ###
+ _add_cached_ice_candidates: ->
+ if @ice_candidates_cache.length
+ @logger.log @logger.levels.INFO, "Adding '#{@ice_candidates_cache.length}' cached ICE candidates"
+ @_add_ice_candidate ice_candidate for ice_candidate in @ice_candidates_cache
+ else
+ @logger.log @logger.levels.INFO, 'No cached ICE candidates found'
+
+ ###
+ Add a remote ICE candidate to the flow directly.
+ @private
+ @param ice_candidate [RTCICECandidate] Received remote ICE candidate
+ ###
+ _add_ice_candidate: (ice_candidate) ->
+ @logger.log @logger.levels.INFO, "Adding ICE candidate to flow with '#{@remote_user.name()}'", ice_candidate
+ @peer_connection.addIceCandidate ice_candidate
+ .then =>
+ @logger.log @logger.levels.DEBUG, 'Adding ICE candidate successful'
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "Adding ICE candidate failed: #{error.name}", error
+ attributes = {cause: error.name, step: 'add_candidate', type: z.calling.rtc.SDPType.OFFER}
+ @call_et.telemetry.track_event z.tracking.EventName.CALLING.FAILED_RTC, undefined, attributes
+
+ ###
+ Create a fake ICE candidate from a message.
+ @param candidate_message [String] Candidate message for the RTCICECandidate
+ @return [Object] Object containing data for RTCICECandidate
+ ###
+ _fake_ice_candidate: (candidate_message) ->
+ return {} =
+ candidate: candidate_message
+ sdpMLineIndex: 0
+ sdpMid: 'audio'
+
+ ###
+ Send an ICE candidate to the backend.
+ @private
+ @param ice_candidate [RTCICECandidate] Local ICE candidate to be send
+ ###
+ _send_ice_candidate: (ice_candidate) ->
+ if not z.util.contains ice_candidate.candidate, 'UDP'
+ return @logger.log @logger.levels.INFO, "Local ICE candidate ignored as it is not of type 'UDP'"
+
+ if @conversation_id and @id
+ ice_info = new z.calling.payloads.ICECandidateInfo @conversation_id, @id, ice_candidate
+ @logger.log @logger.levels.INFO, 'Sending ICE candidate', ice_info
+ amplify.publish z.event.WebApp.CALL.SIGNALING.SEND_ICE_CANDIDATE_INFO, ice_info
+
+ ###
+ Should a local SDP be rewritten to prevent frozen video.
+ @note All sections that rewrite the SDP for this can be removed once we require Chrome 52
+ @return [Boolean] Should SDP be rewritten
+ ###
+ _should_rewrite_codecs: ->
+ return z.util.Environment.browser.requires.calling_codec_rewrite
+
+
+ ###############################################################################
+ # Media stream handling
+ ###############################################################################
+
+ ###
+ Inject an audio file into the flow.
+ @param audio_file_path [String] Path to the audio file
+ @param callback [Function] Function to be called when completed
+ ###
+ inject_audio_file: (audio_file_path, callback) =>
+ @audio.inject_audio_file audio_file_path, callback
+
+ ###
+ Switch out the local MediaStream.
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] Object containing the required MediaStream information
+ @return [Promise] Promise that resolves when the updated MediaStream is used
+ ###
+ switch_media_stream: (media_stream_info) =>
+ if @peer_connection.getSenders?
+ @_replace_media_track media_stream_info
+ .then ->
+ return [media_stream_info, false]
+ else
+ @_replace_media_stream media_stream_info
+ .then (media_stream_info) =>
+ @is_answer false
+ return [media_stream_info, true]
+
+ ###
+ Adds a local MediaStream to the PeerConnection.
+ @private
+ @param media_stream [MediaStream] MediaStream to add to the PeerConnection
+ ###
+ _add_media_stream: (media_stream) =>
+ if media_stream.type is z.calling.enum.MediaType.AUDIO
+ @peer_connection.addStream @audio.wrap_microphone_stream media_stream
+ else
+ @peer_connection.addStream media_stream
+ @logger.log @logger.levels.INFO, "Added local MediaStream of type '#{media_stream.type}' to PeerConnection",
+ {stream: media_stream, audio_tracks: media_stream.getAudioTracks(), video_tracks: media_stream.getVideoTracks()}
+
+ ###
+ Adds the local MediaStreams to the PeerConnection.
+ @private
+ ###
+ _add_media_streams: ->
+ media_streams_identical = @_compare_local_media_streams()
+
+ @_add_media_stream @audio_stream() if @audio_stream()
+ @_add_media_stream @video_stream() if @video_stream() and not media_streams_identical
+
+ ###
+ Compare whether local audio and video streams are identical.
+ @private
+ ###
+ _compare_local_media_streams: ->
+ return @audio_stream() and @video_stream() and @audio_stream().id is @video_stream().id
+
+ ###
+ Replace the MediaStream attached to the PeerConnection.
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] Object containing the required MediaStream information
+ ###
+ _replace_media_stream: (media_stream_info) =>
+ Promise.resolve @_remove_media_streams media_stream_info.type
+ .then =>
+ @negotiation_mode z.calling.enum.SDPNegotiationMode.STREAM_CHANGE
+ return @_upgrade_media_stream media_stream_info
+ .then (media_stream_info) =>
+ @_add_media_stream media_stream_info.stream
+ @logger.log @logger.levels.INFO, 'Replaced the MediaStream successfully', media_stream_info.stream
+ return media_stream_info
+
+ ###
+ Replace the a MediaStreamTrack attached to the MediaStream of the PeerConnection.
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] Object containing the required MediaStream information
+ ###
+ _replace_media_track: (media_stream_info) =>
+ media_stream_track = media_stream_info.stream.getTracks()[0]
+ return Promise.all (sender.replaceTrack media_stream_track for sender in @peer_connection.getSenders() when sender.track.kind is media_stream_info.type)
+ .then =>
+ @logger.log @logger.levels.INFO, "Replaced the '#{media_stream_info.type}' track"
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to replace the '#{media_stream_info.type}' track: #{error.message}", error
+
+ ###
+ Reset the flows MediaStream and media elements.
+ @private
+ @param media_stream [MediaStream] Local MediaStream to remove from the PeerConnection
+ ###
+ _remove_media_stream: (media_stream) =>
+ if @peer_connection
+ try
+ if @peer_connection.signalingState isnt z.calling.rtc.SignalingState.CLOSED
+ @peer_connection.removeStream media_stream
+ @logger.log @logger.levels.INFO, "Removed local MediaStream of type '#{media_stream.type}' from PeerConnection",
+ {stream: media_stream, audio_tracks: media_stream.getAudioTracks(), video_tracks: media_stream.getVideoTracks()}
+ # @param [InvalidStateError] error
+ catch error
+ @logger.log @logger.levels.ERROR, "We caught the #{error.message}", error
+ Raygun.send new Error('Failed to remove MediaStream from PeerConnection'), error
+ else
+ @logger.log @logger.levels.INFO, 'No PeerConnection found to remove MediaStream from'
+
+ ###
+ Reset the flows MediaStream and media elements.
+ @private
+ @param media_type [z.calling.enum.MediaType] Optional media type of MediaStreams to be removed
+ ###
+ _remove_media_streams: (media_type = z.calling.enum.MediaType.AUDIO_VIDEO) =>
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO_VIDEO
+ media_streams_identical = @_compare_local_media_streams()
+
+ @_remove_media_stream @audio_stream() if @audio_stream()
+ @_remove_media_stream @video_stream() if @video_stream() and not media_streams_identical
+ when z.calling.enum.MediaType.AUDIO
+ @_remove_media_stream @audio_stream() if @audio_stream()
+ when z.calling.enum.MediaType.VIDEO
+ @_remove_media_stream @video_stream() if @video_stream()
+
+ ###
+ Upgrade a MediaStream with missing audio or video.
+ @private
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] Contains the info about the MediaStream to be updated
+ @return [z.calling.payloads.MediaStreamInfo]
+ ###
+ _upgrade_media_stream: (media_stream_info) ->
+ if media_stream_info.type is z.calling.enum.MediaType.AUDIO and @video_stream()
+ media_stream_tracks = z.calling.handler.MediaStreamHandler.get_media_tracks @video_stream(), z.calling.enum.MediaType.VIDEO
+
+ else if media_stream_info.type is z.calling.enum.MediaType.VIDEO and @audio_stream()
+ media_stream_tracks = z.calling.handler.MediaStreamHandler.get_media_tracks @audio_stream(), z.calling.enum.MediaType.AUDIO
+
+ if media_stream_tracks?.length
+ @audio_stream().removeTrack media_stream_tracks[0] if @audio_stream()
+ @video_stream().removeTrack media_stream_tracks[0] if @video_stream()
+ media_stream_info.stream.addTrack media_stream_tracks[0]
+ @logger.log @logger.levels.INFO, "Upgraded local MediaStream of type '#{media_stream_info.type}' with '#{media_stream_tracks[0].kind}'",
+ {stream: media_stream_info.stream, audio_tracks: media_stream_info.stream.getAudioTracks(), video_tracks: media_stream_info.stream.getVideoTracks()}
+ media_stream_info.update_stream_type()
+ else
+ @logger.log @logger.levels.INFO, 'No changes to the new local MediaStream',
+ {stream: media_stream_info.stream, audio_tracks: media_stream_info.stream.getAudioTracks(), video_tracks: media_stream_info.stream.getVideoTracks()}
+
+ return media_stream_info
+
+
+ ###############################################################################
+ # Reset
+ ###############################################################################
+
+ ###
+ Reset the flow.
+ @note Reset PC initialized first to prevent new local SDP
+ ###
+ reset_flow: =>
+ @logger.log @logger.levels.INFO, "Resetting flow '#{@id}'"
+ @telemetry.reset_statistics()
+ .then (statistics) =>
+ @logger.log @logger.levels.INFO, 'Flow network stats updated for the last time', statistics
+ amplify.publish z.event.WebApp.DEBUG.UPDATE_LAST_CALL_STATUS, @telemetry.create_report()
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "Failed to reset flow networks stats: #{error.message}"
+ try
+ if @peer_connection?.signalingState isnt z.calling.rtc.SignalingState.CLOSED
+ @_close_peer_connection()
+ catch error
+ @logger.log @logger.levels.ERROR, "We caught the #{error.name}", error
+ @_remove_media_streams()
+ @_reset_signaling_states()
+ @ice_candidates_cache = []
+ @payload undefined
+ @pc_initialized false
+ @logger.log @logger.levels.DEBUG, "Resetting flow '#{@id}' with user '#{@remote_user.name()}' successful"
+
+ ###
+ Reset the signaling states.
+ @private
+ ###
+ _reset_signaling_states: ->
+ @signaling_state z.calling.rtc.SignalingState.NEW
+ @connection_state z.calling.rtc.ICEConnectionState.NEW
+ @gathering_state z.calling.rtc.ICEGatheringState.NEW
+
+
+ ###############################################################################
+ # Logging
+ ###############################################################################
+
+ # Get full telemetry report.
+ get_telemetry: =>
+ @telemetry.get_report()
+
+ # Log flow status to console.
+ log_status: =>
+ @telemetry.log_status @participant_et
+
+ # Log flow setup step timings to console.
+ log_timings: =>
+ @telemetry.log_timings()
+
+ # Report flow status to Raygun.
+ report_status: =>
+ @telemetry.report_status()
+
+ # Report flow setup step timings to Raygun.
+ report_timings: =>
+ @telemetry.report_timings()
diff --git a/app/script/calling/entities/FlowAudio.coffee b/app/script/calling/entities/FlowAudio.coffee
new file mode 100644
index 00000000000..d16710d52e5
--- /dev/null
+++ b/app/script/calling/entities/FlowAudio.coffee
@@ -0,0 +1,120 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.entities ?= {}
+
+class z.calling.entities.FlowAudio
+ constructor: (@flow_et, @audio_context) ->
+ @logger = new z.util.Logger "z.calling.FlowAudio (#{@flow_et.id})", z.config.LOGGER.OPTIONS
+
+ # Panning
+ @panning = @flow_et.participant_et.panning
+ @panning.subscribe (new_value) =>
+ @logger.log @logger.levels.INFO, "Panning of #{@flow_et.remote_user.name()} changed to '#{new_value}'"
+ @set_pan new_value
+
+ @pan_node = undefined
+ @gain_node = undefined
+ @audio_source = undefined
+ @audio_remote = undefined
+
+ amplify.subscribe z.event.WebApp.CALL.MEDIA.MUTE_AUDIO, @set_gain_node
+
+ ###
+ @param is_active [Boolean] Whether the flow is active
+ ###
+ hookup: (is_active) =>
+ if is_active is true
+ @_hookup_audio()
+ else
+ @audio_source.disconnect() if @audio_source?
+
+ inject_audio_file: (audio_file_path, callback) =>
+ return if not @audio_context?
+
+ # Load audio file
+ request = new XMLHttpRequest()
+ request.open 'GET', audio_file_path, true
+ request.responseType = 'arraybuffer'
+ request.onload = =>
+ load = (buffer) =>
+ @logger.log @logger.levels.INFO, "Loaded audio from '#{audio_file_path}'"
+ # Play audio file
+ audio_buffer = buffer
+ file_source = @audio_context.createBufferSource()
+ file_source.buffer = audio_buffer
+ @audio_source.disconnect()
+ file_source.connect @audio_remote
+ file_source.onended = =>
+ @logger.log @logger.levels.INFO, 'Finished playing audio file'
+ file_source.disconnect @audio_remote
+ @_hookup_audio()
+
+ if callback?
+ @logger.log @logger.levels.INFO, 'Invoking callback after playing audio file'
+ callback()
+
+ @logger.log @logger.levels.INFO, 'Playing audio file'
+ file_source.start()
+ fail = =>
+ @logger.log @logger.levels.ERROR, "Failed to load audio from '#{audio_file_path}'"
+ @audio_context.decodeAudioData request.response, load, fail
+ request.send()
+
+ set_gain_node: (is_muted) =>
+ if @gain_node
+ if is_muted
+ @gain_node.gain.value = 0
+ else
+ @gain_node.gain.value = 1
+ @logger.log @logger.levels.INFO, "Outgoing audio on flow muted '#{is_muted}'"
+
+ set_pan: (panning_value) =>
+ if @pan_node
+ @pan_node.pan.value = panning_value
+
+ wrap_microphone_stream: (media_stream) =>
+ wrapped_stream = media_stream
+ if @audio_context
+ @audio_source = @audio_context.createMediaStreamSource media_stream
+ @gain_node = @audio_context.createGain()
+ @audio_remote = @audio_context.createMediaStreamDestination()
+ @_hookup_audio()
+ wrapped_stream = @audio_remote.stream
+ @logger.log @logger.levels.INFO, 'Wrapped audio stream from microphone', wrapped_stream
+ return wrapped_stream
+
+ wrap_speaker_stream: (media_stream) =>
+ wrapped_stream = media_stream
+ if z.util.Environment.browser.firefox
+ if @audio_context
+ remote_source = @audio_context.createMediaStreamSource media_stream
+ @pan_node = @audio_context.createStereoPanner()
+ @pan_node.pan.value = @panning()
+ speaker = @audio_context.createMediaStreamDestination()
+ remote_source.connect @pan_node
+ @pan_node.connect speaker
+ wrapped_stream = speaker.stream
+ @logger.log @logger.levels.INFO, "Wrapped audio stream to speaker to create stereo. Initial panning set to '#{@panning()}'.", wrapped_stream
+ return wrapped_stream
+
+ _hookup_audio: =>
+ @audio_source.connect @gain_node if @audio_source
+ @gain_node.connect @audio_remote if @gain_node
diff --git a/app/script/calling/entities/Participant.coffee b/app/script/calling/entities/Participant.coffee
new file mode 100644
index 00000000000..6c9be11f283
--- /dev/null
+++ b/app/script/calling/entities/Participant.coffee
@@ -0,0 +1,58 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.entities ?= {}
+
+# Participant entity.
+class z.calling.entities.Participant
+ ###
+ Construct a new participant.
+ @param user [z.entity.User] User entity to base the participant on
+ ###
+ constructor: (@user) ->
+ @flow = ko.observable()
+ @is_connected = ko.observable false
+ @panning = ko.observable 0.0
+ @was_connected = false
+
+ @state =
+ muted: ko.observable false
+ screen_shared: ko.observable false
+ videod: ko.observable false
+
+ @is_connected.subscribe (is_connected) ->
+ if is_connected and not @was_connected
+ amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.READY_TO_TALK
+ @was_connected = true
+
+ ###
+ Add a new flow to the participant.
+ @param flow_et [z.calling.Flow] Flow entity to be added to the flow
+ ###
+ add_flow: (flow_et) =>
+ @flow flow_et unless @flow()?.id is flow_et.id
+
+
+ ###
+ Get the flow of the participant.
+ @return [z.calling.Flow] Flow entity of participant
+ ###
+ get_flow: =>
+ return @flow()
diff --git a/app/script/calling/enum/CallFinishedReason.coffee b/app/script/calling/enum/CallFinishedReason.coffee
new file mode 100644
index 00000000000..ef384930da4
--- /dev/null
+++ b/app/script/calling/enum/CallFinishedReason.coffee
@@ -0,0 +1,29 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.CallFinishedReason =
+ CONNECTION_DROPPED: 'connection_dropped'
+ COMPLETED: 'completed'
+ MISSED: 'missed'
+ OTHER_USER: 'other_user'
+ SELF_USER: 'self_user'
+ UNKNOWN: 'unknown'
diff --git a/app/script/calling/enum/CallState.coffee b/app/script/calling/enum/CallState.coffee
new file mode 100644
index 00000000000..a03ea642feb
--- /dev/null
+++ b/app/script/calling/enum/CallState.coffee
@@ -0,0 +1,31 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.CallState =
+ CANCELED: 'canceled'
+ CONNECTING: 'connecting'
+ DELETED: 'deleted'
+ IGNORED: 'ignored'
+ INCOMING: 'incoming'
+ ONGOING: 'ongoing'
+ OUTGOING: 'outgoing'
+ UNKNOWN: 'unknown'
diff --git a/app/script/calling/enum/CallStateEventCause.coffee b/app/script/calling/enum/CallStateEventCause.coffee
new file mode 100644
index 00000000000..01c29327abd
--- /dev/null
+++ b/app/script/calling/enum/CallStateEventCause.coffee
@@ -0,0 +1,27 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.CallStateEventCause =
+ REQUESTED: 'requested' # Voluntarily by client through the API
+ DISCONNECTED: 'disconnected' # WebSocket disconnected
+ INTERRUPTED: 'interrupted' # Involuntarily by client through the API (e.g. GSM)
+ GONE: 'gone'
diff --git a/app/script/calling/enum/CallStateGroups.coffee b/app/script/calling/enum/CallStateGroups.coffee
new file mode 100644
index 00000000000..7451a85f963
--- /dev/null
+++ b/app/script/calling/enum/CallStateGroups.coffee
@@ -0,0 +1,43 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.CallStateGroups =
+ IS_ACTIVE: [
+ z.calling.enum.CallState.CONNECTING
+ z.calling.enum.CallState.INCOMING
+ z.calling.enum.CallState.ONGOING
+ z.calling.enum.CallState.OUTGOING
+ ]
+ IS_ENDED: [
+ z.calling.enum.CallState.DELETED
+ z.calling.enum.CallState.UNKNOWN
+ ]
+ IS_RINGING: [
+ z.calling.enum.CallState.INCOMING
+ z.calling.enum.CallState.OUTGOING
+ ]
+ CAN_CONNECT: [
+ z.calling.enum.CallState.IGNORED
+ z.calling.enum.CallState.INCOMING
+ z.calling.enum.CallState.ONGOING
+ z.calling.enum.CallState.OUTGOING
+ ]
diff --git a/app/script/calling/enum/MediaDeviceType.coffee b/app/script/calling/enum/MediaDeviceType.coffee
new file mode 100644
index 00000000000..b1855ee054e
--- /dev/null
+++ b/app/script/calling/enum/MediaDeviceType.coffee
@@ -0,0 +1,27 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.MediaDeviceType =
+ AUDIO_INPUT: 'audioinput'
+ AUDIO_OUTPUT: 'audiooutput'
+ SCREEN_INPUT: 'screeninput'
+ VIDEO_INPUT: 'videoinput'
diff --git a/app/script/calling/enum/MediaStreamSource.coffee b/app/script/calling/enum/MediaStreamSource.coffee
new file mode 100644
index 00000000000..62de1868d2f
--- /dev/null
+++ b/app/script/calling/enum/MediaStreamSource.coffee
@@ -0,0 +1,25 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.MediaStreamSource =
+ LOCAL: 'local'
+ REMOTE: 'remote'
diff --git a/app/script/calling/enum/MediaType.coffee b/app/script/calling/enum/MediaType.coffee
new file mode 100644
index 00000000000..7ca71d4606c
--- /dev/null
+++ b/app/script/calling/enum/MediaType.coffee
@@ -0,0 +1,28 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.MediaType =
+ AUDIO: 'audio'
+ AUDIO_VIDEO: 'audio/video'
+ NONE: 'none'
+ SCREEN: 'screen'
+ VIDEO: 'video'
diff --git a/app/script/calling/enum/ParticipantState.coffee b/app/script/calling/enum/ParticipantState.coffee
new file mode 100644
index 00000000000..0afce09bf7b
--- /dev/null
+++ b/app/script/calling/enum/ParticipantState.coffee
@@ -0,0 +1,25 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.ParticipantState =
+ IDLE: 'idle'
+ JOINED: 'joined'
diff --git a/app/script/calling/enum/SDPNegotiationMode.coffee b/app/script/calling/enum/SDPNegotiationMode.coffee
new file mode 100644
index 00000000000..cafd6b34a83
--- /dev/null
+++ b/app/script/calling/enum/SDPNegotiationMode.coffee
@@ -0,0 +1,26 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.SDPNegotiationMode =
+ DEFAULT: 'default'
+ ICE_RESTART: 'ice_restart'
+ STREAM_CHANGE: 'stream_change'
diff --git a/app/script/calling/enum/SDPSource.coffee b/app/script/calling/enum/SDPSource.coffee
new file mode 100644
index 00000000000..4fe3f2f8985
--- /dev/null
+++ b/app/script/calling/enum/SDPSource.coffee
@@ -0,0 +1,25 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.SDPSource =
+ LOCAL: 'local'
+ REMOTE: 'remote'
diff --git a/app/script/calling/enum/VideoOrientation.coffee b/app/script/calling/enum/VideoOrientation.coffee
new file mode 100644
index 00000000000..a19ddf27687
--- /dev/null
+++ b/app/script/calling/enum/VideoOrientation.coffee
@@ -0,0 +1,25 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.enum ?= {}
+
+z.calling.enum.VideoOrientation =
+ LANDSCAPE: 'landscape'
+ PORTRAIT: 'portrait'
diff --git a/app/script/calling/handler/CallSignalingHandler.coffee b/app/script/calling/handler/CallSignalingHandler.coffee
new file mode 100644
index 00000000000..1cc804ba7f0
--- /dev/null
+++ b/app/script/calling/handler/CallSignalingHandler.coffee
@@ -0,0 +1,333 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.handler ?= {}
+
+# Call signaling handler
+class z.calling.handler.CallSignalingHandler
+ ###
+ Construct a new call signaling handler.
+ @param call_center [z.calling.CallCenter] Call center with references to all other handlers
+ ###
+ constructor: (@call_center) ->
+ @logger = new z.util.Logger 'z.calling.handler.CallSignalingHandler', z.config.LOGGER.OPTIONS
+
+ # Caches
+ @candidate_cache = {}
+ @sdp_cache = {}
+
+ # Mapper
+ @ice_mapper = new z.calling.mapper.ICECandidateMapper()
+ @sdp_mapper = new z.calling.mapper.SDPMapper()
+
+ @subscribe_to_events()
+ return
+
+ # Subscribe to amplify topics.
+ subscribe_to_events: =>
+ amplify.subscribe z.event.WebApp.CALL.SIGNALING.DELETE_FLOW, @delete_flow
+ amplify.subscribe z.event.WebApp.CALL.SIGNALING.POST_FLOWS, @post_for_flows
+ amplify.subscribe z.event.WebApp.CALL.SIGNALING.SEND_ICE_CANDIDATE_INFO, @post_ice_candidate
+ amplify.subscribe z.event.WebApp.CALL.SIGNALING.SEND_LOCAL_SDP_INFO, @put_local_sdp
+
+ # Un-subscribe from amplify topics.
+ un_subscribe: ->
+ subscriptions = [
+ z.event.WebApp.CALL.SIGNALING.DELETE_FLOW
+ z.event.WebApp.CALL.SIGNALING.POST_FLOWS
+ z.event.WebApp.CALL.SIGNALING.SEND_ICE_CANDIDATE_INFO
+ z.event.WebApp.CALL.SIGNALING.SEND_LOCAL_SDP_INFO
+ ]
+ amplify.unsubscribeAll topic for topic in subscriptions
+
+
+ ###############################################################################
+ # Events
+ ###############################################################################
+
+ ###
+ Handling of 'call.flow-add' events.
+ @param event [Object] Event payload
+ ###
+ on_flow_add_event: (event) =>
+ @call_center.get_call_by_id event.conversation
+ .then (call_et) =>
+ @_add_flow call_et, flow for flow in event.flows
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "Ignored 'call.flow-add' in '#{event.conversation}' that has no call", error
+
+ ###
+ Handling of 'call.flow-delete' events.
+ @param event [Object] Event payload
+ ###
+ on_flow_delete_event: (event) =>
+ @call_center.get_call_by_id event.conversation
+ .then (call_et) =>
+ @_add_flow call_et, event.flow
+ .catch =>
+ @logger.log @logger.levels.WARN, "Ignored 'call.flow-delete' in '#{event.conversation}' that has no call", event
+
+ ###
+ Handling of 'call.remote-candidates-add' and 'call.remote-candidates-update' events.
+ @param event [Object] Event payload
+ ###
+ on_remote_ice_candidates: (event) =>
+ mapped_candidates = (@ice_mapper.map_ice_message_to_object candidate for candidate in event.candidates)
+
+ @call_center.get_call_by_id event.conversation
+ .then (call_et) =>
+ # And either add
+ if flow_et = call_et.get_flow_by_id event.flow
+ @logger.log @logger.levels.INFO, "Received '#{mapped_candidates.length}' ICE candidates for existing flow '#{event.flow}'", mapped_candidates
+ for ice_candidate in mapped_candidates
+ flow_et.add_remote_ice_candidate ice_candidate
+ else
+ throw new z.calling.CallError "Flow '#{event.flow}' not found", z.calling.CallError::TYPE.FLOW_NOT_FOUND
+ .catch =>
+ # Or cache them
+ @logger.log @logger.levels.INFO, "Cached '#{mapped_candidates.length}' ICE candidates for unknown flow '#{event.flow}'", mapped_candidates
+ for ice_candidate in mapped_candidates
+ @_cache_ice_candidate event.flow, ice_candidate
+
+ ###
+ Handling of 'call.remote-sdp' events.
+ @param event [Object] Event payload
+ ###
+ on_remote_sdp: (event) =>
+ remote_sdp = @sdp_mapper.map_sdp_event_to_object event
+
+ @call_center.get_call_by_id event.conversation
+ .then (call_et) =>
+ if flow_et = call_et.get_flow_by_id event.flow
+ @logger.log @logger.levels.INFO, "Received remote SDP for existing flow '#{event.flow}'", remote_sdp
+ flow_et.save_remote_sdp remote_sdp
+ else
+ throw new z.calling.CallError "Flow '#{event.flow}' not found", z.calling.CallError::TYPE.FLOW_NOT_FOUND
+ .catch =>
+ if event.state is z.calling.rtc.SDPType.OFFER
+ @_cache_remote_sdp event.flow, remote_sdp
+ @logger.log @logger.levels.INFO, "Cached remote SDP for unknown flow '#{event.flow}'", remote_sdp
+ else
+ @logger.log @logger.levels.WARN, "Ignored remote SDP non-offer before call for flow '#{event.flow}'", remote_sdp
+
+
+ ###############################################################################
+ # Flows
+ ###############################################################################
+
+ ###
+ Delete a flow on the backend.
+ @private
+ @param delete_flow_info [z.calling.payloads.FlowDeletionInfo] Contains Conversation ID, Flow ID and Reason for flow deletion
+ ###
+ delete_flow: (flow_info) =>
+ Promise.resolve @call_center.media_element_handler.remove_media_element flow_info.flow_id
+ .then =>
+ @logger.log @logger.levels.INFO, "DELETEing flow '#{flow_info.flow_id}'"
+ return @call_center.call_service.delete_flow flow_info, []
+ .then (response_array) =>
+ [response, jqXHR] = response_array
+ @call_center.telemetry.trace_request flow_info.conversation_id, jqXHR
+ @logger.log @logger.levels.DEBUG, "DELETEing flow '#{flow_info.flow_id}' successful"
+ if flow_info.reason is z.calling.payloads.FlowDeletionReason.RELEASED
+ @logger.log @logger.levels.DEBUG, 'Flow was released - We need implement posting for flows to renegotiate'
+ return response
+ .catch (error) =>
+ if error.label is z.service.BackendClientError::LABEL.IN_USE
+ @logger.log @logger.levels.WARN, "DELETEing flow '#{flow_info.flow_id}' would have to be forced"
+ flow_info.reason = z.calling.payloads.FlowDeletionReason.RELEASED
+ return @delete_flow flow_info
+ else
+ @logger.log @logger.levels.ERROR, "DELETEing flow '#{flow_info.flow_id}' failed: #{error.message}", error
+ attributes = {cause: error.label or error.name, method: 'delete', request: 'flows'}
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUEST, undefined, attributes
+
+ ###
+ Post for flows.
+ @param conversation_id [String] Conversation ID of call to be posted for flows
+ ###
+ post_for_flows: (conversation_id) =>
+ @logger.log @logger.levels.INFO, "POSTing for flows in conversation '#{conversation_id}'"
+ @call_center.call_service.post_flows conversation_id, []
+ .then (response_array) =>
+ [response, jqXHR] = response_array
+ @call_center.telemetry.trace_request conversation_id, jqXHR
+ return @call_center.get_call_by_id conversation_id
+ .then (call_et) =>
+ @logger.log @logger.levels.DEBUG, "POSTing for flows in '#{conversation_id}' successful", response
+ @_add_flow call_et, flow for flow in response.flows when flow.active is true
+ .catch (error) =>
+ if error.type is z.calling.CallError::TYPE.CALL_NOT_FOUND
+ @logger.log @logger.levels.WARN, "POSTing for flows in '#{conversation_id}' successful, call gone", error
+ else
+ @logger.log @logger.levels.ERROR,
+ "POSTing for flows in conversation '#{conversation_id}' failed: #{error.message}", error
+ attributes = {cause: error.label or error.name, method: 'post', request: 'flows'}
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUEST, undefined, attributes
+
+ ###
+ Create a flow in a call.
+
+ @private
+ @param call_et [z.calling.Call] Call entity
+ @param payload [Object] Payload for call to be created
+ ###
+ _add_flow: (call_et, payload) ->
+ @call_center.user_repository.get_user_by_id payload.remote_user, (user_et) =>
+ # Get or construct flow entity
+ flow_et = call_et.get_flow_by_id payload.id
+ if not flow_et
+ flow_et = call_et.construct_flow payload.id, user_et, @call_center.audio_repository.get_audio_context(), @call_center.timings()
+
+ # Add payload to flow entity
+ flow_et.add_payload payload
+
+ # Unpack cache entries
+ if @sdp_cache[flow_et.id] isnt undefined
+ flow_et.save_remote_sdp @sdp_cache[flow_et.id]
+ delete @sdp_cache[flow_et.id]
+ if @candidate_cache[flow_et.id] isnt undefined
+ for ice_candidate in @candidate_cache[flow_et.id]
+ flow_et.add_remote_ice_candidate ice_candidate
+ delete @candidate_cache[flow_et.id]
+
+ ###
+ Delete all flows from a call.
+ @private
+ @param conversation_id [String] Conversation ID to get and delete all flows for
+ ###
+ _delete_flows: (conversation_id) ->
+ @logger.log @logger.levels.WARN, "Deleting all flows for '#{conversation_id}'"
+ @_get_flows conversation_id
+ .then (flows) =>
+ flows_to_delete = flows.length
+
+ if flows_to_delete is 0
+ @logger.log @logger.levels.INFO, "No flows for conversation '#{conversation_id}' to delete"
+ return
+
+ @logger.log @logger.levels.INFO, "We will cleanup '#{flows.length}' flows from conversation '#{conversation_id}'"
+
+ deletions = 0
+ for flow in flows
+ flow_deletion_info = new z.calling.payloads.FlowDeletionInfo conversation_id, flow.id
+ @delete_flow flow_deletion_info
+ .then =>
+ deletions += 1
+ if deletions is flows_to_delete
+ @logger.log @logger.levels.INFO, "We deleted all '#{deletions}' flows for conversation '#{conversation_id}'"
+
+ ###
+ Get flows from backend.
+ @private
+ @param conversation_id [String] Conversation ID of call to get flows for
+ ###
+ _get_flows: (conversation_id) ->
+ @logger.log @logger.levels.INFO, "GETting flows for '#{conversation_id}'"
+ return @call_center.call_service.get_flows conversation_id, []
+ .then (response_array) =>
+ [response, jqXHR] = response_array
+ @logger.log @logger.levels.DEBUG, "GETting flows for '#{conversation_id}' successful"
+ return response.flows
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "GETting flows for '#{conversation_id}' failed: #{error.message}", error
+ attributes = {cause: error.label or error.name, method: 'get', request: 'flows'}
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUEST, undefined, attributes
+
+
+ ###############################################################################
+ # SDP handling
+ ###############################################################################
+
+ ###
+ Put the local SDP on the backend.
+
+ @param sdp_info [z.calling.payloads.SDPInfo] SDP info to be send
+ @param on_success [Function] Function to be called on success
+ @param on_error [Function] Function to be called on failure
+ ###
+ put_local_sdp: (sdp_info, on_success, on_failure) =>
+ @logger.log @logger.levels.INFO, "PUTting local SDP for flow '#{sdp_info.flow_id}'", sdp_info
+ @call_center.call_service.put_local_sdp sdp_info.conversation_id, sdp_info.flow_id, sdp_info.sdp, []
+ .then (response_array) =>
+ [response, jqXHR] = response_array
+ @call_center.telemetry.trace_request sdp_info.conversation_id, jqXHR
+ @logger.log @logger.levels.DEBUG, "PUTting local SDP for flow '#{sdp_info.flow_id}' successful"
+ on_success? response
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR,
+ "PUTting local SDP for flow '#{sdp_info.flow_id}' failed: #{error.message}", error
+ attributes = {cause: error.label or error.name, method: 'put', request: 'sdp', sdp_type: sdp_info.sdp.type}
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUEST, undefined, attributes
+ on_failure? new Error error
+
+ ###
+ Cache remote SDP until we have the flow.
+
+ @private
+ @param flow_id [String] Flow ID
+ @param sdp [RTCSessionDescription] Remote SDP
+ ###
+ _cache_remote_sdp: (flow_id, sdp) ->
+ @sdp_cache[flow_id] = sdp
+ window.setTimeout =>
+ delete @sdp_cache[flow_id]
+ , 60000
+
+
+ ###############################################################################
+ # ICE candidate handling
+ ###############################################################################
+
+ ###
+ Post a local ICE candidate to the backend.
+ @param ice_info [z.calling.payloads.ICECandidateInfo] ICE candidate info to be send
+ ###
+ post_ice_candidate: (ice_info) =>
+ candidate = @ice_mapper.map_ice_object_to_message ice_info.ice_candidate
+
+ @logger.log @logger.levels.INFO, "POSTing local ICE candidate for flow '#{ice_info.flow_id}'", candidate
+ @call_center.call_service.post_local_candidates ice_info.conversation_id, ice_info.flow_id, candidate, []
+ .then (response_array) =>
+ [response, jqXHR] = response_array
+ @call_center.telemetry.trace_request ice_info.conversation_id, jqXHR
+ @logger.log @logger.levels.INFO, "POSTing local ICE candidate for flow '#{ice_info.flow_id}' successful"
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR,
+ "POSTing local ICE candidate for flow '#{ice_info.flow_id}' failed: #{error.message}", error
+ attributes = {cause: error.label or error.name, method: 'put', request: 'ice_candidate'}
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUEST, undefined, attributes
+
+ ###
+ Cache remote ICE candidate until we have the flow.
+
+ @private
+ @param flow_id [String] Flow ID
+ @param candidate [RTCIceCandidate] Remote ICE candidate
+ ###
+ _cache_ice_candidate: (flow_id, candidate) ->
+ list = @candidate_cache[flow_id]
+ if list is undefined
+ list = []
+ @candidate_cache[flow_id] = list
+ window.setTimeout =>
+ delete @candidate_cache[flow_id]
+ , 60000
+ list.push candidate
diff --git a/app/script/calling/handler/CallStateHandler.coffee b/app/script/calling/handler/CallStateHandler.coffee
new file mode 100644
index 00000000000..1621efbefc0
--- /dev/null
+++ b/app/script/calling/handler/CallStateHandler.coffee
@@ -0,0 +1,748 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.handler ?= {}
+
+# Call state handler
+class z.calling.handler.CallStateHandler
+ ###
+ Construct a new call state handler.
+ @param call_center [z.calling.CallCenter] Call center with references to all other handlers
+ ###
+ constructor: (@call_center) ->
+ @logger = new z.util.Logger 'z.calling.handler.CallStateHandler', z.config.LOGGER.OPTIONS
+
+ @calls = ko.observableArray []
+ @joined_call = ko.pureComputed => return call_et for call_et in @calls() when call_et.self_client_joined()
+
+ @self_state =
+ muted: @call_center.media_stream_handler.self_stream_state.muted
+ screen_shared: @call_center.media_stream_handler.self_stream_state.screen_shared
+ videod: @call_center.media_stream_handler.self_stream_state.videod
+
+ @is_handling_notifications = ko.observable true
+ @subscribe_to_events()
+ return
+
+ # Subscribe to amplify topics.
+ subscribe_to_events: =>
+ amplify.subscribe z.event.WebApp.CALL.STATE.CHECK, @check_state
+ amplify.subscribe z.event.WebApp.CALL.STATE.DELETE, @delete_call
+ amplify.subscribe z.event.WebApp.CALL.STATE.IGNORE, @ignore_call
+ amplify.subscribe z.event.WebApp.CALL.STATE.JOIN, @join_call
+ amplify.subscribe z.event.WebApp.CALL.STATE.LEAVE, @leave_call
+ amplify.subscribe z.event.WebApp.CALL.STATE.REMOVE_PARTICIPANT, @remove_participant
+ amplify.subscribe z.event.WebApp.CALL.STATE.TOGGLE, @toggle_joined
+ amplify.subscribe z.event.WebApp.EVENT.NOTIFICATION_HANDLING_STATE, @set_notification_handling_state
+
+ # Un-subscribe from amplify topics.
+ un_subscribe: ->
+ subscriptions = [
+ z.event.WebApp.CALL.STATE.CHECK
+ z.event.WebApp.CALL.STATE.DELETE
+ z.event.WebApp.CALL.STATE.JOIN
+ z.event.WebApp.CALL.STATE.LEAVE
+ z.event.WebApp.CALL.STATE.REMOVE_PARTICIPANT
+ z.event.WebApp.CALL.STATE.TOGGLE
+ ]
+ amplify.unsubscribeAll topic for topic in subscriptions
+
+
+ ###############################################################################
+ # Notification stream handling
+ ###############################################################################
+
+ ###
+ Check for ongoing call in conversation.
+ @param conversation_id [String] Conversation ID
+ ###
+ check_state: (conversation_id) =>
+ conversation_et = @call_center.conversation_repository.get_conversation_by_id conversation_id
+ return if not conversation_et? or conversation_et.removed_from_conversation()
+
+ @_is_call_ongoing conversation_id
+ .then ([is_call_ongoing, response]) =>
+ if is_call_ongoing
+ @on_call_state @_fake_on_state_event(response, conversation_id), true
+ @call_center.conversation_repository.unarchive_conversation conversation_et if conversation_et.is_archived()
+
+ ###
+ Set the notification handling state.
+ @note Temporarily ignore call related events when handling notifications from the stream
+ @param handling_notifications [Boolean] Notifications are being handled from the stream
+ ###
+ set_notification_handling_state: (handling_notifications) =>
+ @is_handling_notifications handling_notifications
+ @_update_ongoing_calls() if not @is_handling_notifications()
+ @logger.log @logger.levels.INFO, "Ignoring call events: #{handling_notifications}"
+
+ ###
+ Update state of currently ongoing calls.
+ @private
+ ###
+ _update_ongoing_calls: ->
+ for call_et in @calls()
+ @_is_call_ongoing call_et.id
+ .then ([is_call_ongoing, response]) =>
+ if not is_call_ongoing
+ event = @_fake_on_state_event response, call_et.id
+ @logger.log @logger.levels.DEBUG, "Call in '#{call_et.id}' ended during while connectivity was lost", event
+ @on_call_state event, true
+
+
+ ###############################################################################
+ # Call states
+ ###############################################################################
+
+ ###
+ Handling of 'call.state' events.
+ @param event [Object] Event payload
+ @param client_joined_change [Boolean] Client joined state change triggered by client action
+ ###
+ on_call_state: (event, client_joined_change = false) ->
+ participant_ids = @_get_remote_participant_ids event.participants
+ self_user_joined = @_is_self_user_joined event.participants
+ participants_count = participant_ids.length
+ joined_count = @_get_joined_count participants_count, self_user_joined
+
+ # Update existing call
+ @call_center.get_call_by_id event.conversation
+ .then (call_et) =>
+ if event.self? and not call_et.self_client_joined()
+ if event.self.state is z.calling.enum.ParticipantState.JOINED or event.self.reason is 'ended'
+ client_joined_change = true
+
+ event = @_should_ignore_state event, call_et, client_joined_change
+ return if not event
+
+ if joined_count >= 1
+ @_update_call event, participant_ids
+ @_update_self call_et, self_user_joined, client_joined_change
+ # ...which has ended
+ else
+ @delete_call call_et.id
+ .catch =>
+ # Call with us joined
+ if self_user_joined
+ # ...from this device
+ if client_joined_change
+ @_create_outgoing_call event
+ # ...from another device
+ else
+ @_create_ongoing_call event, participant_ids
+ # ...with other participants
+ # New call we are not joined
+ else if participants_count > 0
+ @_create_incoming_call event, participant_ids
+
+ ###
+ Create the payload for to be set as call state.
+
+ @private
+ @param state [z.calling.enum.ParticipantState] Self participant joined state
+ @return [Object] State object
+ ###
+ _create_state_payload: (state) ->
+ if state is z.calling.enum.ParticipantState.IDLE
+ self_state =
+ state: z.calling.enum.ParticipantState.IDLE
+ muted: false
+ screen_shared: false
+ videod: false
+ else
+ self_state =
+ state: z.calling.enum.ParticipantState.JOINED
+ muted: @self_state.muted()
+ screen_shared: @self_state.screen_shared()
+ videod: @self_state.videod()
+
+ return self_state
+
+ ###
+ Get the call state for a conversation.
+ @private
+ @param conversation_id [String] Conversation ID
+ ###
+ _get_state: (conversation_id) ->
+ if not conversation_id
+ error_description = 'GETting the call state not possible without conversation ID'
+ Raygun.send new Error error_description
+ @logger.log @logger.levels.ERROR, error_description
+ return Promise.reject new Error error_description
+
+ @logger.log @logger.levels.INFO, "GETting call state for '#{conversation_id}'"
+ @call_center.call_service.get_state conversation_id, []
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "GETting call state for '#{conversation_id}' failed: #{error.message}", error
+ attributes = {cause: error.label or error.name, method: 'get', request: 'state'}
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUEST, undefined, attributes
+ throw error
+ .then (response_array) =>
+ [response, jqXHR] = response_array
+ @call_center.telemetry.trace_request conversation_id, jqXHR
+ @logger.log @logger.levels.DEBUG, "GETting call state for '#{conversation_id}' successful", response
+ return response
+
+ ###
+ Put the clients call state for a conversation.
+
+ @private
+ @param conversation_id [String] Conversation ID
+ @param payload [Object] Participant payload to be set
+ ###
+ _put_state: (conversation_id, payload) ->
+ if not conversation_id
+ error_description = "PUTting the state to '#{payload.state}' not possible without conversation ID"
+ @call_center.telemetry.report_error error_description
+ return Promise.reject new Error error_description
+
+ @logger.log @logger.levels.INFO,
+ "PUTting the state to '#{payload.state}' in '#{conversation_id}'", payload
+ @call_center.call_service.put_state conversation_id, payload, []
+ .catch (error) =>
+ @_put_state_failure error, conversation_id, payload
+ .then (response_array) =>
+ [response, jqXHR] = response_array
+ @call_center.telemetry.trace_request conversation_id, jqXHR
+ @logger.log @logger.levels.DEBUG,
+ "PUTting the state to '#{payload.state}' in '#{conversation_id}' successful", response
+ return response
+
+ ###
+ Putting the clients call state for a conversation failed.
+
+ @note Possible errors:
+ - {max_members: 25, member_count: 26, code: 409, message: "too many members for calling", label: "conv-too-big"}
+ - {"max_joined":9,"code":409,"message":"the voice channel is full","label":"voice-channel-full"}
+ - {"code":404,"message":"conversation not found","label":"not-found"}
+
+ @private
+ @param error [JSON] Error object from the backend
+ @param conversation_id [String] Conversation ID
+ @param payload [Object] Participant payload to be set
+ ###
+ _put_state_failure: (error, conversation_id, payload) ->
+ @logger.log @logger.levels.ERROR,
+ "PUTting the state to '#{payload.state}' in '#{conversation_id}' failed: #{error.message}", error
+ attributes = {cause: error.label or error.name, method: 'put', request: 'state', video: payload.videod}
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUEST, undefined, attributes
+ @call_center.media_stream_handler.release_media_streams()
+ switch error.label
+ when z.service.BackendClientError::LABEL.CONVERSATION_TOO_BIG
+ amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CALL_FULL_CONVERSATION,
+ data: error.max_members
+ throw new z.calling.CallError error.message, z.calling.CallError::TYPE.CONVERSATION_TOO_BIG
+ when z.service.BackendClientError::LABEL.INVALID_OPERATION
+ amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CALL_EMPTY_CONVERSATION
+ @delete_call conversation_id
+ throw new z.calling.CallError error.message, z.calling.CallError::TYPE.CONVERSATION_EMPTY
+ when z.service.BackendClientError::LABEL.VOICE_CHANNEL_FULL
+ amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CALL_FULL_VOICE_CHANNEL,
+ data: error.max_joined
+ throw new z.calling.CallError error.message, z.calling.CallError::TYPE.VOICE_CHANNEL_FULL
+ # User has been removed from conversation, call should be deleted
+ else
+ @call_center.telemetry.report_error "PUTting the state to '#{payload.state}' failed: #{error.message}", error
+ @delete_call conversation_id
+ throw error
+
+ ###
+ Put the clients call state for a conversation to z.calling.enum.ParticipantState.IDLE.
+ @private
+ @param conversation_id [String] Conversation ID
+ ###
+ _put_state_to_idle: (conversation_id) ->
+ @_put_state conversation_id, @_create_state_payload z.calling.enum.ParticipantState.IDLE
+ .then (response) =>
+ @on_call_state @_fake_on_state_event(response, conversation_id), true
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to change state for call '#{conversation_id}': #{error.message}"
+
+ ###
+ Put the clients call state for a conversation to z.calling.enum.ParticipantState.JOINED.
+
+ @private
+ @param conversation_id [String] Conversation ID
+ @param self_state [Object] Self state to change into
+ @param client_joined_change [Boolean] Did the self client joined state change
+ ###
+ _put_state_to_join: (conversation_id, self_state, client_joined_change = false) ->
+ @_put_state conversation_id, self_state
+ .then (response) =>
+ @call_center.timings().time_step z.telemetry.calling.CallSetupSteps.STATE_PUT
+ event = @_fake_on_state_event response, conversation_id
+ event.session = @_fake_session_id() if not event.session
+
+ @call_center.telemetry.track_session conversation_id, event
+ @on_call_state event, client_joined_change
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to change state for call '#{conversation_id}': #{error.message}"
+
+ ###
+ Check sequence number of event and decide if it will be processed.
+
+ @private
+ @param event [Object] Event payload
+ @param call_et [z.calling.Call] Call entity
+ @param client_joined_change [Boolean] Client state change
+ @return [Object, undefined] Event or undefined if it should be ignored
+ ###
+ _should_ignore_state: (event, call_et, client_joined_change) ->
+ if event.sequence > call_et.event_sequence
+ @logger.log @logger.levels.INFO, "State processed: Sequence '#{event.sequence}' > '#{call_et.event_sequence}'"
+ event.is_sequential = event.sequence is call_et.event_sequence + 1
+ call_et.event_sequence = event.sequence
+ else if client_joined_change
+ @logger.log @logger.levels.INFO,
+ "State processed: Contains self client change but '#{event.sequence}' <= '#{call_et.event_sequence}'"
+ call_et.event_sequence = event.sequence
+ else if event.sequence <= call_et.event_sequence
+ @logger.log @logger.levels.WARN, "State ignored: Sequence '#{event.sequence}' <= '#{call_et.event_sequence}'"
+ event = undefined
+ return event
+
+
+ ###############################################################################
+ # Call actions
+ ###############################################################################
+
+ ###
+ Delete a call entity.
+ @param conversation_id [String] Conversation ID of call to be deleted
+ ###
+ delete_call: (conversation_id) =>
+ @call_center.get_call_by_id conversation_id
+ .then (call_et) =>
+ @logger.log @logger.levels.INFO, "Delete call in conversation '#{conversation_id}'"
+ # Reset call and delete it afterwards
+ call_et.state z.calling.enum.CallState.DELETED
+ call_et.reset_call()
+ @calls.remove call_et
+ @call_center.media_stream_handler.reset_media_streams()
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "No call found in conversation '#{conversation_id}' to delete", error
+
+ ###
+ User action to ignore incoming call.
+ @param conversation_id [String] Conversation ID of call to be joined
+ ###
+ ignore_call: (conversation_id) =>
+ @call_center.get_call_by_id conversation_id
+ .then (call_et) =>
+ call_et.ignore()
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.INCOMING_CALL_MUTED
+ @logger.log @logger.levels.INFO, "Call in '#{conversation_id}' ignored"
+ @call_center.media_stream_handler.reset_media_streams()
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "No call found in conversation '#{conversation_id}' to ignore", error
+
+ ###
+ User action to join a call.
+ @param conversation_id [String] Conversation ID of call to be joined
+ @param is_videod [Boolean] Is this a video call
+ ###
+ join_call: (conversation_id, is_videod) =>
+ @call_center.timings new z.telemetry.calling.CallSetupTimings conversation_id
+
+ is_outgoing_call = false
+
+ @call_center.get_call_by_id conversation_id
+ .catch ->
+ is_outgoing_call = true
+ .then =>
+ if is_outgoing_call and not z.calling.CallCenter.supports_calling()
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.UNSUPPORTED_OUTGOING_CALL
+ else
+ @call_center.conversation_repository.get_conversation_by_id conversation_id, (conversation_et) =>
+ @_already_joined_in_call conversation_id, is_videod, is_outgoing_call
+ .then ->
+ if is_outgoing_call
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.VOICE_CALL_INITIATED
+ media_action = if is_videod then 'audio_call' else 'video_call'
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.MEDIA.COMPLETED_MEDIA_ACTION,
+ action: media_action, conversation_type: if conversation_et.is_one2one() then 'one_to_one' else 'group'
+ return true
+
+ ###
+ User action to leave a call.
+ @param conversation_id [String] Conversation ID of call to be joined
+ @param has_call_dropped [Boolean] Optional information whether the call has dropped
+ ###
+ leave_call: (conversation_id, has_call_dropped = false) =>
+ @call_center.media_stream_handler.release_media_streams()
+ @call_center.get_call_by_id conversation_id
+ .then (call_et) =>
+ if has_call_dropped
+ call_et.finished_reason = z.calling.enum.CallFinishedReason.CONNECTION_DROPPED
+ else
+ call_et.finished_reason = z.calling.enum.CallFinishedReason.SELF_USER
+ @_put_state_to_idle conversation_id
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "No call found in conversation '#{conversation_id}' to leave", error
+
+ ###
+ Leave a call we are joined immediately in case the browser window is closed.
+ @note Should only used by "window.onbeforeunload".
+ ###
+ leave_call_on_beforeunload: =>
+ conversation_id = @_self_client_on_a_call()
+ @leave_call conversation_id if conversation_id
+
+ ###
+ Remove a participant from a call if he was removed from the group.
+ @param conversation_id [String] Conversation ID of call that the user should be removed from
+ @param user_id [String] ID of user to be removed
+ ###
+ remove_participant: (conversation_id, user_id) =>
+ @call_center.get_call_by_id conversation_id
+ .then (call_et) ->
+ if participant_et = call_et.get_participant_by_id user_id
+ call_et.delete_participant participant_et, false
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "No call found in conversation '#{conversation_id}' to remove participant from", error
+
+ ###
+ User action to toggle the audio muted state of a call.
+ @param conversation_id [String] Conversation ID of call
+ ###
+ toggle_audio: (conversation_id) =>
+ @call_center.media_stream_handler.toggle_microphone_muted()
+ .then =>
+ return @_put_state_to_join conversation_id, @_create_state_payload z.calling.enum.ParticipantState.JOINED if conversation_id
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to toggle video state: #{error.message}", error
+
+ ###
+ User action to toggle the call state.
+ @param conversation_id [String] Conversation ID of call for which state will be toggled
+ @param is_videod [Boolean] Is this a video call
+ ###
+ toggle_joined: (conversation_id, is_videod) =>
+ if @_self_participant_on_a_call() is conversation_id
+ @leave_call conversation_id if @_self_client_on_a_call()
+ else
+ @join_call conversation_id, is_videod
+
+ ###
+ User action to toggle the screen sharing state of a call.
+ @param conversation_id [String] Conversation ID of call
+ ###
+ toggle_screen: (conversation_id) =>
+ @call_center.media_stream_handler.toggle_screen_shared()
+ .then =>
+ return @_put_state_to_join conversation_id, @_create_state_payload z.calling.enum.ParticipantState.JOINED if conversation_id
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to toggle screen sharing state: #{error.message}", error
+
+ ###
+ User action to toggle the video state of a call.
+ @param conversation_id [String] Conversation ID of call
+ ###
+ toggle_video: (conversation_id) =>
+ @call_center.media_stream_handler.toggle_camera_paused()
+ .then =>
+ return @_put_state_to_join conversation_id, @_create_state_payload z.calling.enum.ParticipantState.JOINED if conversation_id
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to toggle audio state: #{error.message}", error
+
+ ###
+ Check whether we are actively participating in a call.
+
+ @private
+ @param new_call_id [String] Conversation ID of call about to be joined
+ @param is_videod [Boolean] Is video enabled for this new call
+ @param is_outgoing_call [Boolean] Is the new call outgoing
+ @return [Promise] Promise that resolves when the new call was joined
+ ###
+ _already_joined_in_call: (new_call_id, is_videod, is_outgoing_call) =>
+ return new Promise (resolve) =>
+ ongoing_call_id = @_self_participant_on_a_call()
+ if ongoing_call_id
+ @logger.log @logger.levels.WARN, 'You cannot start a second call while already participating in another one.'
+ amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.CALL_START_ANOTHER,
+ action: =>
+ @leave_call ongoing_call_id
+ window.setTimeout =>
+ @_join_call new_call_id, is_videod
+ .then -> resolve()
+ , 1000
+ close: ->
+ amplify.publish z.event.WebApp.CALL.STATE.IGNORE, new_call_id if not is_outgoing_call
+ data: is_outgoing_call
+ else
+ @_join_call new_call_id, is_videod
+ .then -> resolve()
+
+ ###
+ Check whether a call is ongoing in a conversation.
+
+ @private
+ @param conversation_id [String] Conversation ID
+ @return [Promise] Promise that resolves with an array whether a call was found and the current call state
+ ###
+ _is_call_ongoing: (conversation_id) =>
+ @_get_state conversation_id
+ .then (response) =>
+ for id, participant of response.participants when participant.state is z.calling.enum.ParticipantState.JOINED
+ @logger.log @logger.levels.DEBUG, "Detected ongoing call in '#{conversation_id}'"
+ return [true, response]
+ return [false, response]
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "Detecting ongoing call in '#{conversation_id}' failed: #{error.message}", error
+
+ ###
+ Join a call and get a MediaStream.
+
+ @private
+ @param conversation_id [String] ID of conversation to call in
+ @param is_videod [Boolean] Is call a video call
+ ###
+ _join_call: (conversation_id, is_videod) ->
+ @call_center.get_call_by_id conversation_id
+ .catch (error) ->
+ throw error if error.type isnt z.calling.CallError::TYPE.CALL_NOT_FOUND
+ .then =>
+ if @call_center.media_stream_handler.has_active_streams()
+ @logger.log @logger.levels.INFO, 'MediaStream has already been initialized', @call_center.media_stream_handler.local_media_streams
+ else
+ return @call_center.media_stream_handler.initiate_media_stream conversation_id, is_videod
+ .then =>
+ return @_put_state_to_join conversation_id, @_create_state_payload(z.calling.enum.ParticipantState.JOINED), true
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Joining call in '#{conversation_id}' failed: #{error.name}", error
+
+ ###
+ Update a call with new state.
+
+ @private
+ @param event [Object] 'call.state' event containing info to update call
+ @param joined_participant_ids [Array] User IDs of joined participants
+ ###
+ _update_call: (event, joined_participant_ids) ->
+ conversation_id = event.conversation
+
+ @call_center.get_call_by_id conversation_id
+ .then (call_et) =>
+ @call_center.user_repository.get_users_by_id joined_participant_ids, (participant_ets) ->
+ # This happens if we leave an ongoing call or if we accept a call on another device that we have ignored.
+ limit = event.is_sequential and event.cause is z.calling.enum.CallStateEventCause.REQUESTED
+ call_et.update_participants (new z.calling.entities.Participant user_et for user_et in participant_ets), limit
+ call_et.update_remote_state event.participants
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "No call found in conversation '#{conversation_id}' to update", error
+
+ ###
+ Update the self states of the call.
+
+ @private
+ @param call_et [z.calling.Call] Call entity to update the self status off
+ @param user_joined_change [Boolean] Is the self user joined in the call
+ @param client_joined_change [Boolean] Was the state of the client changed
+ ###
+ _update_self: (call_et, self_user_joined, client_joined_change) ->
+ call_et.self_user_joined self_user_joined
+ if client_joined_change
+ call_et.self_client_joined self_user_joined
+
+ if call_et.self_user_joined() and not call_et.self_client_joined()
+ call_et.state z.calling.enum.CallState.ONGOING
+ else if call_et.state() in z.calling.enum.CallState.OUTGOING
+ call_et.state z.calling.enum.CallState.CONNECTING if call_et.get_number_of_participants() > 0
+ else if call_et.state() in z.calling.enum.CallStateGroups.CAN_CONNECT
+ if call_et.self_client_joined() and client_joined_change
+ call_et.state z.calling.enum.CallState.CONNECTING
+ else if call_et.state() is z.calling.enum.CallState.CONNECTING
+ call_et.state z.calling.enum.CallState.ONGOING if not call_et.self_client_joined()
+
+ if call_et.is_remote_videod() and call_et.is_ongoing_on_another_client()
+ @call_center.media_stream_handler.release_media_streams()
+
+
+ ###############################################################################
+ # Call entity creation
+ ###############################################################################
+
+ ###
+ Constructs a call entity.
+
+ @private
+ @param event [Object] 'call.state' event containing info to create call
+ @return [z.calling.Call] Call entity
+ ###
+ _create_call: (event) ->
+ @call_center.get_call_by_id event.conversation
+ .then (call_et) =>
+ @logger.log @logger.levels.WARN, "Call entity for '#{event.conversation}' already exists", call_et
+ .catch =>
+ conversation_et = @call_center.conversation_repository.get_conversation_by_id event.conversation
+ call_et = new z.calling.entities.Call conversation_et, @call_center.user_repository.self(), @call_center.telemetry
+ call_et.local_audio_stream = @call_center.media_stream_handler.local_media_streams.audio
+ call_et.local_video_stream = @call_center.media_stream_handler.local_media_streams.video
+ call_et.session_id event.session or @_fake_session_id()
+ call_et.event_sequence = event.sequence
+ conversation_et.call call_et
+ @calls.push call_et
+ return call_et
+
+ ###
+ Constructs an incoming call entity.
+
+ @private
+ @param event [Object] 'call.state' event containing info to create call
+ @param remote_participant_ids [Array] User IDs of remote participants joined in call
+ @return [z.calling.Call] Call entity
+ ###
+ _create_incoming_call: (event, remote_participant_ids) ->
+ @_create_call event
+ .then (call_et) =>
+ creator_id = @call_center.get_creator_id event
+ remote_participant_ids.push creator_id if creator_id not in remote_participant_ids
+ @call_center.user_repository.get_users_by_id remote_participant_ids, (remote_user_ets) =>
+ call_et.set_creator @call_center.user_repository.get_user_by_id creator_id
+ participant_ets = (new z.calling.entities.Participant user_et for user_et in remote_user_ets)
+ call_et.update_participants participant_ets
+ call_et.update_remote_state event.participants
+ call_et.state z.calling.enum.CallState.INCOMING
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.RECEIVED_CALL, call_et
+ @call_center.media_stream_handler.initiate_media_stream call_et.id, true if call_et.is_remote_videod()
+ @logger.log @logger.levels.DEBUG,
+ "Incoming '#{call_et.remote_media_type()}' call to '#{call_et.conversation_et.display_name()}'", call_et
+
+ ###
+ Constructs an ongoing call entity.
+
+ @private
+ @param event [Object] 'call.state' event containing info to create call
+ @param remote_participant_ids [Array, undefined] User IDs of remote participants joined in call
+ @return [z.calling.Call] Call entity
+ ###
+ _create_ongoing_call: (event, remote_participant_ids = []) ->
+ @_create_call event
+ .then (call_et) =>
+ call_et.state z.calling.enum.CallState.ONGOING
+ call_et.self_user_joined true
+ @call_center.user_repository.get_users_by_id remote_participant_ids, (remote_user_ets) =>
+ participant_ets = (new z.calling.entities.Participant user_et for user_et in remote_user_ets)
+ call_et.update_participants participant_ets
+ call_et.update_remote_state event.participants
+ conversation_name = call_et.conversation_et.display_name()
+ @logger.log @logger.levels.DEBUG,
+ "Ongoing '#{call_et.remote_media_type()}' call to '#{conversation_name}' from another client", call_et
+
+ ###
+ Constructs an outgoing call entity.
+
+ @private
+ @param event [Object] 'call.state' event containing info to create call
+ @return [z.calling.Call] Call entity
+ ###
+ _create_outgoing_call: (event) ->
+ @_create_call event
+ .then (call_et) =>
+ call_et.state z.calling.enum.CallState.OUTGOING
+ call_et.self_client_joined true
+ call_et.self_user_joined true
+ call_et.set_creator @call_center.user_repository.self()
+ @logger.log @logger.levels.DEBUG,
+ "Outgoing '#{@call_center.media_stream_handler.local_media_type()}' call to '#{call_et.conversation_et.display_name()}'", call_et
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.INITIATED_CALL, call_et
+ return call_et
+
+
+ ###############################################################################
+ # Helper functions
+ ###############################################################################
+
+ ###
+ Create a fake 'call.state' event from a request response.
+
+ @private
+ @param event [Object] Request response to be changed into 'call.state' event
+ @param conversation_id [String] Conversation ID for request response
+ ###
+ _fake_on_state_event: (event, conversation_id) ->
+ event.conversation = conversation_id
+ event.type = z.event.Backend.CALL.STATE
+ event.cause = z.calling.enum.CallStateEventCause.REQUESTED
+ return event
+
+ ###
+ Create a fake session ID.
+
+ @note Backend does not always provide a session ID, so we have to fake one
+ @private
+ @return [String] Random faked session ID
+ ###
+ _fake_session_id: ->
+ @logger.log @logger.levels.WARN, 'There is no session ID. We faked one.'
+ return "FAKE-#{z.util.create_random_uuid()}"
+
+ ###
+ Get the count of joined users.
+
+ @private
+ @param remote_participant_count [Number] Count of remote participants
+ @param is_self_user_joined [Boolean] Is the self user joined in the call
+ @return [Number] Number of users joined in the call
+ ###
+ _get_joined_count: (remote_participant_count, is_self_user_joined) ->
+ remote_participant_count++ if is_self_user_joined
+ return remote_participant_count
+
+ ###
+ Get the IDs of remote participants.
+
+ @private
+ @param participant_ets [Object] Object containing participants
+ @return [Array] Array user user IDs of joined, remote participants
+ ###
+ _get_remote_participant_ids: (participants) ->
+ participant_ids = []
+ for id, participant of participants when participant.state is z.calling.enum.ParticipantState.JOINED
+ participant_ids.push id if id isnt @call_center.user_repository.self().id
+ return participant_ids
+
+ ###
+ Check if self user is joined in call event.
+
+ @private
+ @param participants [Object] JSON object containing call participants
+ @return [Boolean] Is the self user joined in the call
+ ###
+ _is_self_user_joined: (participants) ->
+ self = participants[@call_center.user_repository.self().id]
+ return self?.state is z.calling.enum.ParticipantState.JOINED
+
+
+ ###
+ Check if self client is participating in a call.
+ @private
+ @return [String, Boolean] Conversation ID of call or false
+ ###
+ _self_client_on_a_call: ->
+ return call_et.id for call_et in @calls() when call_et.self_client_joined()
+
+ ###
+ Check if self participant is participating in a call.
+ @private
+ @return [String, Boolean] Conversation ID of call or false
+ ###
+ _self_participant_on_a_call: ->
+ return call_et.id for call_et in @calls() when call_et.self_user_joined()
diff --git a/app/script/calling/handler/MediaDevicesHandler.coffee b/app/script/calling/handler/MediaDevicesHandler.coffee
new file mode 100644
index 00000000000..5edf6a198e1
--- /dev/null
+++ b/app/script/calling/handler/MediaDevicesHandler.coffee
@@ -0,0 +1,255 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.handler ?= {}
+
+# MediaDevices handler
+class z.calling.handler.MediaDevicesHandler
+ ###
+ Construct a new MediaDevices handler.
+ @param call_center [z.calling.CallCenter] Call center with references to all other handlers
+ ###
+ constructor: (@call_center) ->
+ @logger = new z.util.Logger 'z.calling.handler.MediaDevicesHandler', z.config.LOGGER.OPTIONS
+
+ @available_devices =
+ audio_input: ko.observableArray []
+ audio_output: ko.observableArray []
+ screen_input: ko.observableArray []
+ video_input: ko.observableArray []
+
+ @current_device_id =
+ audio_input: ko.observable 'default'
+ audio_output: ko.observable 'default'
+ screen_input: ko.observable()
+ video_input: ko.observable()
+
+ @current_device_index =
+ audio_input: ko.observable 0
+ audio_output: ko.observable 0
+ screen_input: ko.observable 0
+ video_input: ko.observable 0
+
+ @has_camera = ko.pureComputed => return @available_devices.video_input().length > 0
+ @has_microphone = ko.pureComputed => return @available_devices.audio_input().length > 0
+
+ @get_media_devices()
+ .then =>
+ if @available_devices.video_input().length
+ default_device_index = @available_devices.video_input().length - 1
+ @current_device_id.video_input @available_devices.video_input()[default_device_index].deviceId
+ @current_device_index.video_input default_device_index
+ @_subscribe_to_observables()
+
+ @_subscribe_to_devices()
+
+ # Subscribe to Knockout observables.
+ _subscribe_to_observables: =>
+ @available_devices.audio_input.subscribe (media_devices) =>
+ @_update_current_index_from_devices z.calling.enum.MediaDeviceType.AUDIO_INPUT, media_devices if media_devices
+
+ @available_devices.audio_output.subscribe (media_devices) =>
+ @_update_current_index_from_devices z.calling.enum.MediaDeviceType.AUDIO_OUTPUT, media_devices if media_devices
+
+ @available_devices.screen_input.subscribe (media_devices) =>
+ @_update_current_index_from_devices z.calling.enum.MediaDeviceType.SCREEN_INPUT, media_devices if media_devices
+
+ @available_devices.video_input.subscribe (media_devices) =>
+ @_update_current_index_from_devices z.calling.enum.MediaDeviceType.VIDEO_INPUT, media_devices if media_devices
+
+ @current_device_id.audio_input.subscribe (media_device_id) =>
+ if media_device_id and @call_center.joined_call()
+ @call_center.media_stream_handler.replace_input_source z.calling.enum.MediaType.AUDIO
+ @_update_current_index_from_id z.calling.enum.MediaDeviceType.AUDIO_INPUT, media_device_id
+
+ @current_device_id.audio_output.subscribe (media_device_id) =>
+ if media_device_id and @call_center.joined_call()
+ @call_center.media_element_handler.switch_media_element_output media_device_id
+ @_update_current_index_from_id z.calling.enum.MediaDeviceType.AUDIO_OUTPUT, media_device_id
+
+ @current_device_id.screen_input.subscribe (media_device_id) =>
+ if media_device_id and @call_center.joined_call() and @call_center.media_stream_handler.local_media_type() is z.calling.enum.MediaType.SCREEN
+ @call_center.media_stream_handler.replace_input_source z.calling.enum.MediaType.SCREEN
+ @_update_current_index_from_id z.calling.enum.MediaDeviceType.SCREEN_INPUT, media_device_id
+
+ @current_device_id.video_input.subscribe (media_device_id) =>
+ if media_device_id and @call_center.joined_call() and @call_center.media_stream_handler.local_media_type() is z.calling.enum.MediaType.VIDEO
+ @call_center.media_stream_handler.replace_input_source z.calling.enum.MediaType.VIDEO
+ @_update_current_index_from_id z.calling.enum.MediaDeviceType.VIDEO_INPUT, media_device_id
+
+ # Subscribe to MediaDevices updates if available.
+ _subscribe_to_devices: =>
+ if navigator.mediaDevices.ondevicechange?
+ navigator.mediaDevices.ondevicechange = =>
+ @logger.log @logger.levels.INFO, 'List of available MediaDevices has changed'
+ @get_media_devices()
+
+ ###
+ Update list of available MediaDevices.
+ @return [Promise] Promise that resolves with all MediaDevices when the list has been updated
+ ###
+ get_media_devices: =>
+ navigator.mediaDevices.enumerateDevices()
+ .then (media_devices) =>
+ if media_devices
+ @_remove_all_devices()
+ for media_device in media_devices
+ switch media_device.kind
+ when z.calling.enum.MediaDeviceType.AUDIO_INPUT
+ @available_devices.audio_input.push media_device
+ when z.calling.enum.MediaDeviceType.AUDIO_OUTPUT
+ @available_devices.audio_output.push media_device
+ when z.calling.enum.MediaDeviceType.VIDEO_INPUT
+ @available_devices.video_input.push media_device
+
+ @logger.log @logger.levels.INFO, 'Updated MediaDevice list', media_devices
+ return media_devices
+ else
+ throw new z.calling.CallError 'No MediaDevices found', z.calling.CallError::TYPE.NO_DEVICES_FOUND
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to update MediaDevice list: #{error.message}", error
+
+ ###
+ Update list of available Screens.
+ @return [Promise] Promise that resolves with all screen sources when the list has been updated
+ ###
+ get_screen_sources: ->
+ return new Promise (resolve, reject) =>
+ options =
+ types: ['screen']
+ thumbnailSize:
+ width: 312
+ height: 176
+
+ window.desktopCapturer.getSources options, (error, screen_sources) =>
+ if error
+ reject error
+ else
+ @logger.log @logger.levels.INFO, "Found '#{screen_sources.length}' possible sources for screen sharing on Electron", screen_sources
+ @available_devices.screen_input screen_sources
+ if screen_sources.length is 1
+ @current_device_id.screen_input ''
+ @logger.log @logger.levels.INFO, "Selected '#{screen_sources[0].name}' for screen sharing", screen_sources[0]
+ @current_device_id.screen_input screen_sources[0].id
+ resolve screen_sources
+
+ # Toggle between the available cameras.
+ toggle_next_camera: =>
+ @get_media_devices()
+ .then =>
+ [current_device, current_index] = @_get_current_device @available_devices.video_input(), @current_device_id.video_input()
+ next_device = @available_devices.video_input()[z.util.iterate_array_index(@available_devices.video_input(), @current_device_index.video_input()) or 0]
+ @current_device_id.video_input next_device.deviceId
+ @logger.log @logger.levels.INFO, "Switching the active camera from '#{current_device.label or current_device.deviceId}' to '#{next_device.label or next_device.deviceId}'"
+
+ # Toggle between the available screens.
+ toggle_next_screen: =>
+ @get_screen_sources()
+ .then =>
+ [current_device, current_index] = @_get_current_device @available_devices.screen_input(), @current_device_id.screen_input()
+ next_device = @available_devices.screen_input()[z.util.iterate_array_index(@available_devices.screen_input(), @current_device_index.screen_input()) or 0]
+ @current_device_id.screen_input next_device.id
+ @logger.log @logger.levels.INFO, "Switching the active screen from '#{current_device.name or current_device.id}' to '#{next_device.name or next_device.id}'"
+
+ ###
+ Check for availability of selected devices.
+ @param is_videod [Boolean] Also check for video devices
+ ###
+ update_current_devices: (is_videod) =>
+ @get_media_devices()
+ .then =>
+ _check_device = (media_type, device_type) =>
+ device_type = @_type_conversion device_type
+ device_id_observable = @current_device_id["#{device_type}"]
+ media_devices = @available_devices["#{device_type}"]()
+ [media_device, media_device_index] = @_get_current_device media_devices, device_id_observable()
+ if not media_device.deviceId
+ if updated_device = @available_devices["#{device_type}"]()[0]
+ @logger.log @logger.levels.WARN,
+ "Current '#{media_type}' device '#{device_id_observable()}' not found and replaced by '#{updated_device.name}'", media_devices
+ device_id_observable updated_device.deviceId
+ else
+ @logger.log @logger.levels.WARN, "Current '#{media_type}' device '#{device_id_observable()}' not found and reset'", media_devices
+ device_id_observable ''
+
+ _check_device z.calling.enum.MediaType.AUDIO, z.calling.enum.MediaDeviceType.AUDIO_INPUT
+ _check_device z.calling.enum.MediaType.VIDEO, z.calling.enum.MediaDeviceType.VIDEO_INPUT if is_videod
+
+ ###
+ Get the currently selected MediaDevice.
+
+ @param media_devices [Array] Array of MediaDevices
+ @param current_device_id [String] ID of selected MediaDevice
+ @return [Array] Selected MediaDevice and its array index
+ ###
+ _get_current_device: (media_devices, current_device_id) ->
+ for media_device, index in media_devices when media_device.deviceId is current_device_id or media_device.id is current_device_id
+ return [media_device, index]
+ return [{}, 0]
+
+ ###
+ Remove all known MediaDevices from the lists.
+ @private
+ ###
+ _remove_all_devices: ->
+ @available_devices.audio_input.removeAll()
+ @available_devices.audio_output.removeAll()
+ @available_devices.video_input.removeAll()
+
+ ###
+ Add underscore to MediaDevice types.
+ @private
+ @param device_type [String] Device type string to update
+ @return [String]
+ ###
+ _type_conversion: (device_type) ->
+ device_type = device_type.replace('input', '_input').replace 'output', '_output'
+
+ ###
+ Update the current index by searching for the current device.
+
+ @private
+ @param index_observable [ko.obserable] Observable containing the current index
+ @param available_devices [Array] Array of MediaDevices
+ @param current_device_id [String] Current device ID to look for
+ ###
+ _update_current_device_index: (index_observable, available_devices, current_device_id) ->
+ [media_device, current_device_index] = @_get_current_device available_devices, current_device_id
+ index_observable current_device_index if _.isNumber current_device_index
+
+ ###
+ Update the index for current device after the list of devices changed.
+ @private
+ @param device_type [z.calling.enum.MediaDeviceType] MediaDeviceType to be updates
+ @param available_devices [Array] Array of MediaDevices
+ ###
+ _update_current_index_from_devices: (device_type, available_devices) =>
+ device_type = @_type_conversion device_type
+ @_update_current_device_index @current_device_index["#{device_type}"], available_devices, @current_device_id["#{device_type}"]()
+
+ ###
+ Update the index for current device after the current device changed.
+ @private
+ @param device_type [z.calling.enum.MediaDeviceType] MediaDeviceType to be updates
+ @param selected_input_device_id [String] ID of selected input device
+ ###
+ _update_current_index_from_id: (device_type, selected_input_device_id) ->
+ device_type = @_type_conversion device_type
+ @_update_current_device_index @current_device_index["#{device_type}"], @available_devices["#{device_type}"](), selected_input_device_id
diff --git a/app/script/calling/handler/MediaElementHandler.coffee b/app/script/calling/handler/MediaElementHandler.coffee
new file mode 100644
index 00000000000..b7c4a9524e6
--- /dev/null
+++ b/app/script/calling/handler/MediaElementHandler.coffee
@@ -0,0 +1,109 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.handler ?= {}
+
+# MediaElement handler
+class z.calling.handler.MediaElementHandler
+ ###
+ Construct a new MediaElement handler.
+ @param call_center [z.calling.CallCenter] Call center with references to all other handlers
+ ###
+ constructor: (@call_center) ->
+ @logger = new z.util.Logger 'z.calling.handler.MediaElementHandler', z.config.LOGGER.OPTIONS
+
+ @remote_media_elements = ko.observableArray []
+
+ ###
+ Add MediaElement for new stream.
+ @param media_stream_info [z.calling.payload.MediaStreamInfo] MediaStream information
+ ###
+ add_media_element: (media_stream_info) =>
+ remote_media_element = @_create_media_element media_stream_info
+ @remote_media_elements.push remote_media_element
+ @logger.log @logger.levels.INFO, "Created MediaElement of type '#{remote_media_element.nodeName.toLowerCase()}' for MediaStream of flow '#{media_stream_info.flow_id}'", remote_media_element
+
+ ###
+ Destroy the remote media element of a flow.
+ @private
+ @param flow_id [String] Flow ID for which to destroy the remote media element
+ ###
+ remove_media_element: (flow_id) =>
+ for media_element in @_get_media_elements flow_id
+ @_destroy_media_element media_element
+ @remote_media_elements.remove media_element
+ @logger.log @logger.levels.INFO, "Deleted MediaElement of type '#{media_element.tagName.toLocaleLowerCase()}' for flow '#{flow_id}'"
+
+ ###
+ Switch the output device used for all MediaElements.
+ @param media_device_id [String] Media Device ID to be used for playback
+ ###
+ switch_media_element_output: (media_device_id) =>
+ @_set_media_element_output media_element, media_device_id for media_element in @remote_media_elements()
+
+ ###
+ Create a new media element.
+
+ @private
+ @param media_stream_info [z.calling.payload.MediaStreamInfo] MediaStream information
+ @return [HTMLMediaElement] HTMLMediaElement of type HTMLAudioElement that has the stream attached to it
+ ###
+ _create_media_element: (media_stream_info) ->
+ try
+ media_element = document.createElement 'audio'
+ media_element.srcObject = media_stream_info.stream
+ media_element.dataset['conversation_id'] = media_stream_info.conversation_id
+ media_element.dataset['flow_id'] = media_stream_info.flow_id
+ media_element.muted = false
+ media_element.setAttribute 'autoplay', true
+ return media_element
+ catch error
+ @logger.log @logger.levels.ERROR,
+ "Unable to create AudioElement for flow '#{media_stream_info.flow_id}'", error
+
+ ###
+ Stop the media element.
+ @param media_element [HTMLMediaElement] A HTMLMediaElement that has the media stream attached to it
+ ###
+ _destroy_media_element: (media_element) ->
+ return if not media_element
+ media_element.pause()
+ media_element.srcObject = undefined
+
+ ###
+ Get all the MediaElements related to a given flow ID.
+ @param flow_id [String] ID of flow to search MediaElements for
+ @return [Array] Related MediaElements
+ ###
+ _get_media_elements: (flow_id) ->
+ return (media_element for media_element in @remote_media_elements() when flow_id is media_element.dataset['flow_id'])
+
+ ###
+ Change the output device used for audio playback of a media element.
+ @param media_element [HTMLMediaElement] HTMLMediaElement to change playback device for
+ @param sink_id [String] ID of MediaDevice to be used
+ ###
+ _set_media_element_output: (media_element, sink_id) ->
+ media_element.setSinkId sink_id
+ .then =>
+ @logger.log @logger.levels.INFO, "Audio output device attached to flow '#{media_element.dataset['flow_id']} changed to '#{sink_id}'", media_element
+ .catch (error) =>
+ @logger.log @logger.levels.INFO,
+ "Failed to attach audio output device '#{sink_id}' to flow '#{media_element.dataset['flow_id']}: #{error.message}", error
diff --git a/app/script/calling/handler/MediaStreamHandler.coffee b/app/script/calling/handler/MediaStreamHandler.coffee
new file mode 100644
index 00000000000..9f0623caf3e
--- /dev/null
+++ b/app/script/calling/handler/MediaStreamHandler.coffee
@@ -0,0 +1,625 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.handler ?= {}
+
+# MediaStream handler
+class z.calling.handler.MediaStreamHandler
+ ###
+ Detect whether a MediaStream has a video MediaStreamTrack attached
+ @param media_stream [MediaStream] MediaStream to detect the type off
+ @return [MediaStream] MediaStream with new type information
+ ###
+ @detect_media_stream_type: (media_stream) ->
+ if media_stream.getVideoTracks()?.length
+ if media_stream.getAudioTracks()?.length
+ media_stream.type = z.calling.enum.MediaType.AUDIO_VIDEO
+ else
+ media_stream.type = z.calling.enum.MediaType.VIDEO
+ else if media_stream.getAudioTracks()?.length
+ media_stream.type = z.calling.enum.MediaType.AUDIO
+ else
+ media_stream.type = z.calling.enum.MediaType.NONE
+ return media_stream
+
+ ###
+ Get MediaStreamTracks from a MediaStream.
+
+ @param media_stream [MediaStream] MediaStream to get tracks from
+ @param media_type [z.calling.enum.MediaType.AUDIO_VIDEO]
+ @return [Array] Array of MediaStreamTracks optionally matching the requested type
+ ###
+ @get_media_tracks: (media_stream, media_type = z.calling.enum.MediaType.AUDIO_VIDEO) ->
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO
+ media_stream_tracks = media_stream.getAudioTracks()
+ when z.calling.enum.MediaType.AUDIO_VIDEO
+ media_stream_tracks = media_stream.getTracks()
+ when z.calling.enum.MediaType.VIDEO
+ media_stream_tracks = media_stream.getVideoTracks()
+ return media_stream_tracks
+
+ ###
+ Construct a new MediaStream handler.
+ @param call_center [z.calling.CallCenter] Call center with references to all other handlers
+ ###
+ constructor: (@call_center) ->
+ @logger = new z.util.Logger 'z.calling.handler.MediaDevicesHandler', z.config.LOGGER.OPTIONS
+
+ @local_media_streams =
+ audio: ko.observable()
+ video: ko.observable()
+
+ @remote_media_streams =
+ audio: ko.observableArray []
+ video: ko.observable()
+
+ @self_stream_state =
+ muted: ko.observable false
+ screen_shared: ko.observable false
+ videod: ko.observable false
+
+ @local_media_type = ko.observable z.calling.enum.MediaType.AUDIO
+
+ @has_active_streams = ko.pureComputed =>
+ return @local_media_streams.audio()?.active or @local_media_streams.video()?.active
+
+ @request_hint_timeout = undefined
+
+ @local_media_streams.audio.subscribe (media_stream) =>
+ if media_stream
+ @logger.log @logger.levels.DEBUG, "Local MediaStream contains MediaStreamTrack of kind 'audio'",
+ {stream: media_stream, audio_tracks: media_stream.getAudioTracks()}
+ @local_media_streams.video.subscribe (media_stream) =>
+ if media_stream
+ @logger.log @logger.levels.DEBUG, "Local MediaStream contains MediaStreamTrack of kind 'video'",
+ {stream: media_stream, video_tracks: media_stream.getVideoTracks()}
+
+ @current_device_id = @call_center.media_devices_handler.current_device_id
+
+ amplify.subscribe z.event.WebApp.CALL.MEDIA.ADD_STREAM, @add_remote_media_stream
+
+
+ ###############################################################################
+ # MediaStream constraints
+ ###############################################################################
+
+ ###
+ Get the MediaStreamConstraints to be used for MediaStream creation.
+
+ @private
+ @param request_audio [Boolean] Request audio in the constraints
+ @param request_video [Boolean] Request video in the constraints
+ @return [Object] MediaStreamConstraints
+ ###
+ get_media_stream_constraints: (request_audio = false, request_video = false) ->
+ Promise.resolve()
+ .then =>
+ constraints =
+ audio: if request_audio then @_get_audio_stream_constraints @current_device_id.audio_input() else undefined
+ video: if request_video then @_get_video_stream_constraints @current_device_id.video_input() else undefined
+ @logger.log @logger.levels.INFO, 'Set constraints for MediaStream', constraints
+ media_type = if request_video then z.calling.enum.MediaType.VIDEO else z.calling.enum.MediaType.AUDIO
+ return [media_type, constraints]
+
+ ###
+ Get the MediaStreamConstraints to be used for screen sharing.
+ @return [Object] MediaStreamConstraints
+ ###
+ get_screen_stream_constraints: =>
+ return new Promise (resolve, reject) =>
+ if window.desktopCapturer
+ @logger.log @logger.levels.INFO, 'Enabling screen sharing from Electron'
+
+ constraints =
+ audio: false
+ video:
+ mandatory:
+ chromeMediaSource: 'desktop'
+ chromeMediaSourceId: @current_device_id.screen_input()
+ maxHeight: 720
+ maxWidth: 1280
+ minWidth: 1280
+ minHeight: 720
+
+ resolve [z.calling.enum.MediaType.SCREEN, constraints]
+
+ else if z.util.Environment.browser.firefox
+ @logger.log @logger.levels.INFO, 'Enabling screen sharing from Firefox'
+
+ constraints =
+ audio: false
+ video:
+ mediaSource: 'screen'
+
+ resolve [z.calling.enum.MediaType.SCREEN, constraints]
+ else
+ reject new z.calling.CallError 'Screen sharing is not yet supported by this browser', z.calling.CallError::TYPE.NOT_SUPPORTED
+
+ ###
+ Get the video constraints to be used for MediaStream creation.
+ @private
+ @param media_device_id [String] Optional ID of MediaDevice to be used
+ @return [Object] Video stream constraints
+ ###
+ _get_audio_stream_constraints: (media_device_id) ->
+ if _.isString media_device_id and media_device_id isnt 'default'
+ media_stream_constraints =
+ deviceId:
+ exact: media_device_id
+ else
+ media_stream_constraints = true
+
+ return media_stream_constraints
+
+ ###
+ Get the video constraints to be used for MediaStream creation.
+ @private
+ @param media_device_id [String] Optional ID of MediaDevice to be used
+ @return [Object] Video stream constraints
+ ###
+ _get_video_stream_constraints: (media_device_id) ->
+ media_stream_constraints =
+ facingMode: 'user'
+ frameRate: 30
+ width:
+ min: 640
+ ideal: 640
+ max: 1280
+ height:
+ min: 360
+ ideal: 360
+ max: 720
+
+ if _.isString media_device_id
+ media_stream_constraints.deviceId =
+ exact: media_device_id
+
+ return media_stream_constraints
+
+
+ ###############################################################################
+ # Local MediaStream handling
+ ###############################################################################
+
+ ###
+ Initiate the MediaStream.
+ @param conversation_id [String] Conversation ID of call
+ @param is_videod [Boolean] Should MediaStreamContain video
+ @return [Promise] Promise that resolve when the MediaStream has been initiated
+ ###
+ initiate_media_stream: (conversation_id, is_videod) =>
+ @call_center.media_devices_handler.update_current_devices is_videod
+ .then =>
+ return @get_media_stream_constraints true, is_videod
+ .then ([media_type, media_stream_constraints]) =>
+ return @request_media_stream media_type, media_stream_constraints, conversation_id
+ .then (media_stream_info) =>
+ @self_stream_state.videod is_videod
+ @local_media_type z.calling.enum.MediaType.VIDEO if is_videod
+ @_initiate_media_stream_success media_stream_info
+ .catch (error) =>
+ if _.isArray error
+ [error, media_type] = error
+ @_initiate_media_stream_failure error, media_type, conversation_id
+ @logger.log @logger.levels.ERROR, "Requesting MediaStream failed: #{error.name}", error
+ @call_center.telemetry.track_event z.tracking.EventName.CALLING.FAILED_REQUESTING_MEDIA, undefined, {cause: error.name, video: is_videod}
+
+ # Release the MediaStreams.
+ release_media_streams: =>
+ media_streams_identical = @_compare_local_media_streams()
+
+ @local_media_streams.audio undefined if @_release_media_stream @local_media_streams.audio()
+ @local_media_streams.video undefined if media_streams_identical or @_release_media_stream @local_media_streams.video()
+
+ ###
+ Replace the MediaStream after a change of the selected input device.
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] Info about new MediaStream
+ ###
+ replace_media_stream: (media_stream_info) =>
+ @logger.log @logger.levels.DEBUG, "Received new MediaStream with '#{media_stream_info.stream.getTracks().length}' MediaStreamTrack/s",
+ {stream: media_stream_info.stream, audio_tracks: media_stream_info.stream.getAudioTracks(), video_tracks: media_stream_info.stream.getVideoTracks()}
+ @_set_stream_state media_stream_info
+ return Promise.all (flow_et.switch_media_stream media_stream_info for flow_et in @call_center.joined_call().get_flows())
+ .then (resolve_array) =>
+ [media_stream_info, replaced_stream] = resolve_array[0]
+ if replaced_stream
+ @release_media_streams media_stream_info.type
+ else
+ if media_stream_info.type is z.calling.enum.MediaType.VIDEO
+ @_release_media_stream @local_media_streams.video(), z.calling.enum.MediaType.VIDEO
+ else
+ @_release_media_stream @local_media_streams.audio(), z.calling.enum.MediaType.AUDIO
+ @set_local_media_stream media_stream_info
+
+ ###
+ Update the used MediaStream after a new input device was selected.
+ @param type [z.calling.enum.MediaType] Media type of device that was replaced
+ ###
+ replace_input_source: (media_type) =>
+ return if not @needs_media_stream()
+
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO
+ constraints_promise = @get_media_stream_constraints true, false
+ when z.calling.enum.MediaType.SCREEN
+ constraints_promise = @get_screen_stream_constraints()
+ when z.calling.enum.MediaType.VIDEO
+ request_audio = not z.util.Environment.browser.firefox
+ constraints_promise = @get_media_stream_constraints request_audio, true
+
+ constraints_promise.then ([media_type, media_stream_constraints]) =>
+ return @request_media_stream media_type, media_stream_constraints
+ .then (media_stream_info) =>
+ @_set_self_stream_state media_type
+ return @replace_media_stream media_stream_info
+ .catch (error) =>
+ [error, media_type] = error if _.isArray error
+ @_replace_input_source_failure error, media_type
+
+ ###
+ Request a MediaStream.
+
+ @param media_type [z.calling.enum.MediaType] Type of MediaStream to be requested
+ @param media_stream_constraints [RTCMediaStreamConstraints] Constraints for the MediaStream to be requested
+ @param conversation_id [String] Conversation ID
+ @return [Promise] Promise that will resolve with an array of the stream type and the stream
+ ###
+ request_media_stream: (media_type, media_stream_constraints, conversation_id) =>
+ return new Promise (resolve, reject) =>
+ if not @call_center.media_devices_handler.has_microphone()
+ @logger.log @logger.levels.WARN, "Requesting MediaStream access aborted - 'No microphone'"
+ @_show_device_not_found_hint z.calling.enum.MediaType.AUDIO, conversation_id
+ reject new z.calling.CallError 'No microphone found', z.calling.CallError::TYPE.NO_MICROPHONE_FOUND
+ else if not @call_center.media_devices_handler.has_camera() and media_stream_constraints.video
+ @logger.log @logger.levels.WARN, "Requesting MediaStream access aborted - 'No camera'"
+ @_show_device_not_found_hint z.calling.enum.MediaType.VIDEO, conversation_id
+ reject new z.calling.CallError 'No camera found', z.calling.CallError::TYPE.NO_CAMERA_FOUND
+ else
+ @logger.log @logger.levels.INFO, "Requesting MediaStream access for '#{media_type}'", media_stream_constraints
+ @request_hint_timeout = window.setTimeout =>
+ @_hide_permission_failed_hint media_type
+ @_show_permission_request_hint media_type
+ @request_hint_timeout = undefined
+ , 200
+
+ @call_center.timings().time_step z.telemetry.calling.CallSetupSteps.STREAM_REQUESTED if @call_center.timings()
+ navigator.mediaDevices.getUserMedia media_stream_constraints
+ .then (media_stream) =>
+ @_clear_permission_request_hint media_type
+ resolve new z.calling.payloads.MediaStreamInfo z.calling.enum.MediaStreamSource.LOCAL, 'self', media_stream
+ .catch (error) =>
+ @_clear_permission_request_hint media_type
+ reject [error, media_type]
+
+ ###
+ Save a reference to a local MediaStream.
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] MediaStream and meta information
+ ###
+ set_local_media_stream: (media_stream_info) =>
+ if media_stream_info.type in [z.calling.enum.MediaType.AUDIO, z.calling.enum.MediaType.AUDIO_VIDEO]
+ @local_media_streams.audio media_stream_info.stream
+ if media_stream_info.type in [z.calling.enum.MediaType.AUDIO_VIDEO, z.calling.enum.MediaType.VIDEO]
+ @local_media_streams.video media_stream_info.stream
+
+ ###
+ Clear the permission request hint timeout or hide the warning.
+ @private
+ @param media_type [z.calling.enum.MediaType] Type of requested stream
+ ###
+ _clear_permission_request_hint: (media_type) ->
+ if @request_hint_timeout
+ window.clearTimeout @request_hint_timeout
+ else
+ @_hide_permission_request_hint media_type
+
+ ###
+ Compare the local MediaStreams for equality.
+ @private
+ @return [Boolean] True if both audio and video stream are identical
+ ###
+ _compare_local_media_streams: ->
+ return @local_media_streams.audio()?.id is @local_media_streams.video()?.id
+
+ ###
+ Hide the permission denied hint banner.
+ @private
+ @param media_type [z.calling.enum.MediaType] Type of requested stream
+ ###
+ _hide_permission_failed_hint: (media_type) ->
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO
+ amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.DENIED_MICROPHONE
+ when z.calling.enum.MediaType.SCREEN
+ amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.DENIED_SCREEN
+ when z.calling.enum.MediaType.VIDEO
+ amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.DENIED_CAMERA
+
+ ###
+ Hide the permission request hint banner.
+ @private
+ @param media_type [z.calling.enum.MediaType] Type of requested stream
+ ###
+ _hide_permission_request_hint: (media_type) ->
+ return if z.util.Environment.electron
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO
+ amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.REQUEST_MICROPHONE
+ when z.calling.enum.MediaType.SCREEN
+ amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.REQUEST_SCREEN
+ when z.calling.enum.MediaType.VIDEO
+ amplify.publish z.event.WebApp.WARNINGS.DISMISS, z.ViewModel.WarningType.REQUEST_CAMERA
+
+ ###
+ Initial request for local MediaStream was successful.
+ @private
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] Type of requested MediaStream
+ ###
+ _initiate_media_stream_success: (media_stream_info) =>
+ return if not media_stream_info
+ @call_center.timings().time_step z.telemetry.calling.CallSetupSteps.STREAM_RECEIVED if @call_center.timings()
+ @logger.log @logger.levels.DEBUG, "Received initial MediaStream with '#{media_stream_info.stream.getTracks().length}' MediaStreamTrack/s",
+ {stream: media_stream_info.stream, audio_tracks: media_stream_info.stream.getAudioTracks(), video_tracks: media_stream_info.stream.getVideoTracks()}
+ @set_local_media_stream media_stream_info
+
+ ###
+ Local MediaStream creation failed.
+
+ @private
+ @param error [MediaStreamError] Error message from navigator.MediaDevices.getUserMedia()
+ @param media_type [z.calling.enum.MediaType] Type of requested MediaStream
+ @param conversation_id [String] Conversation ID
+ ###
+ _initiate_media_stream_failure: (error, media_type, conversation_id) =>
+ if error.name in z.calling.rtc.MediaStreamErrorTypes.PERMISSION
+ @_show_permission_denied_hint media_type
+ else if error.name in z.calling.rtc.MediaStreamErrorTypes.MISC
+ @_show_permission_denied_hint media_type
+ else if error.name in z.calling.rtc.MediaStreamErrorTypes.DEVICE
+ @_show_device_not_found_hint media_type, conversation_id
+
+ ###
+ Release the MediaStream.
+
+ @private
+ @param stream [MediaStream] MediaStream to be released
+ @param media_type [z.calling.enum.MediaType] Type of MediaStreamTracks to be released
+ ###
+ _release_media_stream: (media_stream, media_type = z.calling.enum.MediaType.AUDIO_VIDEO) =>
+ return false if not media_stream
+
+ media_stream_tracks = z.calling.handler.MediaStreamHandler.get_media_tracks media_stream, media_type
+
+ if media_stream_tracks.length
+ for media_stream_track in media_stream_tracks
+ media_stream.removeTrack media_stream_track
+ media_stream_track.stop()
+ @logger.log @logger.levels.INFO, "Stopping MediaStreamTrack of kind '#{media_stream_track.kind}' successful", media_stream_track
+ return true
+ else
+ @logger.log @logger.levels.WARN, 'No MediaStreamTrack found to stop', media_stream
+ return false
+
+ ###
+ Failed to replace an input source.
+
+ @private
+ @param error [Error] Error thrown when attempting to replace the source
+ @param media_type [z.calling.enum.MediaType] Type of failed request
+ ###
+ _replace_input_source_failure: (error, media_type) ->
+ if media_type is z.calling.enum.MediaType.SCREEN
+ if z.util.Environment.browser.firefox and error.name is z.calling.rtc.MediaStreamError.NOT_ALLOWED_ERROR
+ @logger.log @logger.levels.WARN, 'We are not on the white list. Manually add the current domain to media.getusermedia.screensharing.allowed_domains on about:config'
+ amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.WHITELIST_SCREENSHARING
+ else
+ @logger.log @logger.levels.ERROR, "Failed to enable screen sharing: #{error.message}", error
+ else
+ @logger.log @logger.levels.ERROR, "Failed to replace '#{media_type}' input source: #{error.message}", error
+
+ ###
+ Show microphone not found hin banner.
+
+ @private
+ @param media_type [z.calling.enum.MediaType] Type of device not found
+ @param conversation_id [String] Optional conversation ID
+ ###
+ _show_device_not_found_hint: (media_type, conversation_id) ->
+ if media_type is z.calling.enum.MediaType.AUDIO
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.NOT_FOUND_MICROPHONE
+ else if media_type is z.calling.enum.MediaType.VIDEO
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.NOT_FOUND_CAMERA
+ amplify.publish z.event.WebApp.CALL.STATE.IGNORE, conversation_id if conversation_id
+
+ ###
+ Show permission denied hint banner.
+ @private
+ @param media_type [z.calling.enum.MediaType] Type of media access request
+ ###
+ _show_permission_denied_hint: (media_type) ->
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.DENIED_MICROPHONE
+ when z.calling.enum.MediaType.SCREEN
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.DENIED_SCREEN
+ when z.calling.enum.MediaType.VIDEO
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.DENIED_CAMERA
+
+ ###
+ Show permission request hint banner.
+ @private
+ @param media_type [z.calling.enum.MediaType] Type of requested MediaStream
+ ###
+ _show_permission_request_hint: (media_type) ->
+ return if z.util.Environment.electron
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.REQUEST_MICROPHONE
+ when z.calling.enum.MediaType.SCREEN
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.REQUEST_SCREEN
+ when z.calling.enum.MediaType.VIDEO
+ amplify.publish z.event.WebApp.WARNINGS.SHOW, z.ViewModel.WarningType.REQUEST_CAMERA
+
+
+ ###############################################################################
+ # Remote MediaStream handling
+ ###############################################################################
+
+ ###
+ Add a remote MediaStream.
+ @param media_stream_info [z.calling.payload.MediaStreamInfo] MediaStream information
+ ###
+ add_remote_media_stream: (media_stream_info) =>
+ switch media_stream_info.type
+ when z.calling.enum.MediaType.AUDIO
+ @remote_media_streams.audio.push media_stream_info.stream
+ @call_center.media_element_handler.add_media_element media_stream_info
+ when z.calling.enum.MediaType.AUDIO_VIDEO, z.calling.enum.MediaType.VIDEO
+ @remote_media_streams.video media_stream_info.stream
+
+
+ ###############################################################################
+ # Media handling
+ ###############################################################################
+
+ ###
+ Check for active calls that need a MediaStream.
+ @return [Boolean] Returns true if an active media stream is needed for at least one call
+ ###
+ needs_media_stream: ->
+ for call_et in @call_center.calls()
+ return true if call_et.is_remote_videod() and call_et.state() is z.calling.enum.CallState.INCOMING
+ return true if call_et.self_client_joined()
+ return false
+
+ # Toggle the camera.
+ toggle_camera_paused: =>
+ if @local_media_streams.video() and @local_media_type() is z.calling.enum.MediaType.VIDEO
+ @_toggle_video_enabled()
+ else
+ @replace_input_source z.calling.enum.MediaType.VIDEO
+
+ # Toggle the mute state of the microphone.
+ toggle_microphone_muted: =>
+ @_toggle_audio_enabled() if @local_media_streams.audio()
+
+ # Toggle the screen.
+ toggle_screen_shared: =>
+ if @local_media_streams.video() and @local_media_type() is z.calling.enum.MediaType.SCREEN
+ @_toggle_screen_enabled()
+ else
+ @replace_input_source z.calling.enum.MediaType.SCREEN
+
+ # Reset the enabled states of media types.
+ reset_self_states: =>
+ @self_stream_state.muted false
+ @self_stream_state.screen_shared false
+ @self_stream_state.videod false
+ @local_media_type z.calling.enum.MediaType.AUDIO
+
+ # Reset the MediaStreams and states.
+ reset_media_streams: =>
+ if not @needs_media_stream()
+ @call_center.audio_repository.close_audio_context()
+ @release_media_streams()
+ @reset_self_states()
+
+ ###
+ Set the self stream state to reflect current call type.
+ @param media_type [z.calling.enum.MediaType] Type of state to enable
+ ###
+ _set_self_stream_state: (media_type) ->
+ switch media_type
+ when z.calling.enum.MediaType.AUDIO
+ @self_stream_state.muted false
+ when z.calling.enum.MediaType.SCREEN
+ @self_stream_state.videod false
+ @self_stream_state.screen_shared true
+ @local_media_type z.calling.enum.MediaType.SCREEN
+ when z.calling.enum.MediaType.VIDEO
+ @self_stream_state.videod true
+ @self_stream_state.screen_shared false
+ @local_media_type z.calling.enum.MediaType.VIDEO
+
+ ###
+ Set the enabled state of a new MediaStream.
+ @private
+ @param media_stream_info [z.calling.payloads.MediaStreamInfo] Info about MediaStream to set state off
+ ###
+ _set_stream_state: (media_stream_info) ->
+ if media_stream_info.type in [z.calling.enum.MediaType.AUDIO, z.calling.enum.MediaType.AUDIO_VIDEO]
+ audio_stream_tracks = z.calling.handler.MediaStreamHandler.get_media_tracks media_stream_info.stream, z.calling.enum.MediaType.AUDIO
+ audio_stream_tracks[0].enabled = not @self_stream_state.muted()
+
+ if media_stream_info.type in [z.calling.enum.MediaType.AUDIO_VIDEO, z.calling.enum.MediaType.VIDEO]
+ video_stream_tracks = z.calling.handler.MediaStreamHandler.get_media_tracks media_stream_info.stream, z.calling.enum.MediaType.VIDEO
+ video_stream_tracks[0].enabled = @self_stream_state.screen_shared() or @self_stream_state.videod()
+
+ ###
+ Toggle the audio stream.
+ @private
+ ###
+ _toggle_audio_enabled: ->
+ @_toggle_stream_enabled z.calling.enum.MediaType.AUDIO, @local_media_streams.audio(), @self_stream_state.muted
+ .then (audio_track) =>
+ @logger.log @logger.levels.INFO, "Microphone muted: #{@self_stream_state.muted()}", audio_track
+ return @self_stream_state.muted()
+
+ ###
+ Toggle the screen stream.
+ @private
+ ###
+ _toggle_screen_enabled: ->
+ @_toggle_stream_enabled z.calling.enum.MediaType.VIDEO, @local_media_streams.video(), @self_stream_state.screen_shared
+ .then (video_track) =>
+ @logger.log @logger.levels.INFO, "Screen enabled: #{@self_stream_state.screen_shared()}", video_track
+ return @self_stream_state.screen_shared()
+
+ ###
+ Toggle the video stream.
+ @private
+ ###
+ _toggle_video_enabled: ->
+ @_toggle_stream_enabled z.calling.enum.MediaType.VIDEO, @local_media_streams.video(), @self_stream_state.videod
+ .then (video_track) =>
+ @logger.log @logger.levels.INFO, "Camera enabled: #{@self_stream_state.videod()}", video_track
+ return @self_stream_state.videod()
+
+ ###
+ Toggle the enabled state of a MediaStream.
+
+ @private
+ @param media_type [z.calling.enum.MediaType] Media type to toggle
+ @param media_stream [MediaStream] MediaStream to toggle enabled state off
+ @param state_observable [ko.observable] State observable to invert
+ @return [MediaStreamTrack] Updated MediaStreamTrack with new enabled state
+ ###
+ _toggle_stream_enabled: (media_type, media_stream, state_observable) ->
+ Promise.resolve()
+ .then ->
+ state_observable not state_observable()
+ media_stream_track = (z.calling.handler.MediaStreamHandler.get_media_tracks media_stream, media_type)[0]
+ if media_type is z.calling.enum.MediaType.AUDIO
+ enabled_state = not state_observable()
+ amplify.publish z.event.WebApp.CALL.MEDIA.MUTE_AUDIO, state_observable()
+ else
+ enabled_state = state_observable()
+ media_stream_track.enabled = enabled_state
+ return media_stream_track
diff --git a/app/script/calling/mapper/ICECandidateMapper.coffee b/app/script/calling/mapper/ICECandidateMapper.coffee
new file mode 100644
index 00000000000..4e710c5c5d7
--- /dev/null
+++ b/app/script/calling/mapper/ICECandidateMapper.coffee
@@ -0,0 +1,45 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.mapper ?= {}
+
+class z.calling.mapper.ICECandidateMapper
+ constructor: ->
+ @logger = new z.util.Logger 'z.calling.mapper.ICECandidateMapper', z.config.LOGGER.OPTIONS
+
+ ###
+ @param ice_candidate [RTCIceCandidate] Interactive Connectivity Establishment (ICE) Candidate
+ ###
+ map_ice_object_to_message: (ice_candidate) ->
+ message =
+ sdp: ice_candidate.candidate
+ sdp_mline_index: ice_candidate.sdpMLineIndex
+ sdp_mid: ice_candidate.sdpMid
+
+ return message
+
+ # We have to convert camel-case to underscores
+ map_ice_message_to_object: (ice_message) ->
+ candidate_info =
+ candidate: ice_message.sdp
+ sdpMLineIndex: ice_message.sdp_mline_index
+ sdpMid: ice_message.sdp_mid
+
+ return new RTCIceCandidate candidate_info
diff --git a/app/script/calling/mapper/SDPMapper.coffee b/app/script/calling/mapper/SDPMapper.coffee
new file mode 100644
index 00000000000..72f12a906d2
--- /dev/null
+++ b/app/script/calling/mapper/SDPMapper.coffee
@@ -0,0 +1,41 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.mapper ?= {}
+
+class z.calling.mapper.SDPMapper
+ constructor: ->
+ @logger = new z.util.Logger 'z.calling.mapper.SDPMapper', z.config.LOGGER.OPTIONS
+
+ map_sdp_event_to_object: (event) =>
+ remote_sdp =
+ sdp: @_convert_sdp_fingerprint_to_uppercase event.sdp
+ type: event.state
+
+ return new window.RTCSessionDescription remote_sdp
+
+ _convert_sdp_fingerprint_to_uppercase: (sdp_string) ->
+ sdp_lines = sdp_string.split '\r\n'
+
+ for sdp_line, index in sdp_lines when sdp_line.startsWith 'a=fingerprint'
+ sdp_line_parts = sdp_line.split ' '
+ sdp_lines[index] = "#{sdp_line_parts[0]} #{sdp_line_parts[1].toUpperCase()}"
+
+ return sdp_lines.join '\r\n'
diff --git a/app/script/calling/payloads/FlowDeletionInfo.coffee b/app/script/calling/payloads/FlowDeletionInfo.coffee
new file mode 100644
index 00000000000..0628f12ed94
--- /dev/null
+++ b/app/script/calling/payloads/FlowDeletionInfo.coffee
@@ -0,0 +1,29 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.payloads ?= {}
+
+z.calling.payloads.FlowDeletionReason =
+ RELEASED: 'released'
+ TIMEOUT: 'timeout'
+
+class z.calling.payloads.FlowDeletionInfo
+ constructor: (@conversation_id, @flow_id, @reason) ->
+ return @
diff --git a/app/script/calling/payloads/ICECandidateInfo.coffee b/app/script/calling/payloads/ICECandidateInfo.coffee
new file mode 100644
index 00000000000..d00e3816403
--- /dev/null
+++ b/app/script/calling/payloads/ICECandidateInfo.coffee
@@ -0,0 +1,34 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.payloads ?= {}
+
+class z.calling.payloads.ICECandidateInfo
+ ###
+ Object to keep an ICE candidate bundled with signaling information.
+
+ @param conversation_id [String] Conversation ID
+ @param flow_id [String] Flow ID
+ @param ice_candidate [RTCIceCandidate] Interactive Connectivity Establishment (ICE) Candidate
+ ###
+ constructor: (conversation_id, flow_id, ice_candidate) ->
+ @conversation_id = conversation_id
+ @flow_id = flow_id
+ @ice_candidate = ice_candidate
diff --git a/app/script/calling/payloads/MediaStreamInfo.coffee b/app/script/calling/payloads/MediaStreamInfo.coffee
new file mode 100644
index 00000000000..aecf513d4f5
--- /dev/null
+++ b/app/script/calling/payloads/MediaStreamInfo.coffee
@@ -0,0 +1,33 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.payloads ?= {}
+
+class z.calling.payloads.MediaStreamInfo
+ constructor: (@source, @flow_id, @stream, @call_et) ->
+ @type = z.calling.enum.MediaType.NONE
+
+ @conversation_id = @call_et?.id
+ @update_stream_type()
+ return @
+
+ update_stream_type: =>
+ @stream = z.calling.handler.MediaStreamHandler.detect_media_stream_type @stream
+ @type = @stream.type
diff --git a/app/script/calling/payloads/SDPInfo.coffee b/app/script/calling/payloads/SDPInfo.coffee
new file mode 100644
index 00000000000..f925521db3a
--- /dev/null
+++ b/app/script/calling/payloads/SDPInfo.coffee
@@ -0,0 +1,35 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.payloads ?= {}
+
+class z.calling.payloads.SDPInfo
+ ###
+ Object to keep an SDP bundled with signaling information.
+
+ @param params [Object] Properties to setup the ICE information container
+ @option params [String] conversation_id Conversation ID
+ @option params [String] flow_id Flow ID
+ @option params [RTCSessionDescription, mozRTCSessionDescription] sdp Session Description Protocol (SDP)
+ ###
+ constructor: (params) ->
+ @conversation_id = params.conversation_id
+ @flow_id = params.flow_id
+ @sdp = params.sdp
diff --git a/app/script/calling/rtc/ICEConnectionState.coffee b/app/script/calling/rtc/ICEConnectionState.coffee
new file mode 100644
index 00000000000..78d2d16ec2a
--- /dev/null
+++ b/app/script/calling/rtc/ICEConnectionState.coffee
@@ -0,0 +1,32 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.rtc ?= {}
+
+# http://www.w3.org/TR/webrtc/#rtciceconnectionstate-enum
+# https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection.iceConnectionState#Value
+z.calling.rtc.ICEConnectionState =
+ NEW: 'new'
+ CHECKING: 'checking'
+ CONNECTED: 'connected'
+ COMPLETED: 'completed'
+ FAILED: 'failed'
+ DISCONNECTED: 'disconnected'
+ CLOSED: 'closed'
diff --git a/app/script/calling/rtc/ICEGatheringState.coffee b/app/script/calling/rtc/ICEGatheringState.coffee
new file mode 100644
index 00000000000..3f82395c456
--- /dev/null
+++ b/app/script/calling/rtc/ICEGatheringState.coffee
@@ -0,0 +1,28 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.rtc ?= {}
+
+# http://www.w3.org/TR/webrtc/#rtcicegatheringstate-enum
+# https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection.iceGatheringState#Value
+z.calling.rtc.ICEGatheringState =
+ NEW: 'new'
+ GATHERING: 'gathering'
+ COMPLETE: 'complete'
diff --git a/app/script/calling/rtc/MediaStreamError.coffee b/app/script/calling/rtc/MediaStreamError.coffee
new file mode 100644
index 00000000000..72e6f1f0d7c
--- /dev/null
+++ b/app/script/calling/rtc/MediaStreamError.coffee
@@ -0,0 +1,37 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.rtc ?= {}
+
+# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Errors
+z.calling.rtc.MediaStreamError =
+ ABORT_ERROR: 'AbortError'
+ DEVICES_NOT_FOUND_ERROR: 'DevicesNotFoundError'
+ INTERNAL_ERROR: 'InternalError'
+ INVALID_STATE_ERROR: 'InvalidStateError'
+ NOT_ALLOWED_ERROR: 'NotAllowedError'
+ NOT_FOUND_ERROR: 'NotFoundError'
+ NOT_READABLE_ERROR: 'NotReadableError'
+ OVER_CONSTRAINED_ERROR: 'OverConstrainedError'
+ PERMISSION_DENIED_ERROR: 'PermissionDeniedError'
+ PERMISSION_DISMISSED_ERROR: 'PermissionDismissedError'
+ SECURITY_ERROR: 'SecurityError'
+ TYPE_ERROR: 'TypeError'
+ SOURCE_UNAVAILABLE_ERROR: 'SourceUnavailableError'
diff --git a/app/script/calling/rtc/MediaStreamErrorTypes.coffee b/app/script/calling/rtc/MediaStreamErrorTypes.coffee
new file mode 100644
index 00000000000..b7764dd91bc
--- /dev/null
+++ b/app/script/calling/rtc/MediaStreamErrorTypes.coffee
@@ -0,0 +1,42 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.rtc ?= {}
+
+# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Errors
+z.calling.rtc.MediaStreamErrorTypes =
+ DEVICE: [
+ z.calling.rtc.MediaStreamError.ABORT_ERROR
+ z.calling.rtc.MediaStreamError.DEVICES_NOT_FOUND_ERROR
+ z.calling.rtc.MediaStreamError.NOT_FOUND_ERROR
+ z.calling.rtc.MediaStreamError.NOT_READABLE_ERROR
+ ]
+ MISC: [
+ z.calling.rtc.MediaStreamError.INTERNAL_ERROR
+ z.calling.rtc.MediaStreamError.INVALID_STATE_ERROR
+ z.calling.rtc.MediaStreamError.SOURCE_UNAVAILABLE_ERROR
+ z.calling.rtc.MediaStreamError.OVER_CONSTRAINED_ERROR
+ z.calling.rtc.MediaStreamError.TYPE_ERROR
+ ]
+ PERMISSION: [
+ z.calling.rtc.MediaStreamError.PERMISSION_DENIED_ERROR
+ z.calling.rtc.MediaStreamError.PERMISSION_DISMISSED_ERROR
+ z.calling.rtc.MediaStreamError.SECURITY_ERROR
+ ]
diff --git a/app/script/calling/rtc/SDPType.coffee b/app/script/calling/rtc/SDPType.coffee
new file mode 100644
index 00000000000..fdcb9a66077
--- /dev/null
+++ b/app/script/calling/rtc/SDPType.coffee
@@ -0,0 +1,31 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.rtc ?= {}
+
+# http://www.w3.org/TR/webrtc/#rtcsdptype
+# https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription#RTCSdpType
+z.calling.rtc.SDPType =
+ ANSWER: 'answer'
+ LOCAL: 'local'
+ REMOTE: 'remote'
+ OFFER: 'offer'
+ PROVISIONAL_ANSWER: 'pranswer'
+ ROLLBACK: 'rollback'
diff --git a/app/script/calling/rtc/SignalingState.coffee b/app/script/calling/rtc/SignalingState.coffee
new file mode 100644
index 00000000000..35d75a4741b
--- /dev/null
+++ b/app/script/calling/rtc/SignalingState.coffee
@@ -0,0 +1,32 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.rtc ?= {}
+
+# http://www.w3.org/TR/webrtc/#rtcpeerstate-enum
+# https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection.signalingState#Value
+z.calling.rtc.SignalingState =
+ NEW: 'new'
+ STABLE: 'stable'
+ LOCAL_OFFER: 'have-local-offer'
+ REMOTE_OFFER: 'have-remote-offer'
+ LOCAL_PROVISIONAL_ANSWER: 'have-local-pranswer'
+ REMOTE_PROVISIONAL_ANSWER: 'have-remote-pranswer'
+ CLOSED: 'closed'
diff --git a/app/script/calling/rtc/StatsType.coffee b/app/script/calling/rtc/StatsType.coffee
new file mode 100644
index 00000000000..9dbb43e8a07
--- /dev/null
+++ b/app/script/calling/rtc/StatsType.coffee
@@ -0,0 +1,29 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.calling ?= {}
+z.calling.rtc ?= {}
+
+# http://www.w3.org/TR/webrtc/#idl-def-RTCStats
+z.calling.rtc.StatsType =
+ CANDIDATE_PAIR: 'candidatepair'
+ GOOGLE_CANDIDATE_PAIR: 'googCandidatePair'
+ INBOUND_RTP: 'inboundrtp'
+ OUTBOUND_RTP: 'outboundrtp'
+ SSRC: 'ssrc'
diff --git a/app/script/client/Client.coffee b/app/script/client/Client.coffee
new file mode 100644
index 00000000000..da21ff436a4
--- /dev/null
+++ b/app/script/client/Client.coffee
@@ -0,0 +1,75 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.client ?= {}
+
+class z.client.Client
+ constructor: (payload) ->
+ # Preserved data from the backend
+ @[member] = payload[member] for member of payload
+
+ # Maintained meta data by us
+ @meta =
+ is_verified: ko.observable false
+ primary_key: undefined
+
+ @session = {}
+
+ return @
+
+ ###
+ Splits an ID into user ID & client ID.
+ @param id [String] ID
+ @return [Object] Object containing the user ID & client ID
+ ###
+ @dismantle_user_client_id: (id) ->
+ id_parts = id?.split('@') or []
+ return {
+ user_id: id_parts[0]
+ client_id: id_parts[1]
+ }
+
+ ###
+ @return [Boolean] True, if the client is the self user's permanent client.
+ ###
+ is_permanent: ->
+ return @type is z.client.ClientType.PERMANENT
+
+ ###
+ @return [Boolean] True, if it is NOT the client of the self user.
+ ###
+ is_remote: ->
+ return not @is_permanent() and not @is_temporary()
+
+ ###
+ @return [Boolean] True, if the client is the self user's temporary client.
+ ###
+ is_temporary: ->
+ return @type is z.client.ClientType.TEMPORARY
+
+ ###
+ This method returns a JSON object which can be stored in our local database.
+
+ @return [JSON] JSON object
+ ###
+ to_json: ->
+ json = ko.toJSON @
+ real_json = JSON.parse json
+ delete real_json.session
+ return real_json
diff --git a/app/script/client/ClientError.coffee b/app/script/client/ClientError.coffee
new file mode 100644
index 00000000000..6ace75a396b
--- /dev/null
+++ b/app/script/client/ClientError.coffee
@@ -0,0 +1,41 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.client ?= {}
+
+class z.client.ClientError
+ constructor: (message, type) ->
+ @name = @constructor.name
+ @message = message
+ @type = type
+ @stack = (new Error()).stack
+
+ @:: = new Error()
+ @::constructor = @
+ @::TYPE = {
+ CLIENT_NOT_SET: 'z.client.ClientError::TYPE.CLIENT_NOT_SET'
+ DATABASE_FAILURE: 'z.client.ClientError::TYPE.DATABASE_FAILURE'
+ MISSING_ON_BACKEND: 'z.client.ClientError::TYPE.MISSING_ON_BACKEND'
+ NO_CLIENT_ID: 'z.client.ClientError::TYPE.NO_CLIENT_ID'
+ NO_LOCAL_CLIENT: 'z.client.ClientError::TYPE.NO_LOCAL_CLIENT'
+ NO_USER_ID: 'z.client.ClientError::TYPE.NO_USER_ID'
+ REQUEST_FAILURE: 'z.client.ClientError::TYPE.REQUEST_FAILURE'
+ REQUEST_FORBIDDEN: 'z.client.ClientError::TYPE.REQUEST_FORBIDDEN'
+ TOO_MANY_CLIENTS: 'z.client.ClientError::TYPE.TOO_MANY_CLIENTS'
+ }
diff --git a/app/script/client/ClientMapper.coffee b/app/script/client/ClientMapper.coffee
new file mode 100644
index 00000000000..a8a48788e60
--- /dev/null
+++ b/app/script/client/ClientMapper.coffee
@@ -0,0 +1,60 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.client ?= {}
+
+class z.client.ClientMapper
+
+ ###
+ Maps a JSON into a Client entity.
+ @param client_payload [JSON] Client payload
+ @return [z.client.Client] Client entity
+ ###
+ map_client: (client_payload) ->
+ client_et = new z.client.Client client_payload
+
+ if client_payload.meta
+ client_et.meta.is_verified client_payload.meta.is_verified
+ client_et.meta.primary_key = client_payload.meta.primary_key
+ client_et.meta.user_id = (z.client.Client.dismantle_user_client_id client_payload.meta.primary_key).user_id
+
+ return client_et
+
+ ###
+ Maps an object of client IDs with their payloads to client entities.
+ @param clients_payload [Array] Client payloads
+ @return [Array] Array of client entities
+ ###
+ map_clients: (clients_payload) ->
+ return (@map_client client_payload for client_payload in clients_payload)
+
+ ###
+ Update a client entity or object from JSON.
+
+ @param client [z.client.Client or Object] Client
+ @param update_payload [JSON] JSON possibly containing updates
+ @return [Array] An array that contains the client and whether there was a change
+ ###
+ update_client: (client, update_payload) ->
+ contains_update = false
+ for member of update_payload
+ if client[member] isnt update_payload[member]
+ contains_update = true
+ client[member] = update_payload[member]
+ return [client, contains_update]
diff --git a/app/script/client/ClientRepository.coffee b/app/script/client/ClientRepository.coffee
new file mode 100644
index 00000000000..0f9f7fe88af
--- /dev/null
+++ b/app/script/client/ClientRepository.coffee
@@ -0,0 +1,581 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.client ?= {}
+
+class z.client.ClientRepository
+ PRIMARY_KEY_CURRENT_CLIENT: 'local_identity'
+ constructor: (@client_service, @cryptography_repository) ->
+ @self_user = ko.observable undefined
+ @logger = new z.util.Logger 'z.client.ClientRepository', z.config.LOGGER.OPTIONS
+
+ @client_mapper = new z.client.ClientMapper()
+ @clients = ko.observableArray()
+ @current_client = ko.observable undefined
+
+ amplify.subscribe z.event.Backend.USER.CLIENT.ADD, @on_client_add
+ amplify.subscribe z.event.Backend.USER.CLIENT.REMOVE, @on_client_remove
+ amplify.subscribe z.event.WebApp.CLIENT.DELETE, @delete_client_and_session
+
+ return @
+
+ init: (self_user) ->
+ @self_user self_user
+ @logger.log @logger.levels.INFO, "Initialized repository with user ID '#{@self_user().id}'"
+
+ ###############################################################################
+ # Service interactions
+ ###############################################################################
+
+ ###
+ Delete the temporary client on the backend.
+ @return [Promise] Promise that resolves when the temporary client was deleted on the backend
+ ###
+ delete_temporary_client: ->
+ return @client_service.delete_temporary_client @current_client().id
+
+ ###
+ Load all known clients from the database.
+ @return [Promise] Promise that resolves with all the clients found in the local database
+ ###
+ get_all_clients_from_db: =>
+ return @client_service.load_all_clients_from_db()
+ .then (clients) =>
+ user_client_map = {}
+ for client in clients
+ ids = z.client.Client.dismantle_user_client_id client.meta.primary_key
+ continue if not ids.user_id or ids.user_id in [@self_user().id, @PRIMARY_KEY_CURRENT_CLIENT]
+ user_client_map[ids.user_id] ?= []
+ client_et = @client_mapper.map_client client
+ client_et.session = @cryptography_repository.load_session ids.user_id, ids.client_id
+ user_client_map[ids.user_id].push client_et
+ return user_client_map
+
+ ###
+ Retrieves meta information about specific client of the self user.
+ @param client_id [String] ID of client to be retrieved
+ @return [Promise] Promise that resolves with the retrieved client information
+ ###
+ get_client_by_id_from_backend: (client_id) =>
+ @client_service.get_client_by_id client_id
+
+ ###
+ Load all clients of a given user from the database.
+ @param user_id [String] ID of user to retrieve clients for
+ @return [Promise] Promise that resolves with all the known client entities for that user
+ ###
+ get_clients_from_db: (user_id) =>
+ @client_service.load_clients_from_db_by_user_id user_id
+ .then (clients_payload) =>
+ client_ets = @client_mapper.map_clients clients_payload
+ return client_ets
+
+ ###
+ Loads a client from the database (if it exists).
+ @return [Promise] Promise that resolves with the local client
+ ###
+ get_current_client_from_db: =>
+ @client_service.load_client_from_db @PRIMARY_KEY_CURRENT_CLIENT
+ .catch (error) ->
+ throw new z.client.ClientError error.message, z.client.ClientError::TYPE.DATABASE_FAILURE
+ .then (client_payload) =>
+ if _.isString client_payload
+ error_message = "No current local client connected to '#{@PRIMARY_KEY_CURRENT_CLIENT}' found in database"
+ @logger.log @logger.levels.INFO, error_message
+ throw new z.client.ClientError error_message, z.client.ClientError::TYPE.NO_LOCAL_CLIENT
+ else
+ client_et = @client_mapper.map_client client_payload
+ @current_client client_et
+ @logger.log @logger.levels.INFO,
+ "Loaded local client '#{client_et.id}' connected to '#{@PRIMARY_KEY_CURRENT_CLIENT}'", @current_client()
+ return @current_client()
+
+ ###
+ Updates properties for a client record in database.
+
+ @todo Merge "meta" property before updating it, Object.assign(payload.meta, changes.meta)
+ @param user_id [String] User ID of the client owner
+ @param client_id [String] Client ID which needs to get updated
+ @param changes [String] New values which should be updated on the client
+ @return [Integer] Number of updated records
+ ###
+ update_client_in_db: (user_id, client_id, changes) ->
+ primary_key = @_construct_primary_key user_id, client_id
+ # Preserve primary key on update
+ changes.meta.primary_key = primary_key
+ return @client_service.update_client_in_db primary_key, changes
+
+ ###
+ Construct the primary key to store clients in database.
+ @private
+
+ @param user_id [String] User ID from the owner of the client
+ @param client_id [String] Client ID
+ @return [String] Primary key
+ ###
+ _construct_primary_key: (user_id, client_id) ->
+ throw new z.client.ClientError 'User ID is not defined', z.client.ClientError::TYPE.NO_USER_ID if not user_id
+ throw new z.client.ClientError 'Client ID is not defined', z.client.ClientError::TYPE.NO_CLIENT_ID if not client_id
+ return "#{user_id}@#{client_id}"
+
+ ###
+ Save the a client into the database.
+
+ @private
+ @param user_id [String] ID of user client to be stored belongs to
+ @param client_payload [Object] Client data to be stored in database
+ @return [Promise] Promise that resolves with the record stored in database
+ ###
+ _save_client: (user_id, client_payload) =>
+ primary_key = @_construct_primary_key user_id, client_payload.id
+ return @client_service.save_client_in_db primary_key, client_payload
+
+ ###
+ Save the local client into the database.
+
+ @private
+ @param client_payload [Object] Client data to be stored in database
+ @return [Promise] Promise that resolves with the record stored in database
+ ###
+ _save_current_client: (client_payload) =>
+ client_payload.meta =
+ is_verified: true
+ return @client_service.save_client_in_db @PRIMARY_KEY_CURRENT_CLIENT, client_payload
+
+
+ ###############################################################################
+ # Login and registration
+ ###############################################################################
+
+ ###
+ Constructs the value for a cookie label.
+ @param login [String] Email or phone number of the user
+ @param client_type [z.client.ClientType] Temporary or permanent client type
+ ###
+ construct_cookie_label: (login, client_type) ->
+ login_hash = z.util.murmurhash3 login, 42
+ client_type = @_load_current_client_type() if not client_type
+ return "webapp@#{login_hash}@#{client_type}@#{Date.now()}"
+
+ ###
+ Constructs the key for a cookie label.
+ @param login [String] Email or phone number of the user
+ @param client_type [z.client.ClientType] Temporary or permanent client type
+ ###
+ construct_cookie_label_key: (login, client_type) ->
+ login_hash = z.util.murmurhash3 login, 42
+ client_type = @_load_current_client_type() if not client_type
+ return "#{z.storage.StorageKey.AUTH.COOKIE_LABEL}@#{login_hash}@#{client_type}"
+
+ ###
+ Validate existence of a local client online.
+ @param client [z.client.Client] Client retrieved from IndexedDB
+ @return [Promise] Promise that will resolve with an observable containing the client if valid
+ ###
+ get_valid_local_client: =>
+ @get_current_client_from_db()
+ .then (client_et) =>
+ return @get_client_by_id_from_backend client_et.id
+ .then (client) =>
+ @logger.log @logger.levels.INFO, "Client with ID '#{client.id}' (#{client.type}) validated on backend"
+ return @current_client
+ .catch (error) =>
+ client_et = @current_client()
+ @current_client undefined
+ error_message = error.code or error.message
+
+ if error.code is z.service.BackendClientError::STATUS_CODE.NOT_FOUND
+ error_message = "Local client '#{client_et.id}' (#{client_et.type}) no longer exists on the backend"
+ @logger.log @logger.levels.WARN, error_message, error
+ @cryptography_repository.storage_repository.delete_everything()
+ .catch (error) =>
+ error_message = "Deleting database after failed client validation unsuccessful: #{error.message}"
+ @logger.log @logger.levels.ERROR, error_message, error
+ throw new z.client.ClientError error_message, z.client.ClientError::TYPE.DATABASE_FAILURE
+ .then ->
+ throw new z.client.ClientError error_message, z.client.ClientError::TYPE.MISSING_ON_BACKEND
+ else if error.type is z.client.ClientError::TYPE.NO_LOCAL_CLIENT
+ throw error
+ else
+ @logger.log @logger.levels.ERROR, "Getting valid local client failed: #{error_message}", error
+ throw error
+
+ ###
+ Register a new client.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/registerClient
+
+ @note Password is needed for the registration of a client once 1st client has been registered.
+
+ @param password [String] User password for verification
+ @return [Promise] Promise that will resolve with the newly registered client
+ ###
+ register_client: (password) =>
+ return new Promise (resolve, reject) =>
+ client_type = @_load_current_client_type()
+
+ @cryptography_repository.generate_client_keys()
+ .then (keys) =>
+ return @client_service.post_clients @_create_registration_payload client_type, password, keys
+ .catch (error) =>
+ if error.label is z.service.BackendClientError::LABEL.TOO_MANY_CLIENTS
+ throw new z.client.ClientError error.message, z.client.ClientError::TYPE.TOO_MANY_CLIENTS
+ else
+ error_message = "Client registration request failed: #{error.message}"
+ @logger.log @logger.levels.ERROR, error_message, error
+ throw new z.client.ClientError error_message, z.client.ClientError::TYPE.REQUEST_FAILURE
+ .then (response) =>
+ @logger.log @logger.levels.INFO,
+ "Registered '#{response.type}' client '#{response.id}' with cookie label '#{response.cookie}'", response
+ @current_client @client_mapper.map_client response
+ # Save client
+ return @_save_current_client response
+ .catch (error) =>
+ if error.type in [z.client.ClientError::TYPE.REQUEST_FAILURE, z.client.ClientError::TYPE.TOO_MANY_CLIENTS]
+ throw error
+ else
+ error_message = "Failed to save client: #{error.message}"
+ @logger.log @logger.levels.ERROR, error_message, error
+ throw new z.client.ClientError error_message, z.client.ClientError::TYPE.DATABASE_FAILURE
+ .then (client_payload) =>
+ # Update cookie
+ return @_transfer_cookie_label client_type, client_payload.cookie
+ .then =>
+ resolve @current_client
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Client registration failed: #{error.message}", error
+ reject error
+
+ ###
+ Create payload for client registration.
+
+ @private
+ @param client_type [z.client.ClientType] Type of client to be registered
+ @param password [String] User password
+ @param keys [Array] Array containing last resort key, pre-keys and signaling keys
+ @return [Object] Payload to register client with backend
+ ###
+ _create_registration_payload: (client_type, password, keys) ->
+ [last_resort_key, pre_keys, signaling_keys] = keys
+
+ device_label = "#{platform.os.family}"
+ device_label += " #{platform.os.version}" if platform.os.version
+ device_model = platform.name
+
+ if z.util.Environment.electron
+ if z.util.Environment.os.mac then identifier = z.string.wire_osx else identifier = z.string.wire_windows
+ device_model = z.localization.Localizer.get_text identifier
+ device_model = "#{device_model} (Internal)" if not z.util.Environment.frontend.is_production()
+ else
+ device_model = "#{device_model} (Temporary)" if client_type is z.client.ClientType.TEMPORARY
+
+ return {} =
+ class: 'desktop'
+ cookie: @_get_cookie_label_value @self_user().email() or @self_user().phone()
+ label: device_label
+ lastkey: last_resort_key
+ model: device_model
+ password: password
+ prekeys: pre_keys
+ sigkeys: signaling_keys
+ type: client_type
+
+ ###
+ Gets the value for a cookie label.
+ @private
+ @param login [String] Email or phone number of the user
+ ###
+ _get_cookie_label_value: (login) ->
+ return z.storage.get_value @construct_cookie_label_key login
+
+ ###
+ Loads the cookie label value from the Local Storage and saves it into IndexedDB.
+
+ @private
+ @param client_type [z.client.ClientType] Temporary or permanent client type
+ @param cookie_label [String] Cookie label, something like "webapp@2153234453@temporary@145770538393"
+ @return [Promise] Promise that resolves with the key of the stored cookie label
+ ###
+ _transfer_cookie_label: (client_type, cookie_label) =>
+ indexed_db_key = z.storage.StorageKey.AUTH.COOKIE_LABEL
+ local_storage_key = @construct_cookie_label_key @self_user().email() or @self_user().phone(), client_type
+
+ if cookie_label is undefined
+ cookie_label = @construct_cookie_label @self_user().email() or @self_user().phone(), client_type
+ @logger.log @logger.levels.WARN, "Cookie label is in an invalid state. We created a new one: '#{cookie_label}'"
+ z.storage.set_value local_storage_key, cookie_label
+
+ @logger.log "Saving cookie label '#{cookie_label}' in IndexedDB", {
+ key: local_storage_key
+ value: cookie_label
+ }
+
+ return @cryptography_repository.storage_repository.save_value indexed_db_key, cookie_label
+
+ ###
+ Load current client type from amplify store.
+ @private
+ @return [z.client.ClientType] Type of current client
+ ###
+ _load_current_client_type: ->
+ return @current_client().type if @current_client()
+ is_permanent = z.storage.get_value z.storage.StorageKey.AUTH.PERSIST
+ type = if is_permanent then z.client.ClientType.PERMANENT else z.client.ClientType.TEMPORARY
+ type = if z.util.Environment.electron then z.client.ClientType.PERMANENT else type
+ return type
+
+
+ ###############################################################################
+ # Client handling
+ ###############################################################################
+
+ ###
+ Delete client of a user on backend and removes it locally.
+
+ @param client_id [String] ID of the client that should be deleted
+ @param password [String] Password entered by user
+ @return [Promise] Promise that resolves with the remaining user devices
+ ###
+ delete_client: (client_id, password) =>
+ return new Promise (resolve, reject) =>
+ if not password
+ error_message = "Could not delete client '#{client_id}' because password was not submitted"
+ @logger.log @logger.levels.ERROR, error_message
+ reject new z.client.ClientError error_message, z.client.ClientError::TYPE.REQUEST_FORBIDDEN
+
+ @client_service.delete_client client_id, password
+ .then =>
+ @_remove_client client_id
+ resolve @clients()
+ .catch (error) =>
+ error_message = "Unable to delete client '#{client_id}': #{error.message}"
+ @logger.log @logger.levels.ERROR, error_message,
+ {error: error, password: password}
+
+ if error.code is z.service.BackendClientError::STATUS_CODE.FORBIDDEN
+ error = new z.client.ClientError error_message, z.client.ClientError::TYPE.REQUEST_FORBIDDEN
+ else
+ error = new z.client.ClientError error_message, z.client.ClientError::TYPE.REQUEST_FAILURE
+ reject error
+
+ ###
+ Delete a stored client and the session connected with it.
+
+ @param user_id [String] ID of user
+ @param client_id [String] ID of client to be deleted
+ @return [Promise] Promise that resolves when a client and its session have been deleted
+ ###
+ delete_client_and_session: (user_id, client_id) =>
+ @cryptography_repository.reset_session user_id, client_id
+ .then =>
+ @delete_client_from_db user_id, client_id
+
+ delete_client_from_db: (user_id, client_id) ->
+ primary_key = @_construct_primary_key user_id, client_id
+ return @client_service.delete_client_from_db primary_key
+
+ ###
+ Retrieves meta information about all the clients of a given user.
+ @note If you want to get very detailed information about the devices from the own user, then use "@get_clients"
+
+ @param user_id [String] User ID to retrieve client information for
+ @return [Promise] Promise that resolves with an array of client entities
+ ###
+ get_clients_by_user_id: (user_id) =>
+ @client_service.get_clients_by_user_id user_id
+ .then (clients) =>
+ return @_get_clients_by_user_id clients, user_id
+
+ ###
+ Retrieves meta information about all the clients of the self user.
+ @param expect_current_client [Boolean] Should we check against the current local client
+ @return [Promise] Promise that resolves with the retrieved information about the clients
+ ###
+ get_clients_for_self: (expect_current_client = true) ->
+ @logger.log @logger.levels.INFO, "Retrieving all clients for the self user '#{@self_user().id}'"
+ @client_service.get_clients()
+ .then (response) =>
+ return @_get_clients_by_user_id response, @self_user().id, expect_current_client
+ .then (client_ets) =>
+ for possibly_new_client in client_ets
+ found = false
+
+ @clients().forEach (client_et) ->
+ found = true if possibly_new_client.id is client_et.id
+
+ @clients.push possibly_new_client if not found
+ @clients.sort (client_a, client_b) ->
+ return new Date(client_b.time) - new Date(client_a.time)
+ return @clients()
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Unable to retrieve clients data: #{error}"
+ throw error
+
+ ###
+ Is the current client permanent.
+ @return [Boolean] Type of current client is permanent
+ ###
+ is_current_client_permanent: =>
+ throw new z.client.ClientError 'No current client', z.client.ClientError::TYPE.CLIENT_NOT_SET if not @current_client()
+ if z.util.Environment.electron
+ is_permanent = true
+ else
+ is_permanent = @current_client().is_permanent()
+ return is_permanent
+
+ ###
+ Removes a client locally.
+ @param client_id [String] ID of the client that should be removed
+ @return [Promise] Promise that resolves with the primary key of the removed client
+ ###
+ remove_client: (client_id) ->
+ return new Promise (resolve, reject) =>
+ user_id = @self_user().id
+ primary_key = @_construct_primary_key user_id, client_id
+ primary_key = @PRIMARY_KEY_CURRENT_CLIENT if @_is_current_client user_id, client_id
+ @client_service.delete_client_from_db primary_key
+ .then (primary_key) =>
+ @clients.remove (client_et) ->
+ client_et.id is client_id
+ resolve primary_key
+ .catch (error) -> reject error
+
+ ###
+ Match backend client response with locally stored ones.
+ @note: This function matches clients retrieved from the backend with the data stored in the local database.
+ Clients will then be updated with the backend payload in the database and mapped into entities.
+
+ @private
+ @param clients [JSON] Payload from the backend
+ @param user_id [String] User ID
+ @param expect_current_client [Boolean] Should we check against the current local client
+ @return [Promise] Client entities
+ ###
+ _get_clients_by_user_id: (clients, user_id, expect_current_client) ->
+ return new Promise (resolve, reject) =>
+ clients_from_backend = {}
+ clients_stored_in_db = []
+
+ client_keys = []
+
+ for client in clients
+ client_keys.push @_construct_primary_key user_id, client.id
+ clients_from_backend[client.id] = client
+
+ # Find clients in database
+ @client_service.load_clients_from_db client_keys
+ .then (results) =>
+ # Save new clients and cache existing ones
+ promises = []
+
+ # Updates a client payload if it does not fit the current database structure
+ update_client_schema = (user_id, client_payload) =>
+ client_payload.meta =
+ is_verified: false
+ primary_key: @_construct_primary_key user_id, client_payload.id
+ return @_save_client user_id, client_payload
+
+ # Known clients will be returned as object, unknown clients will resolve with their expected primary key
+ for result in results
+ # Handle new data which was not stored already in our local database
+ if _.isString result
+ ids = z.client.Client.dismantle_user_client_id result
+ if expect_current_client and @_is_current_client user_id, ids.client_id
+ @logger.log @logger.levels.INFO, "Current client '#{ids.client_id}' will not be changed in database"
+ continue
+ @logger.log @logger.levels.INFO, "Client '#{ids.client_id}' was not previously stored in database"
+ client_payload = clients_from_backend[ids.client_id]
+ promises.push update_client_schema user_id, client_payload
+ else
+ # Update existing clients with backend information
+ @logger.log @logger.levels.INFO, "Client '#{result.id}' was previously stored in database", result
+ [client_payload, contains_update] = @client_mapper.update_client result, clients_from_backend[result.id]
+ if contains_update
+ @logger.log @logger.levels.INFO, "Client '#{result.id}' will be overwritten with update in database", client_payload
+ promises.push @_save_client user_id, client_payload
+ else
+ clients_stored_in_db.push client_payload
+
+ return Promise.all promises
+ .then (new_records) ->
+ # Cache new clients
+ return clients_stored_in_db.concat new_records
+ .then (all_clients) =>
+ # Map clients to entities
+ client_ets = @client_mapper.map_clients all_clients
+ resolve client_ets
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Unable to retrieve clients for user '#{user_id}': #{error.message}", error
+ reject error
+
+ ###
+ Check if client is current local client.
+
+ @private
+ @param user_id [String] User ID to be checked
+ @param client_id [String] ID of client to be checked
+ @return [Boolean] Is the client the current local client
+ ###
+ _is_current_client: (user_id, client_id) ->
+ throw new z.client.ClientError 'No current client', z.client.ClientError::TYPE.CLIENT_NOT_SET if not @current_client()
+ throw new z.client.ClientError 'User ID is not defined', z.client.ClientError::TYPE.NO_USER_ID if not user_id
+ throw new z.client.ClientError 'Client ID is not defined', z.client.ClientError::TYPE.NO_CLIENT_ID if not client_id
+ return user_id is @self_user().id and client_id is @current_client().id
+
+ ###
+ Remove a client from the local clients.
+ @private
+ @param client_id [String] ID of client to be removed
+ ###
+ _remove_client: (client_id) =>
+ for client in @clients() when client.id is client_id
+ @clients.remove client
+ break
+
+
+ ###############################################################################
+ # Conversation Events
+ ###############################################################################
+
+ ###
+ A client was added by the self user.
+ @todo map, save and add to user
+ @param event_json [Object] JSON data of 'user.client-add' event
+ ###
+ on_client_add: (event_json) =>
+ @logger.log @logger.levels.INFO, 'Client of self user added', event_json
+ amplify.publish z.event.WebApp.SELF.CLIENT_ADD, event_json.client
+
+ ###
+ A client was added by the self user.
+ @param event_json [Object] JSON data of 'user.client-remove' event
+ ###
+ on_client_remove: (event_json) =>
+ client_id = event_json?.client.id
+ @_client_removal client_id if client_id
+
+ ###
+ Remove a client of the self user identified by id.
+ @private
+ @param client_id [String] ID of client to be removed
+ ###
+ _client_removal: (client_id) ->
+ @remove_client client_id
+ .then =>
+ @logger.log "Removed client from database: #{client_id}"
+ amplify.publish z.event.WebApp.SIGN_OUT, 'client_removed', true if client_id is @current_client().id
diff --git a/app/script/client/ClientService.coffee b/app/script/client/ClientService.coffee
new file mode 100644
index 00000000000..0e4fd118dc9
--- /dev/null
+++ b/app/script/client/ClientService.coffee
@@ -0,0 +1,193 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.client ?= {}
+
+class z.client.ClientService
+ URL_CLIENTS: '/clients'
+ URL_USERS: '/users'
+
+ constructor: (@client, @storage_service) ->
+ @logger = new z.util.Logger 'z.client.ClientService', z.config.LOGGER.OPTIONS
+ return @
+
+
+ ###############################################################################
+ # Backend requests
+ ###############################################################################
+
+ ###
+ Deletes a specific client from a user.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/deleteClient
+
+ @param client_id [String] ID of the client that should be deleted
+ @param password [String] Password entered by user
+ @return [Promise] Promise that resolves once the deletion of the client is complete
+ ###
+ delete_client: (client_id, password) ->
+ @client.send_json
+ url: @client.create_url "#{@URL_CLIENTS}/#{client_id}"
+ type: 'DELETE'
+ data:
+ password: password
+
+ ###
+ Deletes the temporary client of a user.
+ @param client_id [String] ID of the temporary client to be deleted
+ @return [Promise] Promise that resolves once the deletion of the temporary client is complete
+ ###
+ delete_temporary_client: (client_id) ->
+ @client.send_json
+ url: @client.create_url "#{@URL_CLIENTS}/#{client_id}"
+ type: 'DELETE'
+ data: {}
+
+ ###
+ Retrieves meta information about a specific client.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/getClients
+
+ @param client_id [String] ID of client to be retrieved
+ @return [Promise] Promise that resolves with the requested client
+ ###
+ get_client_by_id: (client_id) ->
+ @client.send_request
+ url: @client.create_url "#{@URL_CLIENTS}/#{client_id}"
+ type: 'GET'
+
+ ###
+ Retrieves meta information about all the clients self user.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/listClients
+ @return [Promise] Promise that resolves with the clients of the self user
+ ###
+ get_clients: ->
+ @client.send_request
+ url: @client.create_url @URL_CLIENTS
+ type: 'GET'
+
+ ###
+ Retrieves meta information about all the clients of a specific user.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/users/getClients
+
+ @param user_id [String] ID of user to retrieve clients for
+ @return [Promise] Promise that resolves with the clients of a user
+ ###
+ get_clients_by_user_id: (user_id) ->
+ @client.send_request
+ url: @client.create_url "#{@URL_USERS}/#{user_id}#{@URL_CLIENTS}"
+ type: 'GET'
+
+ ###
+ Register a new client.
+ @param payload [Object] Client payload
+ @return [Promise] Promise that resolves with the registered client information
+ ###
+ post_clients: (payload) ->
+ @client.send_json
+ type: 'POST'
+ url: @client.create_url @URL_CLIENTS
+ data: payload
+
+
+ ###############################################################################
+ # Database requests
+ ###############################################################################
+
+ ###
+ Removes a client from the database.
+ @param primary_key [String] Primary key used to find the client for deletion in the database
+ @return [Promise] Promise that resolves once the client is deleted
+ ###
+ delete_client_from_db: (primary_key) ->
+ return @storage_service.delete @storage_service.OBJECT_STORE_CLIENTS, primary_key
+
+ ###
+ Load all clients we have stored in the database.
+ @return [Promise] Promise that resolves with all the clients payloads
+ ###
+ load_all_clients_from_db: =>
+ return @storage_service.get_all @storage_service.OBJECT_STORE_CLIENTS
+
+ ###
+ Loads a persisted client from the database.
+ @param primary_key [String] Primary key used to find a client in the database
+ @return [Promise] Promise that resolves with the client's payload or the primary key if not found
+ ###
+ load_client_from_db: (primary_key) ->
+ return new Promise (resolve, reject) =>
+ @storage_service.db[@storage_service.OBJECT_STORE_CLIENTS]
+ .where 'meta.primary_key'
+ .equals primary_key
+ .first()
+ .then (client_record) =>
+ if client_record is undefined
+ @logger.log @logger.levels.INFO, "Client with primary key '#{primary_key}' not found in database"
+ resolve primary_key
+ else
+ @logger.log @logger.levels.INFO, "Loaded client record from database '#{primary_key}'", client_record
+ resolve client_record
+ .catch (error) ->
+ reject error
+
+ load_clients_from_db_by_user_id: (user_id) ->
+ return new Promise (resolve) =>
+ store = @storage_service.OBJECT_STORE_CLIENTS
+ @storage_service.get_keys store, user_id
+ .then (primary_keys) =>
+ return @load_clients_from_db primary_keys
+ .then (client_ets) ->
+ resolve client_ets
+
+ ###
+ Loads persisted clients from the database.
+ @param primary_keys [Array] Primary keys used to find clients in the database
+ @return [Promise] Promise that resolves with the clients' payloads or the primary keys if not found
+ ###
+ load_clients_from_db: (primary_keys) ->
+ promises = (@load_client_from_db primary_key for primary_key in primary_keys)
+ return Promise.all promises
+
+ ###
+ Persists a client.
+
+ @param primary_key [String] Primary key used to find a client in the database
+ @param client_payload [JSON] Client payload
+ @return [Promise] Promise that resolves with the client payload stored in database
+ ###
+ save_client_in_db: (primary_key, client_payload) ->
+ client_payload.meta ?= {}
+ client_payload.meta.primary_key = primary_key
+
+ return new Promise (resolve, reject) =>
+ @storage_service.save @storage_service.OBJECT_STORE_CLIENTS, primary_key, client_payload
+ .then (primary_key) =>
+ @logger.log @logger.levels.INFO,
+ "Client '#{client_payload.id}' stored with primary key '#{primary_key}'", client_payload
+ return @load_client_from_db primary_key
+ .then (record) -> resolve record
+ .catch (error) -> reject error
+
+ ###
+ Updates a persisted client in the database.
+
+ @param primary_key [String] Primary key used to find a client in the database
+ @param changes [JSON] Incremental update changes of the client JSON
+ @return [Promise] Number of updated records (1 if an object was updated, otherwise 0)
+ ###
+ update_client_in_db: (primary_key, changes) ->
+ return @storage_service.update @storage_service.OBJECT_STORE_CLIENTS, primary_key, changes
diff --git a/app/script/client/ClientType.coffee b/app/script/client/ClientType.coffee
new file mode 100644
index 00000000000..a0501a67d79
--- /dev/null
+++ b/app/script/client/ClientType.coffee
@@ -0,0 +1,24 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.client ?= {}
+
+z.client.ClientType =
+ PERMANENT: 'permanent'
+ TEMPORARY: 'temporary'
diff --git a/app/script/components/accentColorPicker.coffee b/app/script/components/accentColorPicker.coffee
new file mode 100644
index 00000000000..4820f12b4f1
--- /dev/null
+++ b/app/script/components/accentColorPicker.coffee
@@ -0,0 +1,58 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.AccentColorPicker
+ ###
+ Construct a new accent color picker view model.
+
+ @param params [Object]
+ @option params [z.entity.User] user User entity
+ @option params [Object] selected Selected accent color
+ ###
+ constructor: (params) ->
+
+ @user = ko.unwrap params.user
+
+ @accent_colors = ko.computed =>
+ [1..7].map (id) =>
+ css_class = "accent-color-#{id}"
+ if @user? and @user.accent_id() is id
+ css_class += ' selected'
+ color =
+ css: css_class
+ id: id
+
+ @on_select = (color) ->
+ params?.selected color
+
+
+# Knockout registration of the accent color picker component.
+ko.components.register 'accent-color-picker',
+ viewModel: z.components.AccentColorPicker
+ template: """
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/asset/audioAsset.coffee b/app/script/components/asset/audioAsset.coffee
new file mode 100644
index 00000000000..c69ffbec6d5
--- /dev/null
+++ b/app/script/components/asset/audioAsset.coffee
@@ -0,0 +1,116 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.AudioAssetComponent
+ ###
+ Construct a new audio asset.
+
+ @param params [Object]
+ @option params [ko.observableArray] asset
+ ###
+ constructor: (params, component_info) ->
+ @logger = new z.util.Logger 'AudioAssetComponent', z.config.LOGGER.OPTIONS
+ @asset = params.asset
+
+ @audio_src = ko.observable()
+ @audio_element = $(component_info.element).find('audio')[0]
+ @audio_time = ko.observable 0
+ @audio_is_loaded = ko.observable false
+
+ @show_loudness_preview = ko.pureComputed =>
+ return @asset.meta?.loudness?.length > 0
+
+ if @asset.meta?
+ @audio_time @asset.meta.duration
+
+ $(component_info.element).attr
+ 'data-uie-name': 'audio-asset'
+ 'data-uie-value': @asset.file_name
+
+ on_loadedmetadata: =>
+ @_send_tracking_event()
+
+ on_timeupdate: =>
+ @audio_time @audio_element.currentTime
+
+ on_play_button_clicked: =>
+ if @audio_src()?
+ @audio_element?.play()
+ else
+ @asset.load()
+ .then (blob) =>
+ @audio_src window.URL.createObjectURL blob
+ @audio_is_loaded true
+ @audio_element?.play()
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, 'Failed to load audio asset ', error
+
+ on_pause_button_clicked: =>
+ @audio_element?.pause()
+
+ _send_tracking_event: =>
+ duration = Math.floor @audio_element.duration
+
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.MEDIA.PLAYED_AUDIO_MESSAGE,
+ duration: z.util.bucket_values(duration, [0, 10, 30, 60, 300, 900, 1800])
+ duration_actual: duration
+ type: z.util.get_file_extension @asset.file_name
+
+
+ko.components.register 'audio-asset',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.AudioAssetComponent params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/asset/controls/audioSeekBar.coffee b/app/script/components/asset/controls/audioSeekBar.coffee
new file mode 100644
index 00000000000..ce88147cfa5
--- /dev/null
+++ b/app/script/components/asset/controls/audioSeekBar.coffee
@@ -0,0 +1,100 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.AudioSeekBarComponent
+ ###
+ Construct a audio seek bar that renders audio levels
+
+ @param params [Object]
+ @option src [HTMLElement] media src
+ @option asset [z.entity.File]
+ @option disabled [Boolean]
+ ###
+ constructor: (params, component_info) ->
+ @audio_element = params.src
+ @asset = params.asset
+
+ @element = component_info.element
+ @loudness = []
+
+ @disabled = ko.computed =>
+ is_disabled = params.disabled?()
+ $(@element).toggleClass 'element-disabled', is_disabled
+
+ if @asset.meta?.loudness?.length
+ @loudness = @_normalize_loudness @asset.meta.loudness, component_info.element.clientHeight
+
+ @_on_resize_fired = _.debounce =>
+ @_render_levels()
+ @_on_time_update()
+ , 500
+
+ @_render_levels()
+
+ @audio_element.addEventListener 'ended', @_on_audio_ended
+ @audio_element.addEventListener 'timeupdate', @_on_time_update
+ component_info.element.addEventListener 'click', @_on_level_click
+ window.addEventListener 'resize', @_on_resize_fired
+
+ _render_levels: =>
+ number_of_levels_fit_on_screen = Math.floor @element.clientWidth / 3 # 2px + 1px
+ scaled_loudness = z.util.ArrayUtil.interpolate @loudness, number_of_levels_fit_on_screen
+
+ $(@element).empty()
+ $('').height(level).appendTo(@element) for level in scaled_loudness
+
+ _normalize_loudness: (loudness, max) ->
+ peak = Math.max.apply Math, loudness
+ return if peak > max then loudness.map (level) -> level * max / peak else loudness
+
+ _on_level_click: (e) =>
+ mouse_x = e.pageX - $(e.currentTarget).offset().left
+ @audio_element.currentTime = @audio_element.duration * mouse_x / e.currentTarget.clientWidth
+ @_on_time_update()
+
+ _on_time_update: =>
+ $levels = @_clear_theme()
+ index = Math.floor @audio_element.currentTime / @audio_element.duration * $levels.length
+ @_add_theme index
+
+ _on_audio_ended: =>
+ @_clear_theme()
+
+ _clear_theme: =>
+ $(@element).children().removeClass 'bg-theme'
+
+ _add_theme: (index) =>
+ $(@element).children()
+ .eq index
+ .prevAll().addClass 'bg-theme'
+
+ dispose: =>
+ @disabled.dispose()
+ @audio_element.removeEventListener 'ended', @_on_audio_ended
+ @audio_element.removeEventListener 'timeupdate', @_on_time_update
+ @element.removeEventListener 'click', @_on_level_click
+ window.removeEventListener 'resize', @_on_resize_fired
+
+
+ko.components.register 'audio-seek-bar',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.AudioSeekBarComponent params, component_info
+ template: """"""
diff --git a/app/script/components/asset/controls/mediaButton.coffee b/app/script/components/asset/controls/mediaButton.coffee
new file mode 100644
index 00000000000..c2a83db8cad
--- /dev/null
+++ b/app/script/components/asset/controls/mediaButton.coffee
@@ -0,0 +1,85 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.MediaButtonComponent
+ ###
+ Construct a media button.
+
+ @param params [Object]
+ @option src [HTMLElement] media src
+ @option large [Boolean] display large button
+ @option asset [z.entity.File]
+ ###
+ constructor: (params, component_info) ->
+ @media_element = params.src
+ @large = params.large
+ @asset = params.asset
+
+ if @large
+ component_info.element.classList.add 'media-button-lg'
+
+ @media_is_playing = ko.observable false
+
+ @svg_view_box = ko.pureComputed =>
+ size = if @large then 64 else 32
+ return "0 0 #{size} #{size}"
+
+ @circle_upload_progress = ko.pureComputed =>
+ size = if @large then '200' else '100'
+ return "#{@asset.upload_progress() * 2} #{size}"
+
+ @circle_download_progress = ko.pureComputed =>
+ size = if @large then '200' else '100'
+ return "#{@asset.download_progress() * 2} #{size}"
+
+ @on_play_button_clicked = -> params.play?()
+ @on_pause_button_clicked = -> params.pause?()
+ @on_cancel_button_clicked = -> params.cancel?()
+
+ @media_element.addEventListener 'playing', => @media_is_playing true
+ @media_element.addEventListener 'pause', => @media_is_playing false
+
+
+ko.components.register 'media-button',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.MediaButtonComponent params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/asset/controls/seekBar.coffee b/app/script/components/asset/controls/seekBar.coffee
new file mode 100644
index 00000000000..979fdc82d72
--- /dev/null
+++ b/app/script/components/asset/controls/seekBar.coffee
@@ -0,0 +1,86 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.SeekBarComponent
+ ###
+ Construct a seek bar.
+
+ @param params [Object]
+ @option src [HTMLElement] media src
+ ###
+ constructor: (params, component_info) ->
+ @media_element = params.src
+ @dark_mode = params.dark
+ @disabled = ko.pureComputed -> params.disabled?()
+
+ @seek_bar = $(component_info.element).find('input')[0]
+ @seek_bar_mouse_over = ko.observable false
+ @seek_bar_thumb_dragged = ko.observable false
+
+ @show_seek_bar_thumb = ko.pureComputed =>
+ return @seek_bar_thumb_dragged() or @seek_bar_mouse_over()
+
+ @seek_bar.addEventListener 'mousedown', =>
+ @media_element.pause()
+ @seek_bar_thumb_dragged true
+
+ @seek_bar.addEventListener 'mouseup', =>
+ @media_element.play()
+ @seek_bar_thumb_dragged false
+
+ @seek_bar.addEventListener 'mouseenter', =>
+ @seek_bar_mouse_over true
+
+ @seek_bar.addEventListener 'mouseleave', =>
+ @seek_bar_mouse_over false
+
+ @seek_bar.addEventListener 'change', =>
+ time = @media_element.duration * (@seek_bar.value / 100)
+ @media_element.currentTime = time
+
+ @media_element.addEventListener 'timeupdate', =>
+ value = (100 / @media_element.duration) * @media_element.currentTime
+ @_update_seek_bar value
+
+ @media_element.addEventListener 'ended', =>
+ @_update_seek_bar 100
+
+ @_update_seek_bar_style 0
+
+ _update_seek_bar: (progress) =>
+ return if @media_element.paused and progress < 100
+ @seek_bar.value = progress
+ @_update_seek_bar_style progress
+
+ _update_seek_bar_style: (progress) =>
+ # TODO check if we can find a css solution
+ if @dark_mode
+ @seek_bar.style.backgroundImage = "linear-gradient(to right, currentColor #{progress}%, rgba(141,152,159,0.24) #{progress}%)"
+ else
+ @seek_bar.style.backgroundImage = "linear-gradient(to right, currentColor #{progress}%, rgba(255,255,255,0.4) #{progress}%)"
+
+
+ko.components.register 'seek-bar',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.SeekBarComponent params, component_info
+ template: """
+
+ """
diff --git a/app/script/components/asset/fileAsset.coffee b/app/script/components/asset/fileAsset.coffee
new file mode 100644
index 00000000000..ef4590bb92c
--- /dev/null
+++ b/app/script/components/asset/fileAsset.coffee
@@ -0,0 +1,112 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.FileAssetComponent
+ ###
+ Construct a new audio asset.
+
+ @param params [Object]
+ @option params [ko.observableArray] asset
+ ###
+ constructor: (params, component_info) ->
+ @asset = params.asset
+
+ @circle_upload_progress = ko.pureComputed =>
+ size = if @large then '200' else '100'
+ return "#{@asset.upload_progress() * 2} #{size}"
+
+ @circle_download_progress = ko.pureComputed =>
+ size = if @large then '200' else '100'
+ return "#{@asset.download_progress() * 2} #{size}"
+
+ @file_extension = ko.pureComputed =>
+ ext = z.util.get_file_extension @asset.file_name
+ return if ext.length <= 3 then ext else ''
+
+
+ko.components.register 'file-asset',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.FileAssetComponent params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/asset/linkPreviewAsset.coffee b/app/script/components/asset/linkPreviewAsset.coffee
new file mode 100644
index 00000000000..9760a6c66d9
--- /dev/null
+++ b/app/script/components/asset/linkPreviewAsset.coffee
@@ -0,0 +1,60 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.LinkPreviewAssetComponent
+ ###
+ Construct a new audio asset.
+
+ @param params [Object]
+ @option params [z.entity.LinkPreview] preview
+ ###
+ constructor: (params, component_info) ->
+ @preview = params.preview
+ @viewport_changed = params.viewport_changed
+ @element = component_info.element
+
+ on_link_preview_click: =>
+ window.open @preview.permanent_url
+
+ dispose: =>
+ @element.removeEventListener 'click', @on_link_preview_click
+
+
+ko.components.register 'link-preview-asset',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.LinkPreviewAssetComponent params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/asset/locationAsset.coffee b/app/script/components/asset/locationAsset.coffee
new file mode 100644
index 00000000000..680ef003d49
--- /dev/null
+++ b/app/script/components/asset/locationAsset.coffee
@@ -0,0 +1,39 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.LocationAssetComponent
+ ###
+ Construct a new audio asset.
+
+ @param params [Object]
+ @option params [ko.observableArray] asset
+ ###
+ constructor: (params) ->
+ @asset = params.asset
+
+
+ko.components.register 'location-asset',
+ viewModel: z.components.LocationAssetComponent
+ template: """
+
+
+
+ """
diff --git a/app/script/components/asset/videoAsset.coffee b/app/script/components/asset/videoAsset.coffee
new file mode 100644
index 00000000000..89ad6cb1b5d
--- /dev/null
+++ b/app/script/components/asset/videoAsset.coffee
@@ -0,0 +1,131 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.VideoAssetComponent
+ ###
+ Construct a new video asset.
+
+ @param params [Object]
+ @option asset [z.entity.File]
+ ###
+ constructor: (params, component_info) ->
+ @logger = new z.util.Logger 'VideoAssetComponent', z.config.LOGGER.OPTIONS
+ @asset = params.asset
+
+ @video_element = $(component_info.element).find('video')[0]
+ @video_src = ko.observable()
+ @video_time = ko.observable()
+
+ @video_playback_error = ko.observable false
+ @show_bottom_controls = ko.observable false
+
+ @video_time_rest = ko.pureComputed =>
+ return @video_element.duration - @video_time()
+
+ if @asset.preview_resource()
+ @_load_video_preview()
+ else
+ @asset.preview_resource.subscribe @_load_video_preview
+
+ _load_video_preview: =>
+ @asset.load_preview()
+ .then (blob) =>
+ @video_element.setAttribute 'poster', window.URL.createObjectURL blob
+ @video_element.style.backgroundColor = '#000'
+
+ on_loadedmetadata: =>
+ @video_time @video_element.duration
+ @_send_tracking_event()
+
+ on_timeupdate: =>
+ @video_time @video_element.currentTime
+
+ on_error: =>
+ @video_playback_error true
+
+ on_play_button_clicked: =>
+ if @video_src()?
+ @video_element?.play()
+ else
+ @asset.load()
+ .then (blob) =>
+ @video_src window.URL.createObjectURL blob
+ @video_element?.play()
+ @show_bottom_controls true
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, 'Failed to load video asset ', error
+
+ on_pause_button_clicked: =>
+ @video_element?.pause()
+
+ on_video_playing: =>
+ @video_element.style.backgroundColor = '#000'
+
+ _send_tracking_event: =>
+ duration = Math.floor @video_element.duration
+
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.MEDIA.PLAYED_VIDEO_MESSAGE,
+ duration: z.util.bucket_values(duration, [0, 10, 30, 60, 300, 900, 1800])
+ duration_actual: duration
+
+ko.components.register 'video-asset',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.VideoAssetComponent params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/calling/chooseScreen.coffee b/app/script/components/calling/chooseScreen.coffee
new file mode 100644
index 00000000000..84c4a732ac7
--- /dev/null
+++ b/app/script/components/calling/chooseScreen.coffee
@@ -0,0 +1,45 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.ChooseScreen
+ constructor: (params) ->
+ @on_cancel = params.cancel
+ @on_choose = params.choose
+ @screens = params.screens or []
+
+
+ko.components.register 'choose-screen',
+ viewModel: z.components.ChooseScreen
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/calling/deviceToggleButton.coffee b/app/script/components/calling/deviceToggleButton.coffee
new file mode 100644
index 00000000000..55b5b31a64c
--- /dev/null
+++ b/app/script/components/calling/deviceToggleButton.coffee
@@ -0,0 +1,38 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.DeviceToggleButton
+ constructor: (params) ->
+ @current_device_index = params.index
+ @number_of_devices = params.length
+ @icon_class = if params.type is z.calling.enum.MediaDeviceType.VIDEO_INPUT then 'icon-video' else 'icon-screensharing'
+
+
+ko.components.register 'device-toggle-button',
+ viewModel: z.components.DeviceToggleButton
+ template: """
+
+
+
+
+
+
+ """
diff --git a/app/script/components/commonContacts.coffee b/app/script/components/commonContacts.coffee
new file mode 100644
index 00000000000..7df158bea3b
--- /dev/null
+++ b/app/script/components/commonContacts.coffee
@@ -0,0 +1,64 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.CommonContactsViewModel
+ constructor: (params, component_info) ->
+ # parameter list
+ @user = ko.unwrap params.user
+ @element = component_info.element
+ @search_repository = wire.app.repository.search
+
+ @displayed_contacts = ko.observableArray()
+ @more_contacts = ko.observable 0
+ @_set_contacts_data 0
+
+ @search_repository.get_common_contacts @user.id
+ .then (user_ets) =>
+ @_display_contacts user_ets
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Could not update users common contacts: #{error.message}", error
+
+ _set_contacts_data: (value) ->
+ $(@element).attr 'data-contacts', value
+
+ _display_contacts: (contacts) =>
+ number_of_contacts = contacts.length
+ number_to_show = if number_of_contacts is 4 then 4 else 3
+ @displayed_contacts contacts.slice 0, number_to_show
+ @more_contacts number_of_contacts - number_to_show
+ @_set_contacts_data number_of_contacts
+
+
+ko.components.register 'common-contacts',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.CommonContactsViewModel params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/deviceCard.coffee b/app/script/components/deviceCard.coffee
new file mode 100644
index 00000000000..66f5a93f6e7
--- /dev/null
+++ b/app/script/components/deviceCard.coffee
@@ -0,0 +1,85 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.DeviceCard
+ constructor: (params, component_info) ->
+ @device = ko.unwrap params.device
+ @id = @device?.id or ''
+ @label = @device?.label or '?'
+ @model = @device?.model or @device?.class or '?' # devices for other users will only provide the device class
+ @class = @device?.class or '?'
+
+ @current = params.current or false
+ @detailed = params.detailed or false
+ @click = params.click
+
+ @data_uie_name = 'device-card-info'
+ @data_uie_name += '-current' if @current
+
+ @location = ko.pureComputed =>
+ result = ko.observable '?'
+ z.location.get_location @device.location?.lat, @device.location?.lon, (error, location) ->
+ result "#{location.place}, #{location.country_code}" if location
+ return result
+
+ $(component_info.element).addClass 'device-card-no-hover' if @detailed or not @click
+ $(component_info.element).addClass 'device-card-detailed' if @detailed
+
+ on_click_device: =>
+ @click? @device
+
+ print_time: (timestamp) ->
+ reg_moment = moment(timestamp)
+ reg_format = if moment().year() is reg_moment.year() then 'ddd D MMM, HH:mm' else 'ddd D MMM YYYY, HH:mm'
+ return reg_moment.format reg_format
+
+
+ko.components.register 'device-card',
+ viewModel:
+ createViewModel: (params, component_info) ->
+ return new z.components.DeviceCard params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/deviceRemove.coffee b/app/script/components/deviceRemove.coffee
new file mode 100644
index 00000000000..bb596b648ed
--- /dev/null
+++ b/app/script/components/deviceRemove.coffee
@@ -0,0 +1,69 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.DeviceRemove
+ constructor: (params, component_info) ->
+ @device = ko.unwrap params.device
+ @device_remove_error = params.error or ko.observable false
+ @model = @device.model
+
+ @remove_form_visible = ko.observable false
+
+ @password = ko.observable ''
+ @password.subscribe (value) =>
+ @device_remove_error false if value.length > 0
+
+ @click_on_submit = =>
+ params.remove? @password(), @device
+
+ @click_on_cancel = =>
+ @remove_form_visible false
+ params.cancel?()
+
+ @click_on_remove_device = =>
+ @remove_form_visible true
+
+
+ko.components.register 'device-remove',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.DeviceRemove params, component_info
+ template: """
+
+
+
+
+
+
+ """
diff --git a/app/script/components/groupList.coffee b/app/script/components/groupList.coffee
new file mode 100644
index 00000000000..2985e45fa19
--- /dev/null
+++ b/app/script/components/groupList.coffee
@@ -0,0 +1,52 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.GroupListViewModel
+ ###
+ Construct a new group list view model.
+
+ @param params [Object]
+ @option params [ko.observableArray] groups Data source
+ @option params [Function] click Function called when a list item is clicked
+ ###
+ constructor: (params) ->
+ # parameter list
+ @groups = params.groups
+ @avatar = params.avatar or false
+ @on_select = params.click
+
+
+# Knockout registration of the group list component.
+ko.components.register 'group-list',
+ viewModel: z.components.GroupListViewModel
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/inputElement.coffee b/app/script/components/inputElement.coffee
new file mode 100644
index 00000000000..b11d85255dd
--- /dev/null
+++ b/app/script/components/inputElement.coffee
@@ -0,0 +1,56 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.InputElement
+ constructor: (params, component_info) ->
+
+ @value = params.value
+
+ @change = (data, event) =>
+ new_name = z.util.remove_line_breaks event.target.value.trim()
+ old_name = @value().trim()
+ event.target.value = old_name
+ @editing false
+ params.change? new_name if new_name isnt old_name
+
+ @edit = -> @editing true
+
+ @editing = ko.observable false
+
+ @editing.subscribe (value) =>
+ if value
+ $(component_info.element).find('textarea').one 'keydown', (e) =>
+ @editing false if e.keyCode is z.util.KEYCODE.ESC
+ else
+ $(component_info.element).find('textarea').off 'keydown', 'esc', @abort
+
+ @placeholder = params.placeholder
+
+
+# Knockout registration of the input element component.
+ko.components.register 'input-element',
+ viewModel:
+ createViewModel: (params, component_info) ->
+ return new z.components.InputElement params, component_info
+ template: """
+
+
+ """
diff --git a/app/script/components/topPeople.coffee b/app/script/components/topPeople.coffee
new file mode 100644
index 00000000000..0a2b13e8f1c
--- /dev/null
+++ b/app/script/components/topPeople.coffee
@@ -0,0 +1,49 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.TopPeopleViewModel
+ constructor: (params) ->
+ @user_ets = params.user
+ @user_selected = params.selected
+ @max_users = params.max or 9
+
+ @displayed_users = ko.pureComputed =>
+ return @user_ets().slice 0, @max_users
+
+ @on_select = (user_et) =>
+ if @is_selected user_et then @user_selected.remove user_et else @user_selected.push user_et
+
+ @is_selected = (user_et) =>
+ return user_et in @user_selected()
+
+
+ko.components.register 'top-people',
+ viewModel: z.components.TopPeopleViewModel
+ template: """
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/userAvatar.coffee b/app/script/components/userAvatar.coffee
new file mode 100644
index 00000000000..c1289bc0478
--- /dev/null
+++ b/app/script/components/userAvatar.coffee
@@ -0,0 +1,89 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.UserAvatar
+ constructor: (params, component_info) ->
+ @user = ko.unwrap params.user
+ @badge = params.badge or false
+ @element = $(component_info.element)
+
+ if not @user?
+ @user = new z.entity.User()
+
+ @element.attr
+ 'id': z.util.create_random_uuid()
+ 'user-id': @user.id
+
+ @initials = ko.computed =>
+ if @element.hasClass 'user-avatar-xs'
+ return z.util.get_first_character @user.initials()
+ else
+ return @user.initials()
+
+ @state = ko.computed =>
+ status = @user.connection().status()
+ return 'self' if @user.is_me
+ return 'selected' if params.selected?() is true
+ return 'blocked' if status is z.user.ConnectionStatus.BLOCKED
+ return 'pending' if status in [z.user.ConnectionStatus.SENT, z.user.ConnectionStatus.PENDING]
+ return 'ignored' if status is z.user.ConnectionStatus.IGNORED
+ return 'unknown' if status in [z.user.ConnectionStatus.UNKNOWN, z.user.ConnectionStatus.CANCELLED]
+ return ''
+
+ @css_classes = ko.computed =>
+ class_string = "accent-color-#{@user.accent_id()}"
+ class_string += " #{@state()}" if @state()
+ return class_string
+
+ @on_click = (data, event) ->
+ params.click? data.user, event.currentTarget.parentNode
+
+ ko.computed =>
+ image_url = @user.picture_preview_url()
+ image_was_already_loaded = false
+
+ if image_url?
+ image = new Image()
+ image.onload = =>
+ @avatar_image = @element.find '.user-avatar-image'
+ @avatar_image.empty().append image
+
+ requestAnimFrame =>
+ if not image_was_already_loaded
+ @element.addClass 'user-avatar-loading-transition'
+ @element.addClass 'user-avatar-image-loaded'
+
+ image.src = z.util.strip_url_wrapper image_url
+ image_was_already_loaded = image.complete
+
+ko.components.register 'user-avatar',
+ viewModel:
+ createViewModel: (params, component_info) ->
+ return new z.components.UserAvatar params, component_info
+ template: """
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/userInput.coffee b/app/script/components/userInput.coffee
new file mode 100644
index 00000000000..4446da1545f
--- /dev/null
+++ b/app/script/components/userInput.coffee
@@ -0,0 +1,67 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+class z.components.UserListInputViewModel
+ constructor: (params, component_info) ->
+ @input = params.input
+ @selected = params.selected or ko.observableArray []
+ @placeholder = params.placeholder
+ @on_enter = params.enter
+ @on_close = params.close
+
+ @element = component_info.element
+ @input_element = $(@element).find '.search-input'
+ @inner_element = $(@element).find '.search-inner'
+
+ @selected.subscribe =>
+ @input ''
+ @input_element.focus()
+ setTimeout =>
+ @inner_element.scrollTop @inner_element[0].scrollHeight
+
+ @placeholder = ko.computed =>
+ if @input() is '' and @selected().length is 0
+ return z.localization.Localizer.get_text params.placeholder
+ else
+ return ''
+
+ on_key_press: (data, event) =>
+ @selected.pop() if event.keyCode is z.util.KEYCODE.DELETE and @input() is ''
+ return true
+
+
+ko.components.register 'user-input',
+ viewModel:
+ createViewModel: (params, component_info) ->
+ return new z.components.UserListInputViewModel params, component_info
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/userList.coffee b/app/script/components/userList.coffee
new file mode 100644
index 00000000000..e8ea0e5f805
--- /dev/null
+++ b/app/script/components/userList.coffee
@@ -0,0 +1,142 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+z.components.UserListMode =
+ DEFAULT: 'default'
+ COMPACT: 'compact'
+ INFO: 'info'
+
+# displays a list of user_ets
+#
+# @param [Object] params to adjust user list
+# @option params [ko.observableArray] user data source
+# @option params [ko.observable] filter filter list items
+# @option params [function] click is called when a list item is selected
+# @option params [function] connect is called when the connect button is clicked
+# @option params [function] dismiss is called when the dismiss button is clicked
+# @option params [ko.observable] selected will be populated will all the selected items
+# @option params [function] selected_filter that determines if the user can be selected
+#
+class z.components.UserListViewModel
+ constructor: (params) ->
+ # parameter list
+ @user_ets = params.user
+ @user_click = params.click
+ @user_connect = params.connect
+ @user_dismiss = params.dismiss
+ @user_filter = params.filter
+ @user_selected = params.selected
+ @user_selected_filter = params.selectable
+ @mode = params.mode or z.components.UserListMode.DEFAULT
+
+ @css_classes = ko.computed =>
+ if @mode is z.components.UserListMode.COMPACT
+ return 'search-list-sm'
+ else if @mode is z.components.UserListMode.INFO
+ return 'search-list-lg'
+ else
+ return 'search-list-md'
+
+ @show_buttons = =>
+ return @user_connect?
+
+ # defaults
+ @filtered_user_ets = @user_ets
+ @is_selected = -> return false
+ @is_selectable = -> return true
+ @on_select = (user_et, e) => @user_click? user_et, e
+ @on_dismiss = (user_et, e) =>
+ e.stopPropagation()
+ @user_dismiss? user_et, e
+ @on_connect = (user_et, e) =>
+ e.stopPropagation()
+ @user_connect? user_et, e
+
+ # filter all list items if a filter is provided
+ if @user_filter?
+ @filtered_user_ets = ko.computed =>
+ ko.utils.arrayFilter @user_ets(), (user_et) =>
+ user_name = window.getSlug user_et.name()
+ search_query = window.getSlug @user_filter()
+ matches_name = z.util.contains user_name, search_query
+ matches_email = user_et.email() is @user_filter()
+ return matches_name or matches_email
+
+ # check every list item before selection if selected_filter is provided
+ if @user_selected_filter?
+ @is_selectable = @user_selected_filter
+
+ # list will be selectable if select is provided
+ if @user_selected?
+ @on_select = (user_et) =>
+ is_selected = @is_selected user_et
+ if is_selected
+ @user_selected.remove user_et
+ else
+ @user_selected.push user_et if @is_selectable user_et
+
+ @user_click? user_et, not is_selected
+
+ @is_selected = (user_et) =>
+ return user_et in @user_selected()
+
+
+ko.components.register 'user-list',
+ viewModel: z.components.UserListViewModel
+ template: """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
diff --git a/app/script/components/userProfile.coffee b/app/script/components/userProfile.coffee
new file mode 100644
index 00000000000..39218d9ac82
--- /dev/null
+++ b/app/script/components/userProfile.coffee
@@ -0,0 +1,269 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.components ?= {}
+
+
+z.components.UserProfileMode =
+ DEFAULT: 'default'
+ PEOPLE: 'people'
+ SEARCH: 'search'
+
+class z.components.UserProfileViewModel
+ constructor: (params, component_info) ->
+ @logger = new z.util.Logger 'z.components.UserProfileViewModel', z.config.LOGGER.OPTIONS
+
+ @user = params.user
+ @conversation = params.conversation
+ @mode = params.mode or z.components.UserProfileMode.DEFAULT
+
+ # repository references
+ @client_repository = wire.app.repository.client
+ @conversation_repository = wire.app.repository.conversation
+ @cryptography_repository = wire.app.repository.cryptography
+ @user_repository = wire.app.repository.user
+
+ # component dom element
+ @element = $(component_info.element)
+
+ # actions
+ @on_accept = -> params.accept? @user()
+ @on_add_people = => params.add_people? @user()
+ @on_block = -> params.block? @user()
+ @on_close = -> params.close?()
+ @on_ignore = -> params.ignore? @user()
+ @on_leave = => params.leave? @user()
+ @on_profile = => params.profile? @user()
+ @on_remove = => params.remove? @user()
+ @on_unblock = -> params.unblock? @user()
+
+ # cancel request confirm dialog
+ @confirm_dialog = undefined
+
+ # tabs
+ @click_on_tab = (index) => @tab_index index
+ @tab_index = ko.observable 0
+ @tab_index.subscribe @on_tab_index_changed
+
+ # devices
+ @devices = ko.observableArray()
+ @devices_found = ko.observable()
+ @selected_device = ko.observable()
+ @fingerprint_remote = ko.observable ''
+ @fingerprint_local = ko.observable ''
+ @is_resetting_session = ko.observable false
+
+ # destroy confirm dialog when user changes
+ ko.computed =>
+ @confirm_dialog?.destroy() if @user()?
+ @tab_index 0
+ @devices_found null
+ @selected_device null
+ @fingerprint_remote ''
+ @is_resetting_session false
+
+ @selected_device.subscribe =>
+ if @selected_device()?
+ @cryptography_repository.get_session @user().id, @selected_device().id
+ .then (cryptobox_session) =>
+ @fingerprint_remote cryptobox_session.fingerprint_remote()
+ @fingerprint_local cryptobox_session.fingerprint_local()
+
+ @add_people_tooltip = z.localization.Localizer.get_text {
+ id: z.string.tooltip_people_add
+ replace: {placeholder: '%shortcut', content: z.ui.Shortcut.get_shortcut_tooltip z.ui.ShortcutType.ADD_PEOPLE}
+ }
+
+ @device_headline = ko.pureComputed =>
+ z.localization.Localizer.get_text {
+ id: z.string.people_tabs_devices_headline
+ replace: {placeholder: '%@.name', content: @user().first_name()}
+ }
+
+ @no_device_headline = ko.pureComputed =>
+ z.localization.Localizer.get_text {
+ id: z.string.people_tabs_no_devices_headline
+ replace: {placeholder: '%@.name', content: @user().first_name()}
+ }
+
+ @detail_message = ko.pureComputed =>
+ z.localization.Localizer.get_text {
+ id: z.string.people_tabs_device_detail_headline
+ replace: [
+ {placeholder: '%bold', content: ""}
+ {placeholder: '%@.name', content: z.util.escape_html @user().first_name()}
+ {placeholder: '%end', content: ''}
+ ]
+ }
+
+ @on_cancel_request = =>
+ amplify.publish z.event.WebApp.AUDIO.PLAY, z.audio.AudioType.ALERT
+ @confirm_dialog = @element.confirm
+ template: '#template-confirm-cancel_request'
+ data:
+ user: @user()
+ confirm: =>
+ should_block = @element.find('.checkbox input').is ':checked'
+ if should_block
+ @user_repository.block_user @user()
+ else
+ @user_repository.cancel_connection_request @user()
+
+ conversation_et = @conversation_repository.get_one_to_one_conversation @user().id
+ if @conversation_repository.is_active_conversation conversation_et
+ amplify.publish z.event.WebApp.CONVERSATION.PEOPLE.HIDE
+ next_conversation_et = @conversation_repository.get_next_conversation conversation_et
+ setTimeout ->
+ amplify.publish z.event.WebApp.CONVERSATION.SHOW, next_conversation_et
+ , 550
+
+ params.cancel_request? @user()
+
+ @on_open = =>
+ amplify.publish z.event.WebApp.CONVERSATION.PEOPLE.HIDE
+ conversation_et = @conversation_repository.get_one_to_one_conversation @user().id
+ @conversation_repository.unarchive_conversation conversation_et if conversation_et.is_archived()
+ setTimeout =>
+ amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et
+ params.open? @user()
+ , 550
+
+ @on_connect = =>
+ @user_repository.create_connection @user(), true
+ .then ->
+ amplify.publish z.event.WebApp.CONVERSATION.PEOPLE.HIDE
+
+ params.connect? @user()
+
+ @on_pending = =>
+ if @user().connection().status() in [z.user.ConnectionStatus.PENDING, z.user.ConnectionStatus.IGNORED]
+ params.pending? @user()
+ else
+ @on_open()
+
+ @accent_color = ko.computed =>
+ return "accent-color-#{@user()?.accent_id()}"
+ , @, deferEvaluation: true
+
+ @show_gray_image = ko.computed =>
+ return false if not @user()?
+ return true if @user().connection().status() isnt z.user.ConnectionStatus.ACCEPTED and not @user().is_me
+ return false
+ , @, deferEvaluation: true
+
+ @connection_is_not_established = ko.computed =>
+ @user()?.connection().status() in [z.user.ConnectionStatus.PENDING, z.user.ConnectionStatus.SENT, z.user.ConnectionStatus.IGNORED]
+ , @, deferEvaluation: true
+
+ @user_is_removed_from_conversation = ko.computed =>
+ return true if not @user()? or not @conversation()?
+ return not (@user() in @conversation().participating_user_ets())
+ , @, deferEvaluation: true
+
+ @render_common_contacts = ko.pureComputed =>
+ return @user()?.id and not @user().connected() and not @user().is_me
+
+ # footer
+ @get_footer_template = ko.computed =>
+ return 'user-profile-footer-empty' if not @user()?
+
+ ConversationType = z.conversation.ConversationType
+ status = @user().connection().status()
+ is_me = @user().is_me
+
+ # When used in conversation!
+ if @conversation?
+ type = @conversation().type()
+
+ if type in [ConversationType.ONE2ONE, ConversationType.CONNECT]
+ return 'user-profile-footer-profile' if is_me
+ return 'user-profile-footer-add-block' if status is z.user.ConnectionStatus.ACCEPTED
+ return 'user-profile-footer-pending' if status is z.user.ConnectionStatus.SENT
+
+ else if type is ConversationType.REGULAR
+ return 'user-profile-footer-profile-leave' if is_me
+ return 'user-profile-footer-connect-remove' if status in [z.user.ConnectionStatus.UNKNOWN, z.user.ConnectionStatus.CANCELLED]
+ return 'user-profile-footer-pending-remove' if status in [z.user.ConnectionStatus.PENDING, z.user.ConnectionStatus.SENT, z.user.ConnectionStatus.IGNORED]
+ return 'user-profile-footer-message-remove' if status is z.user.ConnectionStatus.ACCEPTED
+ return 'user-profile-footer-unblock-remove' if status is z.user.ConnectionStatus.BLOCKED
+
+ # When used in Search!
+ else
+ return 'user-profile-footer-unblock' if status is z.user.ConnectionStatus.BLOCKED
+ return 'user-profile-footer-pending' if status is z.user.ConnectionStatus.SENT
+ return 'user-profile-footer-ignore-accept' if status in [z.user.ConnectionStatus.PENDING, z.user.ConnectionStatus.IGNORED]
+ return 'user-profile-footer-add' if status in [z.user.ConnectionStatus.UNKNOWN, z.user.ConnectionStatus.CANCELLED]
+
+ return 'user-profile-footer-empty'
+
+ click_on_device: (client_et) =>
+ @selected_device client_et
+
+ click_on_device_detail_back_button: =>
+ @selected_device null
+
+ click_on_my_fingerprint_button: =>
+ @confirm_dialog = $('#participants').confirm
+ template: '#template-confirm-my-fingerprint'
+ data:
+ device: @client_repository.current_client
+ fingerprint_local: @fingerprint_local
+ click_on_show_my_devices: ->
+ amplify.publish z.event.WebApp.PROFILE.SETTINGS.SHOW
+
+ click_on_reset_session: =>
+ reset_progress = =>
+ window.setTimeout =>
+ @is_resetting_session false
+ , 550
+
+ @is_resetting_session true
+ @conversation_repository.reset_session @user().id, @selected_device().id, @conversation().id
+ .then -> reset_progress()
+ .catch -> reset_progress()
+
+ click_on_verify_client: =>
+ toggle_verified = !!!@selected_device().meta.is_verified()
+
+ @client_repository.update_client_in_db @user().id, @selected_device().id, {
+ meta:
+ is_verified: toggle_verified
+ }
+ .then => @selected_device().meta.is_verified toggle_verified
+ .catch (error) => @logger.log @logger.levels.WARN, "Client cannot be updated: #{error.message}"
+
+ on_tab_index_changed: (index) =>
+ if index is 1
+
+ user_id = @user().id
+ @client_repository.get_clients_by_user_id user_id
+ .then (client_ets) =>
+ if client_ets?.length > 0
+ @user().devices client_ets
+ @devices_found true
+ else
+ @devices_found false
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Unable to retrieve clients data for user '#{user_id}': #{error}"
+
+ko.components.register 'user-profile',
+ viewModel: createViewModel: (params, component_info) ->
+ return new z.components.UserProfileViewModel params, component_info
+ template:
+ element: 'user-profile-template'
diff --git a/app/script/config.coffee b/app/script/config.coffee
new file mode 100644
index 00000000000..f664ffe9f62
--- /dev/null
+++ b/app/script/config.coffee
@@ -0,0 +1,112 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+
+
+z.config =
+ BROWSER_NOTIFICATION:
+ TIMEOUT: 5000
+ TITLE_LENGTH: 38
+ BODY_LENGTH: 80
+
+ LOGGER:
+ OPTIONS:
+ name_length: 60
+ domains:
+ 'app.wire.com': -> 0
+ 'localhost': -> 300
+ 'wire.ms': -> 300
+ 'wire-webapp-edge.wire.com': -> 300
+ 'wire-webapp-staging.wire.com': -> 300
+ 'zinfra.io': -> 300
+
+ TIME_BETWEEN_PING: 30000
+
+ # number of message that will be pulled
+ MESSAGES_FETCH_LIMIT: 30
+
+ # number of users displayed in people you may know
+ SUGGESTIONS_FETCH_LIMIT: 30
+
+ # number of top people displayed in the start ui
+ TOP_PEOPLE_FETCH_LIMIT: 24
+
+ #Accent color IDs
+ ACCENT_ID:
+ BLUE: 1
+ GREEN: 2
+ YELLOW: 3
+ RED: 4
+ ORANGE: 5
+ PINK: 6
+ PURPLE: 7
+
+ # Ignored by the emoji lib
+ EXCLUDE_EMOJI: [
+ '\u2122' # trademark
+ '\u00A9' # copyright
+ '\u00AE' # registered
+ ]
+
+ # Conversation size
+ MAXIMUM_CONVERSATION_SIZE: 128
+
+ # self profile image size
+ MINIMUM_PROFILE_IMAGE_SIZE:
+ WIDTH: 320
+ HEIGHT: 320
+
+ # 5 megabyte image upload limit
+ MAXIMUM_IMAGE_FILE_SIZE: 5 * 1024 * 1024
+
+ # 25 megabyte upload limit ( minus iv and padding )
+ MAXIMUM_ASSET_FILE_SIZE: 25 * 1024 * 1024 - 16 - 16
+
+ # Maximum of parallel uploads
+ MAXIMUM_ASSET_UPLOADS: 10
+
+ # Maximum characters per message
+ MAXIMUM_MESSAGE_LENGTH: 8000
+
+ SUPPORTED_IMAGE_TYPES: [
+ 'image/jpg',
+ 'image/jpeg',
+ 'image/png',
+ 'image/bmp'
+ ]
+
+ MINIMUM_USERNAME_LENGTH: 2
+ MINIMUM_PASSWORD_LENGTH: 8
+
+ # Time until phone code expires
+ LOGIN_CODE_EXPIRATION: 10 * 60
+
+ # measured in pixel
+ SCROLL_TO_LAST_MESSAGE_THRESHOLD: 100
+
+ # defines if it was a recently viewed conversation (5 min)
+ CONVERSATION_ACTIVITY_TIMEOUT: 5 * 60 * 1000
+
+ PROPERTIES_KEY: 'webapp'
+
+ # bigger requests will be split in chunks with a maximum size as defined
+ MAXIMUM_USERS_PER_REQUEST: 200
+
+ UNSPLASH_URL: 'https://source.unsplash.com/1200x1200/?landscape'
+ ANNOUNCE_URL: 'https://wire.com/api/v1/announce/'
diff --git a/app/script/connect/ConnectError.coffee b/app/script/connect/ConnectError.coffee
new file mode 100644
index 00000000000..04f7153b343
--- /dev/null
+++ b/app/script/connect/ConnectError.coffee
@@ -0,0 +1,35 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.connect ?= {}
+
+class z.connect.ConnectError
+ constructor: (message, type) ->
+ @name = @constructor.name
+ @message = message
+ @stack = (new Error()).stack
+ @type = type
+
+ @:: = new Error()
+ @::constructor = @
+ @::TYPE =
+ GOOGLE_CLIENT: 'z.connect.ConnectError::TYPE.GOOGLE_CLIENT'
+ GOOGLE_DOWNLOAD: 'z.connect.ConnectError::TYPE.GOOGLE_DOWNLOAD'
+ NO_CONTACTS: 'z.connect.ConnectError::TYPE.NO_CONTACTS'
+ UPLOAD: 'z.connect.ConnectError::TYPE.UPLOAD'
diff --git a/app/script/connect/ConnectGoogleService.coffee b/app/script/connect/ConnectGoogleService.coffee
new file mode 100644
index 00000000000..739de94db7d
--- /dev/null
+++ b/app/script/connect/ConnectGoogleService.coffee
@@ -0,0 +1,129 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.connect ?= {}
+
+###
+Connect Google Service for calls to the Google's REST API.
+
+@see https://github.com/google/google-api-javascript-client
+ https://developers.google.com/api-client-library/javascript/
+ https://developers.google.com/google-apps/contacts/v3
+ Use updated-min for newer updates
+ max-results
+###
+class z.connect.ConnectGoogleService
+ # Construct a new Connect Google Service.
+ constructor: (@client) ->
+ @logger = new z.util.Logger 'z.connect.ConnectGoogleService', z.config.LOGGER.OPTIONS
+ @client_id = '481053726221-71f8tbhghg4ug5put5v3j5pluv0di2fc.apps.googleusercontent.com'
+ @scopes = 'https://www.googleapis.com/auth/contacts.readonly'
+
+ ###
+ Retrieves the user's Google Contacts.
+ @return [Promise] Promise that resolves with the Google contacts
+ ###
+ get_contacts: =>
+ @_init_library()
+ .then =>
+ return @_get_access_token()
+ .then (access_token) =>
+ @_get_contacts access_token
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to import contacts from Google: #{error.message}", error
+
+ ###
+ Authenticate before getting the contacts.
+ @private
+ @return [Promise] Promise that resolves when the user has been successfully authenticated
+ ###
+ _authenticate: =>
+ return new Promise (resolve, reject) =>
+ @logger.log @logger.levels.INFO, 'Authenticating with Google for contacts access'
+
+ on_response = (response) =>
+ if not response?.error
+ @logger.log @logger.levels.INFO, 'Received access token from Google', response
+ resolve response.access_token
+ else
+ @logger.log @logger.levels.ERROR, 'Failed to authenticate with Google', response
+ reject response?.error
+
+ window.gapi.auth.authorize {client_id: @client_id, scope: @scopes, immediate: false}, on_response
+
+ ###
+ Check for cached access token or authenticate with Google.
+ @return [Promise] Promise that resolves with the access token
+ ###
+ _get_access_token: =>
+ return new Promise (resolve, reject) =>
+ if window.gapi.auth
+ if auth_token = window.gapi.auth.getToken()
+ @logger.log @logger.levels.INFO, 'Using cached access token to access Google contacts', auth_token
+ resolve auth_token.access_token
+ else
+ @_authenticate().then(resolve).catch reject
+ else
+ error_message = 'Google Auth Client for JavaScript not loaded'
+ @logger.log @logger.levels.WARN, error_message
+ error = new z.connect.ConnectError error_message, z.connect.ConnectError::TYPE.GOOGLE_CLIENT
+ Raygun.send error
+ reject error
+
+ ###
+ Retrieve the user's Google Contacts using a call to their backend.
+ @private
+ @return [Promise] Promise that resolves with the user's contacts
+ ###
+ _get_contacts: (access_token) ->
+ return new Promise (resolve, reject) =>
+ @logger.log @logger.levels.INFO, 'Fetching address book from Google'
+ api_endpoint = 'https://www.google.com/m8/feeds/contacts/default/full'
+ $.get "#{api_endpoint}?access_token=#{access_token}&alt=json&max-results=15000&v=3.0"
+ .always (data_or_jqXHR, textStatus) =>
+ if textStatus isnt 'error'
+ @logger.log @logger.levels.INFO, 'Received address book from Google', data_or_jqXHR
+ resolve data_or_jqXHR
+ else
+ @logger.log @logger.levels.ERROR, 'Failed to fetch address book from Google', data_or_jqXHR
+ reject data_or_jqXHR.responseJSON or new z.service.BackendClientError data_or_jqXHR.status
+
+ ###
+ Initialize Google Auth Client for JavaScript is loaded.
+ @return [Promise] Promise that resolves when the authentication library is initialized
+ ###
+ _init_library: ->
+ if window.gapi
+ return Promise.resolve()
+ else
+ return @_load_library()
+
+ ###
+ Lazy loading of the Google Auth Client for JavaScript.
+ @return [Promise] Promise that resolves when the authentication library is loaded
+ ###
+ _load_library: ->
+ return new Promise (resolve) =>
+ window.gapi_loaded = resolve
+
+ @logger.log @logger.levels.INFO, 'Lazy loading Google Auth API'
+ script_node = document.createElement 'script'
+ script_node.src = 'https://apis.google.com/js/auth.js?onload=gapi_loaded'
+ script_element = document.getElementsByTagName('script')[0]
+ script_element.parentNode.insertBefore script_node, script_element
diff --git a/app/script/connect/ConnectRepository.coffee b/app/script/connect/ConnectRepository.coffee
new file mode 100644
index 00000000000..b392f4c9e8c
--- /dev/null
+++ b/app/script/connect/ConnectRepository.coffee
@@ -0,0 +1,196 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.connect ?= {}
+
+# Connect Repository for all address book interactions with the connect service.
+class z.connect.ConnectRepository
+ ###
+ Construct a new Connect Repository.
+
+ @param connect_service [z.connect.ConnectService] Backend REST API service implementation
+ @param connect_google_service [z.connect.ConnectGoogleService] Google REST API implementation
+ @param user_repository [z.user.UserRepository] Repository for all user and connection interactions
+ ###
+ constructor: (@connect_service, @connect_google_service, @user_repository) ->
+ @logger = new z.util.Logger 'z.connect.ConnectRepository', z.config.LOGGER.OPTIONS
+
+ ###
+ Retrieve a user's Google Contacts.
+ @return [Promise] Promise that resolves with the user's Google contacts that match on Wire
+ ###
+ get_google_contacts: ->
+ return new Promise (resolve, reject) =>
+ @connect_google_service.get_contacts()
+ .catch (error) =>
+ error_message = 'Google Contacts SDK error'
+ @logger.log @logger.levels.INFO, error_message, error
+ connect_error = new z.connect.ConnectError error_message, z.connect.ConnectError::TYPE.GOOGLE_DOWNLOAD
+ reject connect_error
+ throw connect_error
+ .then (response) =>
+ return @_parse_google_contacts response
+ .then (phone_book) =>
+ if phone_book.cards.length is 0
+ error_message = 'No contacts found for upload'
+ @logger.log @logger.levels.WARN, error_message
+ resolve []
+ throw new z.connect.ConnectError error_message, z.connect.ConnectError::TYPE.NO_CONTACTS
+ else
+ @logger.log @logger.levels.INFO, "Uploading hashes of '#{phone_book.cards.length}' contacts for matching", phone_book
+ return @connect_service.post_onboarding phone_book
+ .then (response) =>
+ @logger.log @logger.levels.INFO,
+ "Gmail contacts upload successful: #{response.results.length} matches, #{response['auto-connects'].length} auto connects", response
+ @user_repository.save_property_contact_import_google Date.now()
+ resolve response
+ .catch (error) =>
+ if error not instanceof z.connect.ConnectError
+ if error.code is z.service.BackendClientError::STATUS_CODE.TOO_MANY_REQUESTS
+ error_message = 'Backend refused Gmail contacts upload: Endpoint used too frequent'
+ @logger.log @logger.levels.ERROR, error_message
+ else
+ error_message = 'Gmail contacts upload failed'
+ @logger.log @logger.levels.ERROR, error_message, error
+ reject new z.connect.ConnectError error_message, z.connect.ConnectError::TYPE.UPLOAD
+
+
+ ###
+ Retrieve a user's OSX address book contacts.
+ @return [Promise] Promise that resolves with the user's address book contacts that match on Wire
+ ###
+ get_osx_contacts: ->
+ return new Promise (resolve, reject) =>
+ phone_book = @_parse_osx_contacts()
+
+ if phone_book.cards.length is 0
+ error_message = 'No contacts found for upload'
+ @logger.log @logger.levels.WARN, error_message
+ reject new z.connect.ConnectError 'No contacts found for upload', z.connect.ConnectError::TYPE.NO_CONTACTS
+ else
+ @logger.log @logger.levels.INFO, "Uploading hashes of '#{phone_book.cards.length}' contacts for matching", phone_book
+ @connect_service.post_onboarding phone_book
+ .then (response) =>
+ @logger.log @logger.levels.INFO,
+ "OS X contacts upload successful: #{response.results.length} matches, #{response['auto-connects'].length} auto connects", response
+ @user_repository.save_property_contact_import_osx Date.now()
+ resolve response
+ .catch (error) =>
+ if error.code is z.service.BackendClientError::STATUS_CODE.TOO_MANY_REQUESTS
+ error_message = 'Backend refused OS X contacts upload: Endpoint used too frequent'
+ @logger.log @logger.levels.ERROR, error_message
+ else
+ error_message = 'OS X contacts upload failed'
+ @logger.log @logger.levels.ERROR, error_message, error
+ reject new z.connect.ConnectError error_message, z.connect.ConnectError::TYPE.UPLOAD
+
+ ###
+ Encode phone book
+
+ @private
+ @param phone_book [z.connect.PhoneBook] Object containing un-encoded phone book data
+ @return [z.connect.PhoneBook] Object containing encoded phone book data
+ ###
+ _encode_phone_book: (phone_book) ->
+ for entry, index in phone_book.self
+ phone_book.self[index] = z.util.encode_sha256_base64 entry
+
+ for card, card_index in phone_book.cards
+ for contact, contact_index in card.contact
+ card.contact[contact_index] = z.util.encode_sha256_base64 contact
+ phone_book.cards[card_index] = card
+
+ return phone_book
+
+ ###
+ Parse a user's OSX address book Contacts.
+ @private
+ @return [z.connect.PhoneBook] Encoded phone book data
+ ###
+ _parse_osx_contacts: ->
+ return if not window.zAddressBook
+
+ if _.isFunction window.zAddressBook
+ address_book = window.zAddressBook() # for osx >= 2.7
+ else
+ address_book = window.zAddressBook # for osx < 2.7
+
+ phone_book = new z.connect.PhoneBook @user_repository.self()
+
+ me = address_book.getMe()
+ for email in me.emails
+ phone_book.self.push email
+ for number in me.numbers
+ phone_book.self.push number
+
+ x = 0
+ while x < address_book.contactCount()
+ contact = address_book.getContact x
+ card =
+ contact: []
+ card_id: CryptoJS.MD5("#{contact.firstName}#{contact.lastName}").toString()
+ for email in contact.emails
+ card.contact.push email.toLowerCase().trim()
+ for number in contact.numbers
+ card.contact.push z.util.phone_number_to_e164 number, navigator.language
+
+ if card.contact.length > 0
+ phone_book.cards.push card
+ x++
+
+ return @_encode_phone_book phone_book
+
+ ###
+ Parse a user's Google Contacts.
+ @private
+ @param response [JSON] Response from Google API
+ @return [z.connect.PhoneBook] Encoded phone book data
+ ###
+ _parse_google_contacts: (response) ->
+ phone_book = new z.connect.PhoneBook @user_repository.self()
+
+ # Add self info from Google
+ if response.feed.author?
+ self = response.feed.author
+ google_email = self[0].email.$t.toLowerCase().trim()
+ if not @user_repository.self().email() is google_email
+ phone_book.self.push google_email
+
+ # Add Google contacts
+ if response.feed.entry?
+ users = response.feed.entry
+ for user in users
+ if user.gd$email? or user.gd$phoneNumber?
+ card =
+ contact: []
+ card_id: user.gd$etag
+
+ if user.gd$email?
+ for email in user.gd$email
+ card.contact.push email.address.toLowerCase().trim()
+
+ if user.gd$phoneNumber?
+ for number in user.gd$phoneNumber
+ if number.uri?
+ card.contact.push number.uri
+ else
+ card.contact.push number.$t
+
+ phone_book.cards.push card
+ return @_encode_phone_book phone_book
diff --git a/app/script/connect/ConnectService.coffee b/app/script/connect/ConnectService.coffee
new file mode 100644
index 00000000000..57a932f237d
--- /dev/null
+++ b/app/script/connect/ConnectService.coffee
@@ -0,0 +1,42 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.connect ?= {}
+
+# Connect Service for all addressbook calls to the backend REST API.
+class z.connect.ConnectService
+ ###
+ Construct a new Connect Service.
+ @param client [z.service.Client] Client for the API calls
+ ###
+ constructor: (@client) ->
+ @logger = new z.util.Logger 'z.connect.ConnectService', z.config.LOGGER.OPTIONS
+
+ ###
+ Upload address book data for matching.
+ @see https://staging-nginz-https.zinfra.io/swagger-ui/#!/addressbook/onboardingV3
+
+ @param phone_book [Object] Phone book containing the address cards
+ @return [Promise] Promise that resolves with the matched contacts from the user's phone book
+ ###
+ post_onboarding: (phone_book) ->
+ @client.send_json
+ type: 'POST'
+ url: @client.create_url '/onboarding/v3'
+ data: phone_book
diff --git a/app/script/connect/ConnectSource.coffee b/app/script/connect/ConnectSource.coffee
new file mode 100644
index 00000000000..1b6dd2d39fd
--- /dev/null
+++ b/app/script/connect/ConnectSource.coffee
@@ -0,0 +1,24 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.connect ?= {}
+
+z.connect.ConnectSource =
+ GMAIL: 'gmail'
+ ICLOUD: 'icloud'
diff --git a/app/script/connect/ConnectTrigger.coffee b/app/script/connect/ConnectTrigger.coffee
new file mode 100644
index 00000000000..7ff0d8f43f8
--- /dev/null
+++ b/app/script/connect/ConnectTrigger.coffee
@@ -0,0 +1,25 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.connect ?= {}
+
+z.connect.ConnectTrigger =
+ ONBOARDING: 'registration'
+ SEARCH: 'search'
+ SETTINGS: 'settings'
diff --git a/app/script/connect/PhoneBook.coffee b/app/script/connect/PhoneBook.coffee
new file mode 100644
index 00000000000..243afcd0c1b
--- /dev/null
+++ b/app/script/connect/PhoneBook.coffee
@@ -0,0 +1,30 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.connect ?= {}
+
+# Phone book entity.
+class z.connect.PhoneBook
+ ###
+ Construct a new Phone book.
+ @param self_user [z.entity.User] Self user
+ ###
+ constructor: (self_user) ->
+ @self = [self_user.email()]
+ @cards = []
diff --git a/app/script/conversation/ConversationMapper.coffee b/app/script/conversation/ConversationMapper.coffee
new file mode 100644
index 00000000000..f1cccdac44b
--- /dev/null
+++ b/app/script/conversation/ConversationMapper.coffee
@@ -0,0 +1,156 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.conversation ?= {}
+
+# Conversation Mapper to convert all server side JSON conversation objects into core entities
+class z.conversation.ConversationMapper
+ # Construct a new Conversation Mapper.
+ constructor: ->
+ @logger = new z.util.Logger 'z.conversation.ConversationMapper', z.config.LOGGER.OPTIONS
+
+ ###
+ Convert a JSON conversation into a conversation entity.
+ @param json [Object] Conversation data
+ @return [z.entity.Conversation] Mapped conversation entity
+ ###
+ map_conversation: (json) ->
+ conversation_ets = @map_conversations [json?.data or json]
+ return conversation_ets[0]
+
+ ###
+ Convert multiple JSON conversations into a conversation entities.
+ @param json [Object] Conversation data
+ @return [Array] Mapped conversation entities
+ ###
+ map_conversations: (json) ->
+ return (@_create_conversation_et conversation for conversation in json)
+
+ ###
+ Updates all properties of a conversation specified
+
+ @example data: {"name":"ThisIsMyNewConversationName"}
+ @todo make utility?
+
+ @param conversation_et [z.entity.Conversation] Conversation to be updated
+ @param data [Object] Conversation data
+ @return [z.entity.Conversation] Updated conversation entity
+ ###
+ update_properties: (conversation_et, data) ->
+ for key, value of data
+ continue if key is 'id'
+ if conversation_et[key]?
+ if ko.isObservable conversation_et[key]
+ conversation_et[key] value
+ else
+ conversation_et[key] = value
+
+ return conversation_et
+
+ ###
+ Update the membership properties of a conversation.
+
+ @param conversation_et [z.entity.Conversation] Conversation to be updated
+ @param self [Object] Conversation self data
+ @return [z.entity.Conversation] Updated conversation entity
+ ###
+ update_self_status: (conversation_et, self) ->
+ return if not conversation_et?
+
+ if self.status?
+ conversation_et.removed_from_conversation self.status is z.conversation.ConversationStatus.PAST_MEMBER
+
+ # Last Event Timestamp from storage
+ if self.last_event_timestamp
+ conversation_et.set_timestamp self.last_event_timestamp,
+ z.conversation.ConversationUpdateType.LAST_EVENT_TIMESTAMP
+
+ if self.otr_archived?
+ timestamp = new Date(self.otr_archived_ref).getTime()
+ conversation_et.set_timestamp timestamp, z.conversation.ConversationUpdateType.ARCHIVED_TIMESTAMP
+ conversation_et.archived_state self.otr_archived
+
+ if self.archived_timestamp
+ timestamp = self.archived_timestamp
+ conversation_et.set_timestamp timestamp, z.conversation.ConversationUpdateType.ARCHIVED_TIMESTAMP
+ conversation_et.archived_state self.archived_state
+
+ if self.cleared_timestamp
+ conversation_et.set_timestamp self.cleared_timestamp, z.conversation.ConversationUpdateType.CLEARED_TIMESTAMP
+
+ # Last read
+ if self.last_read_timestamp
+ conversation_et.set_timestamp self.last_read_timestamp, z.conversation.ConversationUpdateType.LAST_READ_TIMESTAMP
+
+ # Muted
+ if self.otr_muted?
+ timestamp = new Date(self.otr_muted_ref).getTime()
+ conversation_et.set_timestamp timestamp, z.conversation.ConversationUpdateType.MUTED_TIMESTAMP
+ conversation_et.muted_state self.otr_muted
+
+ if self.muted_timestamp
+ conversation_et.set_timestamp self.muted_timestamp, z.conversation.ConversationUpdateType.MUTED_TIMESTAMP
+ conversation_et.muted_state self.muted_state
+
+ return conversation_et
+
+ ###
+ Creates a conversation entity from JSON data.
+
+ @private
+ @param data [Object] Conversation data
+ @return [z.entity.Conversation] Mapped conversation entity
+ ###
+ _create_conversation_et: (data) ->
+ return if not data?
+ return @_update_conversation_et new z.entity.Conversation(data.id), data
+
+ ###
+ Updates a given conversation entity from JSON data.
+
+ @private
+ @param conversation_et [z.entity.Conversation] Conversation to be updated
+ @param data [Object] Conversation data
+ @return [z.entity.Conversation] Updated conversation entity
+ ###
+ _update_conversation_et: (conversation_et, data) ->
+ self = data.members.self
+ others = data.members.others
+
+ conversation_et.id = data.id
+ conversation_et.creator = data.creator
+ conversation_et.type data.type
+ conversation_et.name data.name ? ''
+
+ # Last event
+ timestamp = new Date(data.last_event_time).getTime()
+ conversation_et.set_timestamp timestamp, z.conversation.ConversationUpdateType.LAST_EVENT_TIMESTAMP
+
+ conversation_et = @update_self_status conversation_et, self
+
+ # all users ( with all status codes )
+ conversation_et.all_user_ids others.map (value) -> value.id
+
+ # all users that are still active
+ participating_user_ids = []
+ others.forEach (other) ->
+ participating_user_ids.push other.id if other.status is z.conversation.ConversationStatus.CURRENT_MEMBER
+ conversation_et.participating_user_ids participating_user_ids
+
+ return conversation_et
diff --git a/app/script/conversation/ConversationRepository.coffee b/app/script/conversation/ConversationRepository.coffee
new file mode 100644
index 00000000000..ba887b1b98e
--- /dev/null
+++ b/app/script/conversation/ConversationRepository.coffee
@@ -0,0 +1,1813 @@
+#
+# Wire
+# Copyright (C) 2016 Wire Swiss GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see http://www.gnu.org/licenses/.
+#
+
+window.z ?= {}
+z.conversation ?= {}
+
+# Conversation repository for all conversation interactions with the conversation service
+class z.conversation.ConversationRepository
+ ###
+ Construct a new Conversation Repository.
+
+ @param conversation_service [z.conversation.ConversationService] Backend REST API conversation service implementation
+ @param asset_service [z.assets.AssetService] Backend REST API asset service implementation
+ @param user_repository [z.user.UserRepository] Repository for all user and connection interactions
+ @param giphy_repository [z.extension.GiphyRepository] Repository for Giphy GIFs
+ @param cryptography_repository [z.cryptography.CryptographyRepository] Repository for all cryptography interactions
+ @param link_repository [z.links.LinkPreviewRepository] Repository for link previews
+ ###
+ constructor: (@conversation_service, @asset_service, @user_repository, @giphy_repository, @cryptography_repository, @link_repository) ->
+ @logger = new z.util.Logger 'z.conversation.ConversationRepository', z.config.LOGGER.OPTIONS
+
+ @conversation_mapper = new z.conversation.ConversationMapper()
+ @event_mapper = new z.conversation.EventMapper @asset_service, @user_repository
+
+ @active_conversation = ko.observable()
+
+ @conversations = ko.observableArray []
+ @has_initialized_participants = false
+ @is_handling_notifications = true
+ @fetching_conversations = {}
+
+ @self_conversation = ko.computed =>
+ return @find_conversation_by_id @user_repository.self().id if @user_repository.self()
+
+ @filtered_conversations = ko.computed =>
+ @conversations().filter (conversation_et) ->
+ states_to_filter = [z.user.ConnectionStatus.BLOCKED, z.user.ConnectionStatus.CANCELLED, z.user.ConnectionStatus.PENDING]
+ return false if conversation_et.connection().status() in states_to_filter
+ return false if conversation_et.is_self()
+ return false if conversation_et.is_cleared() and conversation_et.removed_from_conversation()
+ return true
+
+ @sorted_conversations = ko.computed =>
+ @filtered_conversations().sort z.util.sort_groups_by_last_event
+
+ @conversations_archived = ko.observableArray []
+ @conversations_call = ko.observableArray []
+ @conversations_cleared = ko.observableArray []
+ @conversations_unarchived = ko.observableArray []
+
+ @processed_event_ids = {}
+ @processed_event_nonces = {}
+
+ @_init_subscriptions()
+
+ _init_state_updates: ->
+ ko.computed =>
+ archived = []
+ calls = []
+ cleared = []
+ unarchived = []
+
+ for conversation_et in @sorted_conversations()
+ if conversation_et.has_active_call()
+ calls.push conversation_et
+ else if conversation_et.is_cleared()
+ cleared.push conversation_et
+ else if conversation_et.is_archived()
+ archived.push conversation_et
+ else
+ unarchived.push conversation_et
+
+ @conversations_archived archived
+ @conversations_call calls
+ @conversations_cleared cleared
+ @conversations_unarchived unarchived
+
+ _init_subscriptions: ->
+ amplify.subscribe z.event.WebApp.CONVERSATION.ASSET.CANCEL, @cancel_asset_upload
+ amplify.subscribe z.event.WebApp.CONVERSATION.EVENT_FROM_BACKEND, @on_conversation_event
+ amplify.subscribe z.event.WebApp.CONVERSATION.MAP_CONNECTION, @map_connection
+ amplify.subscribe z.event.WebApp.CONVERSATION.STORE, @save_conversation_in_db
+ amplify.subscribe z.event.WebApp.EVENT.NOTIFICATION_HANDLING_STATE, @set_notification_handling_state
+ amplify.subscribe z.event.WebApp.SELF.CLIENT_ADD, @on_self_client_add
+ amplify.subscribe z.event.WebApp.USER.UNBLOCKED, @unblocked_user
+ amplify.subscribe z.event.WebApp.CONVERSATION.MESSAGE.DELETE, @delete_message
+
+
+ ###############################################################################
+ # Conversation service interactions
+ ###############################################################################
+
+ ###
+ Create a new conversation.
+ @note Supply at least 2 user IDs! Do not include the requestor
+
+ @param user_ids [Array] IDs of users (excluding the requestor) to be part of the conversation
+ @param name [String] User defined name for the Conversation (optional)
+ @param on_success [Function] Function to be called on success
+ @param on_error [Function] Function to be called on failure
+ ###
+ create_new_conversation: (user_ids, name, on_success, on_error) =>
+ @conversation_service.create_conversation user_ids, name, (response, error) =>
+ if response
+ on_success? @create response
+ else
+ on_error? error
+
+ ###
+ Get a conversation from the backend.
+ @param conversation_et [z.entity.Conversation] Conversation to be saved
+ @return [Boolean] Is the conversation active
+ ###
+ fetch_conversation_by_id: (conversation_id, callback) ->
+ for id, callbacks of @fetching_conversations when id is conversation_id
+ callbacks.push callback
+ return
+
+ @fetching_conversations[conversation_id] = [callback]
+
+ @conversation_service.get_conversation_by_id conversation_id, (response, error) =>
+ if response
+ conversation_et = @conversation_mapper.map_conversation response
+ @save_conversation conversation_et
+ @logger.log @logger.levels.INFO, "Conversation with ID '#{conversation_id}' fetched from backend"
+ callbacks = @fetching_conversations[conversation_id]
+ for callback in callbacks
+ callback? conversation_et
+ delete @fetching_conversations[conversation_id]
+ else
+ @logger.log @logger.levels.ERROR, "Conversation with ID '#{conversation_id}' could not be fetched from backend"
+
+ ###
+ Retrieve all conversations using paging.
+
+ @param limit [Integer] Query limit for conversation
+ @param conversation_id [String] ID of the last conversation in batch
+ @return [Promise] Promise that resolves when all conversations have been retrieved and saved
+ ###
+ get_conversations: (limit = 500, conversation_id) =>
+ return new Promise (resolve, reject) =>
+ @conversation_service.get_conversations limit, conversation_id
+ .then (response) =>
+ if response.has_more
+ last_conversation_et = response.conversations[response.conversations.length - 1]
+ @get_conversations limit, last_conversation_et.id
+ .then => resolve @conversations()
+
+ if response.conversations.length > 0
+ conversation_ets = @conversation_mapper.map_conversations response.conversations
+ @save_conversations conversation_ets
+
+ if not response?.has_more
+ @load_conversation_states()
+ resolve @conversations()
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to retrieve conversations from backend: #{error.message}", error
+ reject error
+
+ ###
+ Get conversation events.
+ @param conversation_et [z.entity.Conversation] Conversation to start from
+ ###
+ get_events: (conversation_et) ->
+ conversation_et.is_pending true
+ timestamp = conversation_et.get_first_message()?.timestamp
+ @conversation_service.load_events_from_db conversation_et.id, timestamp
+ .then (loaded_events) =>
+ [events, has_further_events] = loaded_events
+ conversation_et.has_further_messages has_further_events
+ if events.length is 0
+ @logger.log @logger.levels.INFO, "No events for conversation '#{conversation_et.id}' found", events
+ else if timestamp
+ date = new Date(timestamp).toISOString()
+ @logger.log @logger.levels.INFO,
+ "Loaded #{events.length} event(s) starting at '#{date}' for conversation '#{conversation_et.id}'", events
+ else
+ @logger.log @logger.levels.INFO,
+ "Loaded first #{events.length} event(s) for conversation '#{conversation_et.id}'", events
+ raw_events = (event.mapped or event.raw for event in events)
+ @_add_events_to_conversation events: raw_events, conversation_et
+ conversation_et.is_pending false
+ .catch (error) =>
+ @logger.log @logger.levels.INFO, "Could not load events for conversation: #{conversation_et.id}", error
+
+ ###
+ Get conversation unread events.
+ @param conversation_et [z.entity.Conversation] Conversation to start from
+ ###
+ _get_unread_events: (conversation_et) ->
+ conversation_et.is_pending true
+ timestamp = conversation_et.get_first_message()?.timestamp
+ @conversation_service.load_unread_events_from_db conversation_et, timestamp
+ .then (events) =>
+ if events.length
+ raw_events = (event.mapped or event.raw for event in events)
+ @_add_events_to_conversation events: raw_events, conversation_et
+ conversation_et.is_pending false
+ .catch (error) =>
+ @logger.log @logger.levels.INFO, "Could not load unread events for conversation: #{conversation_et.id}", error
+
+ ###
+ Update conversation with a user you just unblocked
+ @param user_et [z.entity.User] User you unblocked
+ ###
+ unblocked_user: (user_et) =>
+ conversation_et = @get_one_to_one_conversation user_et.id
+ conversation_et?.removed_from_conversation false
+
+ ###
+ Get users and events for conversations.
+ @note To reduce the number of backend calls we merge the user IDs of all conversations first.
+ @param conversation_ets [Array] Array of conversation entities to be updated
+ ###
+ update_conversations: (conversation_ets) =>
+ user_ids = _.flatten(conversation_et.all_user_ids() for conversation_et in conversation_ets)
+ @user_repository.get_users_by_id user_ids, =>
+ @_fetch_users_and_events conversation_et for conversation_et in conversation_ets
+
+ ###
+ Map users to conversations without any backend requests.
+ @param conversation_ets [Array] Array of conversation entities to be updated
+ ###
+ update_conversations_offline: (conversation_ets) =>
+ @update_participating_user_ets conversation_et, undefined, true for conversation_et in conversation_ets
+
+
+ ###############################################################################
+ # Repository interactions
+ ###############################################################################
+
+ ###
+ Find a local conversation by ID.
+ @param conversation_id [String] ID of conversation to get
+ @return [z.entity.Conversation] Conversation
+ ###
+ find_conversation_by_id: (conversation_id) ->
+ return conversation for conversation in @conversations() when conversation.id is conversation_id
+
+ get_all_users_in_conversation: (conversation_id) ->
+ return new Promise (resolve) =>
+ @get_conversation_by_id conversation_id, (conversation_et) =>
+ others = conversation_et.participating_user_ets()
+ resolve others.concat [@user_repository.self()]
+
+ ###
+ Check for conversation locally and fetch it from the server otherwise.
+ @param conversation_id [String] ID of conversation to get
+ @param callback [Function] Function to be called on server return
+ ###
+ get_conversation_by_id: (conversation_id, callback) ->
+ if not conversation_id
+ Raygun.send new Error 'Trying to get conversation without ID'
+ return
+
+ conversation_et = @find_conversation_by_id conversation_id
+ if callback
+ if conversation_et?
+ callback? conversation_et
+ else
+ @fetch_conversation_by_id conversation_id, callback
+
+ return conversation_et
+
+ ###
+ Get group conversations by name
+ @param group_name [String] Query to be searched in group conversation names
+ @return [Array] Matching group conversations
+ ###
+ get_groups_by_name: (group_name) =>
+ @sorted_conversations().filter (conversation_et) ->
+ return false if not conversation_et.is_group()
+ return true if z.util.compare_names conversation_et.display_name(), group_name
+ for user_et in conversation_et.participating_user_ets()
+ return true if z.util.compare_names user_et.name(), group_name
+ return false
+
+ ###
+ Get the next unarchived conversation.
+ @param conversation_et [z.entity.Conversation] Conversation to start from
+ @return [z.entity.Conversation] Next conversation
+ ###
+ get_next_conversation: (conversation_et) ->
+ return z.util.array_get_next @conversations_unarchived(), conversation_et
+
+ ###
+ Get unarchived conversation with the most recent event.
+ @return [z.entity.Conversation] Most recent conversation
+ ###
+ get_most_recent_conversation: ->
+ return @conversations_unarchived()?[0]
+
+ ###
+ Get conversation with a user.
+ @param user_id [String] ID of user for whom to get the conversation
+ @return [z.entity.Conversation] Conversation with requested user
+ ###
+ get_one_to_one_conversation: (user_id) =>
+ for conversation_et in @conversations()
+ if conversation_et.type() in [z.conversation.ConversationType.ONE2ONE, z.conversation.ConversationType.CONNECT]
+ return conversation_et if user_id is conversation_et.participating_user_ids()[0]
+
+ ###
+ Check whether conversation is currently displayed.
+ @param conversation_et [z.entity.Conversation] Conversation to be saved
+ @return [Boolean] Is the conversation active
+ ###
+ is_active_conversation: (conversation_et) ->
+ return @active_conversation() is conversation_et
+
+ ###
+ Check whether message has been read.
+
+ @param conversation_id [String] Conversation ID
+ @param message_id [String] Message ID
+ @return [Boolean] Is the message marked as read
+ ###
+ is_message_read: (conversation_id, message_id) =>
+ conversation_et = @get_conversation_by_id conversation_id
+ message_et = conversation_et.get_message_by_id message_id
+
+ if not message_et
+ @logger.log @logger.levels.WARN, "Message ID '#{message_id}' not found for conversation ID '#{conversation_id}'"
+ return true
+
+ return conversation_et.last_read_timestamp() >= message_et.timestamp
+
+ ###
+ Load the conversation states from the store.
+ ###
+ load_conversation_states: =>
+ @conversation_service.load_conversation_states_from_db()
+ .then (conversation_states) =>
+ for state in conversation_states
+ conversation_et = @get_conversation_by_id state.id
+ @conversation_mapper.update_self_status conversation_et, state
+
+ @logger.log @logger.levels.INFO, "Updated '#{conversation_states.length}' conversation states"
+ amplify.publish z.event.WebApp.CONVERSATION.LOADED_STATES
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, 'Failed to update conversation states', error
+
+ ###
+ Maps a connection to the corresponding conversation
+
+ @note If there is no conversation it will request it from the backend
+ @param [Array] Connections
+ @param [Boolean] open the new conversation
+ ###
+ map_connection: (connection_ets, show_conversation = false) =>
+ for connection_et in connection_ets
+ conversation_et = @get_conversation_by_id connection_et.conversation_id
+
+ # We either accepted a pending connection request or send new connection request
+ states_to_fetch = [z.user.ConnectionStatus.ACCEPTED, z.user.ConnectionStatus.SENT]
+ if not conversation_et and connection_et.status() in states_to_fetch
+ @fetch_conversation_by_id connection_et.conversation_id, (conversation_et) =>
+ if conversation_et
+ @save_conversation conversation_et
+ conversation_et.connection connection_et
+ @update_participating_user_ets conversation_et, (conversation_et) ->
+ amplify.publish z.event.WebApp.CONVERSATION.SHOW, conversation_et if show_conversation
+ else if conversation_et?
+ conversation_et.connection connection_et
+ @update_participating_user_ets conversation_et, (conversation_et) ->
+ if connection_et.status() is z.user.ConnectionStatus.ACCEPTED
+ conversation_et.type z.conversation.ConversationType.ONE2ONE
+
+ if not @has_initialized_participants
+ @logger.log @logger.levels.INFO, 'Updating group participants offline'
+ @_init_state_updates()
+ @update_conversations_offline @conversations_unarchived()
+ @update_conversations_offline @conversations_archived()
+ @update_conversations_offline @conversations_cleared()
+ @has_initialized_participants = true
+
+ ###
+ Mark conversation as read.
+ @param conversation_et [z.entity.Conversation] Conversation to be marked as read
+ ###
+ mark_as_read: (conversation_et) =>
+ return if conversation_et is undefined
+ return if @is_handling_notifications
+ return if conversation_et.number_of_unread_events() is 0
+ return if conversation_et.get_last_message()?.type is z.event.Backend.CONVERSATION.MEMBER_UPDATE
+
+ @_update_last_read_timestamp conversation_et
+ amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.REMOVE_READ
+
+ ###
+ Save a conversation in the repository.
+ @param conversation_et [z.entity.Conversation] Conversation to be saved in the repository
+ ###
+ save_conversation: (conversation_et) =>
+ if not @get_conversation_by_id conversation_et.id
+ @conversations.push conversation_et
+ @save_conversation_in_db conversation_et
+
+ save_conversation_in_db: (conversation_et, updated_field) =>
+ @conversation_service.save_conversation_in_db conversation_et, updated_field
+
+ ###
+ Save conversations in the repository.
+ @param conversation_ets [Array] Conversations to be saved in the repository
+ ###
+ save_conversations: (conversation_ets) =>
+ z.util.ko_array_push_all @conversations, conversation_ets
+
+ ###
+ Set the notification handling state.
+ @note Temporarily do not unarchive conversations when handling the notification stream
+ @param handling_notifications [Boolean] Notifications are being handled from the stream
+ ###
+ set_notification_handling_state: (handling_notifications) =>
+ @is_handling_notifications = handling_notifications
+ @logger.log @logger.levels.INFO, "Ignoring events for unarchiving: #{handling_notifications}"
+
+ ###
+ Update participating users in a conversation.
+
+ @param conversation_et [z.entity.Conversation] Conversation to be updated
+ @param callback [Function] Function to be called on server return
+ @param offline [Boolean] Should we only look for cached contacts
+ ###
+ update_participating_user_ets: (conversation_et, callback, offline = false) =>
+ conversation_et.self = @user_repository.self()
+ user_ids = conversation_et.participating_user_ids()
+ @user_repository.get_users_by_id user_ids, (user_ets) ->
+ conversation_et.participating_user_ets.removeAll()
+ z.util.ko_array_push_all conversation_et.participating_user_ets, user_ets
+ callback? conversation_et
+ , offline
+
+
+ ###############################################################################
+ # Send events
+ ###############################################################################
+
+ ###
+ Add users to an existing conversation.
+
+ @param conversation_et [z.entity.Conversation] Conversation to add users to
+ @param user_ids [Array] IDs of users to be added to the conversation
+ @param callback [Function] Function to be called on server return
+ ###
+ add_members: (conversation_et, users_ids, callback) =>
+ @conversation_service.post_members conversation_et.id, users_ids
+ .then (response) ->
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT,
+ z.tracking.SessionEventName.INTEGER.USERS_ADDED_TO_CONVERSATIONS, users_ids.length
+ amplify.publish z.event.WebApp.EVENT.INJECT, response
+ callback?()
+ .catch (error_response) ->
+ if error_response.label is z.service.BackendClientError::LABEL.TOO_MANY_MEMBERS
+ amplify.publish z.event.WebApp.WARNINGS.MODAL, z.ViewModel.ModalType.TOO_MANY_MEMBERS,
+ data:
+ max: z.config.MAXIMUM_CONVERSATION_SIZE
+ open_spots: Math.max 0, z.config.MAXIMUM_CONVERSATION_SIZE - (conversation_et.number_of_participants() + 1)
+
+ ###
+ Archive a conversation.
+ @param conversation_et [z.entity.Conversation] Conversation to rename
+ @param next_conversation_et [z.entity.Conversation] Next conversation to potentially switch to
+ ###
+ archive_conversation: (conversation_et, next_conversation_et) =>
+
+ # other clients just use the old event id as a flag. if archived is
+ # set they consider the conversation is archived
+ payload =
+ otr_archived: true
+ otr_archived_ref: new Date(conversation_et.last_event_timestamp()).toISOString()
+
+ @conversation_service.update_member_properties conversation_et.id, payload
+ .then =>
+ @member_update conversation_et, {data: payload}, next_conversation_et
+ @logger.log @logger.levels.INFO,
+ "Archived conversation '#{conversation_et.id}' on '#{payload.otr_archived_ref}'"
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR,
+ "Conversation '#{conversation_et.id}' could not be archived: #{error.code}\r\nPayload: #{JSON.stringify(payload)}", error
+
+ ###
+ Clear conversation content and archive the conversation.
+
+ @note According to spec we archive a conversation when we clear it.
+ It will be unarchived once it is opened through search. We use the archive flag to distinguish states.
+
+ @param conversation_et [z.entity.Conversation] Conversation to clear
+ @param leave [Boolean] Should we leave the conversation before clearing the content?
+ ###
+ clear_conversation: (conversation_et, leave = false) =>
+ next_conversation_et = @get_next_conversation conversation_et
+
+ _clear_conversation = =>
+ @_update_cleared_timestamp conversation_et
+ @_delete_messages conversation_et
+ amplify.publish z.event.WebApp.CONVERSATION.SHOW, next_conversation_et
+
+ if leave
+ @leave_conversation conversation_et, next_conversation_et, _clear_conversation
+ else
+ _clear_conversation()
+
+ _update_cleared_timestamp: (conversation_et) ->
+ cleared_timestamp = conversation_et.last_event_timestamp()
+
+ if conversation_et.set_timestamp cleared_timestamp, z.conversation.ConversationUpdateType.CLEARED_TIMESTAMP
+ message_content = new z.proto.Cleared conversation_et.id, cleared_timestamp
+ generic_message = new z.proto.GenericMessage z.util.create_random_uuid()
+ generic_message.set 'cleared', message_content
+
+ @_send_encrypted_value @self_conversation().id, generic_message
+ .then =>
+ @logger.log @logger.levels.INFO,
+ "Cleared conversation '#{conversation_et.id}' as read on '#{new Date(cleared_timestamp).toISOString()}'"
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Error (#{error.label}): #{error.message}"
+ error = new Error 'Event response is undefined'
+ Raygun.send error, source: 'Sending encrypted last read'
+
+ ###
+ Leave conversation.
+
+ @param conversation_et [z.entity.Conversation] Conversation to leave
+ @param next_conversation_et [z.entity.Conversation] Next conversation in list
+ @param callback [Function] Function to be called on server return
+ ###
+ leave_conversation: (conversation_et, next_conversation_et, callback) =>
+ @conversation_service.delete_members conversation_et.id, @user_repository.self().id
+ .then (response) =>
+ amplify.publish z.event.WebApp.EVENT.INJECT, response
+ @member_leave conversation_et, response
+ .then =>
+ if callback?
+ callback next_conversation_et
+ else
+ @archive_conversation conversation_et, next_conversation_et
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to leave conversation (#{conversation_et.id}): #{error}"
+
+ ###
+ Remove member from conversation.
+
+ @param conversation_et [z.entity.Conversation] Conversation to remove member from
+ @param user_id [String] ID of member to be removed from the the conversation
+ @param callback [Function] Function to be called on server return
+ ###
+ remove_member: (conversation_et, user_id, callback) =>
+ @conversation_service.delete_members conversation_et.id, user_id
+ .then (response) ->
+ amplify.publish z.event.WebApp.EVENT.INJECT, response
+ callback?()
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to remove member from conversation (#{conversation_et.id}): #{error}"
+
+ ###
+ Rename conversation.
+
+ @param conversation_et [z.entity.Conversation] Conversation to rename
+ @param name [String] New conversation name
+ @param callback [Function] Function to be called on server return
+ ###
+ rename_conversation: (conversation_et, name, callback) =>
+ @conversation_service.update_conversation_properties conversation_et.id, name
+ .then (response) ->
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.CONVERSATION_RENAMED
+ amplify.publish z.event.WebApp.EVENT.INJECT, response
+ .then ->
+ callback?()
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Failed to rename conversation (#{conversation_et.id}): #{error}"
+
+ reset_session: (user_id, client_id, conversation_id) =>
+ @logger.log @logger.levels.INFO, "Resetting session with client '#{client_id}' of user '#{user_id}'"
+ @cryptography_repository.reset_session user_id, client_id
+ .then (session_id) =>
+ if session_id
+ @logger.log @logger.levels.INFO, "Deleted session with client '#{client_id}' of user '#{user_id}'"
+ else
+ @logger.log @logger.levels.WARN, 'No local session found to delete'
+ return @send_encrypted_session_reset user_id, client_id, conversation_id
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "Failed to reset session for client '#{client_id}' of user '#{user_id}': #{error.message}", error
+ throw error
+
+ reset_all_sessions: =>
+ sessions = @cryptography_repository.storage_repository.sessions
+ @logger.log @logger.levels.INFO, "Resetting '#{Object.keys(sessions).length}' sessions"
+ for session_id, session of sessions
+ ids = z.client.Client.dismantle_user_client_id session_id
+ if ids.user_id is @user_repository.self().id
+ conversation_et = @self_conversation()
+ else
+ conversation_et = @get_one_to_one_conversation ids.user_id
+ @reset_session ids.user_id, ids.client_id, conversation_et.id
+
+ ###
+ Send a specific GIF to a conversation.
+
+ @param conversation_et [z.entity.Conversation] Conversation to send message in
+ @param url [String] URL of giphy image
+ @param tag [String] tag tag used for gif search
+ @param callback [Function] Function to be called on server return
+ ###
+ send_gif: (conversation_et, url, tag, callback) =>
+ if not tag
+ tag = z.localization.Localizer.get_text z.string.extensions_giphy_random
+
+ message = z.localization.Localizer.get_text {
+ id: z.string.extensions_giphy_message
+ replace: {placeholder: '%tag', content: tag}
+ }
+
+ z.util.load_url_blob url, (blob) =>
+ @send_encrypted_message message, conversation_et
+ @upload_images conversation_et, [blob]
+ callback?()
+
+ ###
+ Toggle a conversation between silence and notify.
+ @param conversation_et [z.entity.Conversation] Conversation to rename
+ ###
+ toggle_silence_conversation: (conversation_et) =>
+ return new Promise (resolve, reject) =>
+ if conversation_et.is_muted()
+ payload =
+ muted: false
+ otr_muted: false
+ otr_muted_ref: new Date().toISOString()
+ else
+ payload =
+ muted: true
+ muted_time: new Date().toJSON()
+ otr_muted: true
+ otr_muted_ref: new Date(conversation_et.last_event_timestamp()).toISOString()
+
+ @conversation_service.update_member_properties conversation_et.id, payload
+ .then =>
+ response = {data: payload}
+ @member_update conversation_et, response
+ @logger.log @logger.levels.INFO,
+ "Toggle silence to '#{payload.otr_muted}' for conversation '#{conversation_et.id}' on '#{payload.otr_muted_ref}'"
+ resolve response
+ .catch (error) =>
+ reject_error = new Error "Conversation '#{conversation_et.id}' could not be muted: #{error.code}"
+ @logger.log @logger.levels.WARN, reject_error.message, error
+ reject reject_error
+
+ ###
+ Un-archive a conversation.
+ @param conversation_et [z.entity.Conversation] Conversation to rename
+ @param callback [Function] Function to be called on return
+ ###
+ unarchive_conversation: (conversation_et, callback) =>
+ return new Promise (resolve, reject) =>
+ payload =
+ otr_archived: false
+ otr_archived_ref: new Date(conversation_et.last_event_timestamp()).toISOString()
+
+ @conversation_service.update_member_properties conversation_et.id, payload
+ .then =>
+ response = {data: payload}
+ @member_update conversation_et, response
+ @logger.log @logger.levels.INFO,
+ "Unarchived conversation '#{conversation_et.id}' on '#{payload.otr_archived_ref}'"
+ callback?()
+ resolve response
+ .catch (error) =>
+ reject_error = new Error "Conversation '#{conversation_et.id}' could not be unarchived: #{error.code}"
+ @logger.log @logger.levels.WARN, reject_error.message, error
+ callback?()
+ reject reject_error
+
+ ###
+ Update last read of conversation using timestamp.
+ @private
+ @param conversation_et [z.entity.Conversation] Conversation to update
+ ###
+ _update_last_read_timestamp: (conversation_et) ->
+ timestamp = conversation_et.get_last_message()?.timestamp
+ return if not timestamp?
+
+ if conversation_et.set_timestamp timestamp, z.conversation.ConversationUpdateType.LAST_READ_TIMESTAMP
+ message_content = new z.proto.LastRead conversation_et.id, conversation_et.last_read_timestamp()
+
+ generic_message = new z.proto.GenericMessage z.util.create_random_uuid()
+ generic_message.set 'lastRead', message_content
+
+ @_send_encrypted_value @self_conversation().id, generic_message
+ .then =>
+ @logger.log @logger.levels.INFO,
+ "Marked conversation '#{conversation_et.id}' as read on '#{new Date(timestamp).toISOString()}'"
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Error (#{error.label}): #{error.message}"
+ error = new Error 'Event response is undefined'
+ Raygun.send error, source: 'Sending encrypted last read'
+
+
+ ###############################################################################
+ # Send encrypted events
+ ###############################################################################
+
+ ###
+ Send encrypted assets. Used for file transfers.
+
+ # TODO unify with image asset
+ # create and send original proto message (message-add)
+ # create and send uploaded proto message with status (asset-add)
+
+ @param conversation_id [String] Conversation ID
+ @return [Object] Collection with User IDs which hold their Client IDs in an Array
+ ###
+ send_encrypted_asset: (conversation_et, file, nonce) =>
+ conversation_id = conversation_et.id
+ generic_message = null
+ key_bytes = null
+ sha256 = null
+ ciphertext = null
+ body_payload = null
+ initial_payload = null
+
+ return Promise.resolve()
+ .then ->
+ message_et = conversation_et.get_message_by_id nonce
+ asset_et = message_et.assets()[0]
+ asset_et.upload_id nonce # TODO combine
+ asset_et.uploaded_on_this_client true
+
+ return z.util.load_file_buffer file
+ .then (file_bytes) ->
+ return z.assets.AssetCrypto.encrypt_aes_asset file_bytes
+ .then (data) ->
+ # TODO send original message and
+ [key_bytes, sha256, ciphertext] = data
+ key_bytes = new Uint8Array key_bytes
+ sha256 = new Uint8Array sha256
+ .then =>
+ return @_create_user_client_map conversation_id
+ .then (user_client_map) =>
+ generic_message = new z.proto.GenericMessage nonce
+ generic_message.set 'asset', @_construct_asset_uploaded key_bytes, sha256
+ return @cryptography_repository.encrypt_generic_message user_client_map, generic_message
+ .then (payload) =>
+ payload.inline = false
+ payload.native_push = true
+ body_payload = new Uint8Array ciphertext
+ initial_payload = payload
+ return @asset_service.post_asset_v2 conversation_id, payload, body_payload, false, nonce
+ .catch (error_response) =>
+ return @_update_payload_for_changed_clients error_response, generic_message, initial_payload
+ .then (updated_payload) =>
+ @asset_service.post_asset_v2 conversation_id, updated_payload, body_payload, true, nonce
+ .then (response) =>
+ [json, asset_id] = response
+ event = @_construct_otr_asset_event response, conversation_et.id, asset_id
+ event.data.otr_key = key_bytes
+ event.data.sha256 = sha256
+ event.id = nonce
+ return @asset_upload_complete conversation_et, event
+
+ ###
+ When we reset a session then we must inform the remote client about this action.
+
+ @param conversation_et [z.entity.Conversation] Conversation that should receive the file
+ @param file [File] File to send
+ ###
+ send_encrypted_asset_metadata: (conversation_et, file) =>
+ generic_message = new z.proto.GenericMessage z.util.create_random_uuid()
+ generic_message.set 'asset', @_construct_asset_original file
+ @_send_and_save_encrypted_value conversation_et, generic_message
+
+ ###
+ When we reset a session then we must inform the remote client about this action.
+
+ @param conversation_et [z.entity.Conversation] Conversation that should receive the file
+ @param nonce [String] id of the metadata message
+ @param reason [z.assets.AssetUploadFailedReason] cause for the failed upload (optional)
+ ###
+ send_encrypted_asset_upload_failed: (conversation_et, nonce, reason = z.assets.AssetUploadFailedReason.FAILED) =>
+ generic_message = new z.proto.GenericMessage nonce
+ generic_message.set 'asset', @_construct_asset_not_uploaded reason
+ @_send_and_save_encrypted_value conversation_et, generic_message
+
+ ###
+ Send encrypted external message
+
+ @param conversation_et [z.entity.Conversation] Conversation that should receive the message
+ @param generic_message [z.protobuf.GenericMessage] Generic message to be sent as external message
+ @return [Promise] Promise that resolves after sending the external message
+ ###
+ send_encrypted_external_message: (conversation_et, generic_message) =>
+ @logger.log @logger.levels.INFO, "Sending external message of type '#{generic_message.content}'", generic_message
+
+ conversation_id = conversation_et.id
+ key_bytes = null
+ sha256 = null
+ ciphertext = null
+ initial_payload = null
+
+ z.assets.AssetCrypto.encrypt_aes_asset generic_message.toArrayBuffer()
+ .then (data) =>
+ [key_bytes, sha256, ciphertext] = data
+ return @_create_user_client_map conversation_id
+ .then (user_client_map) =>
+ generic_message_external = new z.proto.GenericMessage z.util.create_random_uuid()
+ generic_message_external.set 'external', new z.proto.External new Uint8Array(key_bytes), new Uint8Array(sha256)
+ return @cryptography_repository.encrypt_generic_message user_client_map, generic_message_external
+ .then (payload) =>
+ payload.data = z.util.array_to_base64 ciphertext
+ payload.native_push = true
+ initial_payload = payload
+ return @conversation_service.post_encrypted_message conversation_id, payload, false
+ .catch (error_response) =>
+ return @_update_payload_for_changed_clients error_response, generic_message, initial_payload
+ .then (updated_payload) =>
+ return @conversation_service.post_encrypted_message conversation_id, updated_payload, true
+ .then (response) =>
+ event = @_construct_otr_message_event response, conversation_et.id
+ return @cryptography_repository.save_encrypted_event generic_message, event
+ .then (record) =>
+ @add_event conversation_et, record.mapped if record?.mapped
+ .catch (error) =>
+ @logger.log @logger.levels.WARN, "Failed to send external message for conversation with id '#{conversation_id}'", error
+
+ ###
+ Sends an OTR Image Asset
+ ###
+ send_encrypted_image_asset: (conversation_et, image) =>
+ return new Promise (resolve, reject) =>
+ asset = null
+ ciphertext = null
+ conversation_id = conversation_et.id
+ generic_message = null
+ initial_payload = null
+
+ @asset_service.create_asset_proto image
+ .then ([asset, asset_ciphertext]) =>
+ ciphertext = asset_ciphertext
+ generic_message = new z.proto.GenericMessage z.util.create_random_uuid(), null, asset
+ return @_create_user_client_map conversation_id
+ .then (user_client_map) =>
+ return @cryptography_repository.encrypt_generic_message user_client_map, generic_message
+ .then (payload) =>
+ initial_payload = payload
+ initial_payload.inline = false
+ initial_payload.native_push = true
+ return @asset_service.post_asset_v2 conversation_id, initial_payload, ciphertext, false
+ .catch (error_response) =>
+ return @_update_payload_for_changed_clients error_response, generic_message, initial_payload
+ .then (updated_payload) =>
+ @asset_service.post_asset_v2 conversation_id, updated_payload, ciphertext, true
+ .then ([json, asset_id]) =>
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.IMAGE_SENT
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.MEDIA.COMPLETED_MEDIA_ACTION,
+ action: 'photo', conversation_type: if conversation_et.is_one2one() then 'one_to_one' else 'group'
+ event = @_construct_otr_asset_event json, conversation_id, asset_id
+ return @cryptography_repository.save_encrypted_event generic_message, event
+ .then (record) =>
+ @add_event conversation_et, record.mapped
+ resolve()
+ .catch (error) =>
+ @logger.log "Failed to upload otr asset for conversation #{conversation_id}", error
+ exception = new Error('Event response is undefined')
+ custom_data =
+ source: 'Sending medium image'
+ error: error
+ Raygun.send exception, custom_data
+ reject error
+
+ ###
+ Send an encrypted knock.
+ @param conversation_et [z.entity.Conversation] Conversation to send knock in
+ @return [Promise] Promise that resolves after sending the knock
+ ###
+ send_encrypted_knock: (conversation_et) =>
+ @_send_and_save_encrypted_value conversation_et, new z.proto.Knock false
+ .then ->
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.PING_SENT
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.MEDIA.COMPLETED_MEDIA_ACTION,
+ action: 'ping', conversation_type: if conversation_et.is_one2one() then 'one_to_one' else 'group'
+ .catch (error) => @logger.log @logger.levels.ERROR, "#{error.message}"
+
+ ###
+ Send message to specific converation.
+
+ @note Will either send a normal or external message.
+ @param message [String] plain text message
+ @param conversation_et [z.entity.Conversation] Conversation that should receive the message
+ @return [Promise] Promise that resolves after sending the message
+ ###
+ send_encrypted_message_with_link_preview: (message, url, offset, conversation_et) =>
+ generic_message = new z.proto.GenericMessage z.util.create_random_uuid()
+ generic_message.set 'text', new z.proto.Text message
+
+ @_send_and_save_encrypted_value conversation_et, generic_message
+ .then =>
+ @link_repository.get_link_preview url, offset
+ .then (link_preview) =>
+ generic_message.text.link_preview.push link_preview
+ @_send_and_save_encrypted_value conversation_et, generic_message
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, 'Error while sending link preview', error
+
+ ###
+ Send message to specific converation.
+
+ @note Will either send a normal or external message.
+ @param message [String] plain text message
+ @param conversation_et [z.entity.Conversation] Conversation that should receive the message
+ @param retry [Boolean] Try to resend as external with first attempt failed (optional)
+ @return [Promise] Promise that resolves after sending the message
+ ###
+ send_encrypted_message: (message, conversation_et, retry = true) =>
+ generic_message = new z.proto.GenericMessage z.util.create_random_uuid()
+ generic_message.set 'text', new z.proto.Text message
+
+ Promise.resolve()
+ .then =>
+ if @_send_as_external_message conversation_et, generic_message
+ @send_encrypted_external_message conversation_et, generic_message
+ else
+ @_send_and_save_encrypted_value conversation_et, generic_message
+ .then (message_record) =>
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.MESSAGE_SENT
+ amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.MEDIA.COMPLETED_MEDIA_ACTION,
+ action: 'text', conversation_type: if conversation_et.is_one2one() then 'one_to_one' else 'group'
+ @_analyze_sent_message message
+ return message_record
+ .catch (error) =>
+ if error.code is z.service.BackendClientError::STATUS_CODE.REQUEST_TOO_LARGE and retry
+ @send_encrypted_external_message conversation_et, generic_message
+ else
+ @logger.log @logger.levels.ERROR, "#{error.message}", error
+ error = new Error "Failed to send message: #{error.message}"
+ custom_data =
+ source: 'Sending message'
+ Raygun.send error, custom_data
+ throw error
+
+ ###
+ Sending a message to the remote end of a session reset.
+
+ @note When we reset a session then we must inform the remote client about this action. It sends a ProtocolBuffer message
+ (which will not be rendered in the view) to the remote client. This message only needs to be sent to the affected
+ remote client, therefore we force the message sending.
+
+ @param user_id [String] User ID
+ @param client_id [String] Client ID
+ @param conversation_id [String] Conversation ID
+ @return [Promise] Promise that resolves after sending the session reset
+ ###
+ send_encrypted_session_reset: (user_id, client_id, conversation_id) =>
+ return new Promise (resolve, reject) =>
+ generic_message = new z.proto.GenericMessage z.util.create_random_uuid()
+ generic_message.setClientAction z.proto.ClientAction.RESET_SESSION
+
+ user_client_map = @_create_user_client_map_from_ids user_id, client_id
+
+ @cryptography_repository.encrypt_generic_message user_client_map, generic_message
+ .then (payload) =>
+ return @conversation_service.post_encrypted_message conversation_id, payload, true
+ .then (response) =>
+ @logger.log @logger.levels.INFO, "Sent info about session reset to client '#{client_id}' of user '#{user_id}'"
+ resolve response
+ .catch (error) =>
+ @logger.log @logger.levels.ERROR, "Sending conversation reset failed: #{error.message}", error
+ reject error
+
+ ###
+ Create not uploaded Asset protobuf message (failure).
+
+ @private
+ @param reason [z.assets.AssetUploadFailedReason] Conversation ID
+ @return [z.proto.Asset] Asset protobuf message
+ ###
+ _construct_asset_not_uploaded: (reason) ->
+ asset = new z.proto.Asset()
+ if reason is z.assets.AssetUploadFailedReason.CANCELLED
+ asset.set 'not_uploaded', z.proto.Asset.NotUploaded.CANCELLED
+ else
+ asset.set 'not_uploaded', z.proto.Asset.NotUploaded.FAILED
+ return asset
+
+ ###
+ Create original Asset protobuf message.
+
+ @private
+ @param file [Object] File data
+ @return [z.proto.Asset] Asset protobuf message
+ ###
+ _construct_asset_original: (file) ->
+ original_asset = new z.proto.Asset.Original file.type, file.size, file.name
+ asset = new z.proto.Asset()
+ asset.set 'original', original_asset
+ return asset
+
+ ###
+ Create uploaded Asset proto (success).
+
+ @private
+ @param otr_key [ByteArray] Encryption key
+ @param sha256 [ByteArray] Sha256
+ @return [z.proto.Asset] Asset protobuf message
+ ###
+ _construct_asset_uploaded: (otr_key, sha256) ->
+ uploaded_asset = new z.proto.Asset.RemoteData otr_key, sha256
+ asset = new z.proto.Asset()
+ asset.set 'uploaded', uploaded_asset
+ return asset
+
+ ###
+ Create MsgDeleted protobuf message.
+
+ @private
+ @param conversation_id [String] Conversation ID
+ @param message_id [String] ID of message to be deleted
+ @return [z.proto.MsgDeleted] MsgDeleted protobuf message
+ ###
+ _construct_delete: (conversation_id, message_id) ->
+ return new z.proto.MsgDeleted conversation_id, message_id
+
+ ###
+ Construct an encrypted message event.
+
+ @private
+ @param response [JSON] Backend response
+ @param conversation_id [String] Conversation ID
+ @return [Object] Object in form of 'conversation.otr-message-add'
+ ###
+ _construct_otr_message_event: (response, conversation_id) ->
+ event =
+ data: undefined
+ from: @user_repository.self().id
+ time: response.time
+ type: 'conversation.otr-message-add'
+ conversation: conversation_id
+
+ return event
+
+ ###
+ Construct an encrypted asset event.
+
+ @private
+ @param response [JSON] Backend response
+ @param conversation_id [String] Conversation ID
+ @param asset_id [String] Asset ID
+ @return [Object] Object in form of 'conversation.otr-asset-add'
+ ###
+ _construct_otr_asset_event: (response, conversation_id, asset_id) ->
+ event =
+ data:
+ id: asset_id
+ from: @user_repository.self().id
+ time: response.time
+ type: 'conversation.otr-asset-add'
+ conversation: conversation_id
+
+ return event
+
+ ###
+ Create a user client map for a given conversation.
+
+ @private
+ @param conversation_id [String] Conversation ID
+ @return [Promise